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/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # dcr-ts
2
+
3
+ Decred (DCR) primitives for TypeScript: **BLAKE-256**, addresses, WIF, **BIP32
4
+ HD keys** with Decred serialization, BIP39 mnemonics, the **transaction wire
5
+ format**, the **Decred signature hash**, and **low-S ECDSA** P2PKH signing.
6
+
7
+ Every consensus-critical byte format is verified byte-for-byte against
8
+ [dcrd](https://github.com/decred/dcrd) — the vectors in
9
+ [`test/fixtures/dcrd-vectors.json`](test/fixtures/dcrd-vectors.json) are
10
+ generated by a [Go program](vectorgen) that imports dcrd directly, so this
11
+ library's output is pinned to the reference implementation rather than to a
12
+ second hand-rolled one.
13
+
14
+ Built from scratch using dcrd as the specification. ISC licensed.
15
+
16
+ > **Not audited.** dcr-ts has had no independent security audit. Byte formats are
17
+ > pinned to dcrd; that says nothing about memory handling, timing or logic bugs.
18
+ > It also cannot erase secrets from memory, offers no constant-time guarantees, and
19
+ > does no transaction-policy validation. Read [SECURITY.md](SECURITY.md) before
20
+ > trusting it with funds.
21
+
22
+ ## Design
23
+
24
+ The hard rule mirrors the Rust sibling [`dcr-rs`](https://github.com/jzbz/dcr-rs):
25
+ **hand-roll nothing that touches elliptic-curve math or standard KDFs.** Those
26
+ come from the audited [`@noble`](https://github.com/paulmillr/noble-curves) /
27
+ [`@scure`](https://github.com/paulmillr/scure-bip39) packages. This library owns
28
+ only the Decred-specific glue:
29
+
30
+ - **BLAKE-256** — the 14-round SHA-3 finalist Decred uses for *everything*
31
+ (txids, sighashes, address hashes, base58 checksums). This is **not** BLAKE2 or
32
+ BLAKE3; it is implemented here from the specification and pinned by dcrd
33
+ vectors.
34
+ - **base58check** with the double-BLAKE-256 checksum, and the single-BLAKE-256
35
+ checksum quirk used by WIF.
36
+ - **Addresses** — P2PKH (secp256k1 ECDSA, Ed25519, Schnorr), P2SH, and the
37
+ full-pubkey address, for mainnet, testnet3, simnet and regnet, with
38
+ decode/classify and the correct `OP_CHECKSIGALT` payment scripts for the
39
+ alternative signature suites.
40
+ - **HD keys** — BIP32 with Decred's `dprv`/`dpub` (and `tprv`/`sprv`/`rprv`…)
41
+ version bytes and base58check checksum; private (signer) and public
42
+ (watch-only) derivation. Hardened derivation follows **Decred's variation on
43
+ BIP32, not BIP32 itself** — see below.
44
+ - **Transactions** — the dcrd `MsgTx` wire format (prefix ‖ witness), byte-exact
45
+ serialize/parse, and all three txid variants.
46
+ - **Signing** — the Decred signature hash (not Bitcoin's BIP143) and
47
+ RFC 6979 / low-S ECDSA signature scripts for P2PKH inputs.
48
+
49
+ Out of scope: networking/RPC, staking/tickets, mixing, and transaction-building
50
+ policy (coin selection, fees).
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ npm install dcr-ts
56
+ ```
57
+
58
+ Ships ESM and CommonJS with type declarations. Node ≥ 18.
59
+
60
+ ## Usage
61
+
62
+ ### Hashing and addresses
63
+
64
+ ```ts
65
+ import { blake256, addressFromPubKey, decodeAddress, mainnet } from "dcr-ts";
66
+
67
+ blake256(new Uint8Array()); // 716f6e86… (BLAKE-256, not BLAKE2/3)
68
+
69
+ const addr = addressFromPubKey(compressedPubKey, mainnet); // "Ds…"
70
+ const { kind, hash, network } = decodeAddress(addr); // "pubkeyhash-ecdsa"
71
+ ```
72
+
73
+ ### HD keys from a mnemonic
74
+
75
+ ```ts
76
+ import { mnemonicToMasterKey, mainnet } from "dcr-ts";
77
+
78
+ const master = mnemonicToMasterKey(mnemonic, mainnet);
79
+ const key = master.derivePath("m/44'/42'/0'/0/0");
80
+ key.toString(); // "dprv…"
81
+ key.address(); // "Ds…"
82
+
83
+ // Watch-only: neuter an account key and derive receive addresses publicly.
84
+ const xpub = master.derivePath("m/44'/42'/0'").neuter();
85
+ xpub.derive(0).derive(0).address();
86
+ ```
87
+
88
+ ### Build and sign a transaction
89
+
90
+ ```ts
91
+ import {
92
+ Transaction,
93
+ outPointFromTxid,
94
+ addressToScript,
95
+ signP2PKHInput,
96
+ decodeWif,
97
+ mainnet,
98
+ } from "dcr-ts";
99
+
100
+ const { privateKey } = decodeWif(wif);
101
+ // The network is required: a payment script commits only to the 20-byte hash, so
102
+ // without it a pasted testnet address would silently pay whoever controls that
103
+ // hash on mainnet.
104
+ const prevScript = addressToScript(myAddress, mainnet);
105
+
106
+ const tx = new Transaction();
107
+ tx.addInput(outPointFromTxid(prevTxid, vout), { valueIn: 200_000_000n });
108
+ tx.addOutput(199_990_000n, addressToScript(destinationAddress, mainnet));
109
+
110
+ signP2PKHInput(tx, 0, prevScript, privateKey); // SigHashAll, low-S, RFC 6979
111
+ tx.serialize(); // Uint8Array ready for the wire
112
+ tx.txid(); // reversed-hex display id
113
+ ```
114
+
115
+ `Transaction` is a mutable builder and does no policy validation: it will not
116
+ check fees, dust, amount bounds or that your inputs cover your outputs. It copies
117
+ the buffers you hand it, so mutating your own scripts afterwards cannot rewrite a
118
+ transaction you have already signed.
119
+
120
+ ### Amounts
121
+
122
+ ```ts
123
+ import { dcrToAtoms, atomsToDcr } from "dcr-ts";
124
+
125
+ dcrToAtoms("1.5"); // 150000000n
126
+ atomsToDcr(150_000_000n); // "1.50000000"
127
+ ```
128
+
129
+ ### Signing several inputs
130
+
131
+ `calcSignatureHash` re-serializes and re-hashes the whole transaction prefix per
132
+ call, so signing N inputs one at a time is O(N²). Under `SigHashAll` the prefix
133
+ half does not depend on which input is being signed, so `signP2PKHInputs`
134
+ computes it once — the same thing dcrd's `cachedPrefix` argument is for:
135
+
136
+ ```ts
137
+ import { signP2PKHInputs } from "dcr-ts";
138
+
139
+ signP2PKHInputs(tx, [
140
+ { idx: 0, subScript: prevScript0, privateKey: key0 },
141
+ { idx: 1, subScript: prevScript1, privateKey: key1 },
142
+ ]);
143
+ ```
144
+
145
+ Byte-identical to calling `signP2PKHInput` per input. On the hashing alone this
146
+ is 12–26x for 50–1000 inputs; end to end the win is smaller (1.7x at 250 inputs)
147
+ because ECDSA dominates. `calcSignatureHash` also takes an optional
148
+ `cachedPrefix` from `sigHashPrefixAll(tx)` if you are building signature scripts
149
+ yourself.
150
+
151
+ ## Hardened derivation is not plain BIP32
152
+
153
+ Decred deviates from BIP32 in the hardened child function, and the difference is
154
+ load-bearing. dcrd's `hdkeychain` strips leading zero bytes from a derived
155
+ private key and carries the shortened string into the next hardened HMAC:
156
+
157
+ > Note that per [BIP32] this should be the fully zero-padded 32-bytes, however,
158
+ > the Decred variation strips leading zeros for legacy reasons and changing it
159
+ > now would break derivation for a lot of Decred wallets that rely on this
160
+ > behavior.
161
+
162
+ So for a parent scalar with a leading zero byte the hardened HMAC input is
163
+ `0x00 ‖ key31 ‖ 0x00 ‖ ser32(i)` rather than BIP32's
164
+ `0x00 ‖ 0x00 ‖ key31 ‖ ser32(i)` — the same length, different bytes, and every
165
+ descendant diverges. Roughly **1 seed in 128** is affected on a BIP44 path — two hardened levels
166
+ below the master, each with a 1/256 chance of a leading zero byte. Measured
167
+ 0.8–0.9% over 20,000 seeds.
168
+
169
+ dcrd exposes both variants and dcrwallet uses the legacy one for the entire
170
+ wallet path, so this library mirrors that:
171
+
172
+ ```ts
173
+ key.derive(0); // Decred variant — dcrd Child, what wallets use
174
+ key.derivePath("m/44'/42'/0'/0/0");
175
+
176
+ key.deriveBip32Std(0); // strict BIP32 — dcrd ChildBIP32Std
177
+ key.derivePathBip32Std("m/44'/42'/0'/0/0");
178
+ ```
179
+
180
+ Use the strict form only when strict BIP32 is genuinely what you want. Anything
181
+ that has to agree with a Decrediton or dcrwallet seed must not: getting it wrong
182
+ is silent, showing the user a different, empty wallet with coins sent to its
183
+ addresses invisible to every other Decred wallet holding the same phrase.
184
+
185
+ Two consequences worth knowing:
186
+
187
+ - **Public (non-hardened) derivation is unaffected.** There is no private key to
188
+ strip, and a stripped scalar has the same value and therefore the same public
189
+ key, so an account `dpub` and every address below it agree between variants.
190
+ - **The stripped state does not survive serialization.** dcrd pads the scalar
191
+ back out in the extended-key string, so a key round-tripped through `dprv`
192
+ derives strictly from then on — in dcrd too, which this mirrors. Only hardened
193
+ steps are affected, and in BIP44 the deepest hardened level is the account key,
194
+ so it rarely shows up in practice.
195
+
196
+ Both variants are pinned against dcrd-generated vectors from a seed chosen to
197
+ make them disagree (`hd.leadingZero` in the fixture).
198
+
199
+ ## Where this deliberately does not match dcrd
200
+
201
+ Byte-exactness is the goal everywhere it is achievable, and every serialization
202
+ this library produces is pinned against dcrd-generated vectors. But byte formats
203
+ are not the whole contract: two implementations can agree on every byte they
204
+ *emit* and still disagree on what they *accept*. Four accept/reject divergences
205
+ are deliberate. Each one fails closed — this library rejects something dcrd
206
+ takes — so "accepted by dcr-ts" implies "accepted by dcrd", never the reverse.
207
+
208
+ - **An extended key whose version and key type disagree is refused.**
209
+ `NewKeyFromString` decides private-vs-public from the key-data byte and treats
210
+ the version bytes only as a network tag, so a string beginning `dpub` that
211
+ wraps `0x00 ‖ privkey32` parses there as a *private* key — and re-serializes as
212
+ `dprv`, since dcrd re-attaches the version matching the key it ended up with.
213
+ `ExtendedKey.fromString` takes the type from the version and throws
214
+ `invalid-public-key` (or `invalid-private-key` the other way). No honest encoder
215
+ emits such a string, dcrd's own `String()` included, so nothing round-trips
216
+ differently. The point is that a `dpub` prefix and `isPrivate === false` can
217
+ never disagree, which matters because "it starts with dpub, so it is safe to
218
+ paste here" is a real pattern.
219
+ - **A WIF with an unknown signature-suite byte is refused.** dcrd's `DecodeWIF`
220
+ switches on that byte with no default arm, so an unrecognised suite yields a
221
+ `WIF` holding a **nil** private key, with the scheme silently defaulted to
222
+ ECDSA and no error. Its own `String()` on that struct is not a WIF. There is
223
+ nothing there to be compatible with.
224
+ - **The signing entry points reject an unusable private key.** dcrd's
225
+ `secp256k1.PrivKeyFromBytes` cannot fail: it reduces mod n and left-pads a
226
+ short slice, so a zero key signs under an all-zero-X public key and a 31-byte
227
+ key is silently padded and signed. `signHash` and friends throw
228
+ `invalid-private-key` or `bad-length` instead.
229
+ - **`Transaction.fromBytes` applies no input/output count caps.** This one is the
230
+ exception to the pattern above: it is *more* permissive than dcrd, which
231
+ rejects counts over `maxTxInPerMessage` (780336) or `maxTxOutPerMessage`
232
+ (3728271). Those bounds exist because dcrd decodes from an `io.Reader` of
233
+ unknown length and sizes `make([]TxIn, count)` from the count before reading
234
+ anything; here the argument is a `Uint8Array` whose length is already the bound,
235
+ and nothing is allocated from a declared count. The only blobs that parse here
236
+ and not there are ~43 MiB or larger, which is over `MaxMessagePayload` and 115x
237
+ mainnet's `MaxTxSize` — neither relayable nor valid. See `fromBytes` for the
238
+ caller-side sizing advice that does matter.
239
+
240
+ One more divergence is worth naming because it is not a rejection at all:
241
+ `ExtendedKey.fromString` takes **no network** and recognises all four, reporting
242
+ which it found on `.network`. dcrd's `NewKeyFromString` takes `NetworkParams` and
243
+ returns `ErrWrongNetwork` for any other network's version. A caller that wants
244
+ dcrd's answer compares `.network` itself.
245
+
246
+ ## Development
247
+
248
+ ```bash
249
+ npm install
250
+ npm test # vitest, all vectors checked against dcrd
251
+ npm run typecheck # tsc --strict
252
+ npm run build # tsup → dist (esm + cjs + d.ts)
253
+ ```
254
+
255
+ To regenerate the dcrd vectors (requires Go), see [`vectorgen`](vectorgen).
256
+
257
+ ## License
258
+
259
+ ISC
package/SECURITY.md ADDED
@@ -0,0 +1,106 @@
1
+ # Security
2
+
3
+ ## This library has not been audited
4
+
5
+ dcr-ts has had no independent security audit. It is a from-specification
6
+ implementation of Decred's byte formats, checked against dcrd-generated test
7
+ vectors. That pins the formats; it does not make the library safe to hold
8
+ significant value with. Read the code before you trust it with money.
9
+
10
+ What the "byte-exact with dcrd" claim in the README does and does not mean:
11
+
12
+ - **Does mean:** every consensus-critical byte format the library implements is
13
+ compared against output generated by a Go program that imports dcrd itself, and
14
+ CI fails if the vectors drift. The covered surface is enumerated in
15
+ [`vectorgen/main.go`](vectorgen/main.go).
16
+ - **Does not mean:** the library is free of memory-handling, timing, validation or
17
+ logic bugs, that the covered surface is complete, or that anyone has attacked it.
18
+
19
+ ## Reporting a vulnerability
20
+
21
+ Email **jz@jz.bz**. Please do not open a public issue for anything that could
22
+ affect funds.
23
+
24
+ There is no bounty and no formal SLA. Expect an acknowledgement within a few days.
25
+
26
+ ## Threat model and known limits
27
+
28
+ Worth understanding before building on this.
29
+
30
+ ### Secret material is not protected in memory
31
+
32
+ JavaScript cannot reliably erase a secret. There is no guaranteed-wipe primitive,
33
+ strings are immutable and interned, and the garbage collector copies objects freely,
34
+ so a private key handed to this library may persist in memory in places nothing can
35
+ reach to clear.
36
+
37
+ Concretely, these hand back live secret material and place no constraints on what
38
+ you do with it:
39
+
40
+ - `decodeWif().privateKey`
41
+ - `ExtendedKey.privateKeyBytes()` and `ExtendedKey.serialize()`
42
+ - `mnemonicToSeed()` and `mnemonicToEntropy()`
43
+ - `ExtendedKey.toString()` for a private key — a base58 string, so it cannot be
44
+ wiped at all
45
+
46
+ dcrd offers `ExtendedKey.Zero()` and the Rust sibling `dcr-rs` zeroizes on drop;
47
+ neither is fully achievable here. Treat process memory as compromised if the
48
+ process is.
49
+
50
+ ### No constant-time guarantees
51
+
52
+ Elliptic-curve operations come from [`@noble/curves`](https://github.com/paulmillr/noble-curves),
53
+ which documents its own timing properties. The Decred-specific code in this library
54
+ — base58, byte serialization, BLAKE-256, comparisons — is **not** written to be
55
+ constant-time, and JIT compilation makes such claims hard to support in JavaScript
56
+ regardless. Do not use this where an attacker can measure your timing.
57
+
58
+ ### Untrusted input must be size-capped by the caller
59
+
60
+ `Transaction.fromBytes` is linear in the length of the buffer it is handed and
61
+ imposes no limit of its own. It allocates nothing from a declared input or output
62
+ count — an inflated count fails at the first read past the end of the buffer, not
63
+ after reserving memory for it — so the cost is bounded by the bytes you actually
64
+ pass. That still means a large buffer costs proportionally large time and memory.
65
+
66
+ dcrd's own `maxTxInPerMessage` / `maxTxOutPerMessage` caps are not reproduced,
67
+ and would not help if they were: they bound counts, not bytes, and a buffer at
68
+ dcrd's 32 MiB wire maximum holds fewer minimal inputs than the cap allows. Cap
69
+ the size of anything untrusted before parsing it.
70
+
71
+ base58 decoding is quadratic, so the bound matters more there than anywhere
72
+ else, and which functions carry it is worth stating precisely:
73
+
74
+ - **Bounded for you.** `decodeAddress`, `isValidAddress`, `addressToScript`,
75
+ `decodeWif` and `ExtendedKey.fromString` each check a length before decoding —
76
+ `MAX_ADDRESS_LENGTH`, `MAX_WIF_LENGTH` and `MAX_EXTENDED_KEY_LENGTH`, which are
77
+ dcrd's own bounds. An address or key arriving from outside should reach one of
78
+ these.
79
+ - **Deliberately not bounded.** `base58Decode` and `checkDecode` are exported as
80
+ general-purpose primitives and take no cap, because any cap on them would have
81
+ to assume a format they know nothing about. A 128 KB argument blocks the event
82
+ loop for several seconds. If you call either on input you did not construct,
83
+ cap it yourself first: `maxBase58Length(n)` gives the ceiling for an `n`-byte
84
+ payload, and it is what the bounded decoders above use.
85
+
86
+ ### Out of scope
87
+
88
+ The library does not implement, and will not protect you from getting wrong:
89
+
90
+ - Script execution or transaction validation. `Transaction` is a mutable builder
91
+ that performs no policy checks: not fees, dust, amount bounds, nor whether inputs
92
+ cover outputs. It will happily build and sign a transaction the network rejects.
93
+ - Signing for the Ed25519 or Schnorr signature suites. Addresses for them are
94
+ recognised; only secp256k1 ECDSA can be signed.
95
+ - Staking, tickets, mixing, networking and RPC.
96
+ - Coin selection, change handling and fee estimation.
97
+
98
+ ### Randomness
99
+
100
+ `generateMnemonic` draws from `@scure/bip39`, which uses the platform CSPRNG. The
101
+ library generates no other randomness — ECDSA nonces are deterministic (RFC 6979),
102
+ which is what makes signatures byte-reproducible against dcrd.
103
+
104
+ ## Supported versions
105
+
106
+ Pre-1.0: only the latest release gets fixes.