@bsv/sdk 1.8.0 → 1.8.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.
Files changed (52) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/kvstore/GlobalKVStore.js +420 -0
  3. package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -0
  4. package/dist/cjs/src/kvstore/LocalKVStore.js +6 -6
  5. package/dist/cjs/src/kvstore/LocalKVStore.js.map +1 -1
  6. package/dist/cjs/src/kvstore/kvStoreInterpreter.js +74 -0
  7. package/dist/cjs/src/kvstore/kvStoreInterpreter.js.map +1 -0
  8. package/dist/cjs/src/kvstore/types.js +11 -0
  9. package/dist/cjs/src/kvstore/types.js.map +1 -0
  10. package/dist/cjs/src/overlay-tools/Historian.js +153 -0
  11. package/dist/cjs/src/overlay-tools/Historian.js.map +1 -0
  12. package/dist/cjs/src/script/templates/PushDrop.js +2 -2
  13. package/dist/cjs/src/script/templates/PushDrop.js.map +1 -1
  14. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  15. package/dist/esm/src/kvstore/GlobalKVStore.js +416 -0
  16. package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -0
  17. package/dist/esm/src/kvstore/LocalKVStore.js +6 -6
  18. package/dist/esm/src/kvstore/LocalKVStore.js.map +1 -1
  19. package/dist/esm/src/kvstore/kvStoreInterpreter.js +47 -0
  20. package/dist/esm/src/kvstore/kvStoreInterpreter.js.map +1 -0
  21. package/dist/esm/src/kvstore/types.js +8 -0
  22. package/dist/esm/src/kvstore/types.js.map +1 -0
  23. package/dist/esm/src/overlay-tools/Historian.js +155 -0
  24. package/dist/esm/src/overlay-tools/Historian.js.map +1 -0
  25. package/dist/esm/src/script/templates/PushDrop.js +2 -2
  26. package/dist/esm/src/script/templates/PushDrop.js.map +1 -1
  27. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  28. package/dist/types/src/kvstore/GlobalKVStore.d.ts +129 -0
  29. package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -0
  30. package/dist/types/src/kvstore/kvStoreInterpreter.d.ts +22 -0
  31. package/dist/types/src/kvstore/kvStoreInterpreter.d.ts.map +1 -0
  32. package/dist/types/src/kvstore/types.d.ts +106 -0
  33. package/dist/types/src/kvstore/types.d.ts.map +1 -0
  34. package/dist/types/src/overlay-tools/Historian.d.ts +92 -0
  35. package/dist/types/src/overlay-tools/Historian.d.ts.map +1 -0
  36. package/dist/types/src/script/templates/PushDrop.d.ts +6 -5
  37. package/dist/types/src/script/templates/PushDrop.d.ts.map +1 -1
  38. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  39. package/dist/umd/bundle.js +1 -1
  40. package/dist/umd/bundle.js.map +1 -1
  41. package/docs/reference/script.md +7 -19
  42. package/docs/reference/transaction.md +53 -2
  43. package/package.json +1 -1
  44. package/src/kvstore/GlobalKVStore.ts +478 -0
  45. package/src/kvstore/LocalKVStore.ts +7 -7
  46. package/src/kvstore/__tests/GlobalKVStore.test.ts +965 -0
  47. package/src/kvstore/__tests/LocalKVStore.test.ts +72 -0
  48. package/src/kvstore/kvStoreInterpreter.ts +49 -0
  49. package/src/kvstore/types.ts +114 -0
  50. package/src/overlay-tools/Historian.ts +195 -0
  51. package/src/overlay-tools/__tests/Historian.test.ts +690 -0
  52. package/src/script/templates/PushDrop.ts +6 -5
