@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.
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/src/kvstore/GlobalKVStore.js +420 -0
- package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -0
- package/dist/cjs/src/kvstore/LocalKVStore.js +6 -6
- package/dist/cjs/src/kvstore/LocalKVStore.js.map +1 -1
- package/dist/cjs/src/kvstore/kvStoreInterpreter.js +74 -0
- package/dist/cjs/src/kvstore/kvStoreInterpreter.js.map +1 -0
- package/dist/cjs/src/kvstore/types.js +11 -0
- package/dist/cjs/src/kvstore/types.js.map +1 -0
- package/dist/cjs/src/overlay-tools/Historian.js +153 -0
- package/dist/cjs/src/overlay-tools/Historian.js.map +1 -0
- package/dist/cjs/src/script/templates/PushDrop.js +2 -2
- package/dist/cjs/src/script/templates/PushDrop.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/kvstore/GlobalKVStore.js +416 -0
- package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -0
- package/dist/esm/src/kvstore/LocalKVStore.js +6 -6
- package/dist/esm/src/kvstore/LocalKVStore.js.map +1 -1
- package/dist/esm/src/kvstore/kvStoreInterpreter.js +47 -0
- package/dist/esm/src/kvstore/kvStoreInterpreter.js.map +1 -0
- package/dist/esm/src/kvstore/types.js +8 -0
- package/dist/esm/src/kvstore/types.js.map +1 -0
- package/dist/esm/src/overlay-tools/Historian.js +155 -0
- package/dist/esm/src/overlay-tools/Historian.js.map +1 -0
- package/dist/esm/src/script/templates/PushDrop.js +2 -2
- package/dist/esm/src/script/templates/PushDrop.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/kvstore/GlobalKVStore.d.ts +129 -0
- package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -0
- package/dist/types/src/kvstore/kvStoreInterpreter.d.ts +22 -0
- package/dist/types/src/kvstore/kvStoreInterpreter.d.ts.map +1 -0
- package/dist/types/src/kvstore/types.d.ts +106 -0
- package/dist/types/src/kvstore/types.d.ts.map +1 -0
- package/dist/types/src/overlay-tools/Historian.d.ts +92 -0
- package/dist/types/src/overlay-tools/Historian.d.ts.map +1 -0
- package/dist/types/src/script/templates/PushDrop.d.ts +6 -5
- package/dist/types/src/script/templates/PushDrop.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +1 -1
- package/dist/umd/bundle.js.map +1 -1
- package/docs/reference/script.md +7 -19
- package/docs/reference/transaction.md +53 -2
- package/package.json +1 -1
- package/src/kvstore/GlobalKVStore.ts +478 -0
- package/src/kvstore/LocalKVStore.ts +7 -7
- package/src/kvstore/__tests/GlobalKVStore.test.ts +965 -0
- package/src/kvstore/__tests/LocalKVStore.test.ts +72 -0
- package/src/kvstore/kvStoreInterpreter.ts +49 -0
- package/src/kvstore/types.ts +114 -0
- package/src/overlay-tools/Historian.ts +195 -0
- package/src/overlay-tools/__tests/Historian.test.ts +690 -0
- 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
|
+
})
|