@bsv/sdk 2.1.3 → 2.1.5

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 (53) hide show
  1. package/dist/cjs/package.json +14 -14
  2. package/dist/cjs/src/compat/Mnemonic.js +12 -0
  3. package/dist/cjs/src/compat/Mnemonic.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/script/Spend.js +8 -1
  8. package/dist/cjs/src/script/Spend.js.map +1 -1
  9. package/dist/cjs/src/transaction/BdkVerifierInterface.js +3 -0
  10. package/dist/cjs/src/transaction/BdkVerifierInterface.js.map +1 -0
  11. package/dist/cjs/src/transaction/Transaction.js +35 -16
  12. package/dist/cjs/src/transaction/Transaction.js.map +1 -1
  13. package/dist/cjs/src/transaction/index.js.map +1 -1
  14. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  15. package/dist/esm/src/compat/Mnemonic.js +12 -0
  16. package/dist/esm/src/compat/Mnemonic.js.map +1 -1
  17. package/dist/esm/src/kvstore/GlobalKVStore.js +18 -3
  18. package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -1
  19. package/dist/esm/src/kvstore/types.js.map +1 -1
  20. package/dist/esm/src/script/Spend.js +8 -1
  21. package/dist/esm/src/script/Spend.js.map +1 -1
  22. package/dist/esm/src/transaction/BdkVerifierInterface.js +2 -0
  23. package/dist/esm/src/transaction/BdkVerifierInterface.js.map +1 -0
  24. package/dist/esm/src/transaction/Transaction.js +35 -16
  25. package/dist/esm/src/transaction/Transaction.js.map +1 -1
  26. package/dist/esm/src/transaction/index.js.map +1 -1
  27. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  28. package/dist/types/src/compat/Mnemonic.d.ts +2 -0
  29. package/dist/types/src/compat/Mnemonic.d.ts.map +1 -1
  30. package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -1
  31. package/dist/types/src/kvstore/types.d.ts +27 -0
  32. package/dist/types/src/kvstore/types.d.ts.map +1 -1
  33. package/dist/types/src/script/Spend.d.ts.map +1 -1
  34. package/dist/types/src/transaction/BdkVerifierInterface.d.ts +22 -0
  35. package/dist/types/src/transaction/BdkVerifierInterface.d.ts.map +1 -0
  36. package/dist/types/src/transaction/Transaction.d.ts +2 -1
  37. package/dist/types/src/transaction/Transaction.d.ts.map +1 -1
  38. package/dist/types/src/transaction/index.d.ts +1 -0
  39. package/dist/types/src/transaction/index.d.ts.map +1 -1
  40. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  41. package/dist/umd/bundle.js +4 -4
  42. package/package.json +14 -14
  43. package/src/compat/Mnemonic.ts +13 -0
  44. package/src/compat/__tests/Mnemonic.test.ts +49 -5
  45. package/src/kvstore/GlobalKVStore.ts +18 -3
  46. package/src/kvstore/__tests/GlobalKVStore.test.ts +36 -0
  47. package/src/kvstore/types.ts +28 -0
  48. package/src/script/Spend.ts +8 -1
  49. package/src/script/__tests/Spend.codeseparator.test.ts +88 -0
  50. package/src/transaction/BdkVerifierInterface.ts +22 -0
  51. package/src/transaction/Transaction.ts +40 -17
  52. package/src/transaction/__tests/Transaction.verifier.test.ts +72 -0
  53. 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.3",
3
+ "version": "2.1.5",
4
4
  "type": "module",
5
5
  "description": "BSV Blockchain Software Development Kit",
6
6
  "main": "dist/cjs/mod.js",
@@ -237,23 +237,23 @@
237
237
  },
238
238
  "homepage": "https://github.com/bsv-blockchain/ts-stack/tree/main/packages/sdk#readme",
239
239
  "devDependencies": {
240
- "@eslint/js": "^9.39.1",
241
- "@jest/globals": "^30.3.0",
242
- "@rspack/cli": "^2.0.0",
243
- "@rspack/core": "^1.6.1",
240
+ "@eslint/js": "^10.0.1",
241
+ "@jest/globals": "^30.4.1",
242
+ "@rspack/cli": "^2.0.8",
243
+ "@rspack/core": "^2.0.8",
244
244
  "@types/jest": "^30.0.0",
245
- "@types/node": "^24.10.1",
246
- "eslint": "^9.39.1",
247
- "globals": "^16.5.0",
248
- "jest": "^30.3.0",
249
- "jest-environment-jsdom": "^30.3.0",
250
- "ts-jest": "^29.4.9",
251
- "ts-loader": "^9.5.4",
245
+ "@types/node": "^25.9.3",
246
+ "eslint": "^10.5.0",
247
+ "globals": "^17.6.0",
248
+ "jest": "^30.4.2",
249
+ "jest-environment-jsdom": "^30.4.1",
250
+ "ts-jest": "^29.4.11",
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
- "typescript": "^5.9.3",
256
- "typescript-eslint": "^8.46.4"
255
+ "typescript": "^6.0.3",
256
+ "typescript-eslint": "^8.61.0"
257
257
  },
