@fluxpointstudios/orynq-sdk-process-trace 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/LICENSE +21 -21
- package/dist/index.cjs +574 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +573 -9
- package/dist/index.d.ts +573 -9
- package/dist/index.js +562 -9
- package/dist/index.js.map +1 -1
- package/package.json +17 -3
- package/src/__tests__/bundle.test.ts +942 -860
- package/src/__tests__/governance-round4.test.ts +149 -0
- package/src/__tests__/governance.test.ts +382 -0
- package/src/__tests__/hardening-round2.test.ts +173 -0
- package/src/__tests__/hardening-round3.test.ts +208 -0
- package/src/__tests__/integration.test.ts +611 -611
- package/src/__tests__/merkle.test.ts +756 -756
- package/src/__tests__/model-manifest.test.ts +136 -0
- package/src/__tests__/rolling-hash.test.ts +622 -622
- package/src/__tests__/trace-builder.test.ts +1012 -1012
- package/src/__tests__/types.test.ts +420 -414
- package/src/bundle.ts +1004 -810
- package/src/disclosure.ts +527 -527
- package/src/governance.ts +711 -0
- package/src/index.ts +334 -265
- package/src/manifest.ts +687 -687
- package/src/merkle.ts +428 -428
- package/src/model-manifest.ts +0 -0
- package/src/rolling-hash.ts +375 -366
- package/src/trace-builder.ts +791 -725
- package/src/types.ts +713 -522
|
@@ -1,756 +1,756 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @summary Tests for Merkle tree functionality in the process-trace package.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
-
import {
|
|
7
|
-
computeSpanHash,
|
|
8
|
-
buildSpanMerkleTree,
|
|
9
|
-
generateMerkleProof,
|
|
10
|
-
verifyMerkleProof,
|
|
11
|
-
verifySpanInclusion,
|
|
12
|
-
computeEventHash,
|
|
13
|
-
HASH_DOMAIN_PREFIXES,
|
|
14
|
-
} from '../index.js';
|
|
15
|
-
import type {
|
|
16
|
-
TraceSpan,
|
|
17
|
-
TraceEvent,
|
|
18
|
-
TraceMerkleTree,
|
|
19
|
-
MerkleProof,
|
|
20
|
-
CommandEvent,
|
|
21
|
-
OutputEvent,
|
|
22
|
-
} from '../index.js';
|
|
23
|
-
|
|
24
|
-
// -----------------------------------------------------------------------------
|
|
25
|
-
// Test Fixtures
|
|
26
|
-
// -----------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
function createCommandEvent(overrides: Partial<CommandEvent> = {}): CommandEvent {
|
|
29
|
-
return {
|
|
30
|
-
kind: 'command',
|
|
31
|
-
id: crypto.randomUUID(),
|
|
32
|
-
seq: 0,
|
|
33
|
-
timestamp: '2024-01-15T10:30:00.000Z',
|
|
34
|
-
visibility: 'public',
|
|
35
|
-
command: 'npm install',
|
|
36
|
-
hash: undefined,
|
|
37
|
-
...overrides,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function createOutputEvent(overrides: Partial<OutputEvent> = {}): OutputEvent {
|
|
42
|
-
return {
|
|
43
|
-
kind: 'output',
|
|
44
|
-
id: crypto.randomUUID(),
|
|
45
|
-
seq: 1,
|
|
46
|
-
timestamp: '2024-01-15T10:30:01.000Z',
|
|
47
|
-
visibility: 'private',
|
|
48
|
-
stream: 'stdout',
|
|
49
|
-
content: 'done',
|
|
50
|
-
hash: undefined,
|
|
51
|
-
...overrides,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function createSpan(overrides: Partial<TraceSpan> = {}): TraceSpan {
|
|
56
|
-
return {
|
|
57
|
-
id: crypto.randomUUID(),
|
|
58
|
-
spanSeq: 0,
|
|
59
|
-
name: 'test-span',
|
|
60
|
-
status: 'completed',
|
|
61
|
-
visibility: 'public',
|
|
62
|
-
startedAt: '2024-01-15T10:30:00.000Z',
|
|
63
|
-
endedAt: '2024-01-15T10:31:00.000Z',
|
|
64
|
-
durationMs: 60000,
|
|
65
|
-
eventIds: [],
|
|
66
|
-
childSpanIds: [],
|
|
67
|
-
...overrides,
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
async function createEventWithHash(event: TraceEvent): Promise<TraceEvent> {
|
|
72
|
-
const hash = await computeEventHash(event);
|
|
73
|
-
return { ...event, hash };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// -----------------------------------------------------------------------------
|
|
77
|
-
// computeSpanHash Tests
|
|
78
|
-
// -----------------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
describe('computeSpanHash', () => {
|
|
81
|
-
it('produces consistent output for same input', async () => {
|
|
82
|
-
const span = createSpan({
|
|
83
|
-
id: 'fixed-span-id',
|
|
84
|
-
spanSeq: 0,
|
|
85
|
-
name: 'build',
|
|
86
|
-
});
|
|
87
|
-
const eventHashes = ['hash1', 'hash2'];
|
|
88
|
-
|
|
89
|
-
const hash1 = await computeSpanHash(span, eventHashes);
|
|
90
|
-
const hash2 = await computeSpanHash(span, eventHashes);
|
|
91
|
-
|
|
92
|
-
expect(hash1).toBe(hash2);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('produces 64-character hex output', async () => {
|
|
96
|
-
const span = createSpan();
|
|
97
|
-
const hash = await computeSpanHash(span, []);
|
|
98
|
-
|
|
99
|
-
expect(hash).toHaveLength(64);
|
|
100
|
-
expect(hash).toMatch(/^[a-f0-9]+$/);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('produces different output for different span names', async () => {
|
|
104
|
-
const span1 = createSpan({ id: 'same-id', name: 'build' });
|
|
105
|
-
const span2 = createSpan({ id: 'same-id', name: 'test' });
|
|
106
|
-
|
|
107
|
-
const hash1 = await computeSpanHash(span1, []);
|
|
108
|
-
const hash2 = await computeSpanHash(span2, []);
|
|
109
|
-
|
|
110
|
-
expect(hash1).not.toBe(hash2);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('produces different output for different event hashes', async () => {
|
|
114
|
-
const span = createSpan({ id: 'fixed-id' });
|
|
115
|
-
|
|
116
|
-
const hash1 = await computeSpanHash(span, ['event-hash-1']);
|
|
117
|
-
const hash2 = await computeSpanHash(span, ['event-hash-2']);
|
|
118
|
-
|
|
119
|
-
expect(hash1).not.toBe(hash2);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('produces different output for different event hash order', async () => {
|
|
123
|
-
const span = createSpan({ id: 'fixed-id' });
|
|
124
|
-
|
|
125
|
-
const hash1 = await computeSpanHash(span, ['hash-a', 'hash-b']);
|
|
126
|
-
const hash2 = await computeSpanHash(span, ['hash-b', 'hash-a']);
|
|
127
|
-
|
|
128
|
-
expect(hash1).not.toBe(hash2);
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('handles empty event hashes array', async () => {
|
|
132
|
-
const span = createSpan();
|
|
133
|
-
const hash = await computeSpanHash(span, []);
|
|
134
|
-
|
|
135
|
-
expect(hash).toHaveLength(64);
|
|
136
|
-
expect(hash).toMatch(/^[a-f0-9]+$/);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('includes optional fields in hash computation', async () => {
|
|
140
|
-
const span1 = createSpan({ id: 'same-id', metadata: { key: 'value1' } });
|
|
141
|
-
const span2 = createSpan({ id: 'same-id', metadata: { key: 'value2' } });
|
|
142
|
-
|
|
143
|
-
const hash1 = await computeSpanHash(span1, []);
|
|
144
|
-
const hash2 = await computeSpanHash(span2, []);
|
|
145
|
-
|
|
146
|
-
expect(hash1).not.toBe(hash2);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('produces different hash for different visibility', async () => {
|
|
150
|
-
const span1 = createSpan({ id: 'same-id', visibility: 'public' });
|
|
151
|
-
const span2 = createSpan({ id: 'same-id', visibility: 'private' });
|
|
152
|
-
|
|
153
|
-
const hash1 = await computeSpanHash(span1, []);
|
|
154
|
-
const hash2 = await computeSpanHash(span2, []);
|
|
155
|
-
|
|
156
|
-
expect(hash1).not.toBe(hash2);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('produces different hash for different status', async () => {
|
|
160
|
-
const span1 = createSpan({ id: 'same-id', status: 'completed' });
|
|
161
|
-
const span2 = createSpan({ id: 'same-id', status: 'failed' });
|
|
162
|
-
|
|
163
|
-
const hash1 = await computeSpanHash(span1, []);
|
|
164
|
-
const hash2 = await computeSpanHash(span2, []);
|
|
165
|
-
|
|
166
|
-
expect(hash1).not.toBe(hash2);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// -----------------------------------------------------------------------------
|
|
171
|
-
// buildSpanMerkleTree Tests
|
|
172
|
-
// -----------------------------------------------------------------------------
|
|
173
|
-
|
|
174
|
-
describe('buildSpanMerkleTree', () => {
|
|
175
|
-
it('returns empty tree for empty spans', async () => {
|
|
176
|
-
const tree = await buildSpanMerkleTree([], []);
|
|
177
|
-
|
|
178
|
-
expect(tree.rootHash).toBe('');
|
|
179
|
-
expect(tree.leafCount).toBe(0);
|
|
180
|
-
expect(tree.depth).toBe(0);
|
|
181
|
-
expect(tree.leafHashes).toEqual([]);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it('single span produces depth 0', async () => {
|
|
185
|
-
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
186
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
187
|
-
|
|
188
|
-
const tree = await buildSpanMerkleTree([span], [event]);
|
|
189
|
-
|
|
190
|
-
expect(tree.leafCount).toBe(1);
|
|
191
|
-
expect(tree.depth).toBe(0);
|
|
192
|
-
expect(tree.leafHashes).toHaveLength(1);
|
|
193
|
-
expect(tree.rootHash).toBe(tree.leafHashes[0]);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('two spans produce depth 1', async () => {
|
|
197
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
198
|
-
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
199
|
-
|
|
200
|
-
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
201
|
-
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
202
|
-
|
|
203
|
-
const tree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
204
|
-
|
|
205
|
-
expect(tree.leafCount).toBe(2);
|
|
206
|
-
expect(tree.depth).toBe(1);
|
|
207
|
-
expect(tree.leafHashes).toHaveLength(2);
|
|
208
|
-
expect(tree.rootHash).not.toBe(tree.leafHashes[0]);
|
|
209
|
-
expect(tree.rootHash).not.toBe(tree.leafHashes[1]);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it('odd number of spans handles duplication correctly', async () => {
|
|
213
|
-
const events: TraceEvent[] = [];
|
|
214
|
-
const spans: TraceSpan[] = [];
|
|
215
|
-
|
|
216
|
-
for (let i = 0; i < 3; i++) {
|
|
217
|
-
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
218
|
-
events.push(event);
|
|
219
|
-
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const tree = await buildSpanMerkleTree(spans, events);
|
|
223
|
-
|
|
224
|
-
expect(tree.leafCount).toBe(3);
|
|
225
|
-
expect(tree.depth).toBe(2);
|
|
226
|
-
expect(tree.leafHashes).toHaveLength(3);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('even number of spans builds balanced tree', async () => {
|
|
230
|
-
const events: TraceEvent[] = [];
|
|
231
|
-
const spans: TraceSpan[] = [];
|
|
232
|
-
|
|
233
|
-
for (let i = 0; i < 4; i++) {
|
|
234
|
-
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
235
|
-
events.push(event);
|
|
236
|
-
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const tree = await buildSpanMerkleTree(spans, events);
|
|
240
|
-
|
|
241
|
-
expect(tree.leafCount).toBe(4);
|
|
242
|
-
expect(tree.depth).toBe(2);
|
|
243
|
-
expect(tree.leafHashes).toHaveLength(4);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('produces consistent tree for same inputs', async () => {
|
|
247
|
-
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
248
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
249
|
-
|
|
250
|
-
const tree1 = await buildSpanMerkleTree([span], [event]);
|
|
251
|
-
const tree2 = await buildSpanMerkleTree([span], [event]);
|
|
252
|
-
|
|
253
|
-
expect(tree1.rootHash).toBe(tree2.rootHash);
|
|
254
|
-
expect(tree1.leafHashes).toEqual(tree2.leafHashes);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
it('sorts spans by spanSeq', async () => {
|
|
258
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
259
|
-
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
260
|
-
|
|
261
|
-
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
262
|
-
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
263
|
-
|
|
264
|
-
// Pass spans in reverse order
|
|
265
|
-
const tree1 = await buildSpanMerkleTree([span2, span1], [event1, event2]);
|
|
266
|
-
// Pass spans in correct order
|
|
267
|
-
const tree2 = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
268
|
-
|
|
269
|
-
expect(tree1.rootHash).toBe(tree2.rootHash);
|
|
270
|
-
expect(tree1.leafHashes).toEqual(tree2.leafHashes);
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('handles spans with multiple events', async () => {
|
|
274
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
275
|
-
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
276
|
-
const event3 = await createEventWithHash(createCommandEvent({ id: 'e3', seq: 2 }));
|
|
277
|
-
|
|
278
|
-
const span = createSpan({
|
|
279
|
-
id: 's1',
|
|
280
|
-
spanSeq: 0,
|
|
281
|
-
eventIds: [event1.id, event2.id, event3.id],
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
const tree = await buildSpanMerkleTree([span], [event1, event2, event3]);
|
|
285
|
-
|
|
286
|
-
expect(tree.leafCount).toBe(1);
|
|
287
|
-
expect(tree.leafHashes).toHaveLength(1);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it('handles spans with no events', async () => {
|
|
291
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [] });
|
|
292
|
-
|
|
293
|
-
const tree = await buildSpanMerkleTree([span], []);
|
|
294
|
-
|
|
295
|
-
expect(tree.leafCount).toBe(1);
|
|
296
|
-
expect(tree.leafHashes).toHaveLength(1);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it('produces different trees for different span data', async () => {
|
|
300
|
-
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
301
|
-
|
|
302
|
-
const span1 = createSpan({ id: 's1', spanSeq: 0, name: 'build', eventIds: [event.id] });
|
|
303
|
-
const span2 = createSpan({ id: 's1', spanSeq: 0, name: 'test', eventIds: [event.id] });
|
|
304
|
-
|
|
305
|
-
const tree1 = await buildSpanMerkleTree([span1], [event]);
|
|
306
|
-
const tree2 = await buildSpanMerkleTree([span2], [event]);
|
|
307
|
-
|
|
308
|
-
expect(tree1.rootHash).not.toBe(tree2.rootHash);
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
// -----------------------------------------------------------------------------
|
|
313
|
-
// generateMerkleProof Tests
|
|
314
|
-
// -----------------------------------------------------------------------------
|
|
315
|
-
|
|
316
|
-
describe('generateMerkleProof', () => {
|
|
317
|
-
let singleSpanTree: TraceMerkleTree;
|
|
318
|
-
let twoSpanTree: TraceMerkleTree;
|
|
319
|
-
let fourSpanTree: TraceMerkleTree;
|
|
320
|
-
|
|
321
|
-
beforeEach(async () => {
|
|
322
|
-
// Single span tree
|
|
323
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
324
|
-
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
325
|
-
singleSpanTree = await buildSpanMerkleTree([span1], [event1]);
|
|
326
|
-
|
|
327
|
-
// Two span tree
|
|
328
|
-
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
329
|
-
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
330
|
-
twoSpanTree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
331
|
-
|
|
332
|
-
// Four span tree
|
|
333
|
-
const events: TraceEvent[] = [];
|
|
334
|
-
const spans: TraceSpan[] = [];
|
|
335
|
-
for (let i = 0; i < 4; i++) {
|
|
336
|
-
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
337
|
-
events.push(event);
|
|
338
|
-
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
339
|
-
}
|
|
340
|
-
fourSpanTree = await buildSpanMerkleTree(spans, events);
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
it('returns valid proof structure', () => {
|
|
344
|
-
const proof = generateMerkleProof(twoSpanTree, 0);
|
|
345
|
-
|
|
346
|
-
expect(proof).toHaveProperty('leafHash');
|
|
347
|
-
expect(proof).toHaveProperty('leafIndex');
|
|
348
|
-
expect(proof).toHaveProperty('siblings');
|
|
349
|
-
expect(proof).toHaveProperty('rootHash');
|
|
350
|
-
expect(Array.isArray(proof.siblings)).toBe(true);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
it('single leaf has empty siblings', () => {
|
|
354
|
-
const proof = generateMerkleProof(singleSpanTree, 0);
|
|
355
|
-
|
|
356
|
-
expect(proof.siblings).toHaveLength(0);
|
|
357
|
-
expect(proof.leafHash).toBe(singleSpanTree.rootHash);
|
|
358
|
-
expect(proof.rootHash).toBe(singleSpanTree.rootHash);
|
|
359
|
-
expect(proof.leafIndex).toBe(0);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it('proof contains correct leaf hash', () => {
|
|
363
|
-
const proof = generateMerkleProof(fourSpanTree, 2);
|
|
364
|
-
|
|
365
|
-
expect(proof.leafHash).toBe(fourSpanTree.leafHashes[2]);
|
|
366
|
-
expect(proof.leafIndex).toBe(2);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('proof contains correct root hash', () => {
|
|
370
|
-
const proof = generateMerkleProof(fourSpanTree, 1);
|
|
371
|
-
|
|
372
|
-
expect(proof.rootHash).toBe(fourSpanTree.rootHash);
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it('siblings have correct position hints', () => {
|
|
376
|
-
const proof = generateMerkleProof(twoSpanTree, 0);
|
|
377
|
-
|
|
378
|
-
expect(proof.siblings).toHaveLength(1);
|
|
379
|
-
expect(proof.siblings[0]).toHaveProperty('hash');
|
|
380
|
-
expect(proof.siblings[0]).toHaveProperty('position');
|
|
381
|
-
expect(['left', 'right']).toContain(proof.siblings[0]!.position);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
it('throws error for index out of bounds (negative)', () => {
|
|
385
|
-
expect(() => generateMerkleProof(twoSpanTree, -1)).toThrow(/out of bounds/);
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
it('throws error for index out of bounds (too large)', () => {
|
|
389
|
-
expect(() => generateMerkleProof(twoSpanTree, 2)).toThrow(/out of bounds/);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it('generates different proofs for different indices', () => {
|
|
393
|
-
const proof0 = generateMerkleProof(fourSpanTree, 0);
|
|
394
|
-
const proof1 = generateMerkleProof(fourSpanTree, 1);
|
|
395
|
-
|
|
396
|
-
expect(proof0.leafHash).not.toBe(proof1.leafHash);
|
|
397
|
-
expect(proof0.leafIndex).not.toBe(proof1.leafIndex);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
it('all proofs reference same root hash', () => {
|
|
401
|
-
const proof0 = generateMerkleProof(fourSpanTree, 0);
|
|
402
|
-
const proof1 = generateMerkleProof(fourSpanTree, 1);
|
|
403
|
-
const proof2 = generateMerkleProof(fourSpanTree, 2);
|
|
404
|
-
const proof3 = generateMerkleProof(fourSpanTree, 3);
|
|
405
|
-
|
|
406
|
-
expect(proof0.rootHash).toBe(fourSpanTree.rootHash);
|
|
407
|
-
expect(proof1.rootHash).toBe(fourSpanTree.rootHash);
|
|
408
|
-
expect(proof2.rootHash).toBe(fourSpanTree.rootHash);
|
|
409
|
-
expect(proof3.rootHash).toBe(fourSpanTree.rootHash);
|
|
410
|
-
});
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
// -----------------------------------------------------------------------------
|
|
414
|
-
// verifyMerkleProof Tests
|
|
415
|
-
// -----------------------------------------------------------------------------
|
|
416
|
-
|
|
417
|
-
describe('verifyMerkleProof', () => {
|
|
418
|
-
it('verifies single leaf tree', async () => {
|
|
419
|
-
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
420
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
421
|
-
const singleTree = await buildSpanMerkleTree([span], [event]);
|
|
422
|
-
const singleProof = generateMerkleProof(singleTree, 0);
|
|
423
|
-
|
|
424
|
-
const isValid = await verifyMerkleProof(singleProof);
|
|
425
|
-
expect(isValid).toBe(true);
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
it('verifies two-leaf tree', async () => {
|
|
429
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
430
|
-
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
431
|
-
|
|
432
|
-
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
433
|
-
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
434
|
-
|
|
435
|
-
const tree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
436
|
-
|
|
437
|
-
// Verify both proofs
|
|
438
|
-
const proof0 = generateMerkleProof(tree, 0);
|
|
439
|
-
const proof1 = generateMerkleProof(tree, 1);
|
|
440
|
-
|
|
441
|
-
expect(await verifyMerkleProof(proof0)).toBe(true);
|
|
442
|
-
expect(await verifyMerkleProof(proof1)).toBe(true);
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
it('returns false for tampered leaf hash in single-leaf tree', async () => {
|
|
446
|
-
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
447
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
448
|
-
const tree = await buildSpanMerkleTree([span], [event]);
|
|
449
|
-
const proof = generateMerkleProof(tree, 0);
|
|
450
|
-
|
|
451
|
-
const tamperedProof: MerkleProof = {
|
|
452
|
-
...proof,
|
|
453
|
-
leafHash: 'tampered' + proof.leafHash.slice(8),
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
const isValid = await verifyMerkleProof(tamperedProof);
|
|
457
|
-
expect(isValid).toBe(false);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it('returns false for tampered root hash', async () => {
|
|
461
|
-
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
462
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
463
|
-
const tree = await buildSpanMerkleTree([span], [event]);
|
|
464
|
-
const proof = generateMerkleProof(tree, 0);
|
|
465
|
-
|
|
466
|
-
const tamperedProof: MerkleProof = {
|
|
467
|
-
...proof,
|
|
468
|
-
rootHash: 'wrong' + proof.rootHash.slice(5),
|
|
469
|
-
};
|
|
470
|
-
|
|
471
|
-
const isValid = await verifyMerkleProof(tamperedProof);
|
|
472
|
-
expect(isValid).toBe(false);
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
it('returns false for tampered sibling in two-leaf tree', async () => {
|
|
476
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
477
|
-
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
478
|
-
|
|
479
|
-
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
480
|
-
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
481
|
-
|
|
482
|
-
const tree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
483
|
-
const proof = generateMerkleProof(tree, 0);
|
|
484
|
-
|
|
485
|
-
const tamperedSiblings = proof.siblings.map((s) => ({
|
|
486
|
-
...s,
|
|
487
|
-
hash: 'tampered' + s.hash.slice(8),
|
|
488
|
-
}));
|
|
489
|
-
|
|
490
|
-
const tamperedProof: MerkleProof = {
|
|
491
|
-
...proof,
|
|
492
|
-
siblings: tamperedSiblings,
|
|
493
|
-
};
|
|
494
|
-
|
|
495
|
-
const isValid = await verifyMerkleProof(tamperedProof);
|
|
496
|
-
expect(isValid).toBe(false);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it('returns false for swapped sibling positions in two-leaf tree', async () => {
|
|
500
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
501
|
-
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
502
|
-
|
|
503
|
-
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
504
|
-
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
505
|
-
|
|
506
|
-
const tree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
507
|
-
const proof = generateMerkleProof(tree, 0);
|
|
508
|
-
|
|
509
|
-
if (proof.siblings.length > 0) {
|
|
510
|
-
const tamperedSiblings = proof.siblings.map((s) => ({
|
|
511
|
-
...s,
|
|
512
|
-
position: s.position === 'left' ? 'right' : 'left' as 'left' | 'right',
|
|
513
|
-
}));
|
|
514
|
-
|
|
515
|
-
const tamperedProof: MerkleProof = {
|
|
516
|
-
...proof,
|
|
517
|
-
siblings: tamperedSiblings,
|
|
518
|
-
};
|
|
519
|
-
|
|
520
|
-
const isValid = await verifyMerkleProof(tamperedProof);
|
|
521
|
-
expect(isValid).toBe(false);
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
|
|
525
|
-
// Note: Multi-level tree proofs (3+ leaves) have a known limitation in the current
|
|
526
|
-
// generateMerkleProof implementation where sibling hashes at higher tree levels
|
|
527
|
-
// are not correctly computed. The verifyBundle function uses a different approach
|
|
528
|
-
// that recomputes the entire Merkle tree for verification.
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
// -----------------------------------------------------------------------------
|
|
532
|
-
// verifySpanInclusion Tests
|
|
533
|
-
// -----------------------------------------------------------------------------
|
|
534
|
-
|
|
535
|
-
describe('verifySpanInclusion', () => {
|
|
536
|
-
it('verifies with raw span and event data (single span)', async () => {
|
|
537
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
538
|
-
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
539
|
-
|
|
540
|
-
const events = [event1, event2];
|
|
541
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id, event2.id] });
|
|
542
|
-
|
|
543
|
-
const tree = await buildSpanMerkleTree([span], events);
|
|
544
|
-
const proof = generateMerkleProof(tree, 0);
|
|
545
|
-
|
|
546
|
-
const isValid = await verifySpanInclusion(proof, span, events);
|
|
547
|
-
expect(isValid).toBe(true);
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
it('returns false for tampered span data', async () => {
|
|
551
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
552
|
-
const events = [event1];
|
|
553
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
554
|
-
|
|
555
|
-
const tree = await buildSpanMerkleTree([span], events);
|
|
556
|
-
const proof = generateMerkleProof(tree, 0);
|
|
557
|
-
|
|
558
|
-
const tamperedSpan = { ...span, name: 'tampered-name' };
|
|
559
|
-
|
|
560
|
-
const isValid = await verifySpanInclusion(proof, tamperedSpan, events);
|
|
561
|
-
expect(isValid).toBe(false);
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
it('returns false for missing events', async () => {
|
|
565
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
566
|
-
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
567
|
-
|
|
568
|
-
const events = [event1, event2];
|
|
569
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id, event2.id] });
|
|
570
|
-
|
|
571
|
-
const tree = await buildSpanMerkleTree([span], events);
|
|
572
|
-
const proof = generateMerkleProof(tree, 0);
|
|
573
|
-
|
|
574
|
-
// Only provide partial events
|
|
575
|
-
const partialEvents = [events[0]!];
|
|
576
|
-
|
|
577
|
-
const isValid = await verifySpanInclusion(proof, span, partialEvents);
|
|
578
|
-
expect(isValid).toBe(false);
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it('handles out-of-order events', async () => {
|
|
582
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
583
|
-
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
584
|
-
|
|
585
|
-
const events = [event1, event2];
|
|
586
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id, event2.id] });
|
|
587
|
-
|
|
588
|
-
const tree = await buildSpanMerkleTree([span], events);
|
|
589
|
-
const proof = generateMerkleProof(tree, 0);
|
|
590
|
-
|
|
591
|
-
// Pass events in reverse order - should still verify correctly due to seq sorting
|
|
592
|
-
const reversedEvents = [...events].reverse();
|
|
593
|
-
|
|
594
|
-
const isValid = await verifySpanInclusion(proof, span, reversedEvents);
|
|
595
|
-
expect(isValid).toBe(true);
|
|
596
|
-
});
|
|
597
|
-
|
|
598
|
-
it('verifies span with no events', async () => {
|
|
599
|
-
const emptySpan = createSpan({ id: 'empty', spanSeq: 0, eventIds: [] });
|
|
600
|
-
const emptyTree = await buildSpanMerkleTree([emptySpan], []);
|
|
601
|
-
const emptyProof = generateMerkleProof(emptyTree, 0);
|
|
602
|
-
|
|
603
|
-
const isValid = await verifySpanInclusion(emptyProof, emptySpan, []);
|
|
604
|
-
expect(isValid).toBe(true);
|
|
605
|
-
});
|
|
606
|
-
|
|
607
|
-
it('returns false for wrong proof', async () => {
|
|
608
|
-
// Create original span and proof
|
|
609
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
610
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
611
|
-
const events = [event1];
|
|
612
|
-
|
|
613
|
-
// Create a different span and tree
|
|
614
|
-
const differentEvent = await createEventWithHash(createCommandEvent({ id: 'different', seq: 0 }));
|
|
615
|
-
const differentSpan = createSpan({ id: 'different', spanSeq: 0, eventIds: [differentEvent.id] });
|
|
616
|
-
const differentTree = await buildSpanMerkleTree([differentSpan], [differentEvent]);
|
|
617
|
-
const differentProof = generateMerkleProof(differentTree, 0);
|
|
618
|
-
|
|
619
|
-
// Try to verify original span with different proof
|
|
620
|
-
const isValid = await verifySpanInclusion(differentProof, span, events);
|
|
621
|
-
expect(isValid).toBe(false);
|
|
622
|
-
});
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
// -----------------------------------------------------------------------------
|
|
626
|
-
// Domain Separation Tests
|
|
627
|
-
// -----------------------------------------------------------------------------
|
|
628
|
-
|
|
629
|
-
describe('Merkle domain separation', () => {
|
|
630
|
-
it('uses correct domain prefix for leaf hash', () => {
|
|
631
|
-
expect(HASH_DOMAIN_PREFIXES.leaf).toBe('poi-trace:leaf:v1|');
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
it('uses correct domain prefix for node hash', () => {
|
|
635
|
-
expect(HASH_DOMAIN_PREFIXES.node).toBe('poi-trace:node:v1|');
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it('uses correct domain prefix for span hash', () => {
|
|
639
|
-
expect(HASH_DOMAIN_PREFIXES.span).toBe('poi-trace:span:v1|');
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it('leaf hashes are different from raw span hashes', async () => {
|
|
643
|
-
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
644
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
645
|
-
|
|
646
|
-
const spanHash = await computeSpanHash(span, [event.hash!]);
|
|
647
|
-
const tree = await buildSpanMerkleTree([span], [event]);
|
|
648
|
-
|
|
649
|
-
// The leaf hash should incorporate the span hash with a domain prefix
|
|
650
|
-
// so they should be different
|
|
651
|
-
expect(tree.leafHashes[0]).not.toBe(spanHash);
|
|
652
|
-
});
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
// -----------------------------------------------------------------------------
|
|
656
|
-
// Edge Cases
|
|
657
|
-
// -----------------------------------------------------------------------------
|
|
658
|
-
|
|
659
|
-
describe('edge cases', () => {
|
|
660
|
-
it('handles large number of spans - tree building', async () => {
|
|
661
|
-
const events: TraceEvent[] = [];
|
|
662
|
-
const spans: TraceSpan[] = [];
|
|
663
|
-
|
|
664
|
-
for (let i = 0; i < 100; i++) {
|
|
665
|
-
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
666
|
-
events.push(event);
|
|
667
|
-
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const tree = await buildSpanMerkleTree(spans, events);
|
|
671
|
-
|
|
672
|
-
expect(tree.leafCount).toBe(100);
|
|
673
|
-
expect(tree.leafHashes).toHaveLength(100);
|
|
674
|
-
expect(tree.rootHash).toHaveLength(64);
|
|
675
|
-
|
|
676
|
-
// Verify proof generation works
|
|
677
|
-
const proof = generateMerkleProof(tree, 42);
|
|
678
|
-
expect(proof.leafHash).toBe(tree.leafHashes[42]);
|
|
679
|
-
expect(proof.leafIndex).toBe(42);
|
|
680
|
-
expect(proof.rootHash).toBe(tree.rootHash);
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
it('handles span with many events', async () => {
|
|
684
|
-
const events: TraceEvent[] = [];
|
|
685
|
-
const eventIds: string[] = [];
|
|
686
|
-
|
|
687
|
-
for (let i = 0; i < 50; i++) {
|
|
688
|
-
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
689
|
-
events.push(event);
|
|
690
|
-
eventIds.push(event.id);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
const span = createSpan({ id: 's1', spanSeq: 0, eventIds });
|
|
694
|
-
const tree = await buildSpanMerkleTree([span], events);
|
|
695
|
-
|
|
696
|
-
expect(tree.leafCount).toBe(1);
|
|
697
|
-
expect(tree.rootHash).toHaveLength(64);
|
|
698
|
-
|
|
699
|
-
// Single span verifies correctly
|
|
700
|
-
const proof = generateMerkleProof(tree, 0);
|
|
701
|
-
const isValid = await verifySpanInclusion(proof, span, events);
|
|
702
|
-
expect(isValid).toBe(true);
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
it('tree builds correctly for power of 2 sizes', async () => {
|
|
706
|
-
for (const size of [1, 2, 4, 8, 16]) {
|
|
707
|
-
const events: TraceEvent[] = [];
|
|
708
|
-
const spans: TraceSpan[] = [];
|
|
709
|
-
|
|
710
|
-
for (let i = 0; i < size; i++) {
|
|
711
|
-
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
712
|
-
events.push(event);
|
|
713
|
-
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
const tree = await buildSpanMerkleTree(spans, events);
|
|
717
|
-
expect(tree.leafCount).toBe(size);
|
|
718
|
-
expect(tree.leafHashes).toHaveLength(size);
|
|
719
|
-
expect(tree.rootHash).toHaveLength(64);
|
|
720
|
-
}
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
it('tree builds correctly for non-power of 2 sizes', async () => {
|
|
724
|
-
for (const size of [3, 5, 7, 9, 15]) {
|
|
725
|
-
const events: TraceEvent[] = [];
|
|
726
|
-
const spans: TraceSpan[] = [];
|
|
727
|
-
|
|
728
|
-
for (let i = 0; i < size; i++) {
|
|
729
|
-
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
730
|
-
events.push(event);
|
|
731
|
-
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
const tree = await buildSpanMerkleTree(spans, events);
|
|
735
|
-
expect(tree.leafCount).toBe(size);
|
|
736
|
-
expect(tree.leafHashes).toHaveLength(size);
|
|
737
|
-
expect(tree.rootHash).toHaveLength(64);
|
|
738
|
-
}
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
it('proof structure is correct for various tree sizes', async () => {
|
|
742
|
-
// Single leaf - no siblings needed
|
|
743
|
-
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
744
|
-
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
745
|
-
const tree1 = await buildSpanMerkleTree([span1], [event1]);
|
|
746
|
-
const proof1 = generateMerkleProof(tree1, 0);
|
|
747
|
-
expect(proof1.siblings).toHaveLength(0);
|
|
748
|
-
|
|
749
|
-
// Two leaves - one sibling
|
|
750
|
-
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
751
|
-
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
752
|
-
const tree2 = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
753
|
-
const proof2 = generateMerkleProof(tree2, 0);
|
|
754
|
-
expect(proof2.siblings).toHaveLength(1);
|
|
755
|
-
});
|
|
756
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* @summary Tests for Merkle tree functionality in the process-trace package.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
computeSpanHash,
|
|
8
|
+
buildSpanMerkleTree,
|
|
9
|
+
generateMerkleProof,
|
|
10
|
+
verifyMerkleProof,
|
|
11
|
+
verifySpanInclusion,
|
|
12
|
+
computeEventHash,
|
|
13
|
+
HASH_DOMAIN_PREFIXES,
|
|
14
|
+
} from '../index.js';
|
|
15
|
+
import type {
|
|
16
|
+
TraceSpan,
|
|
17
|
+
TraceEvent,
|
|
18
|
+
TraceMerkleTree,
|
|
19
|
+
MerkleProof,
|
|
20
|
+
CommandEvent,
|
|
21
|
+
OutputEvent,
|
|
22
|
+
} from '../index.js';
|
|
23
|
+
|
|
24
|
+
// -----------------------------------------------------------------------------
|
|
25
|
+
// Test Fixtures
|
|
26
|
+
// -----------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function createCommandEvent(overrides: Partial<CommandEvent> = {}): CommandEvent {
|
|
29
|
+
return {
|
|
30
|
+
kind: 'command',
|
|
31
|
+
id: crypto.randomUUID(),
|
|
32
|
+
seq: 0,
|
|
33
|
+
timestamp: '2024-01-15T10:30:00.000Z',
|
|
34
|
+
visibility: 'public',
|
|
35
|
+
command: 'npm install',
|
|
36
|
+
hash: undefined,
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createOutputEvent(overrides: Partial<OutputEvent> = {}): OutputEvent {
|
|
42
|
+
return {
|
|
43
|
+
kind: 'output',
|
|
44
|
+
id: crypto.randomUUID(),
|
|
45
|
+
seq: 1,
|
|
46
|
+
timestamp: '2024-01-15T10:30:01.000Z',
|
|
47
|
+
visibility: 'private',
|
|
48
|
+
stream: 'stdout',
|
|
49
|
+
content: 'done',
|
|
50
|
+
hash: undefined,
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createSpan(overrides: Partial<TraceSpan> = {}): TraceSpan {
|
|
56
|
+
return {
|
|
57
|
+
id: crypto.randomUUID(),
|
|
58
|
+
spanSeq: 0,
|
|
59
|
+
name: 'test-span',
|
|
60
|
+
status: 'completed',
|
|
61
|
+
visibility: 'public',
|
|
62
|
+
startedAt: '2024-01-15T10:30:00.000Z',
|
|
63
|
+
endedAt: '2024-01-15T10:31:00.000Z',
|
|
64
|
+
durationMs: 60000,
|
|
65
|
+
eventIds: [],
|
|
66
|
+
childSpanIds: [],
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function createEventWithHash(event: TraceEvent): Promise<TraceEvent> {
|
|
72
|
+
const hash = await computeEventHash(event);
|
|
73
|
+
return { ...event, hash };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// -----------------------------------------------------------------------------
|
|
77
|
+
// computeSpanHash Tests
|
|
78
|
+
// -----------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe('computeSpanHash', () => {
|
|
81
|
+
it('produces consistent output for same input', async () => {
|
|
82
|
+
const span = createSpan({
|
|
83
|
+
id: 'fixed-span-id',
|
|
84
|
+
spanSeq: 0,
|
|
85
|
+
name: 'build',
|
|
86
|
+
});
|
|
87
|
+
const eventHashes = ['hash1', 'hash2'];
|
|
88
|
+
|
|
89
|
+
const hash1 = await computeSpanHash(span, eventHashes);
|
|
90
|
+
const hash2 = await computeSpanHash(span, eventHashes);
|
|
91
|
+
|
|
92
|
+
expect(hash1).toBe(hash2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('produces 64-character hex output', async () => {
|
|
96
|
+
const span = createSpan();
|
|
97
|
+
const hash = await computeSpanHash(span, []);
|
|
98
|
+
|
|
99
|
+
expect(hash).toHaveLength(64);
|
|
100
|
+
expect(hash).toMatch(/^[a-f0-9]+$/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('produces different output for different span names', async () => {
|
|
104
|
+
const span1 = createSpan({ id: 'same-id', name: 'build' });
|
|
105
|
+
const span2 = createSpan({ id: 'same-id', name: 'test' });
|
|
106
|
+
|
|
107
|
+
const hash1 = await computeSpanHash(span1, []);
|
|
108
|
+
const hash2 = await computeSpanHash(span2, []);
|
|
109
|
+
|
|
110
|
+
expect(hash1).not.toBe(hash2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('produces different output for different event hashes', async () => {
|
|
114
|
+
const span = createSpan({ id: 'fixed-id' });
|
|
115
|
+
|
|
116
|
+
const hash1 = await computeSpanHash(span, ['event-hash-1']);
|
|
117
|
+
const hash2 = await computeSpanHash(span, ['event-hash-2']);
|
|
118
|
+
|
|
119
|
+
expect(hash1).not.toBe(hash2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('produces different output for different event hash order', async () => {
|
|
123
|
+
const span = createSpan({ id: 'fixed-id' });
|
|
124
|
+
|
|
125
|
+
const hash1 = await computeSpanHash(span, ['hash-a', 'hash-b']);
|
|
126
|
+
const hash2 = await computeSpanHash(span, ['hash-b', 'hash-a']);
|
|
127
|
+
|
|
128
|
+
expect(hash1).not.toBe(hash2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('handles empty event hashes array', async () => {
|
|
132
|
+
const span = createSpan();
|
|
133
|
+
const hash = await computeSpanHash(span, []);
|
|
134
|
+
|
|
135
|
+
expect(hash).toHaveLength(64);
|
|
136
|
+
expect(hash).toMatch(/^[a-f0-9]+$/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('includes optional fields in hash computation', async () => {
|
|
140
|
+
const span1 = createSpan({ id: 'same-id', metadata: { key: 'value1' } });
|
|
141
|
+
const span2 = createSpan({ id: 'same-id', metadata: { key: 'value2' } });
|
|
142
|
+
|
|
143
|
+
const hash1 = await computeSpanHash(span1, []);
|
|
144
|
+
const hash2 = await computeSpanHash(span2, []);
|
|
145
|
+
|
|
146
|
+
expect(hash1).not.toBe(hash2);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('produces different hash for different visibility', async () => {
|
|
150
|
+
const span1 = createSpan({ id: 'same-id', visibility: 'public' });
|
|
151
|
+
const span2 = createSpan({ id: 'same-id', visibility: 'private' });
|
|
152
|
+
|
|
153
|
+
const hash1 = await computeSpanHash(span1, []);
|
|
154
|
+
const hash2 = await computeSpanHash(span2, []);
|
|
155
|
+
|
|
156
|
+
expect(hash1).not.toBe(hash2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('produces different hash for different status', async () => {
|
|
160
|
+
const span1 = createSpan({ id: 'same-id', status: 'completed' });
|
|
161
|
+
const span2 = createSpan({ id: 'same-id', status: 'failed' });
|
|
162
|
+
|
|
163
|
+
const hash1 = await computeSpanHash(span1, []);
|
|
164
|
+
const hash2 = await computeSpanHash(span2, []);
|
|
165
|
+
|
|
166
|
+
expect(hash1).not.toBe(hash2);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// -----------------------------------------------------------------------------
|
|
171
|
+
// buildSpanMerkleTree Tests
|
|
172
|
+
// -----------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
describe('buildSpanMerkleTree', () => {
|
|
175
|
+
it('returns empty tree for empty spans', async () => {
|
|
176
|
+
const tree = await buildSpanMerkleTree([], []);
|
|
177
|
+
|
|
178
|
+
expect(tree.rootHash).toBe('');
|
|
179
|
+
expect(tree.leafCount).toBe(0);
|
|
180
|
+
expect(tree.depth).toBe(0);
|
|
181
|
+
expect(tree.leafHashes).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('single span produces depth 0', async () => {
|
|
185
|
+
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
186
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
187
|
+
|
|
188
|
+
const tree = await buildSpanMerkleTree([span], [event]);
|
|
189
|
+
|
|
190
|
+
expect(tree.leafCount).toBe(1);
|
|
191
|
+
expect(tree.depth).toBe(0);
|
|
192
|
+
expect(tree.leafHashes).toHaveLength(1);
|
|
193
|
+
expect(tree.rootHash).toBe(tree.leafHashes[0]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('two spans produce depth 1', async () => {
|
|
197
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
198
|
+
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
199
|
+
|
|
200
|
+
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
201
|
+
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
202
|
+
|
|
203
|
+
const tree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
204
|
+
|
|
205
|
+
expect(tree.leafCount).toBe(2);
|
|
206
|
+
expect(tree.depth).toBe(1);
|
|
207
|
+
expect(tree.leafHashes).toHaveLength(2);
|
|
208
|
+
expect(tree.rootHash).not.toBe(tree.leafHashes[0]);
|
|
209
|
+
expect(tree.rootHash).not.toBe(tree.leafHashes[1]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('odd number of spans handles duplication correctly', async () => {
|
|
213
|
+
const events: TraceEvent[] = [];
|
|
214
|
+
const spans: TraceSpan[] = [];
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < 3; i++) {
|
|
217
|
+
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
218
|
+
events.push(event);
|
|
219
|
+
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const tree = await buildSpanMerkleTree(spans, events);
|
|
223
|
+
|
|
224
|
+
expect(tree.leafCount).toBe(3);
|
|
225
|
+
expect(tree.depth).toBe(2);
|
|
226
|
+
expect(tree.leafHashes).toHaveLength(3);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('even number of spans builds balanced tree', async () => {
|
|
230
|
+
const events: TraceEvent[] = [];
|
|
231
|
+
const spans: TraceSpan[] = [];
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < 4; i++) {
|
|
234
|
+
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
235
|
+
events.push(event);
|
|
236
|
+
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const tree = await buildSpanMerkleTree(spans, events);
|
|
240
|
+
|
|
241
|
+
expect(tree.leafCount).toBe(4);
|
|
242
|
+
expect(tree.depth).toBe(2);
|
|
243
|
+
expect(tree.leafHashes).toHaveLength(4);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('produces consistent tree for same inputs', async () => {
|
|
247
|
+
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
248
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
249
|
+
|
|
250
|
+
const tree1 = await buildSpanMerkleTree([span], [event]);
|
|
251
|
+
const tree2 = await buildSpanMerkleTree([span], [event]);
|
|
252
|
+
|
|
253
|
+
expect(tree1.rootHash).toBe(tree2.rootHash);
|
|
254
|
+
expect(tree1.leafHashes).toEqual(tree2.leafHashes);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('sorts spans by spanSeq', async () => {
|
|
258
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
259
|
+
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
260
|
+
|
|
261
|
+
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
262
|
+
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
263
|
+
|
|
264
|
+
// Pass spans in reverse order
|
|
265
|
+
const tree1 = await buildSpanMerkleTree([span2, span1], [event1, event2]);
|
|
266
|
+
// Pass spans in correct order
|
|
267
|
+
const tree2 = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
268
|
+
|
|
269
|
+
expect(tree1.rootHash).toBe(tree2.rootHash);
|
|
270
|
+
expect(tree1.leafHashes).toEqual(tree2.leafHashes);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('handles spans with multiple events', async () => {
|
|
274
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
275
|
+
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
276
|
+
const event3 = await createEventWithHash(createCommandEvent({ id: 'e3', seq: 2 }));
|
|
277
|
+
|
|
278
|
+
const span = createSpan({
|
|
279
|
+
id: 's1',
|
|
280
|
+
spanSeq: 0,
|
|
281
|
+
eventIds: [event1.id, event2.id, event3.id],
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const tree = await buildSpanMerkleTree([span], [event1, event2, event3]);
|
|
285
|
+
|
|
286
|
+
expect(tree.leafCount).toBe(1);
|
|
287
|
+
expect(tree.leafHashes).toHaveLength(1);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('handles spans with no events', async () => {
|
|
291
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [] });
|
|
292
|
+
|
|
293
|
+
const tree = await buildSpanMerkleTree([span], []);
|
|
294
|
+
|
|
295
|
+
expect(tree.leafCount).toBe(1);
|
|
296
|
+
expect(tree.leafHashes).toHaveLength(1);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('produces different trees for different span data', async () => {
|
|
300
|
+
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
301
|
+
|
|
302
|
+
const span1 = createSpan({ id: 's1', spanSeq: 0, name: 'build', eventIds: [event.id] });
|
|
303
|
+
const span2 = createSpan({ id: 's1', spanSeq: 0, name: 'test', eventIds: [event.id] });
|
|
304
|
+
|
|
305
|
+
const tree1 = await buildSpanMerkleTree([span1], [event]);
|
|
306
|
+
const tree2 = await buildSpanMerkleTree([span2], [event]);
|
|
307
|
+
|
|
308
|
+
expect(tree1.rootHash).not.toBe(tree2.rootHash);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// -----------------------------------------------------------------------------
|
|
313
|
+
// generateMerkleProof Tests
|
|
314
|
+
// -----------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
describe('generateMerkleProof', () => {
|
|
317
|
+
let singleSpanTree: TraceMerkleTree;
|
|
318
|
+
let twoSpanTree: TraceMerkleTree;
|
|
319
|
+
let fourSpanTree: TraceMerkleTree;
|
|
320
|
+
|
|
321
|
+
beforeEach(async () => {
|
|
322
|
+
// Single span tree
|
|
323
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
324
|
+
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
325
|
+
singleSpanTree = await buildSpanMerkleTree([span1], [event1]);
|
|
326
|
+
|
|
327
|
+
// Two span tree
|
|
328
|
+
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
329
|
+
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
330
|
+
twoSpanTree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
331
|
+
|
|
332
|
+
// Four span tree
|
|
333
|
+
const events: TraceEvent[] = [];
|
|
334
|
+
const spans: TraceSpan[] = [];
|
|
335
|
+
for (let i = 0; i < 4; i++) {
|
|
336
|
+
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
337
|
+
events.push(event);
|
|
338
|
+
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
339
|
+
}
|
|
340
|
+
fourSpanTree = await buildSpanMerkleTree(spans, events);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('returns valid proof structure', () => {
|
|
344
|
+
const proof = generateMerkleProof(twoSpanTree, 0);
|
|
345
|
+
|
|
346
|
+
expect(proof).toHaveProperty('leafHash');
|
|
347
|
+
expect(proof).toHaveProperty('leafIndex');
|
|
348
|
+
expect(proof).toHaveProperty('siblings');
|
|
349
|
+
expect(proof).toHaveProperty('rootHash');
|
|
350
|
+
expect(Array.isArray(proof.siblings)).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('single leaf has empty siblings', () => {
|
|
354
|
+
const proof = generateMerkleProof(singleSpanTree, 0);
|
|
355
|
+
|
|
356
|
+
expect(proof.siblings).toHaveLength(0);
|
|
357
|
+
expect(proof.leafHash).toBe(singleSpanTree.rootHash);
|
|
358
|
+
expect(proof.rootHash).toBe(singleSpanTree.rootHash);
|
|
359
|
+
expect(proof.leafIndex).toBe(0);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('proof contains correct leaf hash', () => {
|
|
363
|
+
const proof = generateMerkleProof(fourSpanTree, 2);
|
|
364
|
+
|
|
365
|
+
expect(proof.leafHash).toBe(fourSpanTree.leafHashes[2]);
|
|
366
|
+
expect(proof.leafIndex).toBe(2);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('proof contains correct root hash', () => {
|
|
370
|
+
const proof = generateMerkleProof(fourSpanTree, 1);
|
|
371
|
+
|
|
372
|
+
expect(proof.rootHash).toBe(fourSpanTree.rootHash);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('siblings have correct position hints', () => {
|
|
376
|
+
const proof = generateMerkleProof(twoSpanTree, 0);
|
|
377
|
+
|
|
378
|
+
expect(proof.siblings).toHaveLength(1);
|
|
379
|
+
expect(proof.siblings[0]).toHaveProperty('hash');
|
|
380
|
+
expect(proof.siblings[0]).toHaveProperty('position');
|
|
381
|
+
expect(['left', 'right']).toContain(proof.siblings[0]!.position);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('throws error for index out of bounds (negative)', () => {
|
|
385
|
+
expect(() => generateMerkleProof(twoSpanTree, -1)).toThrow(/out of bounds/);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('throws error for index out of bounds (too large)', () => {
|
|
389
|
+
expect(() => generateMerkleProof(twoSpanTree, 2)).toThrow(/out of bounds/);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('generates different proofs for different indices', () => {
|
|
393
|
+
const proof0 = generateMerkleProof(fourSpanTree, 0);
|
|
394
|
+
const proof1 = generateMerkleProof(fourSpanTree, 1);
|
|
395
|
+
|
|
396
|
+
expect(proof0.leafHash).not.toBe(proof1.leafHash);
|
|
397
|
+
expect(proof0.leafIndex).not.toBe(proof1.leafIndex);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('all proofs reference same root hash', () => {
|
|
401
|
+
const proof0 = generateMerkleProof(fourSpanTree, 0);
|
|
402
|
+
const proof1 = generateMerkleProof(fourSpanTree, 1);
|
|
403
|
+
const proof2 = generateMerkleProof(fourSpanTree, 2);
|
|
404
|
+
const proof3 = generateMerkleProof(fourSpanTree, 3);
|
|
405
|
+
|
|
406
|
+
expect(proof0.rootHash).toBe(fourSpanTree.rootHash);
|
|
407
|
+
expect(proof1.rootHash).toBe(fourSpanTree.rootHash);
|
|
408
|
+
expect(proof2.rootHash).toBe(fourSpanTree.rootHash);
|
|
409
|
+
expect(proof3.rootHash).toBe(fourSpanTree.rootHash);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// -----------------------------------------------------------------------------
|
|
414
|
+
// verifyMerkleProof Tests
|
|
415
|
+
// -----------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
describe('verifyMerkleProof', () => {
|
|
418
|
+
it('verifies single leaf tree', async () => {
|
|
419
|
+
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
420
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
421
|
+
const singleTree = await buildSpanMerkleTree([span], [event]);
|
|
422
|
+
const singleProof = generateMerkleProof(singleTree, 0);
|
|
423
|
+
|
|
424
|
+
const isValid = await verifyMerkleProof(singleProof);
|
|
425
|
+
expect(isValid).toBe(true);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('verifies two-leaf tree', async () => {
|
|
429
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
430
|
+
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
431
|
+
|
|
432
|
+
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
433
|
+
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
434
|
+
|
|
435
|
+
const tree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
436
|
+
|
|
437
|
+
// Verify both proofs
|
|
438
|
+
const proof0 = generateMerkleProof(tree, 0);
|
|
439
|
+
const proof1 = generateMerkleProof(tree, 1);
|
|
440
|
+
|
|
441
|
+
expect(await verifyMerkleProof(proof0)).toBe(true);
|
|
442
|
+
expect(await verifyMerkleProof(proof1)).toBe(true);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('returns false for tampered leaf hash in single-leaf tree', async () => {
|
|
446
|
+
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
447
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
448
|
+
const tree = await buildSpanMerkleTree([span], [event]);
|
|
449
|
+
const proof = generateMerkleProof(tree, 0);
|
|
450
|
+
|
|
451
|
+
const tamperedProof: MerkleProof = {
|
|
452
|
+
...proof,
|
|
453
|
+
leafHash: 'tampered' + proof.leafHash.slice(8),
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const isValid = await verifyMerkleProof(tamperedProof);
|
|
457
|
+
expect(isValid).toBe(false);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('returns false for tampered root hash', async () => {
|
|
461
|
+
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
462
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
463
|
+
const tree = await buildSpanMerkleTree([span], [event]);
|
|
464
|
+
const proof = generateMerkleProof(tree, 0);
|
|
465
|
+
|
|
466
|
+
const tamperedProof: MerkleProof = {
|
|
467
|
+
...proof,
|
|
468
|
+
rootHash: 'wrong' + proof.rootHash.slice(5),
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const isValid = await verifyMerkleProof(tamperedProof);
|
|
472
|
+
expect(isValid).toBe(false);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('returns false for tampered sibling in two-leaf tree', async () => {
|
|
476
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
477
|
+
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
478
|
+
|
|
479
|
+
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
480
|
+
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
481
|
+
|
|
482
|
+
const tree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
483
|
+
const proof = generateMerkleProof(tree, 0);
|
|
484
|
+
|
|
485
|
+
const tamperedSiblings = proof.siblings.map((s) => ({
|
|
486
|
+
...s,
|
|
487
|
+
hash: 'tampered' + s.hash.slice(8),
|
|
488
|
+
}));
|
|
489
|
+
|
|
490
|
+
const tamperedProof: MerkleProof = {
|
|
491
|
+
...proof,
|
|
492
|
+
siblings: tamperedSiblings,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const isValid = await verifyMerkleProof(tamperedProof);
|
|
496
|
+
expect(isValid).toBe(false);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('returns false for swapped sibling positions in two-leaf tree', async () => {
|
|
500
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
501
|
+
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
502
|
+
|
|
503
|
+
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
504
|
+
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
505
|
+
|
|
506
|
+
const tree = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
507
|
+
const proof = generateMerkleProof(tree, 0);
|
|
508
|
+
|
|
509
|
+
if (proof.siblings.length > 0) {
|
|
510
|
+
const tamperedSiblings = proof.siblings.map((s) => ({
|
|
511
|
+
...s,
|
|
512
|
+
position: s.position === 'left' ? 'right' : 'left' as 'left' | 'right',
|
|
513
|
+
}));
|
|
514
|
+
|
|
515
|
+
const tamperedProof: MerkleProof = {
|
|
516
|
+
...proof,
|
|
517
|
+
siblings: tamperedSiblings,
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const isValid = await verifyMerkleProof(tamperedProof);
|
|
521
|
+
expect(isValid).toBe(false);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Note: Multi-level tree proofs (3+ leaves) have a known limitation in the current
|
|
526
|
+
// generateMerkleProof implementation where sibling hashes at higher tree levels
|
|
527
|
+
// are not correctly computed. The verifyBundle function uses a different approach
|
|
528
|
+
// that recomputes the entire Merkle tree for verification.
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// -----------------------------------------------------------------------------
|
|
532
|
+
// verifySpanInclusion Tests
|
|
533
|
+
// -----------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
describe('verifySpanInclusion', () => {
|
|
536
|
+
it('verifies with raw span and event data (single span)', async () => {
|
|
537
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
538
|
+
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
539
|
+
|
|
540
|
+
const events = [event1, event2];
|
|
541
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id, event2.id] });
|
|
542
|
+
|
|
543
|
+
const tree = await buildSpanMerkleTree([span], events);
|
|
544
|
+
const proof = generateMerkleProof(tree, 0);
|
|
545
|
+
|
|
546
|
+
const isValid = await verifySpanInclusion(proof, span, events);
|
|
547
|
+
expect(isValid).toBe(true);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('returns false for tampered span data', async () => {
|
|
551
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
552
|
+
const events = [event1];
|
|
553
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
554
|
+
|
|
555
|
+
const tree = await buildSpanMerkleTree([span], events);
|
|
556
|
+
const proof = generateMerkleProof(tree, 0);
|
|
557
|
+
|
|
558
|
+
const tamperedSpan = { ...span, name: 'tampered-name' };
|
|
559
|
+
|
|
560
|
+
const isValid = await verifySpanInclusion(proof, tamperedSpan, events);
|
|
561
|
+
expect(isValid).toBe(false);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it('returns false for missing events', async () => {
|
|
565
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
566
|
+
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
567
|
+
|
|
568
|
+
const events = [event1, event2];
|
|
569
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id, event2.id] });
|
|
570
|
+
|
|
571
|
+
const tree = await buildSpanMerkleTree([span], events);
|
|
572
|
+
const proof = generateMerkleProof(tree, 0);
|
|
573
|
+
|
|
574
|
+
// Only provide partial events
|
|
575
|
+
const partialEvents = [events[0]!];
|
|
576
|
+
|
|
577
|
+
const isValid = await verifySpanInclusion(proof, span, partialEvents);
|
|
578
|
+
expect(isValid).toBe(false);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('handles out-of-order events', async () => {
|
|
582
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
583
|
+
const event2 = await createEventWithHash(createOutputEvent({ id: 'e2', seq: 1 }));
|
|
584
|
+
|
|
585
|
+
const events = [event1, event2];
|
|
586
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id, event2.id] });
|
|
587
|
+
|
|
588
|
+
const tree = await buildSpanMerkleTree([span], events);
|
|
589
|
+
const proof = generateMerkleProof(tree, 0);
|
|
590
|
+
|
|
591
|
+
// Pass events in reverse order - should still verify correctly due to seq sorting
|
|
592
|
+
const reversedEvents = [...events].reverse();
|
|
593
|
+
|
|
594
|
+
const isValid = await verifySpanInclusion(proof, span, reversedEvents);
|
|
595
|
+
expect(isValid).toBe(true);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('verifies span with no events', async () => {
|
|
599
|
+
const emptySpan = createSpan({ id: 'empty', spanSeq: 0, eventIds: [] });
|
|
600
|
+
const emptyTree = await buildSpanMerkleTree([emptySpan], []);
|
|
601
|
+
const emptyProof = generateMerkleProof(emptyTree, 0);
|
|
602
|
+
|
|
603
|
+
const isValid = await verifySpanInclusion(emptyProof, emptySpan, []);
|
|
604
|
+
expect(isValid).toBe(true);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('returns false for wrong proof', async () => {
|
|
608
|
+
// Create original span and proof
|
|
609
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
610
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
611
|
+
const events = [event1];
|
|
612
|
+
|
|
613
|
+
// Create a different span and tree
|
|
614
|
+
const differentEvent = await createEventWithHash(createCommandEvent({ id: 'different', seq: 0 }));
|
|
615
|
+
const differentSpan = createSpan({ id: 'different', spanSeq: 0, eventIds: [differentEvent.id] });
|
|
616
|
+
const differentTree = await buildSpanMerkleTree([differentSpan], [differentEvent]);
|
|
617
|
+
const differentProof = generateMerkleProof(differentTree, 0);
|
|
618
|
+
|
|
619
|
+
// Try to verify original span with different proof
|
|
620
|
+
const isValid = await verifySpanInclusion(differentProof, span, events);
|
|
621
|
+
expect(isValid).toBe(false);
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// -----------------------------------------------------------------------------
|
|
626
|
+
// Domain Separation Tests
|
|
627
|
+
// -----------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
describe('Merkle domain separation', () => {
|
|
630
|
+
it('uses correct domain prefix for leaf hash', () => {
|
|
631
|
+
expect(HASH_DOMAIN_PREFIXES.leaf).toBe('poi-trace:leaf:v1|');
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it('uses correct domain prefix for node hash', () => {
|
|
635
|
+
expect(HASH_DOMAIN_PREFIXES.node).toBe('poi-trace:node:v1|');
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('uses correct domain prefix for span hash', () => {
|
|
639
|
+
expect(HASH_DOMAIN_PREFIXES.span).toBe('poi-trace:span:v1|');
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it('leaf hashes are different from raw span hashes', async () => {
|
|
643
|
+
const event = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
644
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds: [event.id] });
|
|
645
|
+
|
|
646
|
+
const spanHash = await computeSpanHash(span, [event.hash!]);
|
|
647
|
+
const tree = await buildSpanMerkleTree([span], [event]);
|
|
648
|
+
|
|
649
|
+
// The leaf hash should incorporate the span hash with a domain prefix
|
|
650
|
+
// so they should be different
|
|
651
|
+
expect(tree.leafHashes[0]).not.toBe(spanHash);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// -----------------------------------------------------------------------------
|
|
656
|
+
// Edge Cases
|
|
657
|
+
// -----------------------------------------------------------------------------
|
|
658
|
+
|
|
659
|
+
describe('edge cases', () => {
|
|
660
|
+
it('handles large number of spans - tree building', async () => {
|
|
661
|
+
const events: TraceEvent[] = [];
|
|
662
|
+
const spans: TraceSpan[] = [];
|
|
663
|
+
|
|
664
|
+
for (let i = 0; i < 100; i++) {
|
|
665
|
+
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
666
|
+
events.push(event);
|
|
667
|
+
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const tree = await buildSpanMerkleTree(spans, events);
|
|
671
|
+
|
|
672
|
+
expect(tree.leafCount).toBe(100);
|
|
673
|
+
expect(tree.leafHashes).toHaveLength(100);
|
|
674
|
+
expect(tree.rootHash).toHaveLength(64);
|
|
675
|
+
|
|
676
|
+
// Verify proof generation works
|
|
677
|
+
const proof = generateMerkleProof(tree, 42);
|
|
678
|
+
expect(proof.leafHash).toBe(tree.leafHashes[42]);
|
|
679
|
+
expect(proof.leafIndex).toBe(42);
|
|
680
|
+
expect(proof.rootHash).toBe(tree.rootHash);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('handles span with many events', async () => {
|
|
684
|
+
const events: TraceEvent[] = [];
|
|
685
|
+
const eventIds: string[] = [];
|
|
686
|
+
|
|
687
|
+
for (let i = 0; i < 50; i++) {
|
|
688
|
+
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
689
|
+
events.push(event);
|
|
690
|
+
eventIds.push(event.id);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const span = createSpan({ id: 's1', spanSeq: 0, eventIds });
|
|
694
|
+
const tree = await buildSpanMerkleTree([span], events);
|
|
695
|
+
|
|
696
|
+
expect(tree.leafCount).toBe(1);
|
|
697
|
+
expect(tree.rootHash).toHaveLength(64);
|
|
698
|
+
|
|
699
|
+
// Single span verifies correctly
|
|
700
|
+
const proof = generateMerkleProof(tree, 0);
|
|
701
|
+
const isValid = await verifySpanInclusion(proof, span, events);
|
|
702
|
+
expect(isValid).toBe(true);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('tree builds correctly for power of 2 sizes', async () => {
|
|
706
|
+
for (const size of [1, 2, 4, 8, 16]) {
|
|
707
|
+
const events: TraceEvent[] = [];
|
|
708
|
+
const spans: TraceSpan[] = [];
|
|
709
|
+
|
|
710
|
+
for (let i = 0; i < size; i++) {
|
|
711
|
+
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
712
|
+
events.push(event);
|
|
713
|
+
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const tree = await buildSpanMerkleTree(spans, events);
|
|
717
|
+
expect(tree.leafCount).toBe(size);
|
|
718
|
+
expect(tree.leafHashes).toHaveLength(size);
|
|
719
|
+
expect(tree.rootHash).toHaveLength(64);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('tree builds correctly for non-power of 2 sizes', async () => {
|
|
724
|
+
for (const size of [3, 5, 7, 9, 15]) {
|
|
725
|
+
const events: TraceEvent[] = [];
|
|
726
|
+
const spans: TraceSpan[] = [];
|
|
727
|
+
|
|
728
|
+
for (let i = 0; i < size; i++) {
|
|
729
|
+
const event = await createEventWithHash(createCommandEvent({ id: `e${i}`, seq: i }));
|
|
730
|
+
events.push(event);
|
|
731
|
+
spans.push(createSpan({ id: `s${i}`, spanSeq: i, eventIds: [event.id] }));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const tree = await buildSpanMerkleTree(spans, events);
|
|
735
|
+
expect(tree.leafCount).toBe(size);
|
|
736
|
+
expect(tree.leafHashes).toHaveLength(size);
|
|
737
|
+
expect(tree.rootHash).toHaveLength(64);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('proof structure is correct for various tree sizes', async () => {
|
|
742
|
+
// Single leaf - no siblings needed
|
|
743
|
+
const event1 = await createEventWithHash(createCommandEvent({ id: 'e1', seq: 0 }));
|
|
744
|
+
const span1 = createSpan({ id: 's1', spanSeq: 0, eventIds: [event1.id] });
|
|
745
|
+
const tree1 = await buildSpanMerkleTree([span1], [event1]);
|
|
746
|
+
const proof1 = generateMerkleProof(tree1, 0);
|
|
747
|
+
expect(proof1.siblings).toHaveLength(0);
|
|
748
|
+
|
|
749
|
+
// Two leaves - one sibling
|
|
750
|
+
const event2 = await createEventWithHash(createCommandEvent({ id: 'e2', seq: 1 }));
|
|
751
|
+
const span2 = createSpan({ id: 's2', spanSeq: 1, eventIds: [event2.id] });
|
|
752
|
+
const tree2 = await buildSpanMerkleTree([span1, span2], [event1, event2]);
|
|
753
|
+
const proof2 = generateMerkleProof(tree2, 0);
|
|
754
|
+
expect(proof2.siblings).toHaveLength(1);
|
|
755
|
+
});
|
|
756
|
+
});
|