@bsv/sdk 2.1.4 → 2.1.6

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 (69) hide show
  1. package/dist/cjs/package.json +7 -7
  2. package/dist/cjs/src/compat/Utxo.js +1 -1
  3. package/dist/cjs/src/compat/Utxo.js.map +1 -1
  4. package/dist/cjs/src/kvstore/GlobalKVStore.js +18 -3
  5. package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -1
  6. package/dist/cjs/src/kvstore/types.js.map +1 -1
  7. package/dist/cjs/src/primitives/Hash.js +4 -2
  8. package/dist/cjs/src/primitives/Hash.js.map +1 -1
  9. package/dist/cjs/src/script/Spend.js +8 -1
  10. package/dist/cjs/src/script/Spend.js.map +1 -1
  11. package/dist/cjs/src/transaction/BdkVerifierInterface.js +3 -0
  12. package/dist/cjs/src/transaction/BdkVerifierInterface.js.map +1 -0
  13. package/dist/cjs/src/transaction/Transaction.js +35 -16
  14. package/dist/cjs/src/transaction/Transaction.js.map +1 -1
  15. package/dist/cjs/src/transaction/index.js.map +1 -1
  16. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  17. package/dist/esm/src/compat/Utxo.js +1 -1
  18. package/dist/esm/src/compat/Utxo.js.map +1 -1
  19. package/dist/esm/src/kvstore/GlobalKVStore.js +18 -3
  20. package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -1
  21. package/dist/esm/src/kvstore/types.js.map +1 -1
  22. package/dist/esm/src/primitives/Hash.js +4 -2
  23. package/dist/esm/src/primitives/Hash.js.map +1 -1
  24. package/dist/esm/src/script/Spend.js +8 -1
  25. package/dist/esm/src/script/Spend.js.map +1 -1
  26. package/dist/esm/src/transaction/BdkVerifierInterface.js +2 -0
  27. package/dist/esm/src/transaction/BdkVerifierInterface.js.map +1 -0
  28. package/dist/esm/src/transaction/Transaction.js +35 -16
  29. package/dist/esm/src/transaction/Transaction.js.map +1 -1
  30. package/dist/esm/src/transaction/index.js.map +1 -1
  31. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  32. package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -1
  33. package/dist/types/src/kvstore/types.d.ts +27 -0
  34. package/dist/types/src/kvstore/types.d.ts.map +1 -1
  35. package/dist/types/src/primitives/Hash.d.ts.map +1 -1
  36. package/dist/types/src/script/Spend.d.ts.map +1 -1
  37. package/dist/types/src/transaction/BdkVerifierInterface.d.ts +22 -0
  38. package/dist/types/src/transaction/BdkVerifierInterface.d.ts.map +1 -0
  39. package/dist/types/src/transaction/Transaction.d.ts +2 -1
  40. package/dist/types/src/transaction/Transaction.d.ts.map +1 -1
  41. package/dist/types/src/transaction/index.d.ts +1 -0
  42. package/dist/types/src/transaction/index.d.ts.map +1 -1
  43. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  44. package/dist/umd/bundle.js +3 -3
  45. package/package.json +7 -7
  46. package/src/compat/Utxo.ts +1 -1
  47. package/src/compat/__tests/Mnemonic.additional.test.ts +1 -1
  48. package/src/compat/__tests/Mnemonic.test.ts +1 -1
  49. package/src/kvstore/GlobalKVStore.ts +18 -3
  50. package/src/kvstore/__tests/GlobalKVStore.test.ts +36 -0
  51. package/src/kvstore/types.ts +28 -0
  52. package/src/overlay-tools/__tests/Historian.test.ts +1 -1
  53. package/src/overlay-tools/__tests/HostReputationTracker.additional.test.ts +1 -1
  54. package/src/primitives/Hash.ts +4 -2
  55. package/src/primitives/__tests/BigNumber.constructor.test.ts +1 -1
  56. package/src/primitives/__tests/DRBG.test.ts +4 -12
  57. package/src/primitives/__tests/Point.additional.test.ts +2 -2
  58. package/src/primitives/__tests/Point.test.ts +1 -1
  59. package/src/primitives/__tests/SymmetricKeyCompatibility.test.ts +4 -4
  60. package/src/registry/__tests/RegistryClient.additional.test.ts +4 -4
  61. package/src/registry/__tests/RegistryClient.test.ts +2 -2
  62. package/src/script/Spend.ts +8 -1
  63. package/src/script/__tests/Spend.codeseparator.test.ts +88 -0
  64. package/src/storage/__tests/StorageUploader.test.ts +7 -7
  65. package/src/transaction/BdkVerifierInterface.ts +22 -0
  66. package/src/transaction/Transaction.ts +40 -17
  67. package/src/transaction/__tests/Transaction.test.ts +3 -3
  68. package/src/transaction/__tests/Transaction.verifier.test.ts +72 -0
  69. package/src/transaction/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bsv/sdk",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -239,21 +239,21 @@
