global: private xpub support part 2

This commit is contained in:
nym21
2026-06-17 11:25:42 +02:00
parent 0c7861071d
commit 43df9e098c
68 changed files with 1836 additions and 1653 deletions
+270
View File
@@ -0,0 +1,270 @@
import { encodeBase58Check } from "./base58.js";
import { concatBytes } from "./bytes.js";
import { hash160, sha256 } from "./hash.js";
import {
addXOnlyPublicKeyTweak,
getXOnlyPublicKey,
} from "./secp256k1.js";
const bech32Alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const bech32Generator = /** @type {const} */ ([
0x3b6a57b2,
0x26508e6d,
0x1ea119fa,
0x3d4233dd,
0x2a1462b3,
]);
const BECH32_CHECKSUM = 1;
const BECH32M_CHECKSUM = 0x2bc830a3;
const p2pkhVersions = /** @type {const} */ ({
mainnet: 0x00,
testnet: 0x6f,
});
const p2shVersions = /** @type {const} */ ({
mainnet: 0x05,
testnet: 0xc4,
});
const bech32Prefixes = /** @type {const} */ ({
mainnet: "bc",
testnet: "tb",
});
/**
* @typedef {"mainnet" | "testnet"} BitcoinNetwork
* @typedef {"p2pkh" | "p2sh_p2wpkh" | "v0_p2wpkh" | "v1_p2tr" | "v0_p2wsh_sortedmulti"} AddressScript
* @typedef {Object} EncodedAddress
* @property {string} address
* @property {Uint8Array} payload
*/
/**
* @param {number} version
* @param {Uint8Array} payload
*/
function encodeVersionedBase58(version, payload) {
return encodeBase58Check(concatBytes([Uint8Array.of(version), payload]));
}
/**
* @param {string} prefix
*/
function expandBech32Prefix(prefix) {
const values = /** @type {number[]} */ ([]);
for (const character of prefix) {
values.push(character.charCodeAt(0) >> 5);
}
values.push(0);
for (const character of prefix) {
values.push(character.charCodeAt(0) & 31);
}
return values;
}
/**
* @param {number[]} values
*/
function bech32Polymod(values) {
let checksum = 1;
for (const value of values) {
const top = checksum >>> 25;
checksum = ((checksum & 0x1ffffff) << 5) ^ value;
for (let i = 0; i < bech32Generator.length; i += 1) {
if ((top >>> i) & 1) {
checksum ^= bech32Generator[i];
}
}
}
return checksum;
}
/**
* @param {string} prefix
* @param {number[]} values
* @param {number} checksumConstant
*/
function createBech32Checksum(prefix, values, checksumConstant) {
const polymod = bech32Polymod([
...expandBech32Prefix(prefix),
...values,
0,
0,
0,
0,
0,
0,
]);
const checksum = /** @type {number[]} */ ([]);
const combined = polymod ^ checksumConstant;
for (let i = 0; i < 6; i += 1) {
checksum.push((combined >>> (5 * (5 - i))) & 31);
}
return checksum;
}
/**
* @param {Uint8Array} bytes
* @param {number} fromBits
* @param {number} toBits
*/
function convertBits(bytes, fromBits, toBits) {
const maxValue = (1 << toBits) - 1;
const values = /** @type {number[]} */ ([]);
let accumulator = 0;
let bits = 0;
for (const byte of bytes) {
accumulator = (accumulator << fromBits) | byte;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
values.push((accumulator >>> bits) & maxValue);
}
}
if (bits > 0) {
values.push((accumulator << (toBits - bits)) & maxValue);
}
return values;
}
/**
* @param {string} prefix
* @param {number[]} values
* @param {number} checksumConstant
*/
function encodeBech32(prefix, values, checksumConstant) {
const checksum = createBech32Checksum(prefix, values, checksumConstant);
const characters = [...values, ...checksum].map((value) => {
return bech32Alphabet[value];
});
return `${prefix}1${characters.join("")}`;
}
/**
* @param {string} tag
* @param {Uint8Array} bytes
*/
async function taggedHash(tag, bytes) {
const tagHash = await sha256(new TextEncoder().encode(tag));
return sha256(concatBytes([tagHash, tagHash, bytes]));
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
async function encodeP2pkhAddressData(publicKey, network) {
const payload = await hash160(publicKey);
return {
address: await encodeVersionedBase58(p2pkhVersions[network], payload),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
async function encodeP2shP2wpkhAddressData(publicKey, network) {
const publicKeyHash = await hash160(publicKey);
const redeemScript = concatBytes([Uint8Array.of(0x00, 0x14), publicKeyHash]);
const payload = await hash160(redeemScript);
return {
address: await encodeVersionedBase58(p2shVersions[network], payload),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
async function encodeP2wpkhAddressData(publicKey, network) {
const payload = await hash160(publicKey);
const values = [0, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} witnessScript
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2wshAddressData(witnessScript, network) {
const payload = await sha256(witnessScript);
const values = [0, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
async function encodeP2trAddressData(publicKey, network) {
const internalKey = getXOnlyPublicKey(publicKey);
const tweak = await taggedHash("TapTweak", internalKey);
const payload = addXOnlyPublicKeyTweak(publicKey, tweak);
const values = [1, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32M_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {AddressScript} script
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodePublicKeyAddressData(publicKey, script, network) {
if (script === "p2pkh") {
return encodeP2pkhAddressData(publicKey, network);
}
if (script === "p2sh_p2wpkh") {
return encodeP2shP2wpkhAddressData(publicKey, network);
}
if (script === "v0_p2wpkh") {
return encodeP2wpkhAddressData(publicKey, network);
}
if (script === "v1_p2tr") {
return encodeP2trAddressData(publicKey, network);
}
throw new Error("Expected a single-key address script");
}
+114
View File
@@ -0,0 +1,114 @@
import { bytesEqual, bytesToBigInt, concatBytes, createBytes } from "./bytes.js";
import { checksum as createChecksum } from "./hash.js";
const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const base = 58n;
/**
* @param {string} character
*/
function readBase58Character(character) {
const index = alphabet.indexOf(character);
if (index === -1) {
throw new Error(`Invalid Base58 character: ${character}`);
}
return index;
}
/**
* @param {string} text
*/
function countLeadingZeros(text) {
let count = 0;
while (text[count] === "1") {
count += 1;
}
return count;
}
/**
* @param {Uint8Array} bytes
*/
function countLeadingZeroBytes(bytes) {
let count = 0;
while (bytes[count] === 0) {
count += 1;
}
return count;
}
/**
* @param {string} text
*/
function decodeBase58(text) {
let value = 0n;
for (const character of text) {
value = value * base + BigInt(readBase58Character(character));
}
const leadingZeros = countLeadingZeros(text);
const decoded = /** @type {number[]} */ ([]);
while (value > 0n) {
decoded.push(Number(value & 0xffn));
value >>= 8n;
}
decoded.reverse();
const bytes = createBytes(leadingZeros + decoded.length);
bytes.set(decoded, leadingZeros);
return bytes;
}
/**
* @param {Uint8Array} bytes
*/
function encodeBase58(bytes) {
let value = bytesToBigInt(bytes);
let text = "";
while (value > 0n) {
const index = Number(value % base);
text = alphabet[index] + text;
value /= base;
}
return "1".repeat(countLeadingZeroBytes(bytes)) + text;
}
/**
* @param {string} text
*/
export async function decodeBase58Check(text) {
const bytes = decodeBase58(text);
if (bytes.length < 4) {
throw new Error("Invalid Base58Check payload");
}
const payload = bytes.slice(0, -4);
const expected = await createChecksum(payload);
const actual = bytes.slice(-4);
if (!bytesEqual(actual, expected)) {
throw new Error("Invalid Base58Check checksum");
}
return payload;
}
/**
* @param {Uint8Array} payload
*/
export async function encodeBase58Check(payload) {
return encodeBase58(concatBytes([payload, await createChecksum(payload)]));
}
+84
View File
@@ -0,0 +1,84 @@
import { concatBytes, writeUint32 } from "./bytes.js";
import { hmacSha512 } from "./hash.js";
import { parseExtendedPublicKey } from "./key.js";
import { addPublicKeyTweak } from "./secp256k1.js";
const HARDENED_INDEX = 0x80000000;
/**
* @typedef {import("./key.js").ExtendedPublicKey} ExtendedPublicKey
*/
/**
* @typedef {Object} DerivedPublicKey
* @property {number} index
* @property {Uint8Array} publicKey
*/
/**
* @param {ExtendedPublicKey} key
* @param {number} index
* @returns {Promise<ExtendedPublicKey>}
*/
async function derivePublicChild(key, index) {
if (!Number.isSafeInteger(index) || index < 0 || index >= HARDENED_INDEX) {
throw new Error("Expected a non-hardened child index");
}
const data = concatBytes([key.publicKey, writeUint32(index)]);
const digest = await hmacSha512(key.chainCode, data);
const tweak = digest.slice(0, 32);
const chainCode = digest.slice(32);
return {
text: key.text,
depth: key.depth + 1,
childNumber: index,
parentFingerprint: key.parentFingerprint,
chainCode,
publicKey: addPublicKeyTweak(key.publicKey, tweak),
version: key.version,
};
}
/**
* @param {ExtendedPublicKey} key
* @param {readonly number[]} path
*/
async function derivePublicPath(key, path) {
let child = key;
for (const index of path) {
child = await derivePublicChild(child, index);
}
return child;
}
/**
* @param {ExtendedPublicKey} key
* @param {number} start
* @param {number} count
* @param {readonly number[]} [path]
* @returns {Promise<DerivedPublicKey[]>}
*/
export async function derivePublicKeys(key, start, count, path = []) {
const parent = await derivePublicPath(key, path);
const children = /** @type {DerivedPublicKey[]} */ ([]);
for (let offset = 0; offset < count; offset += 1) {
const index = start + offset;
const child = await derivePublicChild(parent, index);
children.push({ index, publicKey: child.publicKey });
}
return children;
}
/**
* @param {string} text
*/
export async function parseXpub(text) {
return parseExtendedPublicKey(text);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* @param {number} length
*/
export function createBytes(length) {
return new Uint8Array(length);
}
/**
* @param {Uint8Array[]} parts
*/
export function concatBytes(parts) {
const length = parts.reduce((total, part) => total + part.length, 0);
const bytes = createBytes(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
}
/**
* @param {Uint8Array} bytes
* @param {number} start
*/
export function readUint32(bytes, start) {
return (
bytes[start] * 0x1000000 +
bytes[start + 1] * 0x10000 +
bytes[start + 2] * 0x100 +
bytes[start + 3]
);
}
/**
* @param {number} value
*/
export function writeUint32(value) {
const bytes = createBytes(4);
bytes[0] = value >>> 24;
bytes[1] = value >>> 16;
bytes[2] = value >>> 8;
bytes[3] = value;
return bytes;
}
/**
* @param {Uint8Array} bytes
*/
export function bytesToBigInt(bytes) {
let value = 0n;
for (const byte of bytes) {
value = (value << 8n) + BigInt(byte);
}
return value;
}
/**
* @param {bigint} value
* @param {number} length
*/
export function bigIntToBytes(value, length) {
const bytes = createBytes(length);
let remaining = value;
for (let i = length - 1; i >= 0; i -= 1) {
bytes[i] = Number(remaining & 0xffn);
remaining >>= 8n;
}
return bytes;
}
/**
* @param {Uint8Array} left
* @param {Uint8Array} right
*/
export function bytesEqual(left, right) {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (left[i] !== right[i]) return false;
}
return true;
}
+401
View File
@@ -0,0 +1,401 @@
import { concatBytes } from "./bytes.js";
import { derivePublicKeys, parseXpub } from "./bip32.js";
import { encodeP2wshAddressData } from "./address.js";
const CHECKSUM_SEPARATOR = "#";
const WSH_SORTEDMULTI_PREFIX = "wsh(sortedmulti(";
const WSH_SORTEDMULTI_SUFFIX = "))";
const OP_CHECKMULTISIG = 0xae;
const COMPRESSED_PUBLIC_KEY_BYTES = 33;
const MAX_WSH_MULTISIG_KEYS = 20;
/**
* @typedef {import("./address.js").BitcoinNetwork} BitcoinNetwork
* @typedef {import("./index.js").GeneratedAddress} GeneratedAddress
*/
/**
* @typedef {Object} DescriptorKey
* @property {string} xpub
* @property {number[]} path
*/
/**
* @typedef {Object} SortedMultisigDescriptor
* @property {"v0_p2wsh_sortedmulti"} script
* @property {number} threshold
* @property {DescriptorKey[]} keys
*/
/**
* @param {string} text
*/
function compactText(text) {
return text.trim().replace(/\s+/g, "");
}
/**
* @param {string} text
*/
function stripDescriptorChecksum(text) {
const value = compactText(text);
const checksumIndex = value.indexOf(CHECKSUM_SEPARATOR);
return checksumIndex === -1 ? value : value.slice(0, checksumIndex);
}
/**
* @param {string} text
*/
function isSupportedDescriptor(text) {
return (
text.startsWith(WSH_SORTEDMULTI_PREFIX) &&
text.endsWith(WSH_SORTEDMULTI_SUFFIX)
);
}
/**
* @param {string} text
*/
function extractOutputDescriptors(text) {
const value = compactText(text);
const descriptors = /** @type {string[]} */ ([]);
let offset = 0;
while (offset < value.length) {
const start = value.indexOf(WSH_SORTEDMULTI_PREFIX, offset);
if (start === -1) break;
let depth = 0;
let end = -1;
let seenOpen = false;
for (let index = start; index < value.length; index += 1) {
const character = value[index];
if (character === "(") {
depth += 1;
seenOpen = true;
}
if (character === ")") {
depth -= 1;
}
if (seenOpen && depth === 0) {
end = index + 1;
break;
}
}
if (end === -1) break;
const descriptor = stripDescriptorChecksum(value.slice(start, end));
if (isSupportedDescriptor(descriptor)) {
descriptors.push(descriptor);
}
offset = end;
}
return descriptors;
}
/**
* @param {string} text
*/
export function isOutputDescriptor(text) {
return extractOutputDescriptors(text).length > 0;
}
/**
* @param {string} text
*/
function readFirstOutputDescriptor(text) {
const descriptor = extractOutputDescriptors(text)[0];
if (!descriptor) {
throw new Error("Unsupported output descriptor");
}
return descriptor;
}
/**
* @param {string} text
*/
function splitDescriptorArguments(text) {
const values = /** @type {string[]} */ ([]);
let bracketDepth = 0;
let groupDepth = 0;
let start = 0;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === "[") bracketDepth += 1;
if (character === "]") bracketDepth -= 1;
if (character === "(") groupDepth += 1;
if (character === ")") groupDepth -= 1;
if (character === "," && bracketDepth === 0 && groupDepth === 0) {
values.push(text.slice(start, index));
start = index + 1;
}
}
values.push(text.slice(start));
return values;
}
/**
* @param {string} value
*/
function readThreshold(value) {
const threshold = Number(value);
if (!Number.isSafeInteger(threshold) || threshold < 1) {
throw new Error("Invalid multisig threshold");
}
return threshold;
}
/**
* @param {string} value
*/
function readNonHardenedIndex(value) {
if (value.endsWith("'") || value.endsWith("h")) {
throw new Error("Descriptor xpub derivation cannot be hardened");
}
const index = Number(value);
if (!Number.isSafeInteger(index) || index < 0) {
throw new Error("Invalid descriptor derivation path");
}
return index;
}
/**
* @param {string} text
*/
function readDescriptorKeyPath(text) {
if (!text.startsWith("/")) {
throw new Error("Expected a ranged descriptor key path");
}
const segments = text.slice(1).split("/");
if (segments[segments.length - 1] !== "*") {
throw new Error("Expected a descriptor wildcard path");
}
return segments.slice(0, -1).map(readNonHardenedIndex);
}
/**
* @param {string} text
* @returns {DescriptorKey}
*/
function readDescriptorKey(text) {
let value = text;
if (value.startsWith("[")) {
const end = value.indexOf("]");
if (end === -1) {
throw new Error("Invalid descriptor key origin");
}
value = value.slice(end + 1);
}
const pathIndex = value.indexOf("/");
if (pathIndex === -1) {
throw new Error("Expected descriptor key derivation");
}
return {
xpub: value.slice(0, pathIndex),
path: readDescriptorKeyPath(value.slice(pathIndex)),
};
}
/**
* @param {string} text
* @returns {SortedMultisigDescriptor}
*/
export function parseOutputDescriptor(text) {
const value = readFirstOutputDescriptor(text);
const body = value.slice(
WSH_SORTEDMULTI_PREFIX.length,
-WSH_SORTEDMULTI_SUFFIX.length,
);
const [thresholdText, ...keyTexts] = splitDescriptorArguments(body);
const threshold = readThreshold(thresholdText);
const keys = keyTexts.map(readDescriptorKey);
if (
threshold > keys.length ||
keys.length < 1 ||
keys.length > MAX_WSH_MULTISIG_KEYS
) {
throw new Error("Invalid multisig key count");
}
return {
script: "v0_p2wsh_sortedmulti",
threshold,
keys,
};
}
/**
* @param {string} descriptorText
*/
function inferDescriptorBranchId(descriptorText) {
const descriptor = parseOutputDescriptor(descriptorText);
const branchIds = descriptor.keys.map((key) => {
return key.path[key.path.length - 1];
});
const sameBranch = branchIds.every((branchId) => {
return branchId === branchIds[0];
});
if (!sameBranch) return undefined;
if (branchIds[0] === 0) return "receive";
if (branchIds[0] === 1) return "change";
}
/**
* @param {string} text
*/
export function getOutputDescriptorBranchIds(text) {
const branchIds = /** @type {string[]} */ ([]);
for (const descriptor of extractOutputDescriptors(text)) {
const branchId = inferDescriptorBranchId(descriptor);
if (branchId && !branchIds.includes(branchId)) {
branchIds.push(branchId);
}
}
return branchIds.length ? branchIds : ["receive"];
}
/**
* @param {string} source
* @param {string} [branchId]
*/
export function selectOutputDescriptor(source, branchId = "receive") {
const descriptors = extractOutputDescriptors(source);
if (descriptors.length === 0) {
throw new Error("Unsupported output descriptor");
}
return descriptors.find((descriptor) => {
return inferDescriptorBranchId(descriptor) === branchId;
}) ?? descriptors[0];
}
/**
* @param {Uint8Array} left
* @param {Uint8Array} right
*/
function compareBytes(left, right) {
for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
if (left[index] !== right[index]) return left[index] - right[index];
}
return left.length - right.length;
}
/**
* @param {number} value
*/
function encodeScriptNumber(value) {
if (value <= 16) return Uint8Array.of(0x50 + value);
return Uint8Array.of(0x01, value);
}
/**
* @param {readonly Uint8Array[]} publicKeys
* @param {number} threshold
*/
function encodeSortedMultisigScript(publicKeys, threshold) {
const sortedKeys = [...publicKeys].sort(compareBytes);
const pushes = sortedKeys.map((publicKey) => {
if (publicKey.length !== COMPRESSED_PUBLIC_KEY_BYTES) {
throw new Error("Expected compressed multisig public keys");
}
return concatBytes([Uint8Array.of(COMPRESSED_PUBLIC_KEY_BYTES), publicKey]);
});
return concatBytes([
encodeScriptNumber(threshold),
...pushes,
encodeScriptNumber(sortedKeys.length),
Uint8Array.of(OP_CHECKMULTISIG),
]);
}
/**
* @param {string} descriptorText
* @param {Object} options
* @param {number} options.start
* @param {number} options.count
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromDescriptor(descriptorText, options) {
const descriptor = parseOutputDescriptor(descriptorText);
const parsedKeys = await Promise.all(
descriptor.keys.map((key) => parseXpub(key.xpub)),
);
const network = parsedKeys[0].version.network;
const childSets = await Promise.all(
parsedKeys.map((key, index) => {
if (key.version.network !== network) {
throw new Error("Descriptor xpub networks must match");
}
return derivePublicKeys(
key,
options.start,
options.count,
descriptor.keys[index].path,
);
}),
);
const addresses = /** @type {GeneratedAddress[]} */ ([]);
for (let offset = 0; offset < options.count; offset += 1) {
const publicKeys = childSets.map((children) => children[offset].publicKey);
const witnessScript = encodeSortedMultisigScript(
publicKeys,
descriptor.threshold,
);
const addressData = await encodeP2wshAddressData(witnessScript, network);
addresses.push({
index: options.start + offset,
address: addressData.address,
payload: addressData.payload,
script: descriptor.script,
network,
addrType: "v0_p2wsh",
});
}
return addresses;
}
+265
View File
@@ -0,0 +1,265 @@
import { createBytes } from "./bytes.js";
const ripemdLeftIndexes = /** @type {const} */ ([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8,
3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12,
1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2,
4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13,
]);
const ripemdRightIndexes = /** @type {const} */ ([
5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12,
6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2,
15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13,
8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14,
12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11,
]);
const ripemdLeftShifts = /** @type {const} */ ([
11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8,
7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12,
11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5,
11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12,
9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6,
]);
const ripemdRightShifts = /** @type {const} */ ([
8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6,
9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11,
9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5,
15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8,
8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11,
]);
const ripemdLeftConstants = /** @type {const} */ ([
0x00000000,
0x5a827999,
0x6ed9eba1,
0x8f1bbcdc,
0xa953fd4e,
]);
const ripemdRightConstants = /** @type {const} */ ([
0x50a28be6,
0x5c4dd124,
0x6d703ef3,
0x7a6d76e9,
0x00000000,
]);
/**
* @param {Uint8Array} bytes
*/
function toArrayBuffer(bytes) {
const buffer = new ArrayBuffer(bytes.length);
new Uint8Array(buffer).set(bytes);
return buffer;
}
/**
* @param {Uint8Array} bytes
*/
export async function sha256(bytes) {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes)),
);
}
/**
* @param {Uint8Array} bytes
*/
async function doubleSha256(bytes) {
return sha256(await sha256(bytes));
}
/**
* @param {Uint8Array} key
* @param {Uint8Array} bytes
*/
export async function hmacSha512(key, bytes) {
const cryptoKey = await crypto.subtle.importKey(
"raw",
toArrayBuffer(key),
{ name: "HMAC", hash: "SHA-512" },
false,
["sign"],
);
return new Uint8Array(
await crypto.subtle.sign("HMAC", cryptoKey, toArrayBuffer(bytes)),
);
}
/**
* @param {number} value
* @param {number} bits
*/
function rotateLeft(value, bits) {
return (value << bits) | (value >>> (32 - bits));
}
/**
* @param {number} round
* @param {number} x
* @param {number} y
* @param {number} z
*/
function ripemdFunction(round, x, y, z) {
if (round < 16) return x ^ y ^ z;
if (round < 32) return (x & y) | (~x & z);
if (round < 48) return (x | ~y) ^ z;
if (round < 64) return (x & z) | (y & ~z);
return x ^ (y | ~z);
}
/**
* @param {Uint8Array} bytes
*/
function createRipemdBlocks(bytes) {
const bitLength = BigInt(bytes.length) * 8n;
const length = bytes.length + 1 + 8;
const paddedLength = Math.ceil(length / 64) * 64;
const padded = createBytes(paddedLength);
padded.set(bytes);
padded[bytes.length] = 0x80;
for (let i = 0; i < 8; i += 1) {
padded[paddedLength - 8 + i] = Number(
(bitLength >> (BigInt(i) * 8n)) & 0xffn,
);
}
return padded;
}
/**
* @param {Uint8Array} block
* @param {number} offset
*/
function readRipemdWords(block, offset) {
const words = /** @type {number[]} */ ([]);
for (let i = 0; i < 16; i += 1) {
const start = offset + i * 4;
words.push(
block[start] |
(block[start + 1] << 8) |
(block[start + 2] << 16) |
(block[start + 3] << 24),
);
}
return words;
}
/**
* @param {Uint8Array} target
* @param {number} offset
* @param {number} value
*/
function writeRipemdWord(target, offset, value) {
target[offset] = value;
target[offset + 1] = value >>> 8;
target[offset + 2] = value >>> 16;
target[offset + 3] = value >>> 24;
}
/**
* @param {Uint8Array} bytes
*/
function ripemd160(bytes) {
const blocks = createRipemdBlocks(bytes);
const digest = createBytes(20);
let h0 = 0x67452301;
let h1 = 0xefcdab89;
let h2 = 0x98badcfe;
let h3 = 0x10325476;
let h4 = 0xc3d2e1f0;
for (let offset = 0; offset < blocks.length; offset += 64) {
const words = readRipemdWords(blocks, offset);
let al = h0;
let bl = h1;
let cl = h2;
let dl = h3;
let el = h4;
let ar = h0;
let br = h1;
let cr = h2;
let dr = h3;
let er = h4;
for (let round = 0; round < 80; round += 1) {
const leftGroup = Math.floor(round / 16);
const rightGroup = Math.floor(round / 16);
const nextLeft =
(rotateLeft(
(al +
ripemdFunction(round, bl, cl, dl) +
words[ripemdLeftIndexes[round]] +
ripemdLeftConstants[leftGroup]) |
0,
ripemdLeftShifts[round],
) +
el) |
0;
const nextRight =
(rotateLeft(
(ar +
ripemdFunction(79 - round, br, cr, dr) +
words[ripemdRightIndexes[round]] +
ripemdRightConstants[rightGroup]) |
0,
ripemdRightShifts[round],
) +
er) |
0;
al = el;
el = dl;
dl = rotateLeft(cl, 10);
cl = bl;
bl = nextLeft;
ar = er;
er = dr;
dr = rotateLeft(cr, 10);
cr = br;
br = nextRight;
}
const nextH0 = (h1 + cl + dr) | 0;
h1 = (h2 + dl + er) | 0;
h2 = (h3 + el + ar) | 0;
h3 = (h4 + al + br) | 0;
h4 = (h0 + bl + cr) | 0;
h0 = nextH0;
}
writeRipemdWord(digest, 0, h0);
writeRipemdWord(digest, 4, h1);
writeRipemdWord(digest, 8, h2);
writeRipemdWord(digest, 12, h3);
writeRipemdWord(digest, 16, h4);
return digest;
}
/**
* @param {Uint8Array} bytes
*/
export async function hash160(bytes) {
return ripemd160(await sha256(bytes));
}
/**
* @param {Uint8Array} bytes
*/
export async function checksum(bytes) {
return (await doubleSha256(bytes)).slice(0, 4);
}
+134
View File
@@ -0,0 +1,134 @@
import { encodePublicKeyAddressData } from "./address.js";
import { derivePublicKeys, parseXpub } from "./bip32.js";
import {
generateAddressesFromDescriptor,
getOutputDescriptorBranchIds,
isOutputDescriptor,
selectOutputDescriptor,
} from "./descriptor.js";
const DEFAULT_START_INDEX = 0;
const DEFAULT_ADDRESS_COUNT = 20;
const MAX_ADDRESS_COUNT = 100;
const addrTypeByScript = /** @type {const} */ ({
p2pkh: "p2pkh",
p2sh_p2wpkh: "p2sh",
v0_p2wpkh: "v0_p2wpkh",
v1_p2tr: "v1_p2tr",
v0_p2wsh_sortedmulti: "v0_p2wsh",
});
/**
* @typedef {import("./address.js").AddressScript} AddressScript
* @typedef {import("./address.js").BitcoinNetwork} BitcoinNetwork
* @typedef {(typeof addrTypeByScript)[keyof typeof addrTypeByScript]} AddressType
*/
/**
* @typedef {Object} GeneratedAddress
* @property {number} index
* @property {string} address
* @property {Uint8Array} payload
* @property {Uint8Array} [publicKey]
* @property {AddressScript} script
* @property {BitcoinNetwork} network
* @property {AddressType} addrType
*/
/**
* @typedef {Object} GenerateAddressesOptions
* @property {number} [start]
* @property {number} [count]
* @property {AddressScript} [script]
* @property {readonly number[]} [path]
* @property {string} [branchId]
*/
/**
* @param {number | undefined} value
*/
function readStart(value) {
if (value === undefined) return DEFAULT_START_INDEX;
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error("Expected a non-negative start index");
}
return value;
}
/**
* @param {number | undefined} value
*/
function readCount(value) {
const count = value ?? DEFAULT_ADDRESS_COUNT;
if (!Number.isSafeInteger(count) || count < 1 || count > MAX_ADDRESS_COUNT) {
throw new Error(`Expected an address count from 1 to ${MAX_ADDRESS_COUNT}`);
}
return count;
}
/**
* @param {string} source
* @param {GenerateAddressesOptions} [options]
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromKey(source, options = {}) {
const key = await parseXpub(source);
const start = readStart(options.start);
const count = readCount(options.count);
const script = options.script ?? key.version.script;
const addrType = addrTypeByScript[script];
const children = await derivePublicKeys(key, start, count, options.path);
const addresses = /** @type {GeneratedAddress[]} */ ([]);
for (const child of children) {
const addressData = await encodePublicKeyAddressData(
child.publicKey,
script,
key.version.network,
);
addresses.push({
index: child.index,
address: addressData.address,
payload: addressData.payload,
publicKey: child.publicKey,
script,
network: key.version.network,
addrType,
});
}
return addresses;
}
/**
* @param {string} source
* @param {GenerateAddressesOptions} [options]
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromWalletSource(source, options = {}) {
const start = readStart(options.start);
const count = readCount(options.count);
if (isOutputDescriptor(source)) {
return generateAddressesFromDescriptor(
selectOutputDescriptor(source, options.branchId),
{ start, count },
);
}
return generateAddressesFromKey(source, {
...options,
start,
count,
});
}
export {
getOutputDescriptorBranchIds,
isOutputDescriptor,
};
+124
View File
@@ -0,0 +1,124 @@
import { decodeBase58Check } from "./base58.js";
import { readUint32 } from "./bytes.js";
const EXTENDED_PUBLIC_KEY_LENGTH = 78;
const PUBLIC_KEY_LENGTH = 33;
const CHAIN_CODE_LENGTH = 32;
const extendedPublicKeyVersions = /** @type {const} */ ([
{
version: 0x0488b21e,
prefix: "xpub",
network: "mainnet",
script: "p2pkh",
addrType: "p2pkh",
},
{
version: 0x049d7cb2,
prefix: "ypub",
network: "mainnet",
script: "p2sh_p2wpkh",
addrType: "p2sh",
},
{
version: 0x04b24746,
prefix: "zpub",
network: "mainnet",
script: "v0_p2wpkh",
addrType: "v0_p2wpkh",
},
{
version: 0x043587cf,
prefix: "tpub",
network: "testnet",
script: "p2pkh",
addrType: "p2pkh",
},
{
version: 0x044a5262,
prefix: "upub",
network: "testnet",
script: "p2sh_p2wpkh",
addrType: "p2sh",
},
{
version: 0x045f1cf6,
prefix: "vpub",
network: "testnet",
script: "v0_p2wpkh",
addrType: "v0_p2wpkh",
},
]);
/**
* @typedef {typeof extendedPublicKeyVersions[number]} ExtendedPublicKeyVersion
*/
/**
* @typedef {Object} ExtendedPublicKey
* @property {string} text
* @property {number} depth
* @property {number} childNumber
* @property {Uint8Array} parentFingerprint
* @property {Uint8Array} chainCode
* @property {Uint8Array} publicKey
* @property {ExtendedPublicKeyVersion} version
*/
/**
* @param {number} version
* @returns {ExtendedPublicKeyVersion}
*/
function findExtendedPublicKeyVersion(version) {
const metadata = extendedPublicKeyVersions.find((item) => {
return item.version === version;
});
if (!metadata) {
throw new Error(`Unsupported extended public key version: ${version}`);
}
return metadata;
}
/**
* @param {Uint8Array} publicKey
*/
function validateCompressedPublicKey(publicKey) {
if (
publicKey.length !== PUBLIC_KEY_LENGTH ||
(publicKey[0] !== 0x02 && publicKey[0] !== 0x03)
) {
throw new Error("Expected a compressed public key");
}
}
/**
* @param {string} text
* @returns {Promise<ExtendedPublicKey>}
*/
export async function parseExtendedPublicKey(text) {
const value = text.trim();
const bytes = await decodeBase58Check(value);
if (bytes.length !== EXTENDED_PUBLIC_KEY_LENGTH) {
throw new Error("Invalid extended public key length");
}
const version = findExtendedPublicKeyVersion(readUint32(bytes, 0));
const parentFingerprint = bytes.slice(5, 9);
const chainCode = bytes.slice(13, 13 + CHAIN_CODE_LENGTH);
const publicKey = bytes.slice(45);
validateCompressedPublicKey(publicKey);
return {
text: value,
depth: bytes[4],
childNumber: readUint32(bytes, 9),
parentFingerprint,
chainCode,
publicKey,
version,
};
}
+6
View File
@@ -0,0 +1,6 @@
export const addressScripts = /** @type {const} */ ([
{ id: "v0_p2wpkh", label: "P2WPKH" },
{ id: "v1_p2tr", label: "P2TR" },
{ id: "p2sh_p2wpkh", label: "Nested P2WPKH" },
{ id: "p2pkh", label: "P2PKH" },
]);
+255
View File
@@ -0,0 +1,255 @@
import { bigIntToBytes, bytesToBigInt, createBytes } from "./bytes.js";
const FIELD_PRIME =
0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
const GROUP_ORDER =
0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const GENERATOR = /** @type {const} */ ({
x: 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n,
y: 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n,
});
/**
* @typedef {Object} Secp256k1Point
* @property {bigint} x
* @property {bigint} y
*/
/**
* @param {bigint} value
* @param {bigint} modulo
*/
function mod(value, modulo) {
const result = value % modulo;
return result >= 0n ? result : result + modulo;
}
/**
* @param {bigint} value
* @param {bigint} exponent
* @param {bigint} modulo
*/
function modPow(value, exponent, modulo) {
let result = 1n;
let base = mod(value, modulo);
let power = exponent;
while (power > 0n) {
if (power & 1n) result = mod(result * base, modulo);
base = mod(base * base, modulo);
power >>= 1n;
}
return result;
}
/**
* @param {bigint} value
*/
function invertField(value) {
let low = mod(value, FIELD_PRIME);
let high = FIELD_PRIME;
let lowCoefficient = 1n;
let highCoefficient = 0n;
while (low > 1n) {
const ratio = high / low;
[low, high] = [high - low * ratio, low];
[lowCoefficient, highCoefficient] = [
highCoefficient - lowCoefficient * ratio,
lowCoefficient,
];
}
return mod(lowCoefficient, FIELD_PRIME);
}
/**
* @param {Secp256k1Point} point
*/
function isOnCurve(point) {
const left = mod(point.y * point.y, FIELD_PRIME);
const right = mod(point.x * point.x * point.x + 7n, FIELD_PRIME);
return left === right;
}
/**
* @param {Secp256k1Point} point
*/
function doublePoint(point) {
if (point.y === 0n) return null;
const slope = mod(
3n * point.x * point.x * invertField(2n * point.y),
FIELD_PRIME,
);
const x = mod(slope * slope - 2n * point.x, FIELD_PRIME);
const y = mod(slope * (point.x - x) - point.y, FIELD_PRIME);
return { x, y };
}
/**
* @param {Secp256k1Point} point
*/
function negatePoint(point) {
return { x: point.x, y: mod(-point.y, FIELD_PRIME) };
}
/**
* @param {Secp256k1Point} point
*/
function forceEvenY(point) {
return point.y & 1n ? negatePoint(point) : point;
}
/**
* @param {Secp256k1Point | null} left
* @param {Secp256k1Point | null} right
* @returns {Secp256k1Point | null}
*/
function addPoints(left, right) {
if (!left) return right;
if (!right) return left;
if (left.x === right.x) {
if (mod(left.y + right.y, FIELD_PRIME) === 0n) return null;
return doublePoint(left);
}
const slope = mod(
(right.y - left.y) * invertField(right.x - left.x),
FIELD_PRIME,
);
const x = mod(slope * slope - left.x - right.x, FIELD_PRIME);
const y = mod(slope * (left.x - x) - left.y, FIELD_PRIME);
return { x, y };
}
/**
* @param {bigint} scalar
* @param {Secp256k1Point} point
* @returns {Secp256k1Point | null}
*/
function multiplyPoint(scalar, point) {
let result = /** @type {Secp256k1Point | null} */ (null);
let addend = /** @type {Secp256k1Point | null} */ (point);
let remaining = scalar;
while (remaining > 0n) {
if (remaining & 1n) result = addPoints(result, addend);
addend = addPoints(addend, addend);
remaining >>= 1n;
}
return result;
}
/**
* @param {bigint} x
* @param {boolean} odd
*/
function liftX(x, odd) {
if (x >= FIELD_PRIME) {
throw new Error("Invalid secp256k1 x coordinate");
}
let y = modPow(x * x * x + 7n, (FIELD_PRIME + 1n) / 4n, FIELD_PRIME);
if (Boolean(y & 1n) !== odd) {
y = FIELD_PRIME - y;
}
const point = { x, y };
if (!isOnCurve(point)) {
throw new Error("Invalid secp256k1 point");
}
return point;
}
/**
* @param {Uint8Array} publicKey
*/
function parseCompressedPublicKey(publicKey) {
if (
publicKey.length !== 33 ||
(publicKey[0] !== 0x02 && publicKey[0] !== 0x03)
) {
throw new Error("Expected a compressed public key");
}
return liftX(bytesToBigInt(publicKey.slice(1)), publicKey[0] === 0x03);
}
/**
* @param {Secp256k1Point} point
*/
function compressPublicKey(point) {
const publicKey = createBytes(33);
publicKey[0] = point.y & 1n ? 0x03 : 0x02;
publicKey.set(bigIntToBytes(point.x, 32), 1);
return publicKey;
}
/**
* @param {Uint8Array} publicKey
*/
export function getXOnlyPublicKey(publicKey) {
const point = forceEvenY(parseCompressedPublicKey(publicKey));
return bigIntToBytes(point.x, 32);
}
/**
* @param {Uint8Array} publicKey
* @param {Uint8Array} tweak
*/
export function addPublicKeyTweak(publicKey, tweak) {
const scalar = bytesToBigInt(tweak);
if (scalar === 0n || scalar >= GROUP_ORDER) {
throw new Error("Invalid secp256k1 public key tweak");
}
const tweakPoint = multiplyPoint(scalar, GENERATOR);
const childPoint = addPoints(parseCompressedPublicKey(publicKey), tweakPoint);
if (!childPoint) {
throw new Error("Invalid secp256k1 child public key");
}
return compressPublicKey(childPoint);
}
/**
* @param {Uint8Array} publicKey
* @param {Uint8Array} tweak
*/
export function addXOnlyPublicKeyTweak(publicKey, tweak) {
const scalar = bytesToBigInt(tweak);
if (scalar >= GROUP_ORDER) {
throw new Error("Invalid secp256k1 x-only public key tweak");
}
const internalPoint = forceEvenY(parseCompressedPublicKey(publicKey));
const tweakPoint = scalar === 0n ? null : multiplyPoint(scalar, GENERATOR);
const outputPoint = addPoints(internalPoint, tweakPoint);
if (!outputPoint) {
throw new Error("Invalid secp256k1 x-only child public key");
}
return bigIntToBytes(outputPoint.x, 32);
}