@@ -0,0 +1,690 @@
1
+ /** eslint-env jest */
2
+ import { Historian, InterpreterFunction } from '../Historian.js'
3
+ import Transaction from '../../transaction/Transaction.js'
4
+ import { TransactionInput, TransactionOutput } from '../../transaction/index.js'
5
+
6
+ // --- Module mocks ------------------------------------------------------------
7
+ jest.mock('../../transaction/Transaction.js')
8
+
9
+ // --- Test constants ----------------------------------------------------------
10
+ const TEST_TXID_1 = '1111111111111111111111111111111111111111111111111111111111111111'
11
+ const TEST_TXID_2 = '2222222222222222222222222222222222222222222222222222222222222222'
12
+ const TEST_TXID_3 = '3333333333333333333333333333333333333333333333333333333333333333'
13
+
14
+ // --- Test types --------------------------------------------------------------
15
+ interface TestValue {
16
+ data: string
17
+ outputIndex?: number
18
+ }
19
+
20
+ interface TestContext {
21
+ filter?: string
22
+ }
23
+
24
+ // --- Helpers ----------------------------------------------------------------
25
+ type MTx = jest.Mocked<InstanceType<typeof Transaction>>
26
+
27
+ function makeMockTx(txid: string, outputs: any[] = [], inputs: any[] = []): MTx {
28
+ return {
29
+ id: jest.fn().mockReturnValue(txid),
30
+ outputs,
31
+ inputs,
32
+ } as any
33
+ }
34
+
35
+ function makeMockOutput(scriptHex?: string): TransactionOutput {
36
+ const hex = scriptHex || '76a914' // Default to P2PKH prefix if no script provided
37
+ const scriptArray = hex.match(/.{2}/g)?.map(byte => parseInt(byte, 16)) || [0x76, 0xa9, 0x14]
38
+
39
+ return {
40
+ satoshis: 1,
41
+ lockingScript: {
42
+ toHex: jest.fn().mockReturnValue(hex),
43
+ toArray: jest.fn().mockReturnValue(scriptArray),
44
+ },
45
+ } as any
46
+ }
47
+
48
+ function makeMockInput(sourceTransaction?: MTx): TransactionInput {
49
+ return {
50
+ sourceTransaction,
51
+ sourceTXID: sourceTransaction?.id('hex'),
52
+ sourceOutputIndex: 0,
53
+ } as any
54
+ }
55
+
56
+ // Simple interpreter that extracts data from P2PKH scripts (starts with 76a914)
57
+ const simpleInterpreter: InterpreterFunction<TestValue, TestContext> = (
58
+ tx: Transaction,
59
+ outputIndex: number,
60
+ ctx?: TestContext
61
+ ): TestValue | undefined => {
62
+ const output = tx.outputs[outputIndex]
63
+ const scriptHex = output.lockingScript.toHex()
64
+
65
+ // Only interpret P2PKH scripts (starts with 76a914)
66
+ if (!scriptHex.startsWith('76a914')) {
67
+ return undefined
68
+ }
69
+
70
+ // Apply context filter if provided (e.g., only certain script prefixes)
71
+ if (ctx?.filter && !scriptHex.startsWith(ctx.filter)) {
72
+ return undefined
73
+ }
74
+
75
+ return {
76
+ data: `value_from_${scriptHex.slice(0, 8)}`, // Extract identifier from script
77
+ outputIndex
78
+ }
79
+ }
80
+
81
+ // Interpreter that throws errors for specific script patterns
82
+ const throwingInterpreter: InterpreterFunction<TestValue> = (
83
+ tx: Transaction,
84
+ outputIndex: number
85
+ ): TestValue | undefined => {
86
+ const output = tx.outputs[outputIndex]
87
+ const scriptHex = output.lockingScript.toHex()
88
+
89
+ // Throw error for scripts containing 'deadbeef' pattern
90
+ if (scriptHex.includes('deadbeef')) {
91
+ throw new Error('Interpreter error')
92
+ }
93
+
94
+ // Only interpret P2PKH scripts
95
+ if (scriptHex.startsWith('76a914')) {
96
+ return { data: `value_from_${scriptHex.slice(0, 8)}`, outputIndex }
97
+ }
98
+
99
+ return undefined
100
+ }
101
+
102
+ // Async interpreter for testing Promise handling
103
+ const asyncInterpreter: InterpreterFunction<TestValue> = async (
104
+ tx: Transaction,
105
+ outputIndex: number
106
+ ): Promise<TestValue | undefined> => {
107
+ await new Promise(resolve => setTimeout(resolve, 1)) // Simulate async work
108
+
109
+ const output = tx.outputs[outputIndex]
110
+ const scriptHex = output.lockingScript.toHex()
111
+
112
+ if (scriptHex.startsWith('76a914')) {
113
+ return { data: `async_value_from_${scriptHex.slice(0, 8)}`, outputIndex }
114
+ }
115
+
116
+ return undefined
117
+ }
118
+
119
+ // Async interpreter that rejects for testing Promise rejection
120
+ const asyncRejectingInterpreter: InterpreterFunction<TestValue> = async (
121
+ tx: Transaction,
122
+ outputIndex: number
123
+ ): Promise<TestValue | undefined> => {
124
+ await new Promise(resolve => setTimeout(resolve, 1))
125
+
126
+ const output = tx.outputs[outputIndex]
127
+ const scriptHex = output.lockingScript.toHex()
128
+
129
+ // Reject for scripts containing 'badf00d' pattern
130
+ if (scriptHex.includes('badf00d')) {
131
+ throw new Error('Async interpreter error')
132
+ }
133
+
134
+ if (scriptHex.startsWith('76a914')) {
135
+ return { data: `async_value_from_${scriptHex.slice(0, 8)}`, outputIndex }
136
+ }
137
+
138
+ return undefined
139
+ }
140
+
141
+ // Interpreter that returns falsy but valid values
142
+ const falsyValueInterpreter: InterpreterFunction<TestValue> = (
143
+ tx: Transaction,
144
+ outputIndex: number
145
+ ): TestValue | undefined => {
146
+ const output = tx.outputs[outputIndex]
147
+ const scriptHex = output.lockingScript.toHex()
148
+
149
+ // Return empty string for scripts ending with '00'
150
+ if (scriptHex.endsWith('00')) {
151
+ return { data: '' } // Falsy but valid
152
+ }
153
+
154
+ // Return '0' for scripts ending with '30' (ASCII '0')
155
+ if (scriptHex.endsWith('30')) {
156
+ return { data: '0' } // Falsy but valid
157
+ }
158
+
159
+ return undefined
160
+ }
161
+
162
+ // --- Test suite --------------------------------------------------------------
163
+ describe('Historian', () => {
164
+ let historian: Historian<TestValue, TestContext>
165
+ let mockConsoleLog: jest.SpyInstance
166
+
167
+ beforeEach(() => {
168
+ jest.clearAllMocks()
169
+ mockConsoleLog = jest.spyOn(console, 'log').mockImplementation()
170
+ historian = new Historian(simpleInterpreter)
171
+ })
172
+
173
+ afterEach(() => {
174
+ mockConsoleLog.mockRestore()
175
+ })
176
+
177
+ // --------------------------------------------------------------------------
178
+ describe('Constructor', () => {
179
+ it('creates with interpreter function', () => {
180
+ const testHistorian = new Historian(simpleInterpreter)
181
+ expect(testHistorian).toBeInstanceOf(Historian)
182
+ })
183
+
184
+ it('accepts debug option', () => {
185
+ const debugHistorian = new Historian(simpleInterpreter, { debug: true })
186
+ expect(debugHistorian).toBeInstanceOf(Historian)
187
+ })
188
+
189
+ })
190
+
191
+ // --------------------------------------------------------------------------
192
+ describe('buildHistory', () => {
193
+ describe('happy paths', () => {
194
+ it('returns empty array for transaction with no interpretable outputs', async () => {
195
+ const tx = makeMockTx(TEST_TXID_1, [
196
+ makeMockOutput('6a'), // OP_RETURN script (not P2PKH)
197
+ makeMockOutput('a914') // P2SH script (not P2PKH)
198
+ ])
199
+
200
+ const history = await historian.buildHistory(tx)
201
+
202
+ expect(history).toEqual([])
203
+ })
204
+
205
+ it('extracts single value from transaction with one interpretable output', async () => {
206
+ const tx = makeMockTx(TEST_TXID_1, [
207
+ makeMockOutput('76a914abcdef1234567890abcdef1234567890abcdef88ac'), // P2PKH script
208
+ makeMockOutput('6a') // OP_RETURN (not interpretable)
209
+ ])
210
+
211
+ const history = await historian.buildHistory(tx)
212
+
213
+ expect(history).toHaveLength(1)
214
+ expect(history[0]).toMatchObject({ data: 'value_from_76a914ab' })
215
+ })
216
+
217
+ it('extracts multiple values from transaction with multiple interpretable outputs', async () => {
218
+ const tx = makeMockTx(TEST_TXID_1, [
219
+ makeMockOutput('76a914abcdef1234567890abcdef1234567890abcdef88ac'), // P2PKH script 1
220
+ makeMockOutput('76a914123456789012345678901234567890123456788ac'), // P2PKH script 2
221
+ makeMockOutput('6a') // OP_RETURN (not interpretable)
222
+ ])
223
+
224
+ const history = await historian.buildHistory(tx)
225
+
226
+ expect(history).toHaveLength(2)
227
+ // The order is reversed due to history.reverse() at the end
228
+ expect(history[0]).toMatchObject({ data: 'value_from_76a91412' })
229
+ expect(history[1]).toMatchObject({ data: 'value_from_76a914ab' })
230
+ })
231
+
232
+ it('traverses input chain and returns history in chronological order', async () => {
233
+ // Create a chain: tx1 <- tx2 <- tx3 (tx3 is newest)
234
+ const tx1 = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914111111111111111111111111111111111111111188ac')])
235
+ const tx2 = makeMockTx(TEST_TXID_2, [makeMockOutput('76a914222222222222222222222222222222222222222288ac')], [
236
+ makeMockInput(tx1)
237
+ ])
238
+ const tx3 = makeMockTx(TEST_TXID_3, [makeMockOutput('76a914333333333333333333333333333333333333333388ac')], [
239
+ makeMockInput(tx2)
240
+ ])
241
+
242
+ const history = await historian.buildHistory(tx3)
243
+
244
+ expect(history).toHaveLength(3)
245
+ expect(history[0]).toMatchObject({ data: 'value_from_76a91411' }) // Chronological order
246
+ expect(history[1]).toMatchObject({ data: 'value_from_76a91422' })
247
+ expect(history[2]).toMatchObject({ data: 'value_from_76a91433' })
248
+ })
249
+
250
+ it('handles multiple inputs per transaction', async () => {
251
+ const tx1 = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa88ac')])
252
+ const tx2 = makeMockTx(TEST_TXID_2, [makeMockOutput('76a914bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb88ac')])
253
+ const tx3 = makeMockTx(TEST_TXID_3, [makeMockOutput('76a914cccccccccccccccccccccccccccccccccccccccc88ac')], [
254
+ makeMockInput(tx1),
255
+ makeMockInput(tx2)
256
+ ])
257
+
258
+ const history = await historian.buildHistory(tx3)
259
+
260
+ expect(history).toHaveLength(3)
261
+ // The order depends on traversal: tx3 outputs first, then tx1, then tx2, then reversed
262
+ expect(history.map(h => h.data)).toEqual(['value_from_76a914bb', 'value_from_76a914aa', 'value_from_76a914cc'])
263
+ })
264
+
265
+ it('passes context to interpreter function', async () => {
266
+ const contextHistorian = new Historian(simpleInterpreter)
267
+ const tx = makeMockTx(TEST_TXID_1, [
268
+ makeMockOutput('76a914filtered123456789012345678901234567888ac'), // Matches filter
269
+ makeMockOutput('76a914other1234567890123456789012345678988ac') // Doesn't match filter
270
+ ])
271
+
272
+ const history = await contextHistorian.buildHistory(tx, { filter: '76a914filtered' })
273
+
274
+ expect(history).toHaveLength(1)
275
+ expect(history[0]).toMatchObject({ data: 'value_from_76a914fi' })
276
+ })
277
+
278
+ it('works with async interpreter functions', async () => {
279
+ const asyncHistorian = new Historian(asyncInterpreter)
280
+ const tx = makeMockTx(TEST_TXID_1, [
281
+ makeMockOutput('76a914async12345678901234567890123456789088ac')
282
+ ])
283
+
284
+ const history = await asyncHistorian.buildHistory(tx)
285
+
286
+ expect(history).toHaveLength(1)
287
+ expect(history[0]).toMatchObject({ data: 'async_value_from_76a914as' })
288
+ })
289
+
290
+ it('includes falsy but valid values from interpreter', async () => {
291
+ const falsyHistorian = new Historian(falsyValueInterpreter)
292
+ const tx = makeMockTx(TEST_TXID_1, [
293
+ makeMockOutput('76a914123456789012345678901234567890123400'), // Returns { data: '' }
294
+ makeMockOutput('76a914123456789012345678901234567890123430'), // Returns { data: '0' }
295
+ makeMockOutput('76a914123456789012345678901234567890123456') // Returns undefined
296
+ ])
297
+
298
+ const history = await falsyHistorian.buildHistory(tx)
299
+
300
+ expect(history).toHaveLength(2)
301
+ expect(history[0]).toMatchObject({ data: '0' }) // Falsy but valid
302
+ expect(history[1]).toMatchObject({ data: '' }) // Falsy but valid
303
+ })
304
+ })
305
+
306
+ describe('sad paths', () => {
307
+ it('handles interpreter errors gracefully', async () => {
308
+ const errorHistorian = new Historian(throwingInterpreter)
309
+ const tx = makeMockTx(TEST_TXID_1, [
310
+ makeMockOutput('76a914abcdef1234567890abcdef1234567890abcdef88ac'), // Good P2PKH
311
+ makeMockOutput('76a914deadbeef1234567890123456789012345688ac'), // Contains 'deadbeef' - will throw
312
+ makeMockOutput('76a914fedcba0987654321fedcba0987654321fedcba88ac') // Good P2PKH
313
+ ])
314
+
315
+ const history = await errorHistorian.buildHistory(tx)
316
+
317
+ expect(history).toHaveLength(2)
318
+ // The order is reversed due to history.reverse() at the end
319
+ expect(history[0]).toMatchObject({ data: 'value_from_76a914fe' })
320
+ expect(history[1]).toMatchObject({ data: 'value_from_76a914ab' })
321
+ })
322
+
323
+ it('handles async interpreter rejection gracefully', async () => {
324
+ const rejectingHistorian = new Historian(asyncRejectingInterpreter)
325
+ const tx = makeMockTx(TEST_TXID_1, [
326
+ makeMockOutput('76a914abcdef1234567890abcdef1234567890abcdef88ac'), // Good P2PKH
327
+ makeMockOutput('76a914badf00d1234567890123456789012345688ac'), // Contains 'badf00d' - will reject
328
+ makeMockOutput('76a914fedcba0987654321fedcba0987654321fedcba88ac') // Good P2PKH
329
+ ])
330
+
331
+ const history = await rejectingHistorian.buildHistory(tx)
332
+
333
+ expect(history).toHaveLength(2)
334
+ expect(history[0]).toMatchObject({ data: 'async_value_from_76a914fe' })
335
+ expect(history[1]).toMatchObject({ data: 'async_value_from_76a914ab' })
336
+ })
337
+
338
+ it('handles missing sourceTransaction in inputs', async () => {
339
+ const tx1 = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914abcdef1234567890abcdef1234567890abcdef88ac')], [
340
+ makeMockInput(), // No sourceTransaction
341
+ makeMockInput() // No sourceTransaction
342
+ ])
343
+
344
+ const history = await historian.buildHistory(tx1)
345
+
346
+ expect(history).toHaveLength(1)
347
+ expect(history[0]).toMatchObject({ data: 'value_from_76a914ab' })
348
+ })
349
+
350
+ it('prevents infinite loops in circular transaction chains', async () => {
351
+ const tx1 = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914111111111111111111111111111111111111111188ac')])
352
+ const tx2 = makeMockTx(TEST_TXID_2, [makeMockOutput('76a914222222222222222222222222222222222222222288ac')], [
353
+ makeMockInput(tx1)
354
+ ])
355
+
356
+ // Create circular reference: tx1 -> tx2 -> tx1
357
+ tx1.inputs = [makeMockInput(tx2)]
358
+
359
+ const history = await historian.buildHistory(tx2)
360
+
361
+ expect(history).toHaveLength(2)
362
+ expect(history[0]).toMatchObject({ data: 'value_from_76a91411' })
363
+ expect(history[1]).toMatchObject({ data: 'value_from_76a91422' })
364
+ })
365
+ })
366
+
367
+ describe('debug mode', () => {
368
+ it('logs debug information when debug=true', async () => {
369
+ const debugHistorian = new Historian(simpleInterpreter, { debug: true })
370
+ const tx = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914abcdef1234567890abcdef1234567890abcdef88ac')])
371
+
372
+ await debugHistorian.buildHistory(tx)
373
+
374
+ expect(mockConsoleLog).toHaveBeenCalledWith(
375
+ expect.stringContaining('[Historian] Processing transaction:')
376
+ )
377
+ expect(mockConsoleLog).toHaveBeenCalledWith(
378
+ '[Historian] Added value to history:',
379
+ expect.objectContaining({ data: 'value_from_76a914ab' })
380
+ )
381
+ })
382
+
383
+ it('logs cycle detection in debug mode', async () => {
384
+ const debugHistorian = new Historian(simpleInterpreter, { debug: true })
385
+ const tx1 = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914111111111111111111111111111111111111111188ac')])
386
+ const tx2 = makeMockTx(TEST_TXID_2, [makeMockOutput('76a914222222222222222222222222222222222222222288ac')], [
387
+ makeMockInput(tx1)
388
+ ])
389
+
390
+ // Create circular reference
391
+ tx1.inputs = [makeMockInput(tx2)]
392
+
393
+ await debugHistorian.buildHistory(tx2)
394
+
395
+ expect(mockConsoleLog).toHaveBeenCalledWith(
396
+ expect.stringContaining('[Historian] Skipping already visited transaction:')
397
+ )
398
+ })
399
+
400
+ it('logs missing sourceTransaction in debug mode', async () => {
401
+ const debugHistorian = new Historian(simpleInterpreter, { debug: true })
402
+ const tx = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914abcdef1234567890abcdef1234567890abcdef88ac')], [
403
+ makeMockInput() // No sourceTransaction
404
+ ])
405
+
406
+ await debugHistorian.buildHistory(tx)
407
+
408
+ // Precise assertion on exact log message
409
+ expect(mockConsoleLog).toHaveBeenCalledWith(
410
+ '[Historian] Input missing sourceTransaction, skipping'
411
+ )
412
+ })
413
+
414
+ it('logs interpreter errors in debug mode', async () => {
415
+ const debugHistorian = new Historian(throwingInterpreter, { debug: true })
416
+ const tx = makeMockTx(TEST_TXID_1, [
417
+ makeMockOutput('76a914deadbeef1234567890123456789012345688ac') // Contains 'deadbeef' - will throw
418
+ ])
419
+
420
+ await debugHistorian.buildHistory(tx)
421
+
422
+ expect(mockConsoleLog).toHaveBeenCalledWith(
423
+ expect.stringContaining('[Historian] Failed to interpret output'),
424
+ expect.any(Error)
425
+ )
426
+ })
427
+
428
+ it('does not log when debug=false', async () => {
429
+ const quietHistorian = new Historian(simpleInterpreter, { debug: false })
430
+ const tx = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914abcdef1234567890abcdef1234567890abcdef88ac')])
431
+
432
+ await quietHistorian.buildHistory(tx)
433
+
434
+ expect(mockConsoleLog).not.toHaveBeenCalled()
435
+ })
436
+ })
437
+
438
+ describe('caching', () => {
439
+ it('accepts historyCache option in constructor', () => {
440
+ const cache = new Map<string, readonly TestValue[]>()
441
+ const cachedHistorian = new Historian(simpleInterpreter, { historyCache: cache })
442
+ expect(cachedHistorian).toBeInstanceOf(Historian)
443
+ })
444
+
445
+ it('uses cache when provided and returns cached results', async () => {
446
+ const cache = new Map<string, readonly TestValue[]>()
447
+ const cachedHistorian = new Historian(simpleInterpreter, {
448
+ historyCache: cache,
449
+ debug: true // Enable debug to verify cache hit logs
450
+ })
451
+
452
+ const tx = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914cached1234567890123456789012345678888ac')])
453
+
454
+ // First call - should populate cache
455
+ const history1 = await cachedHistorian.buildHistory(tx)
456
+ expect(cache.size).toBe(1)
457
+ expect(history1).toHaveLength(1)
458
+ expect(history1[0]).toMatchObject({ data: 'value_from_76a914ca' })
459
+
460
+ // Verify cache was populated (debug log should contain "cached")
461
+ expect(mockConsoleLog).toHaveBeenCalledWith(
462
+ expect.stringContaining('[Historian] History cached:'),
463
+ expect.any(String)
464
+ )
465
+
466
+ // Second call - should use cache (returns shallow copy, not same reference)
467
+ const history2 = await cachedHistorian.buildHistory(tx)
468
+ expect(history1).toStrictEqual(history2) // Same content from cache
469
+ expect(history1).not.toBe(history2) // Different references (shallow copy)
470
+ expect(cache.size).toBe(1) // No new cache entries
471
+
472
+ // Verify cache hit (debug log should contain "cache hit")
473
+ expect(mockConsoleLog).toHaveBeenCalledWith(
474
+ expect.stringContaining('[Historian] History cache hit:'),
475
+ expect.any(String)
476
+ )
477
+ })
478
+
479
+ it('generates different cache keys for different transactions', async () => {
480
+ const cache = new Map<string, readonly TestValue[]>()
481
+ const cachedHistorian = new Historian(simpleInterpreter, { historyCache: cache })
482
+
483
+ const tx1 = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914tx1data123456789012345678901234567888ac')])
484
+ const tx2 = makeMockTx(TEST_TXID_2, [makeMockOutput('76a914tx2data123456789012345678901234567888ac')])
485
+
486
+ await cachedHistorian.buildHistory(tx1)
487
+ await cachedHistorian.buildHistory(tx2)
488
+
489
+ expect(cache.size).toBe(2) // Different transactions = different cache keys
490
+
491
+ // Verify both are cached independently
492
+ const history1 = await cachedHistorian.buildHistory(tx1)
493
+ const history2 = await cachedHistorian.buildHistory(tx2)
494
+ expect(history1[0].data).toBe('value_from_76a914tx')
495
+ expect(history2[0].data).toBe('value_from_76a914tx')
496
+ expect(cache.size).toBe(2) // Still only 2 entries
497
+ })
498
+
499
+ it('generates different cache keys for different contexts', async () => {
500
+ const cache = new Map<string, readonly TestValue[]>()
501
+ const cachedHistorian = new Historian(simpleInterpreter, { historyCache: cache })
502
+
503
+ const tx = makeMockTx(TEST_TXID_1, [
504
+ makeMockOutput('76a914filtered123456789012345678901234567888ac'), // Matches filter
505
+ makeMockOutput('76a914other1234567890123456789012345678988ac') // Doesn't match filter
506
+ ])
507
+
508
+ // Same transaction, different contexts
509
+ await cachedHistorian.buildHistory(tx, { filter: '76a914filtered' })
510
+ await cachedHistorian.buildHistory(tx, { filter: '76a914other' })
511
+ await cachedHistorian.buildHistory(tx) // No context
512
+
513
+ expect(cache.size).toBe(3) // Different contexts = different cache keys
514
+ })
515
+
516
+ it('invalidates cache when interpreterVersion changes', async () => {
517
+ const cache = new Map<string, readonly TestValue[]>()
518
+ const historian1 = new Historian(simpleInterpreter, {
519
+ historyCache: cache,
520
+ interpreterVersion: 'v1'
521
+ })
522
+ const historian2 = new Historian(simpleInterpreter, {
523
+ historyCache: cache,
524
+ interpreterVersion: 'v2'
525
+ })
526
+
527
+ const tx = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914version123456789012345678901234567888ac')])
528
+
529
+ await historian1.buildHistory(tx)
530
+ await historian2.buildHistory(tx) // Different version = new cache entry
531
+
532
+ expect(cache.size).toBe(2) // Two entries for different versions
533
+
534
+ // Verify both versions work independently
535
+ const history1 = await historian1.buildHistory(tx)
536
+ const history2 = await historian2.buildHistory(tx)
537
+ expect(history1).toBeDefined()
538
+ expect(history2).toBeDefined()
539
+ expect(cache.size).toBe(2) // Still only 2 entries
540
+ })
541
+
542
+ it('returns immutable cached results that cannot be mutated externally', async () => {
543
+ const cache = new Map<string, readonly TestValue[]>()
544
+ const cachedHistorian = new Historian(simpleInterpreter, { historyCache: cache })
545
+
546
+ const tx = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914immutable123456789012345678901234567888ac')])
547
+
548
+ const history1 = await cachedHistorian.buildHistory(tx)
549
+ const history2 = await cachedHistorian.buildHistory(tx)
550
+
551
+ // Should be different references but same content (shallow copies from cache)
552
+ expect(history1).toStrictEqual(history2)
553
+ expect(history1).not.toBe(history2)
554
+
555
+ // Original cached value should be frozen, but returned copies are mutable
556
+ // Mutating returned copy should not affect the cache or future calls
557
+ ; (history1 as any).push({ data: 'malicious', outputIndex: 999 })
558
+
559
+ const history3 = await cachedHistorian.buildHistory(tx)
560
+ expect(history3).toStrictEqual(history2) // Still original content from cache
561
+ expect(history3).toHaveLength(1) // Original length preserved
562
+ expect(history3).not.toStrictEqual(history1) // Different from mutated copy
563
+ })
564
+
565
+ it('works correctly with transaction chains when caching is enabled', async () => {
566
+ const cache = new Map<string, readonly TestValue[]>()
567
+ const cachedHistorian = new Historian(simpleInterpreter, { historyCache: cache })
568
+
569
+ // Create a simple chain: tx1 <- tx2
570
+ const tx1 = makeMockTx(TEST_TXID_1, [makeMockOutput('76a914chain11234567890123456789012345678888ac')])
571
+ const tx2 = makeMockTx(TEST_TXID_2, [makeMockOutput('76a914chain21234567890123456789012345678888ac')], [
572
+ makeMockInput(tx1)
573
+ ])
574
+
575
+ // First call - should cache the results
576
+ const history1 = await cachedHistorian.buildHistory(tx2)
577
+ expect(history1).toHaveLength(2)
578
+ expect(cache.size).toBeGreaterThan(0)
579
+
580
+ // Second call - should use cache (same content, different reference)
581
+ const history2 = await cachedHistorian.buildHistory(tx2)
582
+ expect(history1).toStrictEqual(history2) // Same content from cache
583
+ expect(history1).not.toBe(history2) // Different references (shallow copy)
584
+
585
+ // Individual transaction should also be cached
586
+ const tx1History = await cachedHistorian.buildHistory(tx1)
587
+ expect(tx1History).toHaveLength(1)
588
+ expect(tx1History[0]).toMatchObject({ data: 'value_from_76a914ch' })
589
+ })
590
+ })
591
+ })
592
+
593
+ // --------------------------------------------------------------------------
594
+ describe('Integration scenarios', () => {
595
+ it('handles complex transaction chain with mixed interpretable outputs', async () => {
596
+ // Build a realistic scenario with multiple transactions and branches
597
+ const genesis = makeMockTx(TEST_TXID_1, [
598
+ makeMockOutput('76a914genesis123456789012345678901234567888ac'), // P2PKH
599
+ makeMockOutput('6a') // OP_RETURN (non-interpretable)
600
+ ])
601
+
602
+ const branch1 = makeMockTx(TEST_TXID_2, [
603
+ makeMockOutput('76a914branch1123456789012345678901234567888ac') // P2PKH
604
+ ], [makeMockInput(genesis)])
605
+
606
+ const branch2 = makeMockTx(TEST_TXID_3, [
607
+ makeMockOutput('76a914branch2123456789012345678901234567888ac'), // P2PKH
608
+ makeMockOutput('76a914extrabr123456789012345678901234567888ac') // P2PKH
609
+ ], [makeMockInput(genesis)])
610
+
611
+ const merge = makeMockTx('4444444444444444444444444444444444444444444444444444444444444444', [
612
+ makeMockOutput('76a914finalme123456789012345678901234567888ac') // P2PKH
613
+ ], [
614
+ makeMockInput(branch1),
615
+ makeMockInput(branch2)
616
+ ])
617
+
618
+ const history = await historian.buildHistory(merge)
619
+
620
+ expect(history).toHaveLength(5)
621
+ // Verify all expected values are present by checking for the extracted prefixes
622
+ const dataValues = history.map(h => h.data)
623
+ expect(dataValues).toContain('value_from_76a914ge') // genesis
624
+ expect(dataValues).toContain('value_from_76a914br') // branch1 or branch2
625
+ expect(dataValues).toContain('value_from_76a914ex') // extrabr
626
+ expect(dataValues).toContain('value_from_76a914fi') // finalme
627
+ // All should be P2PKH interpretations
628
+ expect(dataValues.every(data => data.startsWith('value_from_76a914'))).toBe(true)
629
+ })
630
+
631
+ it('handles deep transaction chains efficiently', async () => {
632
+ // Create a chain of 10 transactions
633
+ let currentTx = makeMockTx('0000000000000000000000000000000000000000000000000000000000000000', [
634
+ makeMockOutput('76a914000000000000000000000000000000000000000088ac') // P2PKH for value_0
635
+ ])
636
+
637
+ for (let i = 1; i < 10; i++) { // Keep it reasonable for test performance
638
+ const scriptHex = `76a914${i.toString(16).padStart(2, '0')}000000000000000000000000000000000000000088ac`
639
+ const newTx = makeMockTx(
640
+ i.toString().padStart(64, '0'),
641
+ [makeMockOutput(scriptHex)], // P2PKH scripts
642
+ [makeMockInput(currentTx)]
643
+ )
644
+ currentTx = newTx
645
+ }
646
+
647
+ const history = await historian.buildHistory(currentTx)
648
+
649
+ expect(history).toHaveLength(10)
650
+ expect(history[0]).toMatchObject({ data: 'value_from_76a91400' }) // Oldest first (genesis)
651
+ expect(history[9]).toMatchObject({ data: 'value_from_76a91409' }) // Newest last (tx 9)
652
+ // Verify the chain contains genesis and final transaction
653
+ expect(history.map(h => h.data)).toContain('value_from_76a91400') // genesis
654
+ expect(history.map(h => h.data)).toContain('value_from_76a91409') // tx 9
655
+ })
656
+
657
+ it('handles moderately large chains without stack overflow (sanity check)', async () => {
658
+ // Test stack safety with 50 transactions - not a benchmark, just sanity
659
+ const startTime = Date.now()
660
+
661
+ let currentTx = makeMockTx('0000000000000000000000000000000000000000000000000000000000000000', [
662
+ makeMockOutput('76a914genesis123456789012345678901234567888ac') // P2PKH for genesis
663
+ ])
664
+
665
+ // Build chain of 50 transactions
666
+ for (let i = 1; i < 50; i++) {
667
+ // Create unique P2PKH scripts for each transaction
668
+ const scriptHex = `76a914${i.toString(16).padEnd(4, '0')}00000000000000000000000000000000000088ac`
669
+ const newTx = makeMockTx(
670
+ i.toString().padStart(64, '0'),
671
+ [makeMockOutput(scriptHex)],
672
+ [makeMockInput(currentTx)]
673
+ )
674
+ currentTx = newTx
675
+ }
676
+
677
+ const history = await historian.buildHistory(currentTx)
678
+ const duration = Date.now() - startTime
679
+
680
+ // Sanity checks: should complete quickly without errors
681
+ expect(history).toHaveLength(50)
682
+ expect(history[0]).toMatchObject({ data: 'value_from_76a914ge' }) // genesis - Oldest first
683
+ // Verify we have genesis and some chain transactions
684
+ expect(history.map(h => h.data)).toContain('value_from_76a914ge') // genesis
685
+ expect(history.map(h => h.data)).toContain('value_from_76a91410') // tx 1
686
+ expect(history.map(h => h.data)).toContain('value_from_76a91431') // tx 49 (31 in hex)
687
+ expect(duration).toBeLessThan(1000) // Should complete in under 1 second
688
+ })
689
+ })
690
+ })