239
239
  "devDependencies": {
240
240
  "@eslint/js": "^10.0.1",
241
241
  "@jest/globals": "^30.4.1",
242
- "@rspack/cli": "^2.0.4",
243
- "@rspack/core": "^2.0.4",
242
+ "@rspack/cli": "^2.0.8",
243
+ "@rspack/core": "^2.0.8",
244
244
  "@types/jest": "^30.0.0",
245
- "@types/node": "^25.9.1",
246
- "eslint": "^10.4.0",
245
+ "@types/node": "^25.9.3",
246
+ "eslint": "^10.5.0",
247
247
  "globals": "^17.6.0",
248
248
  "jest": "^30.4.2",
249
249
  "jest-environment-jsdom": "^30.4.1",
250
250
  "ts-jest": "^29.4.11",
251
- "ts-loader": "^9.5.4",
251
+ "ts-loader": "^9.6.0",
252
252
  "ts-standard": "^12.0.2",
253
253
  "ts2md": "^0.2.8",
254
254
  "tsconfig-to-dual-package": "^1.2.0",
255
255
  "typescript": "^6.0.3",
256
- "typescript-eslint": "^8.60.0"
256
+ "typescript-eslint": "^8.61.0"
257
257
  },
258
258
  "ts-standard": {
259
259
  "project": "tsconfig.eslint.json",
@@ -44,7 +44,7 @@ export default function fromUtxo (
44
44
  }
45
45
  ): TransactionInput {
46
46
  const sourceTransaction = new Transaction(0, [], [], 0)
47
- sourceTransaction.outputs = Array(utxo.vout + 1).fill(null)
47
+ sourceTransaction.outputs = new Array(utxo.vout + 1).fill(null)
48
48
  sourceTransaction.outputs[utxo.vout] = {
49
49
  satoshis: utxo.satoshis,
50
50
  lockingScript: LockingScript.fromHex(utxo.script)
@@ -15,7 +15,7 @@ describe('Mnemonic – additional coverage', () => {
15
15
  })
16
16
 
17
17
  it('uses 128 bits when bits is NaN', () => {
18
- const m = new Mnemonic().fromRandom(NaN)
18
+ const m = new Mnemonic().fromRandom(Number.NaN)
19
19
  expect(m.mnemonic.split(' ')).toHaveLength(12)
20
20
  })
21
21
 
@@ -41,7 +41,7 @@ describe('Mnemonic', function () {
41
41
  m = new Mnemonic().fromRandom(128)
42
42
  expect(m.check()).toEqual(true)
43
43
 
44
- const entropy = Array(32)
44
+ const entropy = new Array(32)
45
45
  entropy.fill(0)
46
46
  m = new Mnemonic().fromEntropy(entropy)
47
47
  expect(m.check()).toEqual(true)
@@ -89,11 +89,26 @@ export class GlobalKVStore {
89
89
  this.config = { ...DEFAULT_CONFIG, ...config }
90
90
  this.wallet = config.wallet ?? new WalletClient()
91
91
  this.historian = new Historian<string, KVContext>(kvStoreInterpreter)
92
- this.lookupResolver = new LookupResolver({
93
- networkPreset: this.config.networkPreset
92
+ // Resolve overlay hosts via, in order of precedence: an injected resolver,
93
+ // otherwise a default resolver built from `networkPreset` plus any
94
+ // `hostOverrides` / `slapTrackers`. The same resolver is shared with the
95
+ // topic broadcaster so read lookups and the broadcaster's SHIP host
96
+ // discovery both go through it. Note this shares the *lookup* path, not the
97
+ // broadcast target: writes still go to whatever hosts the `ls_ship` SHIP
98
+ // lookup returns, so pinning the broadcast backend requires that lookup to
99
+ // return the desired host (a host override alone does not force it). With no
100
+ // overrides this is behaviourally identical to the previous
101
+ // networkPreset-only construction.
102
+ // `hostOverrides` / `slapTrackers` are passed straight through; LookupResolver
103
+ // already falls back to its defaults when they're undefined.
104
+ this.lookupResolver = this.config.lookupResolver ?? new LookupResolver({
105
+ networkPreset: this.config.networkPreset,
106
+ hostOverrides: this.config.hostOverrides,
107
+ slapTrackers: this.config.slapTrackers
94
108
  })
95
109
  this.topicBroadcaster = new TopicBroadcaster(this.config.topics as string[], {
96
- networkPreset: this.config.networkPreset
110
+ networkPreset: this.config.networkPreset,
111
+ resolver: this.lookupResolver
97
112
  })
98
113
  }
99
114
 
@@ -264,6 +264,42 @@ describe('GlobalKVStore', () => {
264
264
  it('initializes Historian with kvStoreInterpreter', () => {
265
265
  expect(MockHistorian).toHaveBeenCalledWith(kvStoreInterpreter)
266
266
  })
267
+
268
+ it('builds a default resolver from networkPreset and shares it with the broadcaster', () => {
269
+ MockLookupResolver.mockClear()
270
+ MockTopicBroadcaster.mockClear()
271
+ const store = new GlobalKVStore({ wallet: mockWallet, networkPreset: 'testnet' })
272
+ expect(store).toBeInstanceOf(GlobalKVStore)
273
+ expect(MockLookupResolver).toHaveBeenCalledWith({ networkPreset: 'testnet' })
274
+ // The broadcaster must reuse the same resolver so read lookups and the
275
+ // broadcaster's SHIP host discovery share one lookup path.
276
+ const broadcasterConfig = MockTopicBroadcaster.mock.calls[0][1] as { resolver?: unknown }
277
+ expect(broadcasterConfig.resolver).toBe(mockResolver)
278
+ })
279
+
280
+ it('passes hostOverrides and slapTrackers into the default resolver', () => {
281
+ MockLookupResolver.mockClear()
282
+ const hostOverrides = { ls_kvstore: ['https://host.example'] }
283
+ const slapTrackers = ['https://slap.example']
284
+ const store = new GlobalKVStore({ wallet: mockWallet, hostOverrides, slapTrackers })
285
+ expect(store).toBeInstanceOf(GlobalKVStore)
286
+ expect(MockLookupResolver).toHaveBeenCalledWith({
287
+ networkPreset: 'mainnet',
288
+ hostOverrides,
289
+ slapTrackers,
290
+ })
291
+ })
292
+
293
+ it('uses an injected lookupResolver verbatim and does not build a default one', () => {
294
+ MockLookupResolver.mockClear()
295
+ MockTopicBroadcaster.mockClear()
296
+ const injected = { query: jest.fn() } as unknown as InstanceType<typeof LookupResolver>
297
+ const store = new GlobalKVStore({ wallet: mockWallet, lookupResolver: injected })
298
+ expect(store).toBeInstanceOf(GlobalKVStore)
299
+ expect(MockLookupResolver).not.toHaveBeenCalled()
300
+ const broadcasterConfig = MockTopicBroadcaster.mock.calls[0][1] as { resolver?: unknown }
301
+ expect(broadcasterConfig.resolver).toBe(injected)
302
+ })
267
303
  })
268
304
 
269
305
  // --------------------------------------------------------------------------
@@ -1,6 +1,8 @@
1
1
  import { Beef } from '../transaction/Beef.js'
2
2
  import { PubKeyHex, WalletProtocol } from '../wallet/Wallet.interfaces.js'
3
3
  import { WalletInterface } from '../wallet/index.js'
4
+ // Type-only import — erased at runtime, so it introduces no module cycle with overlay-tools.
5
+ import type { LookupResolver } from '../overlay-tools/index.js'
4
6
 
5
7
  /**
6
8
  * Configuration interface for GlobalKVStore operations.
@@ -23,6 +25,32 @@ export interface KVStoreConfig {
23
25
  wallet?: WalletInterface
24
26
  /** Network preset for overlay services */
25
27
  networkPreset?: 'mainnet' | 'testnet' | 'local'
28
+ /**
29
+ * A pre-built lookup resolver to use for all overlay queries — both reads and
30
+ * write-host (SHIP) discovery. When provided, it takes precedence and
31
+ * `hostOverrides` / `slapTrackers` are ignored for resolver construction.
32
+ * Use this to fully control overlay host resolution.
33
+ */
34
+ lookupResolver?: LookupResolver
35
+ /**
36
+ * Per-service overlay host overrides (`serviceName -> hosts`), applied when
37
+ * the store builds its default lookup resolver. This pins which hosts answer
38
+ * *lookup* queries for a given service (e.g. read lookups via `ls_kvstore`),
39
+ * instead of discovering them via SLAP.
40
+ *
41
+ * Note this does not by itself pin the *broadcast* target: writes are
42
+ * submitted to the hosts that the `ls_ship` SHIP lookup returns, so an
43
+ * `ls_ship` override only changes which tracker answers — the broadcast host
44
+ * is whatever advertisements that lookup names. To force writes to a specific
45
+ * backend, use a resolver / SHIP setup whose `ls_ship` results return the
46
+ * desired host. Ignored when `lookupResolver` is supplied.
47
+ */
48
+ hostOverrides?: Record<string, string[]>
49
+ /**
50
+ * Override the SLAP trackers used by the default lookup resolver. Ignored when
51
+ * `lookupResolver` is supplied.
52
+ */
53
+ slapTrackers?: string[]
26
54
  /** Whether to accept delayed broadcast */
27
55
  acceptDelayedBroadcast?: boolean
28
56
  /** Whether to let overlay handle broadcasting (prevents UTXO spending on rejection) */
@@ -34,7 +34,7 @@ function makeMockTx(txid: string, outputs: any[] = [], inputs: any[] = []): MTx
34
34
 
35
35
  function makeMockOutput(scriptHex?: string): TransactionOutput {
36
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]
37
+ const scriptArray = hex.match(/.{2}/g)?.map(byte => Number.parseInt(byte, 16)) || [0x76, 0xa9, 0x14]
38
38
 
39
39
  return {
40
40
  satoshis: 1,
@@ -93,7 +93,7 @@ describe('HostReputationTracker – additional coverage', () => {
93
93
 
94
94
  it('treats NaN latency as DEFAULT_LATENCY_MS (1500)', () => {
95
95
  const t = new HostReputationTracker()
96
- t.recordSuccess('https://host.com', NaN)
96
+ t.recordSuccess('https://host.com', Number.NaN)
97
97
  const snap = t.snapshot('https://host.com')!
98
98
  expect(snap.avgLatencyMs).toBe(1500)
99
99
  })
@@ -261,8 +261,10 @@ function appendUtf8CodeUnit (msg: string, i: number, out: number[]): number {
261
261
 
262
262
  function utf8StringToArray (msg: string): number[] {
263
263
  const res: number[] = []
264
- for (let i = 0; i < msg.length; i++) {
265
- i = appendUtf8CodeUnit(msg, i, res)
264
+ let i = 0
265
+ while (i < msg.length) {
266
+ const lastConsumed = appendUtf8CodeUnit(msg, i, res)
267
+ i = lastConsumed + 1
266
268
  }
267
269
  return res
268
270
  }
@@ -52,7 +52,7 @@ describe('BN.js/Constructor', () => {
52
52
  it('should accept base-16 with spaces', () => {
53
53
  const num = 'a89c e5af8724 c0a23e0e 0ff77500'
54
54
  expect(new BigNumber(num, 16).toString(16)).toEqual(
55
- num.replace(/ /g, '')
55
+ num.replaceAll(' ', '')
56
56
  )
57
57
  })
58
58
 
@@ -34,36 +34,28 @@ describe('DRBG', () => {
34
34
  const entropy = new Array(31).fill(0x01)
35
35
  const nonce = new Array(32).fill(0x02)
36
36
 
37
- expect(() => {
38
- new DRBG(entropy, nonce)
39
- }).toThrow('Entropy must be exactly 32 bytes (256 bits)')
37
+ expect(() => new DRBG(entropy, nonce)).toThrow('Entropy must be exactly 32 bytes (256 bits)')
40
38
  })
41
39
 
42
40
  it('throws if entropy is longer than 32 bytes', () => {
43
41
  const entropy = new Array(33).fill(0x01)
44
42
  const nonce = new Array(32).fill(0x02)
45
43
 
46
- expect(() => {
47
- new DRBG(entropy, nonce)
48
- }).toThrow('Entropy must be exactly 32 bytes (256 bits)')
44
+ expect(() => new DRBG(entropy, nonce)).toThrow('Entropy must be exactly 32 bytes (256 bits)')
49
45
  })
50
46
 
51
47
  it('throws if nonce is shorter than 32 bytes', () => {
52
48
  const entropy = new Array(32).fill(0x01)
53
49
  const nonce = new Array(31).fill(0x02)
54
50
 
55
- expect(() => {
56
- new DRBG(entropy, nonce)
57
- }).toThrow('Nonce must be exactly 32 bytes (256 bits)')
51
+ expect(() => new DRBG(entropy, nonce)).toThrow('Nonce must be exactly 32 bytes (256 bits)')
58
52
  })
59
53
 
60
54
  it('throws if nonce is longer than 32 bytes', () => {
61
55
  const entropy = new Array(32).fill(0x01)
62
56
  const nonce = new Array(33).fill(0x02)
63
57
 
64
- expect(() => {
65
- new DRBG(entropy, nonce)
66
- }).toThrow('Nonce must be exactly 32 bytes (256 bits)')
58
+ expect(() => new DRBG(entropy, nonce)).toThrow('Nonce must be exactly 32 bytes (256 bits)')
67
59
  })
68
60
 
69
61
  it('accepts both hex strings and number[] inputs equivalently', () => {
@@ -118,7 +118,7 @@ describe('Point – additional coverage', () => {
118
118
  })
119
119
 
120
120
  it('throws for unknown format', () => {
121
- const der = [0x05, ...Array(32).fill(0x01)]
121
+ const der = [0x05, ...new Array(32).fill(0x01)]
122
122
  expect(() => Point.fromDER(der)).toThrow('Unknown point format')
123
123
  })
124
124
  })
@@ -147,7 +147,7 @@ describe('Point – additional coverage', () => {
147
147
  it('fromX accepts number', () => {
148
148
  // Use a valid x value that has a square root mod p
149
149
  const g = G.mul(new BigNumber(7))
150
- const xNum = parseInt(g.getX().toString(16).slice(-4), 16)
150
+ const xNum = Number.parseInt(g.getX().toString(16).slice(-4), 16)
151
151
  // fromX with a number, may produce a point
152
152
  const p = Point.fromX(g.getX(), true)
153
153
  expect(p.validate()).toBe(true)
@@ -40,7 +40,7 @@ describe('Point.fromJSON / fromDER / fromX curve validation (TOB-24)', () => {
40
40
  it('rejects invalid compressed points in fromDER', () => {
41
41
  // 0x02 is a valid compressed prefix, but x = 0 gives y^2 = 7,
42
42
  // which has no square root mod p on secp256k1 → invalid point.
43
- const der = [0x02, ...Array(32).fill(0x00)]
43
+ const der = [0x02, ...new Array(32).fill(0x00)]
44
44
  expect(() => Point.fromDER(der)).toThrow(/Invalid point/)
45
45
  })
46
46
 
@@ -37,7 +37,7 @@ describe('Cross-SDK Compatibility Tests', () => {
37
37
  // Convert hex to byte array
38
38
  const ciphertext: number[] = []
39
39
  for (let i = 0; i < ciphertextHex.length; i += 2) {
40
- ciphertext.push(parseInt(ciphertextHex.slice(i, i + 2), 16))
40
+ ciphertext.push(Number.parseInt(ciphertextHex.slice(i, i + 2), 16))
41
41
  }
42
42
 
43
43
  // Decrypt using TypeScript SDK
@@ -83,7 +83,7 @@ describe('Cross-SDK Compatibility Tests', () => {
83
83
  // Convert hex to byte array
84
84
  const ciphertext: number[] = []
85
85
  for (let i = 0; i < ciphertextHex.length; i += 2) {
86
- ciphertext.push(parseInt(ciphertextHex.slice(i, i + 2), 16))
86
+ ciphertext.push(Number.parseInt(ciphertextHex.slice(i, i + 2), 16))
87
87
  }
88
88
 
89
89
  // Decrypt using TypeScript SDK
@@ -127,7 +127,7 @@ describe('Cross-SDK Compatibility Tests', () => {
127
127
  // Test TypeScript decrypting Go ciphertext
128
128
  const goCiphertextBytes: number[] = []
129
129
  for (let i = 0; i < goCiphertext.length; i += 2) {
130
- goCiphertextBytes.push(parseInt(goCiphertext.substr(i, 2), 16))
130
+ goCiphertextBytes.push(Number.parseInt(goCiphertext.substr(i, 2), 16))
131
131
  }
132
132
 
133
133
  const goDecrypted = symKey.decrypt(goCiphertextBytes, 'utf8')
@@ -136,7 +136,7 @@ describe('Cross-SDK Compatibility Tests', () => {
136
136
  // Test TypeScript decrypting TypeScript ciphertext (sanity check)
137
137
  const tsCiphertextBytes: number[] = []
138
138
  for (let i = 0; i < tsCiphertext.length; i += 2) {
139
- tsCiphertextBytes.push(parseInt(tsCiphertext.substr(i, 2), 16))
139
+ tsCiphertextBytes.push(Number.parseInt(tsCiphertext.substr(i, 2), 16))
140
140
  }
141
141
 
142
142
  const tsDecrypted = symKey.decrypt(tsCiphertextBytes, 'utf8')
@@ -77,10 +77,10 @@ jest.mock('../../transaction/index.js', () => ({
77
77
  jest.mock('../../primitives/index.js', () => ({
78
78
  Utils: {
79
79
  toArray: jest.fn().mockImplementation((str: string) =>
80
- Array.from(str).map((c) => c.charCodeAt(0))
80
+ Array.from(str).map((c) => c.codePointAt(0))
81
81
  ),
82
82
  toUTF8: jest.fn().mockImplementation((arr: number[] | string) => {
83
- if (Array.isArray(arr)) return arr.map((n) => String.fromCharCode(n)).join('')
83
+ if (Array.isArray(arr)) return arr.map((n) => String.fromCodePoint(n)).join('')
84
84
  return arr
85
85
  })
86
86
  }
@@ -628,7 +628,7 @@ describe('RegistryClient.resolve – protocol and certificate parsing', () => {
628
628
  // protocol has 7 fields: protocolID, name, iconURL, description, docURL, operator, sig
629
629
  ;(PushDrop.decode as jest.Mock).mockReturnValueOnce({
630
630
  fields: [
631
- Array.from('[1,"proto"]').map((c) => c.charCodeAt(0)), // protocolID JSON
631
+ Array.from('[1,"proto"]').map((c) => c.codePointAt(0)), // protocolID JSON
632
632
  [110, 97, 109, 101], // 'name'
633
633
  [105, 99, 111, 110], // 'icon'
634
634
  [100, 101, 115, 99], // 'desc'
@@ -658,7 +658,7 @@ describe('RegistryClient.resolve – protocol and certificate parsing', () => {
658
658
  [105, 99, 111, 110], // 'icon'
659
659
  [100, 101, 115, 99], // 'desc'
660
660
  [100, 111, 99], // 'doc'
661
- Array.from(fieldsJSON).map((c) => c.charCodeAt(0)), // fieldsJSON
661
+ Array.from(fieldsJSON).map((c) => c.codePointAt(0)), // fieldsJSON
662
662
  [111, 112], // 'op' - operator
663
663
  [115, 105, 103] // signature
664
664
  ]
@@ -82,11 +82,11 @@ jest.mock('../../primitives/index.js', () => {
82
82
  return {
83
83
  Utils: {
84
84
  toArray: jest.fn().mockImplementation((str: string) =>
85
- Array.from(str).map((c) => c.charCodeAt(0))
85
+ Array.from(str).map((c) => c.codePointAt(0))
86
86
  ),
87
87
  toUTF8: jest.fn().mockImplementation((arr: number[] | string) => {
88
88
  if (Array.isArray(arr)) {
89
- return arr.map((n) => String.fromCharCode(n)).join('')
89
+ return arr.map((n) => String.fromCodePoint(n)).join('')
90
90
  }
91
91
  return arr
92
92
  })
@@ -1213,7 +1213,14 @@ export default class Spend {
1213
1213
  sig = this.parseChecksigSignature(bufSig)
1214
1214
 
1215
1215
  const scriptForChecksig: Script = this.context === 'UnlockingScript' ? this.unlockingScript : this.lockingScript
1216
- const scriptCodeChunks = scriptForChecksig.chunks.slice(this.lastCodeSeparator === null ? 0 : this.lastCodeSeparator + 1)
1216
+ let scriptCodeChunks = scriptForChecksig.chunks.slice(this.lastCodeSeparator === null ? 0 : this.lastCodeSeparator + 1)
1217
+ // When an OP_CODESEPARATOR appears in the unlocking script, the CHECKSIG subscript
1218
+ // continues across the unlock/lock boundary into the full locking script (legacy
1219
+ // combined-script semantics; matches BSV node consensus). Without this, signatures
1220
+ // taken over such a subscript (e.g. OP_PUSH_TX-style contracts) are wrongly rejected.
1221
+ if (this.context === 'UnlockingScript') {
1222
+ scriptCodeChunks = scriptCodeChunks.concat(this.lockingScript.chunks)
1223
+ }
1217
1224
  subscript = new Script(scriptCodeChunks)
1218
1225
  subscript.findAndDelete(new Script().writeBin(bufSig))
1219
1226
 
@@ -0,0 +1,88 @@
1
+ import PrivateKey from '../../primitives/PrivateKey'
2
+ import { sha256 } from '../../primitives/Hash'
3
+ import Spend from '../../script/Spend'
4
+ import Transaction from '../../transaction/Transaction'
5
+ import TransactionSignature from '../../primitives/TransactionSignature'
6
+ import LockingScript from '../../script/LockingScript'
7
+ import UnlockingScript from '../../script/UnlockingScript'
8
+ import Script from '../../script/Script'
9
+ import OP from '../../script/OP'
10
+
11
+ /**
12
+ * Regression test for the CHECKSIG subscript across an OP_CODESEPARATOR in the unlocking script.
13
+ *
14
+ * When an OP_CODESEPARATOR is executed in the *unlocking* script and an OP_CHECKSIG then runs
15
+ * (still in unlocking-script context), the signature's subscript must span from after that
16
+ * separator across the unlock/lock boundary into the FULL locking script (legacy combined-script
17
+ * semantics, which BSV nodes enforce). This is the basis of OP_PUSH_TX-style contracts.
18
+ *
19
+ * Before the fix, Spend built the subscript from the unlocking script alone, so a signature taken
20
+ * over <unlock-tail> ++ <locking-script> was rejected ("OP_CHECKSIGVERIFY requires a valid
21
+ * signature"), even though BSV consensus accepts the transaction.
22
+ */
23
+ describe('Spend — CHECKSIG subscript across OP_CODESEPARATOR in the unlocking script', () => {
24
+ it('validates a signature whose subscript spans into the locking script', () => {
25
+ const priv = new PrivateKey(42)
26
+ const pub = priv.toPublicKey()
27
+ const pubEnc = pub.encode(true) as number[]
28
+ const satoshis = 1000
29
+ const scope = TransactionSignature.SIGHASH_ALL | TransactionSignature.SIGHASH_FORKID
30
+
31
+ // Non-empty locking-script tail that MUST be part of the subscript (proves the concat).
32
+ const lockingScript = new LockingScript([{ op: OP.OP_NOP }, { op: OP.OP_NOP }])
33
+
34
+ // Version 2 -> "relaxed" rules (post-Genesis/Chronicle), so the non-push unlocking script
35
+ // below is permitted (push-only is only enforced for legacy v1 transactions).
36
+ const sourceTx = new Transaction(2, [], [{ lockingScript, satoshis }], 0)
37
+
38
+ // The subscript the interpreter derives (after findAndDelete removes the signature push):
39
+ // <unlock-after-codesep without the sig> ++ <locking script>
40
+ // = [ <pubkey> OP_CHECKSIG ] ++ [ OP_NOP OP_NOP ]
41
+ const subscript = new Script([
42
+ { op: pubEnc.length, data: pubEnc },
43
+ { op: OP.OP_CHECKSIG },
44
+ ...lockingScript.chunks
45
+ ])
46
+
47
+ const preimage = TransactionSignature.formatBytes({
48
+ sourceTXID: sourceTx.id('hex'),
49
+ sourceOutputIndex: 0,
50
+ sourceSatoshis: satoshis,
51
+ transactionVersion: 2,
52
+ otherInputs: [],
53
+ outputs: [],
54
+ inputIndex: 0,
55
+ subscript,
56
+ inputSequence: 0xffffffff,
57
+ lockTime: 0,
58
+ scope
59
+ })
60
+ const raw = priv.sign(sha256(preimage))
61
+ const sig = new TransactionSignature(raw.r, raw.s, scope)
62
+ const sigForScript = sig.toChecksigFormat()
63
+
64
+ // Unlocking script: OP_CODESEPARATOR then <sig> <pubkey> OP_CHECKSIG (checksig runs here).
65
+ const unlockingScript = new UnlockingScript([
66
+ { op: OP.OP_CODESEPARATOR },
67
+ { op: sigForScript.length, data: sigForScript },
68
+ { op: pubEnc.length, data: pubEnc },
69
+ { op: OP.OP_CHECKSIG }
70
+ ])
71
+
72
+ const spend = new Spend({
73
+ sourceTXID: sourceTx.id('hex'),
74
+ sourceOutputIndex: 0,
75
+ sourceSatoshis: satoshis,
76
+ lockingScript,
77
+ transactionVersion: 2,
78
+ otherInputs: [],
79
+ inputIndex: 0,
80
+ unlockingScript,
81
+ outputs: [],
82
+ inputSequence: 0xffffffff,
83
+ lockTime: 0
84
+ })
85
+
86
+ expect(spend.validate()).toBe(true)
87
+ })
88
+ })
@@ -376,7 +376,7 @@ describe('StorageUploader — multi-provider behavior', () => {
376
376
  })
377
377
 
378
378
  // All three quotes requested (up to 2 * resilienceLevel = 4 allowed, but we only have 3 hosts).
379
- expect(quoteCalls.sort()).toEqual([
379
+ expect(quoteCalls.sort((a, b) => a.localeCompare(b))).toEqual([
380
380
  'https://a.example/quote',
381
381
  'https://b.example/quote',
382
382
  'https://c.example/quote'
@@ -611,7 +611,7 @@ describe('StorageUploader — multi-provider behavior', () => {
611
611
  })
612
612
 
613
613
  // Only the first batch of 4 quote requests should have been issued.
614
- expect(quoteCalls.sort()).toEqual([
614
+ expect(quoteCalls.sort((a, b) => a.localeCompare(b))).toEqual([
615
615
  'https://h1.example/quote',
616
616
  'https://h2.example/quote',
617
617
  'https://h3.example/quote',
@@ -649,7 +649,7 @@ describe('StorageUploader — multi-provider behavior', () => {
649
649
  retentionPeriod: 60
650
650
  })
651
651
 
652
- expect(quoteCalls.sort()).toEqual([
652
+ expect(quoteCalls.sort((a, b) => a.localeCompare(b))).toEqual([
653
653
  'https://h1.example/quote',
654
654
  'https://h2.example/quote',
655
655
  'https://h3.example/quote',
@@ -696,7 +696,7 @@ describe('StorageUploader — multi-provider behavior', () => {
696
696
 
697
697
  // First batch queried h1-h4 (4 hosts), second batch queried only h5
698
698
  // (1 host, the exact remainder). Hosts h6-h8 are never contacted.
699
- expect(quoteCalls.sort()).toEqual([
699
+ expect(quoteCalls.sort((a, b) => a.localeCompare(b))).toEqual([
700
700
  'https://h1.example/quote',
701
701
  'https://h2.example/quote',
702
702
  'https://h3.example/quote',
@@ -763,7 +763,7 @@ describe('StorageUploader — multi-host findFile / listUploads / renewFile', ()
763
763
 
764
764
  const result = await uploader.findFile('uhrp://x')
765
765
 
766
- expect(calls.sort()).toEqual([
766
+ expect(calls.sort((a, b) => a.localeCompare(b))).toEqual([
767
767
  'https://a.example/find?uhrpUrl=uhrp%3A%2F%2Fx',
768
768
  'https://b.example/find?uhrpUrl=uhrp%3A%2F%2Fx',
769
769
  'https://c.example/find?uhrpUrl=uhrp%3A%2F%2Fx'
@@ -843,7 +843,7 @@ describe('StorageUploader — multi-host findFile / listUploads / renewFile', ()
843
843
  const listing = await uploader.listUploads()
844
844
  const byUrl = Object.fromEntries(listing.map((e: any) => [e.uhrpUrl, e]))
845
845
 
846
- expect(Object.keys(byUrl).sort()).toEqual(['uhrp://one', 'uhrp://shared', 'uhrp://two'])
846
+ expect(Object.keys(byUrl).sort((a, b) => a.localeCompare(b))).toEqual(['uhrp://one', 'uhrp://shared', 'uhrp://two'])
847
847
  expect(byUrl['uhrp://shared'].expiryTime).toBe(300) // longest wins
848
848
  expect(byUrl['uhrp://shared'].hostedBy.sort()).toEqual(['https://a.example', 'https://b.example'])
849
849
  expect(byUrl['uhrp://one'].hostedBy).toEqual(['https://a.example'])
@@ -1026,7 +1026,7 @@ describe('StorageUploader — multi-host findFile / listUploads / renewFile', ()
1026
1026
  hostedBy: ['https://a.example', 'https://c.example']
1027
1027
  })
1028
1028
 
1029
- expect(calls.sort()).toEqual([
1029
+ expect(calls.sort((a, b) => a.localeCompare(b))).toEqual([
1030
1030
  'https://a.example/renew',
1031
1031
  'https://c.example/renew'
1032
1032
  ])
@@ -0,0 +1,22 @@
1
+ import type Transaction from './Transaction.js'
2
+
3
+ /**
4
+ * A pluggable backend that verifies ALL input scripts of a single transaction.
5
+ *
6
+ * Implementations (e.g. @bsv/verifast's BdkVerifier) typically delegate to a
7
+ * native/WASM engine. The backend operates at whole-transaction granularity,
8
+ * not per input.
9
+ */
10
+ export default interface BdkVerifierInterface {
11
+ /**
12
+ * Verify all input scripts of `params.tx`.
13
+ * @returns Promise resolving true if every input script is valid, false otherwise.
14
+ * @throws If the backend itself fails (load error, marshalling error, unavailable).
15
+ */
16
+ verifyScripts: (params: {
17
+ tx: Transaction
18
+ blockHeight: number
19
+ consensus: boolean
20
+ verifyFlags?: string | string[]
21
+ }) => Promise<boolean>
22
+ }
@@ -18,6 +18,10 @@ import P2PKH from '../script/templates/P2PKH.js'
18
18
  import type { WalletInterface, DescriptionString5to50Bytes, CreateActionOptions } from '../wallet/Wallet.interfaces.js'
19
19
  import TransactionSignature from '../primitives/TransactionSignature.js'
20
20
  import Random from '../primitives/Random.js'
21
+ import type BdkVerifierInterface from './BdkVerifierInterface.js'
22
+
23
+ /** Post-Chronicle height used when an input's source UTXO mined-height is unobtainable. */
24
+ const POST_CHRONICLE_HEIGHT_FALLBACK = 943816
21
25
 
22
26
  /**
23
27
  * Represents a complete Bitcoin transaction. This class encapsulates all the details
@@ -833,7 +837,8 @@ export default class Transaction {
833
837
  async verify (
834
838
  chainTracker: ChainTracker | 'scripts only' = defaultChainTracker(),
835
839
  feeModel?: FeeModel,
836
- memoryLimit?: number
840
+ memoryLimit?: number,
841
+ verifier?: BdkVerifierInterface
837
842
  ): Promise<boolean> {
838
843
  const verifiedTxids = new Set<string>()
839
844
  const txQueue: Transaction[] = [this]
@@ -910,23 +915,41 @@ export default class Transaction {
910
915
  const otherInputs = tx.inputs.filter((_, idx) => idx !== i)
911
916
  input.sourceTXID ??= sourceTxid
912
917
 
913
- const spend = new Spend({
914
- sourceTXID: input.sourceTXID,
915
- sourceOutputIndex: input.sourceOutputIndex,
916
- lockingScript: sourceOutput.lockingScript,
917
- sourceSatoshis: sourceOutput.satoshis ?? 0,
918
- transactionVersion: tx.version,
919
- otherInputs,
920
- unlockingScript: input.unlockingScript,
921
- inputSequence: input.sequence ?? 0xffffffff, // default to max sequence
922
- inputIndex: i,
923
- outputs: tx.outputs,
924
- lockTime: tx.lockTime,
925
- memoryLimit
926
- })
927
- const spendValid = spend.validate()
918
+ if (verifier === undefined) {
919
+ const spend = new Spend({
920
+ sourceTXID: input.sourceTXID,
921
+ sourceOutputIndex: input.sourceOutputIndex,
922
+ lockingScript: sourceOutput.lockingScript,
923
+ sourceSatoshis: sourceOutput.satoshis ?? 0,
924
+ transactionVersion: tx.version,
925
+ otherInputs,
926
+ unlockingScript: input.unlockingScript,
927
+ inputSequence: input.sequence ?? 0xffffffff, // default to max sequence
928
+ inputIndex: i,
929
+ outputs: tx.outputs,
930
+ lockTime: tx.lockTime,
931
+ memoryLimit
932
+ })
933
+ const spendValid = spend.validate()
934
+
935
+ if (!spendValid) {
936
+ return false
937
+ }
938
+ }
939
+ }
928
940
 
929
- if (!spendValid) {
941
+ // When a pluggable verifier is configured, hand the whole transaction to it
942
+ // once (BDK operates at whole-tx granularity). Strict: its verdict is
943
+ // authoritative and any thrown error propagates (no JS fallback).
944
+ if (verifier !== undefined) {
945
+ // A tx reaching here has no merkle proof (mined txs short-circuit above),
946
+ // so its source UTXO mined-height is unobtainable -> post-Chronicle fallback.
947
+ const scriptsValid = await verifier.verifyScripts({
948
+ tx,
949
+ blockHeight: POST_CHRONICLE_HEIGHT_FALLBACK,
950
+ consensus: true
951
+ })
952
+ if (!scriptsValid) {
930
953
  return false
931
954
  }
932
955
  }