dcr-ts
v0.2.1
Published
Decred (DCR) primitives for TypeScript: BLAKE-256, addresses, BIP32 HD keys, WIF, the transaction wire format, the Decred signature hash, and low-S ECDSA signing. Byte-exact with dcrd.
Downloads
288
Maintainers
Readme
dcr-ts
Decred (DCR) primitives for TypeScript: BLAKE-256, addresses, WIF, BIP32 HD keys with Decred serialization, BIP39 mnemonics, the transaction wire format, the Decred signature hash, and low-S ECDSA P2PKH signing.
Every consensus-critical byte format is verified byte-for-byte against
dcrd — the vectors in
test/fixtures/dcrd-vectors.json are
generated by a Go program that imports dcrd directly, so this
library's output is pinned to the reference implementation rather than to a
second hand-rolled one.
Built from scratch using dcrd as the specification. ISC licensed.
Not audited. dcr-ts has had no independent security audit. Byte formats are pinned to dcrd; that says nothing about memory handling, timing or logic bugs. It also cannot erase secrets from memory, offers no constant-time guarantees, and does no transaction-policy validation. Read SECURITY.md before trusting it with funds.
Design
The hard rule mirrors the Rust sibling dcr-rs:
hand-roll nothing that touches elliptic-curve math or standard KDFs. Those
come from the audited @noble /
@scure packages. This library owns
only the Decred-specific glue:
- BLAKE-256 — the 14-round SHA-3 finalist Decred uses for everything (txids, sighashes, address hashes, base58 checksums). This is not BLAKE2 or BLAKE3; it is implemented here from the specification and pinned by dcrd vectors.
- base58check with the double-BLAKE-256 checksum, and the single-BLAKE-256 checksum quirk used by WIF.
- Addresses — P2PKH (secp256k1 ECDSA, Ed25519, Schnorr), P2SH, and the
full-pubkey address, for mainnet, testnet3, simnet and regnet, with
decode/classify and the correct
OP_CHECKSIGALTpayment scripts for the alternative signature suites. - HD keys — BIP32 with Decred's
dprv/dpub(andtprv/sprv/rprv…) version bytes and base58check checksum; private (signer) and public (watch-only) derivation. Hardened derivation follows Decred's variation on BIP32, not BIP32 itself — see below. - Transactions — the dcrd
MsgTxwire format (prefix ‖ witness), byte-exact serialize/parse, and all three txid variants. - Signing — the Decred signature hash (not Bitcoin's BIP143) and RFC 6979 / low-S ECDSA signature scripts for P2PKH inputs.
Out of scope: networking/RPC, staking/tickets, mixing, and transaction-building policy (coin selection, fees).
Install
npm install dcr-tsShips ESM and CommonJS with type declarations. Node ≥ 18.
Usage
Hashing and addresses
import { blake256, addressFromPubKey, decodeAddress, mainnet } from "dcr-ts";
blake256(new Uint8Array()); // 716f6e86… (BLAKE-256, not BLAKE2/3)
const addr = addressFromPubKey(compressedPubKey, mainnet); // "Ds…"
const { kind, hash, network } = decodeAddress(addr); // "pubkeyhash-ecdsa"HD keys from a mnemonic
import { mnemonicToMasterKey, mainnet } from "dcr-ts";
const master = mnemonicToMasterKey(mnemonic, mainnet);
const key = master.derivePath("m/44'/42'/0'/0/0");
key.toString(); // "dprv…"
key.address(); // "Ds…"
// Watch-only: neuter an account key and derive receive addresses publicly.
const xpub = master.derivePath("m/44'/42'/0'").neuter();
xpub.derive(0).derive(0).address();Build and sign a transaction
import {
Transaction,
outPointFromTxid,
addressToScript,
signP2PKHInput,
decodeWif,
mainnet,
} from "dcr-ts";
const { privateKey } = decodeWif(wif);
// The network is required: a payment script commits only to the 20-byte hash, so
// without it a pasted testnet address would silently pay whoever controls that
// hash on mainnet.
const prevScript = addressToScript(myAddress, mainnet);
const tx = new Transaction();
tx.addInput(outPointFromTxid(prevTxid, vout), { valueIn: 200_000_000n });
tx.addOutput(199_990_000n, addressToScript(destinationAddress, mainnet));
signP2PKHInput(tx, 0, prevScript, privateKey); // SigHashAll, low-S, RFC 6979
tx.serialize(); // Uint8Array ready for the wire
tx.txid(); // reversed-hex display idTransaction is a mutable builder and does no policy validation: it will not
check fees, dust, amount bounds or that your inputs cover your outputs. It copies
the buffers you hand it, so mutating your own scripts afterwards cannot rewrite a
transaction you have already signed.
Amounts
import { dcrToAtoms, atomsToDcr } from "dcr-ts";
dcrToAtoms("1.5"); // 150000000n
atomsToDcr(150_000_000n); // "1.50000000"Signing several inputs
calcSignatureHash re-serializes and re-hashes the whole transaction prefix per
call, so signing N inputs one at a time is O(N²). Under SigHashAll the prefix
half does not depend on which input is being signed, so signP2PKHInputs
computes it once — the same thing dcrd's cachedPrefix argument is for:
import { signP2PKHInputs } from "dcr-ts";
signP2PKHInputs(tx, [
{ idx: 0, subScript: prevScript0, privateKey: key0 },
{ idx: 1, subScript: prevScript1, privateKey: key1 },
]);Byte-identical to calling signP2PKHInput per input. On the hashing alone this
is 12–26x for 50–1000 inputs; end to end the win is smaller (1.7x at 250 inputs)
because ECDSA dominates. calcSignatureHash also takes an optional
cachedPrefix from sigHashPrefixAll(tx) if you are building signature scripts
yourself.
Hardened derivation is not plain BIP32
Decred deviates from BIP32 in the hardened child function, and the difference is
load-bearing. dcrd's hdkeychain strips leading zero bytes from a derived
private key and carries the shortened string into the next hardened HMAC:
Note that per [BIP32] this should be the fully zero-padded 32-bytes, however, the Decred variation strips leading zeros for legacy reasons and changing it now would break derivation for a lot of Decred wallets that rely on this behavior.
So for a parent scalar with a leading zero byte the hardened HMAC input is
0x00 ‖ key31 ‖ 0x00 ‖ ser32(i) rather than BIP32's
0x00 ‖ 0x00 ‖ key31 ‖ ser32(i) — the same length, different bytes, and every
descendant diverges. Roughly 1 seed in 128 is affected on a BIP44 path — two hardened levels
below the master, each with a 1/256 chance of a leading zero byte. Measured
0.8–0.9% over 20,000 seeds.
dcrd exposes both variants and dcrwallet uses the legacy one for the entire wallet path, so this library mirrors that:
key.derive(0); // Decred variant — dcrd Child, what wallets use
key.derivePath("m/44'/42'/0'/0/0");
key.deriveBip32Std(0); // strict BIP32 — dcrd ChildBIP32Std
key.derivePathBip32Std("m/44'/42'/0'/0/0");Use the strict form only when strict BIP32 is genuinely what you want. Anything that has to agree with a Decrediton or dcrwallet seed must not: getting it wrong is silent, showing the user a different, empty wallet with coins sent to its addresses invisible to every other Decred wallet holding the same phrase.
Two consequences worth knowing:
- Public (non-hardened) derivation is unaffected. There is no private key to
strip, and a stripped scalar has the same value and therefore the same public
key, so an account
dpuband every address below it agree between variants. - The stripped state does not survive serialization. dcrd pads the scalar
back out in the extended-key string, so a key round-tripped through
dprvderives strictly from then on — in dcrd too, which this mirrors. Only hardened steps are affected, and in BIP44 the deepest hardened level is the account key, so it rarely shows up in practice.
Both variants are pinned against dcrd-generated vectors from a seed chosen to
make them disagree (hd.leadingZero in the fixture).
Where this deliberately does not match dcrd
Byte-exactness is the goal everywhere it is achievable, and every serialization this library produces is pinned against dcrd-generated vectors. But byte formats are not the whole contract: two implementations can agree on every byte they emit and still disagree on what they accept. Four accept/reject divergences are deliberate. Each one fails closed — this library rejects something dcrd takes — so "accepted by dcr-ts" implies "accepted by dcrd", never the reverse.
- An extended key whose version and key type disagree is refused.
NewKeyFromStringdecides private-vs-public from the key-data byte and treats the version bytes only as a network tag, so a string beginningdpubthat wraps0x00 ‖ privkey32parses there as a private key — and re-serializes asdprv, since dcrd re-attaches the version matching the key it ended up with.ExtendedKey.fromStringtakes the type from the version and throwsinvalid-public-key(orinvalid-private-keythe other way). No honest encoder emits such a string, dcrd's ownString()included, so nothing round-trips differently. The point is that adpubprefix andisPrivate === falsecan never disagree, which matters because "it starts with dpub, so it is safe to paste here" is a real pattern. - A WIF with an unknown signature-suite byte is refused. dcrd's
DecodeWIFswitches on that byte with no default arm, so an unrecognised suite yields aWIFholding a nil private key, with the scheme silently defaulted to ECDSA and no error. Its ownString()on that struct is not a WIF. There is nothing there to be compatible with. - The signing entry points reject an unusable private key. dcrd's
secp256k1.PrivKeyFromBytescannot fail: it reduces mod n and left-pads a short slice, so a zero key signs under an all-zero-X public key and a 31-byte key is silently padded and signed.signHashand friends throwinvalid-private-keyorbad-lengthinstead. Transaction.fromBytesapplies no input/output count caps. This one is the exception to the pattern above: it is more permissive than dcrd, which rejects counts overmaxTxInPerMessage(780336) ormaxTxOutPerMessage(3728271). Those bounds exist because dcrd decodes from anio.Readerof unknown length and sizesmake([]TxIn, count)from the count before reading anything; here the argument is aUint8Arraywhose length is already the bound, and nothing is allocated from a declared count. The only blobs that parse here and not there are ~43 MiB or larger, which is overMaxMessagePayloadand 115x mainnet'sMaxTxSize— neither relayable nor valid. SeefromBytesfor the caller-side sizing advice that does matter.
One more divergence is worth naming because it is not a rejection at all:
ExtendedKey.fromString takes no network and recognises all four, reporting
which it found on .network. dcrd's NewKeyFromString takes NetworkParams and
returns ErrWrongNetwork for any other network's version. A caller that wants
dcrd's answer compares .network itself.
Development
npm install
npm test # vitest, all vectors checked against dcrd
npm run typecheck # tsc --strict
npm run build # tsup → dist (esm + cjs + d.ts)To regenerate the dcrd vectors (requires Go), see vectorgen.
License
ISC
