Files
brk/website_next/wallets/derive/descriptor.js
T
2026-06-17 11:25:42 +02:00

402 lines
9.2 KiB
JavaScript

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;
}