@bsv/sdk 2.0.9 → 2.0.11

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 (44) hide show
  1. package/dist/cjs/package.json +1 -1
  2. package/dist/cjs/src/auth/certificates/Certificate.js +1 -1
  3. package/dist/cjs/src/auth/certificates/Certificate.js.map +1 -1
  4. package/dist/cjs/src/primitives/BigNumber.js +4 -5
  5. package/dist/cjs/src/primitives/BigNumber.js.map +1 -1
  6. package/dist/cjs/src/script/OP.js +22 -16
  7. package/dist/cjs/src/script/OP.js.map +1 -1
  8. package/dist/cjs/src/script/Spend.js +21 -78
  9. package/dist/cjs/src/script/Spend.js.map +1 -1
  10. package/dist/cjs/src/transaction/MerklePath.js +9 -4
  11. package/dist/cjs/src/transaction/MerklePath.js.map +1 -1
  12. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  13. package/dist/esm/src/auth/certificates/Certificate.js +1 -1
  14. package/dist/esm/src/auth/certificates/Certificate.js.map +1 -1
  15. package/dist/esm/src/primitives/BigNumber.js +4 -5
  16. package/dist/esm/src/primitives/BigNumber.js.map +1 -1
  17. package/dist/esm/src/script/OP.js +22 -16
  18. package/dist/esm/src/script/OP.js.map +1 -1
  19. package/dist/esm/src/script/Spend.js +21 -78
  20. package/dist/esm/src/script/Spend.js.map +1 -1
  21. package/dist/esm/src/transaction/MerklePath.js +9 -4
  22. package/dist/esm/src/transaction/MerklePath.js.map +1 -1
  23. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  24. package/dist/types/src/primitives/BigNumber.d.ts.map +1 -1
  25. package/dist/types/src/script/OP.d.ts +9 -7
  26. package/dist/types/src/script/OP.d.ts.map +1 -1
  27. package/dist/types/src/script/Spend.d.ts.map +1 -1
  28. package/dist/types/src/transaction/MerklePath.d.ts.map +1 -1
  29. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  30. package/dist/umd/bundle.js +3 -3
  31. package/dist/umd/bundle.js.map +1 -1
  32. package/docs/reference/script.md +76 -0
  33. package/docs/reference/transaction.md +63 -6
  34. package/docs/reference/wallet.md +285 -1094
  35. package/package.json +1 -1
  36. package/src/auth/certificates/Certificate.ts +1 -1
  37. package/src/primitives/BigNumber.ts +4 -5
  38. package/src/primitives/__tests/BigNumber.constructor.test.ts +3 -3
  39. package/src/script/OP.ts +21 -16
  40. package/src/script/Spend.ts +21 -28
  41. package/src/script/__tests/Chronicle.test.ts +39 -103
  42. package/src/script/__tests/ChronicleOpcodes.test.ts +548 -0
  43. package/src/transaction/MerklePath.ts +10 -6
  44. package/src/transaction/__tests/MerklePath.test.ts +60 -22
