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 +563 -0
- package/LICENSE +15 -0
- package/README.md +259 -0
- package/SECURITY.md +106 -0
- package/dist/index.cjs +2424 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +955 -0
- package/dist/index.d.ts +955 -0
- package/dist/index.js +2328 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
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.
|