dcr-ts 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,563 @@
1
+ # Changelog
2
+
3
+ Notable changes per release. Dates are release dates.
4
+
5
+ This library has **not** been independently audited. See [SECURITY.md](SECURITY.md).
6
+
7
+ ## 0.2.0 — 2026-08-25
8
+
9
+ ### Fixed — consensus
10
+
11
+ - **HD derivation now matches dcrd and every real Decred wallet.** Hardened
12
+ derivation followed strict BIP32, but Decred deliberately deviates: dcrd's
13
+ `hdkeychain.Child` strips leading zero bytes from a derived private key and
14
+ carries the shortened key into the next hardened HMAC, and dcrwallet uses that
15
+ variant for the whole wallet path. For roughly **1 seed in 128** on a BIP44 path
16
+ this library derived an entirely different wallet — a restored seed would show an
17
+ empty wallet, and coins sent to a dcr-ts address would be invisible to any other
18
+ wallet holding the same phrase. Verified against dcrd over 400 seeds.
19
+ `deriveBip32Std` / `derivePathBip32Std` provide the strict form.
20
+ - **`NULL_BLOCK_HEIGHT` was `0xffffffff`; dcrd's `wire.NullBlockHeight` is `0`.**
21
+ Only `NullBlockIndex` is `0xffffffff`. Every transaction built without an
22
+ explicit `blockHeight` carried four wrong witness bytes and a wrong
23
+ `TxHashWitness`/`TxHashFull`.
24
+ - **`pushData` emitted non-minimal pushes.** dcrd enforces minimal data pushes
25
+ unconditionally in its script engine, with no verification flag gating it, so a
26
+ single byte in `1..16` must use `OP_1..OP_16` and `0x81` must use `OP_1NEGATE`.
27
+ Scripts built with the old output were unspendable. Pushes are also now capped at
28
+ `MaxScriptElementSize` (2048).
29
+ - **`calcSignatureHash` accepted a `subScript` that does not parse.** dcrd's
30
+ exported `CalcSignatureHash` gates on `checkScriptParses` first, so signing
31
+ against a malformed script produced a signature over a message dcrd would refuse
32
+ to compute.
33
+ - **`calcSignatureHash` accepted hash types wider than a byte.** The preimage
34
+ commits to the hash type as a `uint32` while a signature script carries only its
35
+ low byte, so `0x101` was committed in full and transmitted as `0x01` — the
36
+ verifier recomputes a different hash and the signature can never verify. Signing
37
+ is now restricted to the six values dcrd's `CheckHashTypeEncoding` accepts.
38
+
39
+ ### Fixed — memory and validation
40
+
41
+ - **Parsed values no longer alias a caller's `Buffer`.** Node's `Buffer` overrides
42
+ `slice()` to return a *view*, so `Reader.bytes`, `ExtendedKey.fromSerialized` and
43
+ `extractHash160` aliased the caller's memory for the most common input type in
44
+ Node. Reusing or zeroing that buffer rewrote an already-parsed transaction's txid
45
+ and scripts, and destroyed a just-parsed extended key — so a caller correctly
46
+ wiping a serialized key was the thing that broke it. `addInput`/`addOutput` now
47
+ copy as well.
48
+ - **Public keys are validated before becoming addresses or output scripts.**
49
+ `addressFromPubKey`, `pubKeyAddress` and `payToPubKeyScript` accepted any bytes,
50
+ producing well-formed, valid-checksum, permanently unspendable results. The
51
+ realistic way in: passing `privateKeyBytes()` where `publicKey()` was meant
52
+ type-checks silently, because both are `Uint8Array`.
53
+ - **`verifyHash` is strictly DER and canonical.** `@noble`'s `verify` falls back to
54
+ the 64-byte compact encoding, which dcrd's engine rejects — a co-signer emitting
55
+ compact signatures passed local validation and then failed consensus.
56
+ - **`mnemonicToMasterKey` validates the mnemonic.** BIP39 seed derivation is
57
+ defined for any string, so a typo'd phrase expanded into a different
58
+ valid-looking wallet with every operation appearing to succeed.
59
+ `mnemonicToSeed` remains the unchecked primitive.
60
+ - **`hardened()` no longer wraps.** For an argument at or above `2^31` it produced
61
+ a *non*-hardened index, silently deriving from the wrong branch. `derive()` and
62
+ `Writer.u8/u16/u32` likewise reject out-of-range values instead of coercing them,
63
+ and `calcSignatureHash` rejects a non-integer input index (`NaN` slipped past both
64
+ range checks and produced a hash committing the subScript to no input).
65
+
66
+ - **Ed25519 public keys decode the way dcrd decodes them.** `@noble` enforces the
67
+ RFC 8032 range `Y < P`; dcrd's `edwards.ParsePubKey` goes through AGL's
68
+ `edwards25519`, which masks off only the sign bit, so an encoding of `Y+P` names
69
+ the same point and is accepted. 23 byte strings — every one of them, since `Y+P`
70
+ must fit in 255 bits, bounding `Y` at 18 — decoded as a valid pay-to-pubkey
71
+ address in dcrd and threw `invalid-public-key` here, on both the decoder and the
72
+ encoder. Only acceptance widens; the one encoding dcrd does reject in that
73
+ range, `X = 0` with the sign bit set, is still rejected. Every such key is the
74
+ identity, an order-4 point, or a point of unknown discrete log, so no address
75
+ built from one is spendable — this is a parity fix, not a security one.
76
+ `@noble/curves` moves to `^1.9.2`, the first release whose types declare the
77
+ ZIP-215 argument; nothing upgrades, since the installed 1.9.7 already satisfies
78
+ the old range.
79
+
80
+ - **`encodeWif` validated nothing about the signature-suite argument.** It went
81
+ straight into the payload byte, and a `Uint8Array` store coerces rather than
82
+ rejects: `256` became suite 0, `-1` became suite 255, `1.5` became suite 1, and
83
+ the enum *name* `"Ed25519"` became suite 0 — a well-formed WIF for a suite the
84
+ caller never asked for, or one this library's own `decodeWif` refuses. dcrd's
85
+ `NewWIF` errors on an unsupported scheme. `decodeWif` continues to reject
86
+ unknown suite bytes, which is a **deliberate divergence**: dcrd's `DecodeWIF`
87
+ has no default arm and accepts them as a WIF holding a nil private key, whose
88
+ own `String()` is not a WIF.
89
+ - **`decodeWif` did not bound the Ed25519 scalar.** dcrd runs suite-1 keys through
90
+ `edwards.PrivKeyFromScalar` in both `NewWIF` and `DecodeWIF`, which rejects zero
91
+ and anything above the group order, so a zero-key or all-`ff` Ed25519 WIF
92
+ decoded here and then failed to import anywhere else. Matched exactly, including
93
+ dcrd's acceptance of a scalar equal to the order — the check there is
94
+ `D.Cmp(N) > 0`. Both sides of the codec validate, because Ed25519 keys are 32
95
+ uniform bytes against an order near 2^252: about 15 of every 16 random keys
96
+ exceed it, so a decode-only check would have left `encodeWif` minting strings
97
+ its own decoder rejects. The secp256k1 suites stay unchecked, as in dcrd, whose
98
+ `PrivKeyFromBytes` cannot fail — it reduces mod n and discards the overflow.
99
+
100
+ - **A zero HMAC left half is an invalid child on both derivation paths.** dcrd's
101
+ `hdkeychain` rejects `IL` when `overflow || ilModN.IsZero()`, before it splits
102
+ on private-vs-public, so a zero `IL` invalidates the index for either. This
103
+ library applied the zero check only on the public path; on the private path the
104
+ derived child would have been byte-identical to its parent, silently diverging
105
+ every descendant from what dcrd derives. A zero `IL` is a 2^-256 HMAC output, so
106
+ nothing observable changes — it removes an asymmetry between two sibling
107
+ functions in `keys.ts`. Note this is stricter than BIP32 itself, which permits
108
+ `IL == 0` on the private path.
109
+
110
+ - **`Transaction.version` was masked to 16 bits instead of range-checked.**
111
+ `serialize()` packed it as `(serType << 16) | (version & 0xffff)`, so 65537
112
+ serialized as version 1, -1 as 65535 and `NaN` as 0 — and the txid and every
113
+ signature committed to a version the caller never asked for, with nothing
114
+ raising. The same mask sat in both signature-hash words, so `calcSignatureHash`
115
+ could sign a silently-wrong version without `serialize()` ever being called.
116
+ All three now go through one guard. `TxOutput.version`, the other 16-bit field
117
+ in the same serializer, has always thrown on these inputs via `Writer.u16`.
118
+ dcrd cannot express the case at all — `wire.MsgTx.Version` is a uint16 — so no
119
+ byte changes for any legal version. Decoding stays permissive, matching dcrd's
120
+ own `uint16(version & 0xffff)`.
121
+
122
+ ### Fixed — the typed-error contract at foreign boundaries
123
+
124
+ The contract above held at every one of this library's own `throw` sites, but not
125
+ where a builtin or a dependency threw first. Four boundaries leaked, so a caller
126
+ branching on `hasErrorCode` could not classify the failure.
127
+
128
+ - **`Writer.varInt` threw a bare `RangeError`.** A non-integer `number` reached
129
+ `BigInt(v)`, which the engine rejects itself. It is checked first now, with the
130
+ same `not-an-integer` code `checkUint` already used one screen up.
131
+ - **`isValidPrivateKey` returned `true` for values that are not keys.** Its only
132
+ shape test was `key.length !== 32`, and `length` is satisfied by a 32-*character*
133
+ string, a 32-element `Array<number>`, an `Int8Array` and a `Float64Array` —
134
+ `bytesToBigInt` then read each one into some unrelated number. So the predicate
135
+ answered `true` for a hex string, which is a wrong answer rather than a failure,
136
+ and a string of non-digits escaped as a bare `SyntaxError` out of `BigInt()`.
137
+ The same hole sat behind `assertPrivateKey`, `encodeWif` and the new 32-byte
138
+ hash check, where `@noble`'s own `Error` was doing the rejecting. All four now
139
+ test the value really is a byte array and report `invalid-argument`. The test is
140
+ tag-based rather than `instanceof`, for the same reason `DcrError` carries a
141
+ registry-symbol brand: `instanceof Uint8Array` is false for a typed array from
142
+ another realm. A Node `Buffer` — a `Uint8Array` subclass, and the likeliest
143
+ input of all — keeps working.
144
+
145
+ - **`signHash` and `verifyHash` accepted a hash of any length, and scalar
146
+ reduction is not injective.** A short hash signs identically to itself
147
+ left-padded with zeros to 32 bytes — `signHash(h31)` and
148
+ `signHash(0x00 ‖ h31)` are the same DER bytes — and a long hash is silently
149
+ truncated to its first 32. A caller passing a mis-sliced buffer got a valid
150
+ signature committing to a *different* message than the one they held, with
151
+ nothing raising, and `verifyHash` returned `true` for the short form against a
152
+ signature over the padded one. Both now require exactly 32 bytes. `@noble` and
153
+ dcrd agree byte-for-byte on every one of these malformed cases, so this is not
154
+ a behavioural divergence but a hazard both share; dcrd needs no guard because
155
+ `chainhash.Hash` is `[32]byte` and the only thing reaching `ecdsa.Sign` is a
156
+ BLAKE-256 output, whereas a `Uint8Array` carries no length in its type.
157
+
158
+ - **The signing paths let `@noble`'s own `Error` escape.** `signHash`,
159
+ `publicKeyFromPrivate`, `rawTxInSignature`, `signatureScript`, `signP2PKHInput`
160
+ and `signP2PKHInputs` passed a zeroed, over-order or wrong-length private key
161
+ straight through. They throw `invalid-private-key` or `bad-length` via the new
162
+ `assertPrivateKey(key, who)`, exported alongside `assertPubKey`. This is a
163
+ deliberate divergence: dcrd's `secp256k1.PrivKeyFromBytes` cannot fail — it
164
+ reduces mod n and left-pads a short slice — so a zero key there yields a real
165
+ DER signature under an all-zero-X public key, and a 31-byte key is silently
166
+ padded and signed. Rejecting is the safer contract for a signing API.
167
+ - **The mnemonic wrappers let `@scure`'s errors escape.** `generateMnemonic`,
168
+ `entropyToMnemonic`, `mnemonicToEntropy` and `mnemonicToSeed` now report
169
+ `out-of-range`/`not-an-integer` for a bad strength, `bad-length` for bad entropy
170
+ size, `invalid-argument` for a wordlist that is not 2048 words, and
171
+ `invalid-mnemonic` for the phrase itself. Wrapping also drops `@scure`'s message
172
+ for an unknown word, which inlined the entire 2048-word list.
173
+ - **A non-string mnemonic is an argument fault, not a bad phrase.** `@scure`
174
+ conflates the two — `mnemonicToEntropy` throws the same untyped error for a
175
+ number as for a bad checksum, and `validateMnemonic` merely answers `false` —
176
+ so `mnemonicToMasterKey` told a caller who passed a number that their checksum
177
+ was wrong, or that one of their words was not in the wordlist. All three
178
+ mnemonic entry points now report `invalid-argument` for a non-string and keep
179
+ `invalid-mnemonic` for a phrase that is genuinely bad.
180
+
181
+ - **`mnemonicToSeed` decides for itself instead of catching.** Its wrapper caught
182
+ everything and reported `invalid-mnemonic`, so a bug inside `@scure` — or a new
183
+ failure in a future version — would have been reported to the caller as "your
184
+ mnemonic is bad", the one direction that wastes the most debugging time. The
185
+ two failures it can actually have are now checked directly, and the catch is
186
+ gone. They are exhaustive: `mnemonicToSeedSync` touches the caller's input only
187
+ through `nfkd`, which rejects a non-string, and `normalize`, which rejects a
188
+ word count outside 12, 15, 18, 21 and 24; its salt is a string concatenation
189
+ that cannot throw, and its PBKDF2 parameters are constants. A non-string is now
190
+ `invalid-argument` rather than being conflated with a bad phrase, and the word
191
+ count is named in the message. The checks are verified against `@scure` itself
192
+ by a differential test — including the normalization subtlety that NFKD maps a
193
+ no-break space to a plain one, *creating* a word boundary.
194
+
195
+ - **`mnemonicToSeed` was documented as unchecked and is not.** `@scure` enforces a
196
+ word count of 12, 15, 18, 21 or 24 before the PBKDF2, so `mnemonicToSeed("hello")`
197
+ always threw. Only the checksum and the wordlist go unchecked; the doc comment
198
+ said "defined for any string" and now says what is actually true.
199
+
200
+ - **`isDcrError` and `hasErrorCode` no longer depend on class identity.** The dual
201
+ ESM+CJS build can be loaded twice in one process — Node's exports map hands
202
+ `import` the ESM bundle and `require` the CJS one, and bundlers land in the same
203
+ place resolving `module` for application code and `main` for a CommonJS
204
+ dependency — and `instanceof` is false across the two copies, so
205
+ `hasErrorCode(e, "bad-checksum")` returned false for an error a CommonJS
206
+ dependency threw. Both predicates now also accept a `Symbol.for("dcr-ts.DcrError")`
207
+ brand, which is the same value in every copy and every realm.
208
+
209
+ ### Fixed — found by review
210
+
211
+ An automated review pass over the tree at `4cce672`, plus verification of what it
212
+ reported. Two of its findings did not survive the check as written and are
213
+ recorded here as they actually stand.
214
+
215
+ - **`payToPubKeyAltScript` built Ed25519 outputs from keys that are not curve
216
+ points.** Length was its only validation, so 32 arbitrary bytes produced a
217
+ well-formed script that no key can ever satisfy — coins paid to it are gone.
218
+ Every sibling already checked: the same function's Schnorr arm calls
219
+ `assertCompressedPubKey`, and `pubKeyEd25519Address` calls
220
+ `isValidEd25519PublicKey`. Now pinned against all 78 dcrd-generated Ed25519
221
+ vectors, 30 of which are rejections. Neither review found this one; it sits at
222
+ the seam between `script.ts` and `keys.ts`, and the function had no direct test
223
+ of any kind.
224
+ - **`packVersion` range-checked the version and not the serialization type.** The
225
+ half its own doc comment argues about was guarded; the other half took the
226
+ treatment the comment warns against. `<<` coerces to int32, so a type of
227
+ `0x10000` shifted off the top and silently became `0` (Full) and `-1` corrupted
228
+ the version bits. Only internal callers passing literals could reach it, so no
229
+ wire bytes were ever wrong — a trap for the next caller rather than a live
230
+ fault.
231
+ - **`Writer.varInt` still leaked bare engine errors for non-numbers.** The guard
232
+ added earlier in this section tests `typeof v === "number"` first, so `null`
233
+ gave a bare `TypeError` and an object whose `valueOf` yields `NaN` a bare
234
+ `RangeError` — exactly what the comment beside it says the guard prevents.
235
+ - **The remaining shape-validation gaps at foreign boundaries.** The section above
236
+ fixed four; the siblings were missed. `assertPubKey`, `assertCompressedPubKey`,
237
+ `pushData`, the three pay-to-hash builders, `Blake256.update` (and so
238
+ `blake256`/`hash256`/`hash160`), `Transaction.addInput`/`addOutput`, and the
239
+ string entry points `decodeAddress`/`decodeWif`/`ExtendedKey.fromString` all
240
+ read `.length` or an inner field before establishing there was one. The
241
+ predicates in the same set — `isValidEd25519PublicKey`, `isPayToPubKeyHash`,
242
+ `isPayToScriptHash`, `scriptParses`, `classifyScript` — now answer instead of
243
+ throwing, which is what a predicate is for.
244
+ - **Error messages no longer repeat back whatever the caller supplied.** `${v}`
245
+ on a validation path calls the argument's own `toString`, so an object could
246
+ write newlines, terminal escape sequences or unbounded text into a
247
+ `DcrError.message`, and a string argument was echoed verbatim — including a
248
+ control character pasted into an address. Fifteen sites, not the one the
249
+ review named: `shown` renders primitives unchanged (so no message with a length
250
+ or an index in it moves), escapes and clips strings, and names anything else by
251
+ type without asking it. `dcrToAtoms`, `derivePath` and `tx.version` were the
252
+ unbounded ones. The three raw interpolations left are provably safe — a
253
+ `typeof`, a computed word count, and one inside a `typeof v === "number"`
254
+ branch.
255
+ - **The exported base58 decoders are documented for what they are.** SECURITY.md
256
+ said "the base58 decoders are separately bounded", which is true of
257
+ `decodeAddress`, `decodeWif` and `ExtendedKey.fromString` and not of
258
+ `base58Decode` and `checkDecode` — both exported, both quadratic, both
259
+ deliberately uncapped, and a 128 KB argument still blocks the event loop for
260
+ several seconds. The review reported this as two *unreferenced* helpers
261
+ (`rawDecode`, `rawDecodeExtendedKey`) contradicting the sentence; those were
262
+ dead code and are deleted, but they were never reachable and were tree-shaken
263
+ out of the bundle anyway. The live counterexamples were the two exports beside
264
+ them.
265
+
266
+ ### Fixed — test coverage that was not there
267
+
268
+ Found by mutation rather than by reading the coverage report: each item below is
269
+ a guard that could be deleted from `src/` with the entire suite still green.
270
+ Coverage counted these lines as executed, because they run on the success path —
271
+ what nothing did was reach the branch where they refuse.
272
+
273
+ - **`Transaction.fromBytes` accepted three structurally wrong serializations.**
274
+ Nothing exercised the rejection side at all: the only test feeding `fromBytes`
275
+ gave it well-formed bytes. The non-Full serialization type, a witness input
276
+ count disagreeing with the prefix's, and trailing bytes after the transaction
277
+ were all silently parseable with their checks removed.
278
+ - **`copyOf`'s overrun check.** `subarray` clamps rather than throwing, so
279
+ without it an over-long read returns a short buffer zero-padded to the
280
+ requested length — a truncated key or hash that is the right size.
281
+ - **`ExtendedKey.fromSeed`'s 16..64 byte bound.** Outside it HMAC-SHA512 still
282
+ yields a usable-looking master key, so a one-byte "seed" produced a real wallet
283
+ with almost no entropy.
284
+ - **`decodeAddress`'s `unknown-prefix` code.** Changing it to any other code went
285
+ unnoticed, though telling "not a Decred address" from "this is a testnet
286
+ address" is exactly what a UI branches on.
287
+ - **`verifyHash`'s strict-DER and low-S contract** is now pinned as an outcome —
288
+ compact encodings, trailing bytes, long-form lengths, high-S, wrong message and
289
+ wrong key — rather than per-line.
290
+
291
+ Three guards are *deliberately* not covered, and are now commented as such so
292
+ they are not mistaken for dead code: `assertPubKey`'s prefix check is subsumed by
293
+ `isValidPublicKey`, and `verifyHash`'s canonical-DER re-encode and `hasHighS()`
294
+ are subsumed by `@noble`'s DER parser and its `lowS: true`. None can be
295
+ mutation-killed while the check that subsumes it stands. They are kept as
296
+ backstops against that dependency behaviour changing, since dcrd's
297
+ `IsStrictSignatureEncoding` is a consensus rule.
298
+
299
+ ### Fixed — parity claims the generator now makes
300
+
301
+ Two assertions described themselves as dcrd ground truth without dcrd settling
302
+ them. `vectorgen` already imported `txscript`; it emits both now, so CI
303
+ regenerates them and fails on drift like every other vector.
304
+
305
+ - **The 32-case script-parse oracle was transcribed by hand.** It was the only
306
+ dcrd-parity table in the repo CI could not regenerate, and so the only one that
307
+ could drift silently. Now generated by running each script through dcrd's
308
+ `CalcSignatureHash`, whose sole structural gate is `checkScriptParses` — the
309
+ same call the comment always claimed. All 32 original verdicts were correct;
310
+ nine boundary cases are added, for 41 total.
311
+ - **`sigHashPrefixAll is exactly the transaction prefix hash` asserted
312
+ `f(x) === f(x)`.** The function's whole body is `return tx.hash()`, so the test
313
+ could not fail under any mutation of anything, and the `sighashPrefixReuse`
314
+ fixture that would have made it real was generated, declared in the fixture
315
+ type, and read by no test — while emitting `tx3.TxHash()`, a value already
316
+ pinned as `tx3.txid`. The fixture now carries the prefix serialization and
317
+ dcrd's `SigHashAll` hashes computed with the cache *supplied*, so a cached path
318
+ that diverged from the uncached one is caught rather than assumed.
319
+
320
+
321
+ - **`decodeWif`'s checksum was unverified by any test.** Deleting the comparison
322
+ outright left all 138 tests green. The only negative WIF in the suite was
323
+ `"nonsense"`, which fails on length long before the checksum is reached, and
324
+ Decred's WIF checksum is a *single* BLAKE-256 — a different construction from
325
+ the double-hashed base58check the address tests cover, so it needed its own
326
+ case. Without it a one-character typo would silently import a different key.
327
+ - **`signP2PKHInputs` was only ever compared against the single-input path under
328
+ the default hash type.** All six now, plus the uncompressed-key flag. The five
329
+ non-`All` types are exactly the ones that *ignore* the cached prefix, so that
330
+ half of the branch was the untested half.
331
+
332
+ ### Changed — performance
333
+
334
+ - **`Writer.varInt` takes a number fast path.** It converted every argument
335
+ through `BigInt(v)` before comparing, which is a heap allocation on the
336
+ most-called method in the class — roughly two per input and per output of a
337
+ prefix, one per input of every witness half, so signing N inputs walks it
338
+ O(N²) times. Measured writing N counters: 34 ns -> 10 ns per call at N=250,
339
+ 43 ns -> 23 ns at N=1000. End to end the gain is much smaller and the exponent
340
+ is unchanged; ECDSA still dominates signing. The bigint fallback is deliberately
341
+ retained rather than replaced with a bare assignment: dropping it looks
342
+ equivalent and is not, since relational comparison would then coerce `null` and
343
+ `varInt(null)` would write a `0x00` length prefix instead of throwing.
344
+
345
+ ### Changed — internal
346
+
347
+ - **`signP2PKHInputs` calls the signature-script builders instead of
348
+ reimplementing them.** It carried its own copy of `rawTxInSignature` composed
349
+ with `signatureScript`, differing only in passing the cached prefix, which is
350
+ now an optional trailing parameter on both. Fourteen lines of consensus byte
351
+ assembly existed twice in one file, and "batched is byte-identical to
352
+ one-at-a-time" was a property a test had to keep checking rather than one the
353
+ code could not break.
354
+
355
+ ### Documented — deliberate divergences from dcrd
356
+
357
+ Byte formats are not the whole contract: two implementations can agree on every
358
+ byte they emit and still disagree on what they accept. Four such divergences were
359
+ undocumented, which is the dangerous shape for a parity-targeted library — a
360
+ consumer assuming "accepted by dcr-ts ⇔ accepted by dcrd" had no way to know
361
+ otherwise. They are now stated in a README section and pinned by tests, so they
362
+ stay deliberate rather than becoming accidents.
363
+
364
+ - **An extended key whose version and key type disagree is refused.** dcrd's
365
+ `NewKeyFromString` decides private-vs-public from `keyData[0]` and treats the
366
+ version bytes only as a network tag, so a `dpub`-prefixed string wrapping
367
+ `0x00 ‖ privkey32` parses there as a *private* key and re-serializes as `dprv`.
368
+ Aligning was considered and rejected: it would make a string a user reads as
369
+ public decode to a live private key. Related, and also documented:
370
+ `ExtendedKey.fromString` takes no network and recognises all four, where dcrd
371
+ requires `NetworkParams` and answers `ErrWrongNetwork`.
372
+ - **`Transaction.fromBytes` applies no input/output count caps**, where dcrd
373
+ rejects counts over 780336 / 3728271. dcrd's caps bound an allocation it makes
374
+ from the count before reading; nothing here is sized from a count, so the caps
375
+ would bound nothing. Only blobs of ~43 MiB or larger differ, which are neither
376
+ relayable nor valid. The caller-side sizing advice that does matter is now in
377
+ SECURITY.md and on `fromBytes` itself.
378
+ - The WIF unknown-suite and private-key-rejection divergences recorded above are
379
+ covered in the same section.
380
+
381
+ ### Fixed — availability
382
+
383
+ - **base58 decoding is bounded before it runs.** It is quadratic in input length,
384
+ and `isValidAddress` is exactly where untrusted input arrives: a 128 KB string
385
+ blocked the event loop for ~6 seconds. `decodeAddress`, `decodeWif` and
386
+ `ExtendedKey.fromString` now apply dcrd's own bounds (54, 54 and 113 characters);
387
+ the same string costs 0.002 ms.
388
+
389
+ ### Fixed — regressions introduced while fixing the above
390
+
391
+ Found by re-auditing the cumulative diff against dcrd rather than trusting the
392
+ suite, which passed throughout.
393
+
394
+ - **`deriveBip32Std` read the parent scalar wrong.** dcrd's `strictBIP32` flag
395
+ governs only whether the newly derived *child* is stripped; the parent is always
396
+ read as stored. Letting the requested variant suppress that made
397
+ `derive(a).deriveBip32Std(b)` disagree with dcrd's `Child(a).ChildBIP32Std(b)`.
398
+ Caught by 903 dcrd-generated mixed-variant cases (3 diverged, all at a
399
+ strict-hardened step following a legacy one); zero divergences after the fix.
400
+ Pure-variant paths — including every real wallet path — were never affected.
401
+ The round-trip test had been asserting an equality dcrd does not have, so it was
402
+ pinning the bug rather than catching it; it is replaced with `hd.mixedVariant`
403
+ vectors that check the key after every step.
404
+ - **`fingerprint()` handed out a live view of the memoized identifier**, which
405
+ `derive` passes into each child as its public `parentFingerprint`. One write
406
+ corrupted the parent's cache, its own fingerprint, every sibling and every later
407
+ child. `chainCode` and `parentFingerprint` are now copying getters, so nothing
408
+ the class exposes aliases its internals — mutating `chainCode` also used to
409
+ change what the key derived.
410
+
411
+ ### Added
412
+
413
+ - `deriveBip32Std` / `derivePathBip32Std` — strict BIP32 derivation, mirroring
414
+ dcrd's `ChildBIP32Std`. Note that from a given parent both variants produce the
415
+ same extended key; the flag changes only that child's *own* children.
416
+ - `signP2PKHInputs` — signs several inputs reusing one prefix hash instead of
417
+ recomputing it per input. This lowers the constant, not the exponent: the
418
+ witness half still walks every input per call, so signing stays O(N²).
419
+ `calcSignatureHash` takes an optional `cachedPrefix` from the new
420
+ `sigHashPrefixAll`.
421
+ - `classifyScript` — returns *which* template matched along with the hash, which
422
+ `extractHash160` discards; also recognises the two `OP_CHECKSIGALT` templates.
423
+ - **Pay-to-pubkey addresses for the Ed25519 and Schnorr signature suites.** dcrd
424
+ accepts all three suites under one address ID, distinguished by the payload's
425
+ first byte, so decoding only ECDSA meant `isValidAddress` reported a legitimate
426
+ mainnet address as invalid. Adds `pubKeyEd25519Address`, `pubKeySchnorrAddress`
427
+ and `payToPubKeyAltScript`, all pinned against dcrd. Ed25519 keys are validated
428
+ as real curve points too — `@noble/curves` shares its field arithmetic with
429
+ secp256k1, so this costs 0.13 KB of bundle.
430
+ - `DecodedAddress` is now a **discriminated union** on `kind`, so `hash` and
431
+ `pubKey` exist exactly where they are valid. This removes the non-null
432
+ assertions it previously forced on the library and on every consumer; narrowing
433
+ on `kind` replaces them.
434
+ - Optional `wordlist` on every BIP39 entry point, so a non-English mnemonic is
435
+ validated against its own list rather than English.
436
+ - `scriptParses`, `isSignableSigHashType`, `assertSignableSigHashType`,
437
+ `assertCompressedPubKey`, `assertPubKey`, `copyOf`, `maxBase58Length`, and the
438
+ `MAX_ADDRESS_LENGTH` / `MAX_WIF_LENGTH` / `MAX_EXTENDED_KEY_LENGTH` /
439
+ `MAX_SCRIPT_ELEMENT_SIZE` bounds.
440
+
441
+ ### Changed — performance
442
+
443
+ Measured against the previous build on one machine; every vector still matches
444
+ dcrd byte for byte.
445
+
446
+ | | |
447
+ |---|---|
448
+ | BLAKE-256 (inlined `G`, no BigInt counter) | **2.44x** — 232 MiB/s, from 0.6x behind `@noble` to 4x ahead |
449
+ | script builders | **7.5x** |
450
+ | `Writer.i64` / `Reader.i64` (DataView) | **4.2x** / **5.0x** |
451
+ | transaction serialize (1000 in/out) | 1.5x |
452
+ | watch-only address scan (cached parent point) | 1.26x |
453
+ | signature hashing across N inputs | 12–26x for N=50–1000; 1.7x end to end at N=250, since ECDSA dominates |
454
+
455
+ ### Changed — breaking
456
+
457
+ `0.x`, so these land without a major bump. Ordered by how likely they are to
458
+ affect you.
459
+
460
+ - **HD derivation produces different hardened children** for ~1 seed in 128. This
461
+ is the fix described above, not a regression: the new output is what dcrd and
462
+ dcrwallet produce. Anything that persisted addresses derived by an earlier build
463
+ must re-derive and check both variants.
464
+ - `addressToScript(address, network)` — `network` is now **required**. A payment
465
+ script commits only to the 20-byte hash, so the network-agnostic form returned
466
+ bytes identical to the mainnet address for the same hash: a pasted testnet
467
+ address would pay whoever controls that hash on mainnet.
468
+ - `NULL_BLOCK_HEIGHT` changed from `0xffffffff` to `0`.
469
+ - `pushData` output changed for single-byte data (see above).
470
+ - `verifyHash` no longer accepts 64-byte compact signatures.
471
+ - `mnemonicToMasterKey` throws on an invalid mnemonic.
472
+ - `hardened()`, `derive()`, `Writer.u8/u16/u32`, `Reader.bytes` and
473
+ `calcSignatureHash` throw on input they previously coerced.
474
+ - The signing entry points and `publicKeyFromPrivate` require a 32-byte
475
+ `Uint8Array`. A hex *string* used to reach `@noble` and sign successfully, off
476
+ the typed API; it is rejected with `bad-length`.
477
+
478
+ ### Added — typed errors
479
+
480
+ - **Every throw is now a `DcrError` carrying a stable `code`.** Previously all 88
481
+ throw sites raised a bare `Error`, so the only way to tell a mistyped address
482
+ from a right-address-wrong-network paste was to match on prose — which is not
483
+ part of any API, breaks when a message is reworded, and cannot separate failures
484
+ that happen to share wording. The tests had exactly that problem, and 44 of
485
+ their assertions matched on message text.
486
+
487
+ ```ts
488
+ import { hasErrorCode } from "dcr-ts";
489
+
490
+ try {
491
+ addressToScript(pasted, mainnet);
492
+ } catch (e) {
493
+ if (hasErrorCode(e, "bad-checksum")) showTypoHelp();
494
+ else if (hasErrorCode(e, "wrong-network")) showWrongNetworkHelp();
495
+ else throw e;
496
+ }
497
+ ```
498
+
499
+ `decodeAddress` now distinguishes `wrong-network` from `unknown-prefix`, which
500
+ folding both into one prefix lookup had made impossible. Codes are the stable
501
+ contract; messages remain human-readable and name the operation that failed.
502
+ Exports `DcrError`, `DcrErrorCode`, `isDcrError` and `hasErrorCode`.
503
+
504
+ ### Changed — packaging
505
+
506
+ - **The Ed25519 group order is written out instead of read from `ed25519.CURVE.n`.**
507
+ Reading the curve object evaluated at module scope, so every bundle that reached
508
+ `wif.ts` — which is every bundle that encodes a WIF — anchored `@noble/curves`'
509
+ `ed25519` and `edwards` modules, about 27 KB, even for a consumer that only ever
510
+ touches the secp256k1 suites. `assertWifScalar` returns early for those suites,
511
+ but no bundler can prove that branch dead, and a lazy getter does not help either,
512
+ since `assertWifScalar` is itself always reachable: measured through esbuild, the
513
+ deferred form came out 73 bytes *larger* and still carried the curve. Removing the
514
+ eager read drops an ECDSA-only bundle from 192,587 to 164,902 bytes (−14.4%).
515
+ `isValidEd25519PublicKey` still uses the curve, so a consumer that decodes
516
+ Ed25519 addresses pulls it in as before. A new test in `encoding.test.ts` asserts
517
+ the constant equals `ed25519.CURVE.n`, keeping the value tied to the audited
518
+ source while leaving the reference out of the module graph.
519
+ - `npm run coverage` works: `@vitest/coverage-v8` is now a devDependency, and
520
+ thresholds live in `vitest.config.ts` (currently 97.6% statements, 90.4%
521
+ branches) so a real regression fails CI.
522
+ - The tarball no longer ships `src/`, which was unreachable — `exports` declares
523
+ no subpath, so nothing could import it. It now carries `dist`, the README,
524
+ SECURITY.md, CHANGELOG.md and the licence: 11 files.
525
+ - Dropped the `lint` script, which was a byte-identical duplicate of `typecheck`
526
+ that CI never ran. Added `npm run vectors` for regenerating the fixture.
527
+ - Upgraded vitest to 3.2.7 and pinned vite to 6.x, clearing the critical
528
+ advisories the old 2.x tree carried. Deliberately *not* vitest 4: it requires
529
+ Node `^20 || ^22 || >=24`, and vite 7 requires `>=20.19`, either of which would
530
+ quietly drop the Node 18 this package's `engines` field promises. 3.2.7 is above
531
+ the advisory range (`<=3.2.5`) and still supports `^18.0.0`, so the security fix
532
+ costs no supported runtime.
533
+ - The blocking `npm audit` gate is scoped with `--omit=dev`. Production
534
+ dependencies are what a consumer installs, and a CVE in the test runner never
535
+ reaches them, so letting dev-tool advisories break a library's CI only trains
536
+ people to ignore the signal. Production dependencies audit clean; the full
537
+ audit still runs for visibility, without failing.
538
+ - CI gains three jobs: coverage with thresholds, a package check that both module
539
+ formats load and export the same symbols plus `npm pack --dry-run` and
540
+ `npm audit`, and the fixture-drift check. The test matrix now includes Node 24.
541
+
542
+ ### Fixed — test infrastructure
543
+
544
+ - **The committed fixture was not reproducible from its generator.** It carried
545
+ four alternative-signature-suite vectors and twelve BLAKE-256 padding-boundary
546
+ vectors that `vectorgen` never emitted, so the documented regeneration command
547
+ deleted them and broke the suite — and the README's claim that every vector comes
548
+ from a Go program importing dcrd was not true for those values. CI now
549
+ regenerates the fixture and fails on any diff.
550
+ - Added dcrd ground truth for surfaces that had none: `TxHashWitness`, the null
551
+ witness sentinels, a 300-output transaction (multi-byte varints and writer
552
+ growth), `SigHashSingle` at the last output index, twelve hash types including
553
+ the undefined ones, pay-to-pubkey scripts with an odd-Y key, one WIF per
554
+ signature suite, and a seed chosen to make the two HD variants disagree.
555
+ - Fixture-driven loops are guarded with `nonEmpty()`; they previously reported
556
+ green with zero assertions if a section went missing.
557
+ - Tests: 41 → 103.
558
+
559
+ ## 0.1.0
560
+
561
+ Never released: no tag, and nothing published to npm. Kept as the record of
562
+ what the codebase was before the fixes above, since several of those entries
563
+ describe correcting behaviour introduced here.
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 The dcr-ts developers
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.