@@ -0,0 +1,548 @@
1
+ import Script from '../Script'
2
+ import Spend from '../Spend'
3
+ import LockingScript from '../LockingScript'
4
+ import UnlockingScript from '../UnlockingScript'
5
+ import ScriptChunk from '../ScriptChunk'
6
+ import OP from '../OP'
7
+
8
+ /**
9
+ * Chronicle upgrade opcode tests.
10
+ *
11
+ * Based on the bitcoin-sv node v1.2.0 functional test suite:
12
+ * https://github.com/bitcoin-sv/bitcoin-sv/tree/172c8fa38cce30cf4df0327b33c7418ea6289de8/test/functional/chronicle_upgrade_tests
13
+ *
14
+ * Covers:
15
+ * - opcodes.py → restored/new opcodes (OP_VER, OP_VERIF, OP_VERNOTIF, OP_SUBSTR, OP_LEFT, OP_RIGHT, OP_2MUL, OP_2DIV, OP_LSHIFTNUM, OP_RSHIFTNUM)
16
+ * - script_num_size.py → enlarged script number limits post-Chronicle
17
+ * - Undefined opcodes → 0xba+ must error (no longer NOPs)
18
+ */
19
+
20
+ const ZERO_TXID = '0'.repeat(64)
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Build a ScriptChunk that pushes arbitrary bytes. */
27
+ function pushChunk (bytes: number[]): ScriptChunk {
28
+ if (bytes.length === 0) return { op: OP.OP_0, data: [] }
29
+ if (bytes.length === 1) {
30
+ if (bytes[0] >= 1 && bytes[0] <= 16) return { op: OP.OP_1 + (bytes[0] - 1) }
31
+ if (bytes[0] === 0x81) return { op: OP.OP_1NEGATE }
32
+ }
33
+ let op: number
34
+ if (bytes.length < OP.OP_PUSHDATA1) op = bytes.length
35
+ else if (bytes.length < 256) op = OP.OP_PUSHDATA1
36
+ else if (bytes.length < 65536) op = OP.OP_PUSHDATA2
37
+ else op = OP.OP_PUSHDATA4
38
+ return { op, data: bytes.slice() }
39
+ }
40
+
41
+ /** Create a Spend that evaluates a locking script (with optional unlocking pushes). */
42
+ function createSpend (
43
+ lockingScript: LockingScript,
44
+ unlockingPushes: number[][] = [],
45
+ txVersion: number = 1
46
+ ): Spend {
47
+ return new Spend({
48
+ sourceTXID: ZERO_TXID,
49
+ sourceOutputIndex: 0,
50
+ sourceSatoshis: 1,
51
+ lockingScript,
52
+ transactionVersion: txVersion,
53
+ otherInputs: [],
54
+ outputs: [],
55
+ inputIndex: 0,
56
+ unlockingScript: new UnlockingScript(unlockingPushes.map(pushChunk)),
57
+ inputSequence: 0xffffffff,
58
+ lockTime: 0
59
+ })
60
+ }
61
+
62
+ /** Create a Spend from ASM string for the locking script. */
63
+ function createSpendFromAsm (
64
+ lockingAsm: string,
65
+ unlockingPushes: number[][] = [],
66
+ txVersion: number = 1
67
+ ): Spend {
68
+ const parsed = Script.fromASM(lockingAsm)
69
+ const ls = new LockingScript(parsed.chunks.map(c => ({
70
+ op: c.op,
71
+ data: Array.isArray(c.data) ? c.data.slice() : undefined
72
+ })))
73
+ return createSpend(ls, unlockingPushes, txVersion)
74
+ }
75
+
76
+ /** Build a locking script from a mixture of opcodes and byte-array pushes. */
77
+ function buildLockingScript (items: Array<number | number[]>): LockingScript {
78
+ const chunks: ScriptChunk[] = items.map(item => {
79
+ if (typeof item === 'number') return { op: item }
80
+ return pushChunk(item)
81
+ })
82
+ return new LockingScript(chunks)
83
+ }
84
+
85
+ /** Encode a string to its byte array. */
86
+ function strBytes (s: string): number[] {
87
+ return Array.from(Buffer.from(s, 'ascii'))
88
+ }
89
+
90
+ /** 4-byte little-endian encoding of a 32-bit integer (matching node's to_le). */
91
+ function le4 (v: number): number[] {
92
+ return [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]
93
+ }
94
+
95
+ /** Assert that a locking script built from items validates successfully. */
96
+ function expectValid (items: Array<number | number[]>, txVersion: number = 1): void {
97
+ const spend = createSpend(buildLockingScript(items), [], txVersion)
98
+ expect(spend.validate()).toBe(true)
99
+ }
100
+
101
+ /** Assert that a locking script built from items throws on validation. */
102
+ function expectInvalid (items: Array<number | number[]>, txVersion: number = 1): void {
103
+ const spend = createSpend(buildLockingScript(items), [], txVersion)
104
+ expect(() => spend.validate()).toThrow()
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Tests
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe('Chronicle Opcode Tests (based on bitcoin-sv node v1.2.0 test suite)', () => {
112
+
113
+ // ==========================================================================
114
+ // opcodes.py — OP_VER
115
+ // ==========================================================================
116
+ describe('OP_VER', () => {
117
+ it('pushes the transaction version (1) as 4-byte LE, then DROP + TRUE succeeds', () => {
118
+ // CScript([OP_VER, OP_DROP, OP_TRUE])
119
+ expectValid([OP.OP_VER, OP.OP_DROP, OP.OP_TRUE])
120
+ })
121
+
122
+ it('pushes tx version 2 correctly as 4-byte LE', () => {
123
+ // OP_VER should push [0x02, 0x00, 0x00, 0x00] for version 2
124
+ expectValid([OP.OP_VER, le4(2), OP.OP_EQUAL], 2)
125
+ })
126
+
127
+ it('pushes tx version 1 as [0x01, 0x00, 0x00, 0x00]', () => {
128
+ expectValid([OP.OP_VER, le4(1), OP.OP_EQUAL])
129
+ })
130
+
131
+ it('OP_VER with version 0xFF00 encodes correctly', () => {
132
+ const ver = 0xFF00
133
+ expectValid([OP.OP_VER, le4(ver), OP.OP_EQUAL], ver)
134
+ })
135
+ })
136
+
137
+ // ==========================================================================
138
+ // opcodes.py — OP_VERIF / OP_VERNOTIF
139
+ // ==========================================================================
140
+ describe('OP_VERIF', () => {
141
+ it('branches to TRUE when stack top matches tx version as 4-byte LE', () => {
142
+ // CScript([b'\x01\x00\x00\x00', OP_VERIF, OP_TRUE, OP_ELSE, OP_FALSE, OP_ENDIF])
143
+ expectValid([le4(1), OP.OP_VERIF, OP.OP_TRUE, OP.OP_ELSE, OP.OP_FALSE, OP.OP_ENDIF])
144
+ })
145
+
146
+ it('branches to ELSE when stack top does NOT match tx version', () => {
147
+ // Push version 2 encoding but tx version is 1 → VERIF is false → goes to ELSE
148
+ expectValid([le4(2), OP.OP_VERIF, OP.OP_FALSE, OP.OP_ELSE, OP.OP_TRUE, OP.OP_ENDIF])
149
+ })
150
+
151
+ it('only matches exactly 4-byte items (3-byte push fails match)', () => {
152
+ // Node v1.2.0: only matches when stack item is exactly 4 bytes
153
+ // 3 bytes — should NOT match version 1
154
+ expectValid([[0x01, 0x00, 0x00], OP.OP_VERIF, OP.OP_FALSE, OP.OP_ELSE, OP.OP_TRUE, OP.OP_ENDIF])
155
+ })
156
+
157
+ it('only matches exactly 4-byte items (5-byte push fails match)', () => {
158
+ expectValid([[0x01, 0x00, 0x00, 0x00, 0x00], OP.OP_VERIF, OP.OP_FALSE, OP.OP_ELSE, OP.OP_TRUE, OP.OP_ENDIF])
159
+ })
160
+
161
+ it('requires at least one item on the stack', () => {
162
+ expectInvalid([OP.OP_VERIF, OP.OP_TRUE, OP.OP_ELSE, OP.OP_FALSE, OP.OP_ENDIF])
163
+ })
164
+ })
165
+
166
+ describe('OP_VERNOTIF', () => {
167
+ it('branches to TRUE when stack top does NOT match tx version', () => {
168
+ // CScript([b'\x01\xFF\x00\x00', OP_VERNOTIF, OP_TRUE, OP_ELSE, OP_FALSE, OP_ENDIF])
169
+ // Version 0x00FF01 != version 1 → VERNOTIF negates → true → goes to OP_TRUE
170
+ expectValid([[0x01, 0xFF, 0x00, 0x00], OP.OP_VERNOTIF, OP.OP_TRUE, OP.OP_ELSE, OP.OP_FALSE, OP.OP_ENDIF])
171
+ })
172
+
173
+ it('branches to ELSE when stack top matches tx version', () => {
174
+ expectValid([le4(1), OP.OP_VERNOTIF, OP.OP_FALSE, OP.OP_ELSE, OP.OP_TRUE, OP.OP_ENDIF])
175
+ })
176
+
177
+ it('non-4-byte items always evaluate as not-matching (so VERNOTIF → true)', () => {
178
+ // 1 byte — won't match, so NOT(false) = true
179
+ expectValid([[0x01], OP.OP_VERNOTIF, OP.OP_TRUE, OP.OP_ELSE, OP.OP_FALSE, OP.OP_ENDIF])
180
+ })
181
+ })
182
+
183
+ // ==========================================================================
184
+ // opcodes.py — OP_SUBSTR (restored Chronicle opcode, 0xb3)
185
+ // ==========================================================================
186
+ describe('OP_SUBSTR', () => {
187
+ it("extracts 'oWorl' from 'HelloWorld' at offset 4, length 5", () => {
188
+ // CScript([b'HelloWorld', OP_4, OP_5, OP_SUBSTR, b'oWorl', OP_EQUAL])
189
+ expectValid([strBytes('HelloWorld'), OP.OP_4, OP.OP_5, OP.OP_SUBSTR, strBytes('oWorl'), OP.OP_EQUAL])
190
+ })
191
+
192
+ it('extracts full string with offset 0 and length = size', () => {
193
+ expectValid([strBytes('ABC'), OP.OP_0, OP.OP_3, OP.OP_SUBSTR, strBytes('ABC'), OP.OP_EQUAL])
194
+ })
195
+
196
+ it('extracts single char from beginning', () => {
197
+ expectValid([strBytes('Hello'), OP.OP_0, OP.OP_1, OP.OP_SUBSTR, strBytes('H'), OP.OP_EQUAL])
198
+ })
199
+
200
+ it('fails when offset is out of range', () => {
201
+ // offset 5, but string is only 2 bytes
202
+ expectInvalid([strBytes('Hi'), OP.OP_5, OP.OP_1, OP.OP_SUBSTR])
203
+ })
204
+
205
+ it('fails when length exceeds available bytes from offset', () => {
206
+ // offset 1, length 5, but only 1 byte remaining
207
+ expectInvalid([strBytes('Hi'), OP.OP_1, OP.OP_5, OP.OP_SUBSTR])
208
+ })
209
+
210
+ it('requires at least 3 stack items', () => {
211
+ expectInvalid([OP.OP_1, OP.OP_SUBSTR])
212
+ })
213
+ })
214
+
215
+ // ==========================================================================
216
+ // opcodes.py — OP_LEFT (restored Chronicle opcode, 0xb4)
217
+ // ==========================================================================
218
+ describe('OP_LEFT', () => {
219
+ it("extracts 'Hello' from 'HelloWorld' (left 5 bytes)", () => {
220
+ // CScript([b'HelloWorld', OP_5, OP_LEFT, b'Hello', OP_EQUAL])
221
+ expectValid([strBytes('HelloWorld'), OP.OP_5, OP.OP_LEFT, strBytes('Hello'), OP.OP_EQUAL])
222
+ })
223
+
224
+ it('left 0 bytes returns empty', () => {
225
+ expectValid([strBytes('Hello'), OP.OP_0, OP.OP_LEFT, OP.OP_0, OP.OP_EQUAL])
226
+ })
227
+
228
+ it('left full length returns the whole string', () => {
229
+ expectValid([strBytes('Hello'), OP.OP_5, OP.OP_LEFT, strBytes('Hello'), OP.OP_EQUAL])
230
+ })
231
+
232
+ it('fails when length exceeds string size', () => {
233
+ expectInvalid([strBytes('Hi'), OP.OP_5, OP.OP_LEFT])
234
+ })
235
+ })
236
+
237
+ // ==========================================================================
238
+ // opcodes.py — OP_RIGHT (restored Chronicle opcode, 0xb5)
239
+ // ==========================================================================
240
+ describe('OP_RIGHT', () => {
241
+ it("extracts 'World' from 'HelloWorld' (right 5 bytes)", () => {
242
+ // CScript([b'HelloWorld', OP_5, OP_RIGHT, b'World', OP_EQUAL])
243
+ expectValid([strBytes('HelloWorld'), OP.OP_5, OP.OP_RIGHT, strBytes('World'), OP.OP_EQUAL])
244
+ })
245
+
246
+ it('right 0 bytes returns empty', () => {
247
+ expectValid([strBytes('Hello'), OP.OP_0, OP.OP_RIGHT, OP.OP_0, OP.OP_EQUAL])
248
+ })
249
+
250
+ it('right full length returns the whole string', () => {
251
+ expectValid([strBytes('Hello'), OP.OP_5, OP.OP_RIGHT, strBytes('Hello'), OP.OP_EQUAL])
252
+ })
253
+
254
+ it('right 1 byte returns last char', () => {
255
+ expectValid([strBytes('Hello'), OP.OP_1, OP.OP_RIGHT, strBytes('o'), OP.OP_EQUAL])
256
+ })
257
+
258
+ it('fails when length exceeds string size', () => {
259
+ expectInvalid([strBytes('Hi'), OP.OP_5, OP.OP_RIGHT])
260
+ })
261
+ })
262
+
263
+ // ==========================================================================
264
+ // opcodes.py — OP_2MUL (restored Chronicle opcode, 0x8d)
265
+ // ==========================================================================
266
+ describe('OP_2MUL', () => {
267
+ it('1 * 2 = 2', () => {
268
+ // CScript([OP_1, OP_2MUL, OP_2, OP_EQUAL])
269
+ expectValid([OP.OP_1, OP.OP_2MUL, OP.OP_2, OP.OP_EQUAL])
270
+ })
271
+
272
+ it('0 * 2 = 0', () => {
273
+ expectValid([OP.OP_0, OP.OP_2MUL, OP.OP_0, OP.OP_EQUAL])
274
+ })
275
+
276
+ it('8 * 2 = 16', () => {
277
+ expectValid([OP.OP_8, OP.OP_2MUL, OP.OP_16, OP.OP_EQUAL])
278
+ })
279
+
280
+ it('-1 * 2 = -2', () => {
281
+ // -2 in script num is [0x82]
282
+ expectValid([OP.OP_1NEGATE, OP.OP_2MUL, [0x82], OP.OP_EQUAL])
283
+ })
284
+ })
285
+
286
+ // ==========================================================================
287
+ // opcodes.py — OP_2DIV (restored Chronicle opcode, 0x8e)
288
+ // ==========================================================================
289
+ describe('OP_2DIV', () => {
290
+ it('2 / 2 = 1', () => {
291
+ // CScript([OP_2, OP_2DIV, OP_1, OP_EQUAL])
292
+ expectValid([OP.OP_2, OP.OP_2DIV, OP.OP_1, OP.OP_EQUAL])
293
+ })
294
+
295
+ it('16 / 2 = 8', () => {
296
+ expectValid([OP.OP_16, OP.OP_2DIV, OP.OP_8, OP.OP_EQUAL])
297
+ })
298
+
299
+ it('1 / 2 = 0 (integer division)', () => {
300
+ expectValid([OP.OP_1, OP.OP_2DIV, OP.OP_0, OP.OP_EQUAL])
301
+ })
302
+
303
+ it('0 / 2 = 0', () => {
304
+ expectValid([OP.OP_0, OP.OP_2DIV, OP.OP_0, OP.OP_EQUAL])
305
+ })
306
+ })
307
+
308
+ // ==========================================================================
309
+ // opcodes.py — OP_LSHIFTNUM (restored Chronicle opcode, 0xb6)
310
+ // ==========================================================================
311
+ describe('OP_LSHIFTNUM', () => {
312
+ it('1 << 2 = 4', () => {
313
+ // CScript([OP_1, OP_2, OP_LSHIFTNUM, OP_4, OP_EQUAL])
314
+ expectValid([OP.OP_1, OP.OP_2, OP.OP_LSHIFTNUM, OP.OP_4, OP.OP_EQUAL])
315
+ })
316
+
317
+ it('1 << 1 = 2', () => {
318
+ const spend = createSpendFromAsm('OP_1 OP_1 OP_LSHIFTNUM OP_2 OP_EQUAL')
319
+ expect(spend.validate()).toBe(true)
320
+ })
321
+
322
+ it('1 << 8 produces a 2-byte result', () => {
323
+ const spend = createSpendFromAsm('OP_1 OP_8 OP_LSHIFTNUM 0001 OP_EQUAL')
324
+ expect(spend.validate()).toBe(true)
325
+ })
326
+ })
327
+
328
+ // ==========================================================================
329
+ // opcodes.py — OP_RSHIFTNUM (restored Chronicle opcode, 0xb7)
330
+ // ==========================================================================
331
+ describe('OP_RSHIFTNUM', () => {
332
+ it('16 >> 2 = 4', () => {
333
+ // CScript([OP_16, OP_2, OP_RSHIFTNUM, OP_4, OP_EQUAL])
334
+ expectValid([OP.OP_16, OP.OP_2, OP.OP_RSHIFTNUM, OP.OP_4, OP.OP_EQUAL])
335
+ })
336
+
337
+ it('4 >> 2 = 1', () => {
338
+ const spend = createSpendFromAsm('OP_4 OP_2 OP_RSHIFTNUM OP_1 OP_EQUAL')
339
+ expect(spend.validate()).toBe(true)
340
+ })
341
+
342
+ it('2 >> 1 = 1', () => {
343
+ const spend = createSpendFromAsm('OP_2 OP_1 OP_RSHIFTNUM OP_1 OP_EQUAL')
344
+ expect(spend.validate()).toBe(true)
345
+ })
346
+ })
347
+
348
+ // ==========================================================================
349
+ // All opcodes together — full post-Chronicle validation
350
+ // Mirrors the CHRONICLE_ACTIVATION / POST_CHRONICLE test sets from opcodes.py
351
+ // ==========================================================================
352
+ describe('Post-Chronicle opcode activation (all should succeed)', () => {
353
+ const tests: Array<{ name: string, ls: LockingScript, txVersion?: number }> = [
354
+ {
355
+ name: 'OP_VER OP_DROP OP_TRUE',
356
+ ls: buildLockingScript([OP.OP_VER, OP.OP_DROP, OP.OP_TRUE])
357
+ },
358
+ {
359
+ name: 'OP_VERIF with matching version (v1)',
360
+ ls: buildLockingScript([le4(1), OP.OP_VERIF, OP.OP_TRUE, OP.OP_ELSE, OP.OP_FALSE, OP.OP_ENDIF])
361
+ },
362
+ {
363
+ name: 'OP_VERNOTIF with non-matching version',
364
+ ls: buildLockingScript([[0x01, 0xFF, 0x00, 0x00], OP.OP_VERNOTIF, OP.OP_TRUE, OP.OP_ELSE, OP.OP_FALSE, OP.OP_ENDIF])
365
+ },
366
+ {
367
+ name: "OP_SUBSTR: 'oWorl' from 'HelloWorld'",
368
+ ls: buildLockingScript([strBytes('HelloWorld'), OP.OP_4, OP.OP_5, OP.OP_SUBSTR, strBytes('oWorl'), OP.OP_EQUAL])
369
+ },
370
+ {
371
+ name: "OP_LEFT: 'Hello' from 'HelloWorld'",
372
+ ls: buildLockingScript([strBytes('HelloWorld'), OP.OP_5, OP.OP_LEFT, strBytes('Hello'), OP.OP_EQUAL])
373
+ },
374
+ {
375
+ name: "OP_RIGHT: 'World' from 'HelloWorld'",
376
+ ls: buildLockingScript([strBytes('HelloWorld'), OP.OP_5, OP.OP_RIGHT, strBytes('World'), OP.OP_EQUAL])
377
+ },
378
+ {
379
+ name: 'OP_2MUL: 1 * 2 = 2',
380
+ ls: buildLockingScript([OP.OP_1, OP.OP_2MUL, OP.OP_2, OP.OP_EQUAL])
381
+ },
382
+ {
383
+ name: 'OP_2DIV: 2 / 2 = 1',
384
+ ls: buildLockingScript([OP.OP_2, OP.OP_2DIV, OP.OP_1, OP.OP_EQUAL])
385
+ },
386
+ {
387
+ name: 'OP_LSHIFTNUM: 1 << 2 = 4',
388
+ ls: buildLockingScript([OP.OP_1, OP.OP_2, OP.OP_LSHIFTNUM, OP.OP_4, OP.OP_EQUAL])
389
+ },
390
+ {
391
+ name: 'OP_RSHIFTNUM: 16 >> 2 = 4',
392
+ ls: buildLockingScript([OP.OP_16, OP.OP_2, OP.OP_RSHIFTNUM, OP.OP_4, OP.OP_EQUAL])
393
+ }
394
+ ]
395
+
396
+ for (const t of tests) {
397
+ it(t.name, () => {
398
+ const spend = createSpend(t.ls, [], t.txVersion ?? 1)
399
+ expect(spend.validate()).toBe(true)
400
+ })
401
+ }
402
+ })
403
+
404
+ // ==========================================================================
405
+ // Undefined opcodes >= 0xba must now fail (no longer treated as NOPs)
406
+ // Per node v1.2.0: opcodes >= FIRST_UNDEFINED_OP_VALUE (0xba) return SCRIPT_ERR_BAD_OPCODE
407
+ // ==========================================================================
408
+ describe('Undefined opcodes (>= 0xba) must fail', () => {
409
+ const undefinedOpcodes = [
410
+ { name: 'OP_NOP11 (0xba)', value: 0xba },
411
+ { name: 'OP_NOP12 (0xbb)', value: 0xbb },
412
+ { name: 'OP_NOP16 (0xbf)', value: 0xbf },
413
+ { name: 'OP_NOP20 (0xc3)', value: 0xc3 },
414
+ { name: 'OP_NOP32 (0xcf)', value: 0xcf },
415
+ { name: 'OP_NOP50 (0xe1)', value: 0xe1 },
416
+ { name: 'OP_NOP73 (0xf8)', value: 0xf8 },
417
+ { name: '0xf9 (OP_SMALLDATA)', value: 0xf9 },
418
+ { name: 'OP_INVALIDOPCODE (0xff)', value: 0xff }
419
+ ]
420
+
421
+ for (const { name, value } of undefinedOpcodes) {
422
+ it(`${name} should error when executed`, () => {
423
+ // Build a script that executes the undefined opcode followed by OP_TRUE
424
+ const ls = new LockingScript([{ op: value }, { op: OP.OP_TRUE }])
425
+ const spend = createSpend(ls)
426
+ expect(() => spend.validate()).toThrow()
427
+ })
428
+ }
429
+ })
430
+
431
+ // ==========================================================================
432
+ // Valid NOPs that should still work (OP_NOP1, OP_NOP2/CLTV, OP_NOP3/CSV, OP_NOP9, OP_NOP10)
433
+ // ==========================================================================
434
+ describe('Valid NOPs still work', () => {
435
+ const validNops = [
436
+ { name: 'OP_NOP1 (0xb0)', value: OP.OP_NOP1 },
437
+ { name: 'OP_CHECKLOCKTIMEVERIFY/OP_NOP2 (0xb1)', value: OP.OP_CHECKLOCKTIMEVERIFY },
438
+ { name: 'OP_CHECKSEQUENCEVERIFY/OP_NOP3 (0xb2)', value: OP.OP_CHECKSEQUENCEVERIFY },
439
+ { name: 'OP_NOP9 (0xb8)', value: OP.OP_NOP9 },
440
+ { name: 'OP_NOP10 (0xb9)', value: OP.OP_NOP10 }
441
+ ]
442
+
443
+ for (const { name, value } of validNops) {
444
+ it(`${name} acts as NOP and script succeeds`, () => {
445
+ const ls = new LockingScript([{ op: value }, { op: OP.OP_TRUE }])
446
+ const spend = createSpend(ls)
447
+ expect(spend.validate()).toBe(true)
448
+ })
449
+ }
450
+ })
451
+
452
+ // ==========================================================================
453
+ // script_num_size.py — Larger script numbers work post-Chronicle
454
+ // ==========================================================================
455
+ describe('Script number size (post-Chronicle)', () => {
456
+ it('a script number up to old genesis limit (750000 bytes) executes OP_1ADD successfully', () => {
457
+ // A large script number followed by OP_1ADD OP_DROP OP_TRUE
458
+ // Use a modest size that proves the limit is > 4 bytes (pre-genesis limit)
459
+ const bigNum = new Array(8).fill(42) // 8-byte script number
460
+ expectValid([bigNum, OP.OP_1ADD, OP.OP_DROP, OP.OP_TRUE])
461
+ })
462
+
463
+ it('OP_MUL works with numbers up to the genesis script num size', () => {
464
+ // Use OP_DUP OP_MUL to square a number, verifying arithmetic on larger nums
465
+ expectValid([OP.OP_3, OP.OP_DUP, OP.OP_MUL, OP.OP_9, OP.OP_EQUAL])
466
+ })
467
+ })
468
+
469
+ // ==========================================================================
470
+ // OP_RIGHT fix verification — the PR fixed buf.slice(size - len, len) → buf.slice(size - len)
471
+ // ==========================================================================
472
+ describe('OP_RIGHT slice fix', () => {
473
+ it('correctly returns last N bytes for various inputs', () => {
474
+ const testCases = [
475
+ { input: [1, 2, 3, 4, 5], len: 3, expected: [3, 4, 5] },
476
+ { input: [0xAA, 0xBB, 0xCC, 0xDD], len: 2, expected: [0xCC, 0xDD] },
477
+ { input: [0xFF], len: 1, expected: [0xFF] },
478
+ { input: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], len: 1, expected: [10] }
479
+ ]
480
+
481
+ for (const { input, len, expected } of testCases) {
482
+ const lenOpcode = OP.OP_1 + (len - 1) // len is always 1-10 in our test cases
483
+ expectValid([input, lenOpcode, OP.OP_RIGHT, expected, OP.OP_EQUAL])
484
+ }
485
+ })
486
+ })
487
+
488
+ // ==========================================================================
489
+ // OP_VER encoding fix — the PR changed from script-number to 4-byte LE
490
+ // ==========================================================================
491
+ describe('OP_VER encoding (4-byte LE, not script num)', () => {
492
+ it('version 1 pushes exactly [0x01, 0x00, 0x00, 0x00] (not script num [0x01])', () => {
493
+ // If OP_VER used script num, it would push [0x01] (1 byte).
494
+ // With the fix it pushes 4 bytes. Check by testing SIZE.
495
+ expectValid([
496
+ OP.OP_VER,
497
+ OP.OP_SIZE, // push size of top element
498
+ OP.OP_4, // expected: 4 bytes
499
+ OP.OP_EQUALVERIFY,
500
+ le4(1), // verify actual value
501
+ OP.OP_EQUAL
502
+ ])
503
+ })
504
+
505
+ it('version 256 pushes [0x00, 0x01, 0x00, 0x00]', () => {
506
+ expectValid([OP.OP_VER, [0x00, 0x01, 0x00, 0x00], OP.OP_EQUAL], 256)
507
+ })
508
+ })
509
+
510
+ // ==========================================================================
511
+ // Combined OP_LEFT + OP_RIGHT = OP_SPLIT equivalent
512
+ // ==========================================================================
513
+ describe('OP_LEFT + OP_RIGHT composition', () => {
514
+ it('LEFT + RIGHT can reconstruct the original string', () => {
515
+ // Take 'HelloWorld', LEFT 5 → 'Hello', original RIGHT 5 → 'World', CAT → 'HelloWorld'
516
+ expectValid([
517
+ strBytes('HelloWorld'),
518
+ OP.OP_DUP,
519
+ OP.OP_5,
520
+ OP.OP_LEFT, // stack: 'HelloWorld', 'Hello'
521
+ OP.OP_SWAP,
522
+ OP.OP_5,
523
+ OP.OP_RIGHT, // stack: 'Hello', 'World'
524
+ OP.OP_CAT, // stack: 'HelloWorld'
525
+ strBytes('HelloWorld'),
526
+ OP.OP_EQUAL
527
+ ])
528
+ })
529
+ })
530
+
531
+ // ==========================================================================
532
+ // Edge cases for OP_VERIF/OP_VERNOTIF with different version numbers
533
+ // ==========================================================================
534
+ describe('OP_VERIF/OP_VERNOTIF edge cases', () => {
535
+ it('OP_VERIF with tx version 2 matches [0x02, 0x00, 0x00, 0x00]', () => {
536
+ expectValid([le4(2), OP.OP_VERIF, OP.OP_TRUE, OP.OP_ELSE, OP.OP_FALSE, OP.OP_ENDIF], 2)
537
+ })
538
+
539
+ it('OP_VERIF with empty stack item (0 bytes) does not match any version', () => {
540
+ // OP_0 pushes empty array
541
+ expectValid([OP.OP_0, OP.OP_VERIF, OP.OP_FALSE, OP.OP_ELSE, OP.OP_TRUE, OP.OP_ENDIF])
542
+ })
543
+
544
+ it('OP_VERNOTIF with tx version 2: matching value goes to ELSE', () => {
545
+ expectValid([le4(2), OP.OP_VERNOTIF, OP.OP_FALSE, OP.OP_ELSE, OP.OP_TRUE, OP.OP_ENDIF], 2)
546
+ })
547
+ })
548
+ })
@@ -281,9 +281,13 @@ export default class MerklePath {
281
281
  // special case for blocks with only one transaction
282
282
  if (this.path.length === 1 && this.path[0].length === 1) return workingHash
283
283
 
284
- for (let height = 0; height < this.path.length; height++) {
285
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
286
- const leaves = this.path[height]
284
+ // Determine effective tree height. For a compound path where all txids are at level 0
285
+ // (path.length === 1 or intermediate levels are empty/trimmed), we need to compute up
286
+ // to the height implied by the highest offset present in path[0].
287
+ const maxOffset = this.path[0].reduce((max, l) => Math.max(max, l.offset), 0)
288
+ const treeHeight = Math.max(this.path.length, 32 - Math.clz32(maxOffset))
289
+
290
+ for (let height = 0; height < treeHeight; height++) {
287
291
  const offset = (index >> height) ^ 1
288
292
  const leaf = this.findOrComputeLeaf(height, offset)
289
293
  if (typeof leaf !== 'object') {
@@ -315,9 +319,9 @@ export default class MerklePath {
315
319
  const hash = (m: string): string =>
316
320
  toHex(hash256(toArray(m, 'hex').reverse()).reverse())
317
321
 
318
- let leaf: MerklePathLeaf | undefined = this.path[height].find(
319
- (l) => l.offset === offset
320
- )
322
+ let leaf: MerklePathLeaf | undefined = height < this.path.length
323
+ ? this.path[height].find((l) => l.offset === offset)
324
+ : undefined
321
325
 
322
326
  if (leaf != null) return leaf
323
327
 
@@ -1,8 +1,12 @@
1
1
  import ChainTracker from '../ChainTracker'
2
2
  import MerklePath from '../../transaction/MerklePath'
3
+ import { hash256 } from '../../primitives/Hash'
4
+ import { toHex, toArray } from '../../primitives/utils'
3
5
  import invalidBumps from './bump.invalid.vectors'
4
6
  import validBumps from './bump.valid.vectors'
5
7
 
8
+ const merkleHash = (m: string): string => toHex(hash256(toArray(m, 'hex').reverse()).reverse())
9
+
6
10
  const BRC74Hex =
7
11
  'fe8a6a0c000c04fde80b0011774f01d26412f0d16ea3f0447be0b5ebec67b0782e321a7a01cbdf7f734e30fde90b02004e53753e3fe4667073063a17987292cfdea278824e9888e52180581d7188d8fdea0b025e441996fc53f0191d649e68a200e752fb5f39e0d5617083408fa179ddc5c998fdeb0b0102fdf405000671394f72237d08a4277f4435e5b6edf7adc272f25effef27cdfe805ce71a81fdf50500262bccabec6c4af3ed00cc7a7414edea9c5efa92fb8623dd6160a001450a528201fdfb020101fd7c010093b3efca9b77ddec914f8effac691ecb54e2c81d0ab81cbc4c4b93befe418e8501bf01015e005881826eb6973c54003a02118fe270f03d46d02681c8bc71cd44c613e86302f8012e00e07a2bb8bb75e5accff266022e1e5e6e7b4d6d943a04faadcf2ab4a22f796ff30116008120cafa17309c0bb0e0ffce835286b3a2dcae48e4497ae2d2b7ced4f051507d010a00502e59ac92f46543c23006bff855d96f5e648043f0fb87a7a5949e6a9bebae430104001ccd9f8f64f4d0489b30cc815351cf425e0e78ad79a589350e4341ac165dbe45010301010000af8764ce7e1cc132ab5ed2229a005c87201c9a5ee15c0f91dd53eff31ab30cd4'
8
12
 
@@ -131,6 +135,26 @@ class FakeChainTracker implements ChainTracker {
131
135
  }
132
136
  }
133
137
 
138
+ /** Splits BRC74JSON into two partial paths (A covers txid2, B covers txid3) ready to combine. */
139
+ function buildSplitPaths (): [MerklePath, MerklePath] {
140
+ const path0A = [...BRC74JSON.path[0]]
141
+ const path0B = [...BRC74JSON.path[0]]
142
+ const path1A = [...BRC74JSON.path[1]]
143
+ const path1B = [...BRC74JSON.path[1]]
144
+ const pathRest = [...BRC74JSON.path]
145
+ pathRest.shift()
146
+ pathRest.shift()
147
+ path0A.splice(2, 2)
148
+ path0B.shift()
149
+ path0B.shift()
150
+ path1A.shift()
151
+ path1B.pop()
152
+ return [
153
+ new MerklePath(BRC74JSON.blockHeight, [path0A, path1A, ...pathRest]),
154
+ new MerklePath(BRC74JSON.blockHeight, [path0B, path1B, ...pathRest])
155
+ ]
156
+ }
157
+
134
158
  describe('MerklePath', () => {
135
159
  it('Parses from hex', () => {
136
160
  const path = MerklePath.fromHex(BRC74Hex)
@@ -163,28 +187,7 @@ describe('MerklePath', () => {
163
187
  )
164
188
  })
165
189
  it('Combines two paths', () => {
166
- const path0A = [...BRC74JSON.path[0]]
167
- const path0B = [...BRC74JSON.path[0]]
168
- const path1A = [...BRC74JSON.path[1]]
169
- const path1B = [...BRC74JSON.path[1]]
170
- const pathRest = [...BRC74JSON.path]
171
- pathRest.shift()
172
- pathRest.shift()
173
- path0A.splice(2, 2)
174
- path0B.shift()
175
- path0B.shift()
176
- path1A.shift()
177
- path1B.pop()
178
- const pathAJSON = {
179
- blockHeight: BRC74JSON.blockHeight,
180
- path: [path0A, path1A, ...pathRest]
181
- }
182
- const pathBJSON = {
183
- blockHeight: BRC74JSON.blockHeight,
184
- path: [path0B, path1B, ...pathRest]
185
- }
186
- const pathA = new MerklePath(pathAJSON.blockHeight, pathAJSON.path)
187
- const pathB = new MerklePath(pathBJSON.blockHeight, pathBJSON.path)
190
+ const [pathA, pathB] = buildSplitPaths()
188
191
  expect(pathA.computeRoot(BRC74TXID2)).toEqual(BRC74Root)
189
192
  expect(() => pathA.computeRoot(BRC74TXID3)).toThrow()
190
193
  expect(() => pathB.computeRoot(BRC74TXID2)).toThrow()
@@ -194,6 +197,41 @@ describe('MerklePath', () => {
194
197
  expect(pathA.computeRoot(BRC74TXID2)).toEqual(BRC74Root)
195
198
  expect(pathA.computeRoot(BRC74TXID3)).toEqual(BRC74Root)
196
199
  })
200
+ it('Serializes and deserializes a combined trimmed path', () => {
201
+ const [pathA, pathB] = buildSplitPaths()
202
+ pathA.combine(pathB)
203
+ let deserialized: MerklePath
204
+ expect(() => { deserialized = MerklePath.fromHex(pathA.toHex()) }).not.toThrow()
205
+ expect(deserialized!.computeRoot(BRC74TXID2)).toEqual(BRC74Root)
206
+ expect(deserialized!.computeRoot(BRC74TXID3)).toEqual(BRC74Root)
207
+ })
208
+ it('Constructs a compound path from all txids at level 0 only', () => {
209
+ // A single-level compound path: all txids for a block given at level 0, no higher levels.
210
+ // The implementation should be able to compute the merkle root by calculating up from the leaves.
211
+ const tx0 = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
212
+ const tx1 = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
213
+ const tx2 = 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'
214
+ const tx3 = 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd'
215
+ const root4 = merkleHash(merkleHash(tx3 + tx2) + merkleHash(tx1 + tx0))
216
+ let mp: MerklePath
217
+ expect(() => {
218
+ mp = new MerklePath(100, [[
219
+ { offset: 0, txid: true, hash: tx0 },
220
+ { offset: 1, txid: true, hash: tx1 },
221
+ { offset: 2, txid: true, hash: tx2 },
222
+ { offset: 3, txid: true, hash: tx3 }
223
+ ]])
224
+ }).not.toThrow()
225
+ expect(mp!.computeRoot(tx0)).toEqual(root4)
226
+ expect(mp!.computeRoot(tx1)).toEqual(root4)
227
+ expect(mp!.computeRoot(tx2)).toEqual(root4)
228
+ expect(mp!.computeRoot(tx3)).toEqual(root4)
229
+ // Serializing and deserializing a single-level compound path should also work
230
+ let deserialized: MerklePath
231
+ expect(() => { deserialized = MerklePath.fromHex(mp!.toHex()) }).not.toThrow()
232
+ expect(deserialized!.computeRoot(tx0)).toEqual(root4)
233
+ expect(deserialized!.computeRoot(tx3)).toEqual(root4)
234
+ })
197
235
  it('Rejects invalid bumps', () => {
198
236
  for (const invalid of invalidBumps) {
199
237
  expect(() => MerklePath.fromHex(invalid.bump)).toThrow(invalid.error)