258
258
  "ts-standard": {
259
259
  "project": "tsconfig.eslint.json",
@@ -125,9 +125,22 @@ export default class Mnemonic {
125
125
  * Sets the mnemonic for the instance from a string.
126
126
  * @param {string} mnemonic - The mnemonic phrase as a string.
127
127
  * @returns {this} The Mnemonic instance with the set mnemonic.
128
+ * @throws {Error} If the mnemonic does not pass BIP-39 validation
129
+ * (unknown words, invalid length, or bad checksum).
128
130
  */
129
131
  public fromString (mnemonic: string): this {
130
132
  this.mnemonic = mnemonic
133
+ let valid = false
134
+ try {
135
+ valid = this.check()
136
+ } catch {
137
+ valid = false
138
+ }
139
+ if (!valid) {
140
+ throw new Error(
141
+ 'Mnemonic does not pass the check - was the mnemonic typed incorrectly? Are there extra spaces?'
142
+ )
143
+ }
131
144
  return this
132
145
  }
133
146
 
@@ -49,15 +49,21 @@ describe('Mnemonic', function () {
49
49
  mnemonic = m.mnemonic
50
50
 
51
51
  // mnemonics with extra whitespace do not pass the check
52
- m = new Mnemonic().fromString(mnemonic + ' ')
53
- expect(m.check()).toEqual(false)
52
+ const trailingSpace = mnemonic + ' '
53
+ const trailing = new Mnemonic()
54
+ trailing.mnemonic = trailingSpace
55
+ expect(trailing.check()).toEqual(false)
56
+ expect(() => new Mnemonic().fromString(trailingSpace)).toThrow()
54
57
 
55
58
  // mnemonics with a word replaced do not pass the check
56
59
  const words = mnemonic.split(' ')
57
60
  expect(words[words.length - 1]).not.toEqual('zoo')
58
61
  words[words.length - 1] = 'zoo'
59
- mnemonic = words.join(' ')
60
- expect(new Mnemonic().fromString(mnemonic).check()).toEqual(false)
62
+ const badWord = words.join(' ')
63
+ const replaced = new Mnemonic()
64
+ replaced.mnemonic = badWord
65
+ expect(replaced.check()).toEqual(false)
66
+ expect(() => new Mnemonic().fromString(badWord)).toThrow()
61
67
  })
62
68
 
63
69
  describe('#toBinary', () => {
@@ -132,13 +138,51 @@ describe('Mnemonic', function () {
132
138
  })
133
139
 
134
140
  describe('#fromString', () => {
135
- it('should throw an error in invalid mnemonic', () => {
141
+ it('should throw an error on invalid mnemonic when toSeed is called', () => {
136
142
  expect(() => {
137
143
  new Mnemonic().fromString('invalid mnemonic').toSeed()
138
144
  }).toThrow(
139
145
  'Mnemonic does not pass the check - was the mnemonic typed incorrectly? Are there extra spaces?'
140
146
  )
141
147
  })
148
+
149
+ it('should throw immediately on nonsense input', () => {
150
+ expect(() => {
151
+ Mnemonic.fromString('this is not a real bip39 mnemonic phrase at all')
152
+ }).toThrow(
153
+ 'Mnemonic does not pass the check - was the mnemonic typed incorrectly? Are there extra spaces?'
154
+ )
155
+ })
156
+
157
+ it('should throw on garbage string', () => {
158
+ expect(() => {
159
+ Mnemonic.fromString('asdfghjkl qwertyuiop zxcvbnm')
160
+ }).toThrow()
161
+ })
162
+
163
+ it('should throw on empty string', () => {
164
+ expect(() => {
165
+ Mnemonic.fromString('')
166
+ }).toThrow()
167
+ })
168
+
169
+ it('should throw on valid words but wrong checksum', () => {
170
+ // 12 valid wordlist entries but checksum will not match
171
+ expect(() => {
172
+ Mnemonic.fromString(
173
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon'
174
+ )
175
+ }).toThrow()
176
+ })
177
+
178
+ it('should accept a known-valid BIP-39 phrase', () => {
179
+ const m = Mnemonic.fromString(
180
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
181
+ )
182
+ expect(m.mnemonic).toEqual(
183
+ 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
184
+ )
185
+ })
142
186
  })
143
187
 
144
188
  describe('@isValid', () => {
@@ -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) */
@@ -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
+ })
@@ -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
  }
@@ -0,0 +1,72 @@
1
+ import Transaction from '../Transaction'
2
+ import Script from '../../script/Script'
3
+ import P2PKH from '../../script/templates/P2PKH'
4
+ import PrivateKey from '../../primitives/PrivateKey'
5
+ import MerklePath from '../MerklePath'
6
+ import type BdkVerifierInterface from '../BdkVerifierInterface'
7
+
8
+ // Build a tx whose single P2PKH input is genuinely valid under the pure-JS interpreter.
9
+ async function buildValidTx (): Promise<Transaction> {
10
+ const key = PrivateKey.fromRandom()
11
+ const source = new Transaction()
12
+ source.addInput({
13
+ sourceTXID: '00'.repeat(32),
14
+ sourceOutputIndex: 0,
15
+ unlockingScript: Script.fromASM('OP_TRUE')
16
+ })
17
+ source.addOutput({ satoshis: 2, lockingScript: new P2PKH().lock(key.toAddress()) })
18
+ await source.sign()
19
+ source.merklePath = new MerklePath(1000, [
20
+ [{ offset: 0, hash: source.id('hex'), txid: true }, { offset: 1, duplicate: true }]
21
+ ])
22
+
23
+ const tx = new Transaction()
24
+ tx.addInput({
25
+ sourceTransaction: source,
26
+ sourceOutputIndex: 0,
27
+ unlockingScriptTemplate: new P2PKH().unlock(key)
28
+ })
29
+ tx.addOutput({ satoshis: 1, lockingScript: new P2PKH().lock(key.toAddress()) })
30
+ await tx.sign()
31
+ return tx
32
+ }
33
+
34
+ describe('Transaction.verify with a pluggable verifier', () => {
35
+ it('routes to the verifier and returns its false result, bypassing Spend', async () => {
36
+ const tx = await buildValidTx()
37
+ let called = 0
38
+ const verifier: BdkVerifierInterface = {
39
+ verifyScripts: async () => { called++; return false }
40
+ }
41
+ // Pure-JS would return true; verifier says false -> proves bypass + routing.
42
+ const result = await tx.verify('scripts only', undefined, undefined, verifier)
43
+ expect(called).toBe(1)
44
+ expect(result).toBe(false)
45
+ })
46
+
47
+ it('returns true when the verifier approves', async () => {
48
+ const tx = await buildValidTx()
49
+ const verifier: BdkVerifierInterface = { verifyScripts: async () => true }
50
+ const result = await tx.verify('scripts only', undefined, undefined, verifier)
51
+ expect(result).toBe(true)
52
+ })
53
+
54
+ it('propagates a verifier throw (strict, no fallback)', async () => {
55
+ const tx = await buildValidTx()
56
+ const verifier: BdkVerifierInterface = {
57
+ verifyScripts: async () => { throw new Error('wasm unavailable') }
58
+ }
59
+ await expect(tx.verify('scripts only', undefined, undefined, verifier))
60
+ .rejects.toThrow('wasm unavailable')
61
+ })
62
+
63
+ it('passes the post-Chronicle fallback blockHeight (943816) to the verifier', async () => {
64
+ const tx = await buildValidTx() // unmined tx -> no merkle proof -> fallback height
65
+ let seenHeight = -1
66
+ const verifier: BdkVerifierInterface = {
67
+ verifyScripts: async ({ blockHeight }) => { seenHeight = blockHeight; return true }
68
+ }
69
+ await tx.verify('scripts only', undefined, undefined, verifier)
70
+ expect(seenHeight).toBe(943816)
71
+ })
72
+ })
@@ -1,4 +1,5 @@
1
1
  export { default as Transaction } from './Transaction.js'
2
+ export type { default as BdkVerifierInterface } from './BdkVerifierInterface.js'
2
3
  export { default as MerklePath } from './MerklePath.js'
3
4
  export type { default as TransactionInput } from './TransactionInput.js'
4
5
  export type { default as TransactionOutput } from './TransactionOutput.js'