@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.
- package/dist/cjs/package.json +14 -14
- package/dist/cjs/src/compat/Mnemonic.js +12 -0
- package/dist/cjs/src/compat/Mnemonic.js.map +1 -1
- package/dist/cjs/src/kvstore/GlobalKVStore.js +18 -3
- package/dist/cjs/src/kvstore/GlobalKVStore.js.map +1 -1
- package/dist/cjs/src/kvstore/types.js.map +1 -1
- package/dist/cjs/src/script/Spend.js +8 -1
- package/dist/cjs/src/script/Spend.js.map +1 -1
- package/dist/cjs/src/transaction/BdkVerifierInterface.js +3 -0
- package/dist/cjs/src/transaction/BdkVerifierInterface.js.map +1 -0
- package/dist/cjs/src/transaction/Transaction.js +35 -16
- package/dist/cjs/src/transaction/Transaction.js.map +1 -1
- package/dist/cjs/src/transaction/index.js.map +1 -1
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/src/compat/Mnemonic.js +12 -0
- package/dist/esm/src/compat/Mnemonic.js.map +1 -1
- package/dist/esm/src/kvstore/GlobalKVStore.js +18 -3
- package/dist/esm/src/kvstore/GlobalKVStore.js.map +1 -1
- package/dist/esm/src/kvstore/types.js.map +1 -1
- package/dist/esm/src/script/Spend.js +8 -1
- package/dist/esm/src/script/Spend.js.map +1 -1
- package/dist/esm/src/transaction/BdkVerifierInterface.js +2 -0
- package/dist/esm/src/transaction/BdkVerifierInterface.js.map +1 -0
- package/dist/esm/src/transaction/Transaction.js +35 -16
- package/dist/esm/src/transaction/Transaction.js.map +1 -1
- package/dist/esm/src/transaction/index.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/src/compat/Mnemonic.d.ts +2 -0
- package/dist/types/src/compat/Mnemonic.d.ts.map +1 -1
- package/dist/types/src/kvstore/GlobalKVStore.d.ts.map +1 -1
- package/dist/types/src/kvstore/types.d.ts +27 -0
- package/dist/types/src/kvstore/types.d.ts.map +1 -1
- package/dist/types/src/script/Spend.d.ts.map +1 -1
- package/dist/types/src/transaction/BdkVerifierInterface.d.ts +22 -0
- package/dist/types/src/transaction/BdkVerifierInterface.d.ts.map +1 -0
- package/dist/types/src/transaction/Transaction.d.ts +2 -1
- package/dist/types/src/transaction/Transaction.d.ts.map +1 -1
- package/dist/types/src/transaction/index.d.ts +1 -0
- package/dist/types/src/transaction/index.d.ts.map +1 -1
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/dist/umd/bundle.js +4 -4
- package/package.json +14 -14
- package/src/compat/Mnemonic.ts +13 -0
- package/src/compat/__tests/Mnemonic.test.ts +49 -5
- package/src/kvstore/GlobalKVStore.ts +18 -3
- package/src/kvstore/__tests/GlobalKVStore.test.ts +36 -0
- package/src/kvstore/types.ts +28 -0
- package/src/script/Spend.ts +8 -1
- package/src/script/__tests/Spend.codeseparator.test.ts +88 -0
- package/src/transaction/BdkVerifierInterface.ts +22 -0
- package/src/transaction/Transaction.ts +40 -17
- package/src/transaction/__tests/Transaction.verifier.test.ts +72 -0
- 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
|
+
"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": "^
|
|
241
|
-
"@jest/globals": "^30.
|
|
242
|
-
"@rspack/cli": "^2.0.
|
|
243
|
-
"@rspack/core": "^
|
|
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": "^
|
|
246
|
-
"eslint": "^
|
|
247
|
-
"globals": "^
|
|
248
|
-
"jest": "^30.
|
|
249
|
-
"jest-environment-jsdom": "^30.
|
|
250
|
-
"ts-jest": "^29.4.
|
|
251
|
-
"ts-loader": "^9.
|
|
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": "^
|
|
256
|
-
"typescript-eslint": "^8.
|
|
255
|
+
"typescript": "^6.0.3",
|
|
256
|
+
"typescript-eslint": "^8.61.0"
|
|
257
257
|
},
|
|
258
258
|
"ts-standard": {
|
|
259
259
|
"project": "tsconfig.eslint.json",
|
package/src/compat/Mnemonic.ts
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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
|
// --------------------------------------------------------------------------
|
package/src/kvstore/types.ts
CHANGED
|
@@ -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) */
|
package/src/script/Spend.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/src/transaction/index.ts
CHANGED
|
@@ -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'
|