UNPKG

@scure/btc-signer

Version:

Audited & minimal library for Bitcoin. Handle transactions, Schnorr, Taproot, UTXO & PSBT

641 lines (618 loc) 26.2 kB
import { hex } from '@scure/base'; import * as P from 'micro-packed'; import { Address, type CustomScript, OutScript, checkScript, tapLeafHash } from './payment.js'; import * as psbt from './psbt.js'; import { CompactSizeLen, RawOutput, RawTx, RawWitness, Script, VarBytes } from './script.js'; import { DEFAULT_SEQUENCE, SignatureHash, Transaction, type TxOpts, inputBeforeSign, } from './transaction.js'; // circular import { type Bytes, NETWORK, PubT, TAPROOT_UNSPENDABLE_KEY, compareBytes, equalBytes, isBytes, sha256, validatePubkey, } from './utils.js'; export type PSBTInputs = psbt.PSBTKeyMapKeys<typeof psbt.PSBTInput>; // Normalizes input export function getPrevOut(input: psbt.TransactionInput): P.UnwrapCoder<typeof RawOutput> { if (input.nonWitnessUtxo) { if (input.index === undefined) throw new Error('Unknown input index'); return input.nonWitnessUtxo.outputs[input.index]; } else if (input.witnessUtxo) return input.witnessUtxo; else throw new Error('Cannot find previous output info'); } export function normalizeInput( i: psbt.TransactionInputUpdate, cur?: psbt.TransactionInput, allowedFields?: (keyof psbt.TransactionInput)[], disableScriptCheck = false, allowUnknown = false ): psbt.TransactionInput { let { nonWitnessUtxo, txid } = i; // String support for common fields. We usually prefer Uint8Array to avoid errors // like hex looking string accidentally passed, however, in case of nonWitnessUtxo // it is better to expect string, since constructing this complex object will be // difficult for user if (typeof nonWitnessUtxo === 'string') nonWitnessUtxo = hex.decode(nonWitnessUtxo); if (isBytes(nonWitnessUtxo)) nonWitnessUtxo = RawTx.decode(nonWitnessUtxo); if (!('nonWitnessUtxo' in i) && nonWitnessUtxo === undefined) nonWitnessUtxo = cur?.nonWitnessUtxo; if (typeof txid === 'string') txid = hex.decode(txid); // TODO: if we have nonWitnessUtxo, we can extract txId from here if (txid === undefined) txid = cur?.txid; let res: PSBTInputs = { ...cur, ...i, nonWitnessUtxo, txid }; if (!('nonWitnessUtxo' in i) && res.nonWitnessUtxo === undefined) delete res.nonWitnessUtxo; if (res.sequence === undefined) res.sequence = DEFAULT_SEQUENCE; if (res.tapMerkleRoot === null) delete res.tapMerkleRoot; res = psbt.mergeKeyMap(psbt.PSBTInput, res, cur, allowedFields, allowUnknown); psbt.PSBTInputCoder.encode(res); // Validates that everything is correct at this point let prevOut; if (res.nonWitnessUtxo && res.index !== undefined) prevOut = res.nonWitnessUtxo.outputs[res.index]; else if (res.witnessUtxo) prevOut = res.witnessUtxo; if (prevOut && !disableScriptCheck) checkScript(prevOut && prevOut.script, res.redeemScript, res.witnessScript); return res; } export function getInputType(input: psbt.TransactionInput, allowLegacyWitnessUtxo = false) { let txType = 'legacy'; let defaultSighash = SignatureHash.ALL; const prevOut = getPrevOut(input); const first = OutScript.decode(prevOut.script); let type = first.type; let cur = first; const stack = [first]; if (first.type === 'tr') { defaultSighash = SignatureHash.DEFAULT; return { txType: 'taproot', type: 'tr', last: first, lastScript: prevOut.script, defaultSighash, sighash: input.sighashType || defaultSighash, }; } else { if (first.type === 'wpkh' || first.type === 'wsh') txType = 'segwit'; if (first.type === 'sh') { if (!input.redeemScript) throw new Error('inputType: sh without redeemScript'); let child = OutScript.decode(input.redeemScript); if (child.type === 'wpkh' || child.type === 'wsh') txType = 'segwit'; stack.push(child); cur = child; type += `-${child.type}`; } // wsh can be inside sh if (cur.type === 'wsh') { if (!input.witnessScript) throw new Error('inputType: wsh without witnessScript'); let child = OutScript.decode(input.witnessScript); if (child.type === 'wsh') txType = 'segwit'; stack.push(child); cur = child; type += `-${child.type}`; } const last = stack[stack.length - 1]; if (last.type === 'sh' || last.type === 'wsh') throw new Error('inputType: sh/wsh cannot be terminal type'); const lastScript = OutScript.encode(last); const res = { type, txType, last, lastScript, defaultSighash, sighash: input.sighashType || defaultSighash, }; if (txType === 'legacy' && !allowLegacyWitnessUtxo && !input.nonWitnessUtxo) { throw new Error( `Transaction/sign: legacy input without nonWitnessUtxo, can result in attack that forces paying higher fees. Pass allowLegacyWitnessUtxo=true, if you sure` ); } return res; } } export const toVsize = (weight: number): number => Math.ceil(weight / 4); // UTXO Select export type Output = { address: string; amount: bigint } | { script: Uint8Array; amount: bigint }; export type Accumulated = | { indices: number[]; fee: bigint | undefined; weight: number; total: bigint; } | undefined; type TapLeafScript = psbt.TransactionInput['tapLeafScript']; type TB = Parameters<typeof psbt.TaprootControlBlock.encode>[0]; const encodeTapBlock = (item: TB) => psbt.TaprootControlBlock.encode(item); function iterLeafs(tapLeafScript: TapLeafScript, sigSize: number, customScripts?: CustomScript[]) { if (!tapLeafScript || !tapLeafScript.length) throw new Error('no leafs'); const empty = () => new Uint8Array(sigSize); // If user want to select specific leaf, which can signed, // it is possible to remove all other leafs manually. // Sort leafs by control block length. const leafs = tapLeafScript.sort( (a, b) => encodeTapBlock(a[0]).length - encodeTapBlock(b[0]).length ); for (const [cb, _script] of leafs) { // Last byte is version const script = _script.slice(0, -1); const ver = _script[_script.length - 1]; const outs = OutScript.decode(script); let signatures: Bytes[] = []; if (outs.type === 'tr_ms') { const m = outs.m; const n = outs.pubkeys.length - m; for (let i = 0; i < m; i++) signatures.push(empty()); for (let i = 0; i < n; i++) signatures.push(P.EMPTY); } else if (outs.type === 'tr_ns') { for (const _pub of outs.pubkeys) signatures.push(empty()); } else { if (!customScripts) throw new Error('Finalize: Unknown tapLeafScript'); const leafHash = tapLeafHash(script, ver); for (const c of customScripts) { if (!c.finalizeTaproot) continue; const scriptDecoded = Script.decode(script); const csEncoded = c.encode(scriptDecoded); if (csEncoded === undefined) continue; const pubKeys = scriptDecoded.filter((i) => { if (!isBytes(i)) return false; try { validatePubkey(i, PubT.schnorr); return true; } catch (e) { return false; } }) as Bytes[]; const finalized = c.finalizeTaproot( script, csEncoded, pubKeys.map((pubKey) => [{ pubKey, leafHash }, empty()]) ); if (!finalized) continue; return finalized.concat(encodeTapBlock(cb)); } } // Witness is stack, so last element will be used first return signatures.reverse().concat([script, encodeTapBlock(cb)]); } throw new Error('there was no witness'); } function estimateInput( inputType: ReturnType<typeof getInputType>, input: psbt.TransactionInput, opts: TxOpts ) { let script: Bytes = P.EMPTY; let witness: Bytes[] | undefined; // schnorr sig is always 64 bytes. except for cases when sighash is not default! if (inputType.txType === 'taproot') { const SCHNORR_SIG_SIZE = inputType.sighash !== SignatureHash.DEFAULT ? 65 : 64; if (input.tapInternalKey && !equalBytes(input.tapInternalKey, TAPROOT_UNSPENDABLE_KEY)) { witness = [new Uint8Array(SCHNORR_SIG_SIZE)]; } else if (input.tapLeafScript) { witness = iterLeafs(input.tapLeafScript, SCHNORR_SIG_SIZE, opts.customScripts); } else throw new Error('estimateInput/taproot: unknown input'); } else { // It is possible to grind signatures until it has minimal size (but changing fee value +N satoshi), // which will make estimations exact. But will be very hard for multi sig (need to make sure all signatures has small size). const empty = () => new Uint8Array(72); // max size of sigs const emptyPub = () => new Uint8Array(33); // size of pubkey let inputScript = P.EMPTY; let inputWitness: Uint8Array[] = []; const ltype = inputType.last.type; if (ltype === 'ms') { const m = inputType.last.m; const sig: (number | Uint8Array)[] = [0]; for (let i = 0; i < m; i++) sig.push(empty()); inputScript = Script.encode(sig); } else if (ltype === 'pk') { // 71 sig + 1 sighash inputScript = Script.encode([empty()]); } else if (ltype === 'pkh') { inputScript = Script.encode([empty(), emptyPub()]); } else if (ltype === 'wpkh') { inputScript = P.EMPTY; inputWitness = [empty(), emptyPub()]; } else if (ltype === 'unknown' && !opts.allowUnknownInputs) throw new Error('Unknown inputs are not allowed'); if (inputType.type.includes('wsh-')) { // P2WSH if (inputScript.length && inputType.lastScript.length) { inputWitness = Script.decode(inputScript).map((i) => { if (i === 0) return P.EMPTY; if (isBytes(i)) return i; throw new Error(`Wrong witness op=${i}`); }); } inputWitness = inputWitness.concat(inputType.lastScript); } if (inputType.txType === 'segwit') witness = inputWitness; if (inputType.type.startsWith('sh-wsh-')) { script = Script.encode([Script.encode([0, new Uint8Array(sha256.outputLen)])]); } else if (inputType.type.startsWith('sh-')) { script = Script.encode([...Script.decode(inputScript), inputType.lastScript]); } else if (inputType.type.startsWith('wsh-')) { } else if (inputType.txType !== 'segwit') script = inputScript; } let weight = 160 + 4 * VarBytes.encode(script).length; let hasWitnesses = false; if (witness) { weight += RawWitness.encode(witness).length; hasWitnesses = true; } return { weight, hasWitnesses }; } // Exported for tests, internal method export const _cmpBig = (a: bigint, b: bigint): 0 | 1 | -1 => { const n = a - b; if (n < 0n) return -1; else if (n > 0n) return 1; return 0; }; export type EstimatorOpts = TxOpts & { // NOTE: fees less than 1 satoshi per vbyte is not supported. Please create issue if you have valid use case for that. feePerByte: bigint; // satoshi per vbyte changeAddress: string; // address where change will be sent // Optional alwaysChange?: boolean; // always create change, even if less than dust threshold bip69?: boolean; // https://github.com/bitcoin/bips/blob/master/bip-0069.mediawiki network?: typeof NETWORK; dust?: number; // how much vbytes considered dust? dustRelayFeeRate?: bigint; // fee per dust byte (DUST_RELAY_TX_FEE) createTx?: boolean; // Create tx inside selection requiredInputs?: psbt.TransactionInputUpdate[]; // these inputs always will be used allowSameUtxo?: boolean; // allow using UTXO multiple times (for test purposes) }; function getScript(o: Output, opts: TxOpts = {}, network = NETWORK) { let script; if ('script' in o && isBytes(o.script)) { script = o.script; } if ('address' in o) { if (typeof o.address !== 'string') throw new Error(`Estimator: wrong output address=${o.address}`); script = OutScript.encode(Address(network).decode(o.address)); } if (!script) throw new Error('Estimator: wrong output script'); if (typeof o.amount !== 'bigint') throw new Error( `Estimator: wrong output amount=${ o.amount }, should be of type bigint but got ${typeof o.amount}.` ); if (script && !opts.allowUnknownOutputs && OutScript.decode(script).type === 'unknown') { throw new Error( 'Estimator: unknown output script type, there is a chance that input is unspendable. Pass allowUnknownOutputs=true, if you sure' ); } if (!opts.disableScriptCheck) checkScript(script); return script; } // exact is meaningless without additional accum (will often fail if not possible to find right utxo) // -> we support only exact+accum or accum type SortStrategy = 'Newest' | 'Oldest' | 'Smallest' | 'Biggest'; type ExactStrategy = `exact${SortStrategy}`; type AccumStrategy = `accum${SortStrategy}`; export type SelectionStrategy = | 'all' | 'default' | AccumStrategy | `${ExactStrategy}/${AccumStrategy}`; // class, because we need to re-use normalized inputs, instead of parsing each time // internal stuff, exported for tests only export class _Estimator { private baseWeight: number; private changeWeight: number; private amount: bigint; private requiredIndices: number[] = []; private normalizedInputs: { inputType: ReturnType<typeof getInputType>; normalized: ReturnType<typeof normalizeInput>; amount: bigint; value: bigint; estimate: { weight: number; hasWitnesses: boolean }; }[]; // Dust used in accumExact + change address algo // - change address: can be smaller for segwit // - accumExact: ??? private dust: bigint; // total dust limit (3||opts.dustRelayFeeRate * 182||opts.dust). Default: 546 private outputs: Output[]; private opts: EstimatorOpts; constructor(inputs: psbt.TransactionInputUpdate[], outputs: Output[], opts: EstimatorOpts) { this.outputs = outputs; this.opts = opts; if (typeof opts.feePerByte !== 'bigint') throw new Error( `Estimator: wrong feePerByte=${ opts.feePerByte }, should be of type bigint but got ${typeof opts.feePerByte}.` ); // Dust stuff // TODO: think about this more: // - current dust filters tx which cannot be relayed by core // - but actual dust meaning is 'can be this amount spent?' // - dust contains full tx size. but we can use other inputs to pay for outputDust (and parially inputsDust)? // - not sure if we can spent anything with feePerByte: 3. It will be relayed, but will it be mined? // - for now it works exactly as bitcoin-core. But will create change/outputs which cannot be spent (reasonable). // Number of bytes needed to create and spend a UTXO. // https://github.com/bitcoin/bitcoin/blob/27a770b34b8f1dbb84760f442edb3e23a0c2420b/src/policy/policy.cpp#L28-L41 const inputsDust = 32 + 4 + 1 + 107 + 4; // NOTE: can be smaller for segwit tx? const outputDust = 34; // NOTE: 'nSize = GetSerializeSize(txout)' const dustBytes = opts.dust === undefined ? BigInt(inputsDust + outputDust) : opts.dust; if (typeof dustBytes !== 'bigint') { throw new Error( `Estimator: wrong dust=${opts.dust}, should be of type bigint but got ${typeof opts.dust}.` ); } // 3 sat/vb is the default minimum fee rate used to calculate dust thresholds by bitcoin core. // 3000 sat/kvb -> 3 sat/vb. // https://github.com/bitcoin/bitcoin/blob/27a770b34b8f1dbb84760f442edb3e23a0c2420b/src/policy/policy.h#L55 const dustFee = opts.dustRelayFeeRate === undefined ? 3n : opts.dustRelayFeeRate; if (typeof dustFee !== 'bigint') { throw new Error( `Estimator: wrong dustRelayFeeRate=${opts.dustRelayFeeRate}, should be of type bigint but got ${typeof opts.dustRelayFeeRate}.` ); } // Dust uses feePerbyte by default, but we allow separate dust fee if needed this.dust = dustBytes * dustFee; if (opts.requiredInputs !== undefined && !Array.isArray(opts.requiredInputs)) throw new Error(`Estimator: wrong required inputs=${opts.requiredInputs}`); const network = opts.network || NETWORK; let amount = 0n; // Base weight: tx with outputs, no inputs let baseWeight = 32; for (const o of outputs) { const script = getScript(o, opts, opts.network); baseWeight += 32 + 4 * VarBytes.encode(script).length; amount += o.amount; } if (typeof opts.changeAddress !== 'string') throw new Error(`Estimator: wrong change address=${opts.changeAddress}`); let changeWeight = baseWeight + 32 + 4 * VarBytes.encode(OutScript.encode(Address(network).decode(opts.changeAddress))).length; baseWeight += 4 * CompactSizeLen.encode(outputs.length).length; // If there a lot of outputs change can change fee changeWeight += 4 * CompactSizeLen.encode(outputs.length + 1).length; this.baseWeight = baseWeight; this.changeWeight = changeWeight; this.amount = amount; const allInputs = Array.from(inputs); if (opts.requiredInputs) { for (let i = 0; i < opts.requiredInputs.length; i++) this.requiredIndices.push(allInputs.push(opts.requiredInputs[i]) - 1); } const inputKeys = new Set(); this.normalizedInputs = allInputs.map((i) => { const normalized = normalizeInput( i, undefined, undefined, opts.disableScriptCheck, opts.allowUnknown ); inputBeforeSign(normalized); // check fields const key = `${hex.encode(normalized.txid!)}:${normalized.index}`; if (!opts.allowSameUtxo && inputKeys.has(key)) throw new Error(`Estimator: same input passed multiple times: ${key}`); inputKeys.add(key); const inputType = getInputType(normalized, opts.allowLegacyWitnessUtxo); const prev = getPrevOut(normalized); const estimate = estimateInput(inputType, normalized, this.opts); const value = prev.amount - opts.feePerByte * BigInt(toVsize(estimate.weight)); // value = amount-fee return { inputType, normalized, amount: prev.amount, value, estimate }; }); } private checkInputIdx(idx: number) { if (!Number.isSafeInteger(idx) || 0 > idx || idx >= this.normalizedInputs.length) throw new Error(`Wrong input index=${idx}`); return idx; } private sortIndices(indices: number[]) { return indices.slice().sort((a, b) => { const ai = this.normalizedInputs[this.checkInputIdx(a)]; const bi = this.normalizedInputs[this.checkInputIdx(b)]; const out = compareBytes(ai.normalized.txid!, bi.normalized.txid!); if (out !== 0) return out; return ai.normalized.index! - bi.normalized.index!; }); } private sortOutputs(outputs: Output[]) { const scripts = outputs.map((o) => getScript(o, this.opts, this.opts.network)); const indices = outputs.map((_, j) => j); return indices.sort((a, b) => { const aa = outputs[a].amount; const ba = outputs[b].amount; const out = _cmpBig(aa, ba); if (out !== 0) return out; return compareBytes(scripts[a], scripts[b]); }); } private getSatoshi(weigth: number) { return this.opts.feePerByte * BigInt(toVsize(weigth)); } // Sort by value instead of amount get biggest(): number[] { return this.normalizedInputs .map((_i, j) => j) .sort((a, b) => _cmpBig(this.normalizedInputs[b].value, this.normalizedInputs[a].value)); } get smallest(): number[] { return this.biggest.reverse(); } // These assume that UTXO array has historical order. // Otherwise, we have no way to know which tx is oldest // Explorers usually give UTXO in this order. get oldest(): number[] { return this.normalizedInputs.map((_i, j) => j); } get newest(): number[] { return this.oldest.reverse(); } // exact - like blackjack from coinselect. // exact(biggest) will select one big utxo which is closer to targetValue+dust, if possible. // If not, it will accumulate largest utxo until value is close to targetValue+dust. accumulate(indices: number[], exact = false, skipNegative = true, all = false): Accumulated { // TODO: how to handle change addresses? // - cost of input // - cost of change output (if input requires change) // - cost of output spending // Dust threshold should be significantly bigger, no point in // creating an output, which cannot be spent. // coinselect doesn't consider cost of output address for dust. // Changing that can actually reduce privacy let weight = this.opts.alwaysChange ? this.changeWeight : this.baseWeight; let hasWitnesses = false; let num = 0; let inputsAmount = 0n; const targetAmount = this.amount; const res: Set<number> = new Set(); let fee; for (const idx of this.requiredIndices) { this.checkInputIdx(idx); if (res.has(idx)) throw new Error('required input encountered multiple times'); // should not happen const { estimate, amount } = this.normalizedInputs[idx]; let newWeight = weight + estimate.weight; if (!hasWitnesses && estimate.hasWitnesses) newWeight += 2; // enable witness if needed const totalWeight = newWeight + 4 * CompactSizeLen.encode(num).length; // number of outputs can change weight fee = this.getSatoshi(totalWeight); weight = newWeight; if (estimate.hasWitnesses) hasWitnesses = true; num++; inputsAmount += amount; res.add(idx); // inputsAmount is enough to cover cost of tx if (!all && targetAmount + fee <= inputsAmount) return { indices: Array.from(res), fee, weight: totalWeight, total: inputsAmount }; } for (const idx of indices) { this.checkInputIdx(idx); if (res.has(idx)) continue; // skip required inputs const { estimate, amount, value } = this.normalizedInputs[idx]; let newWeight = weight + estimate.weight; if (!hasWitnesses && estimate.hasWitnesses) newWeight += 2; // enable witness if needed const totalWeight = newWeight + 4 * CompactSizeLen.encode(num).length; // number of outputs can change weight fee = this.getSatoshi(totalWeight); // Best case scenario exact(biggest) -> we find biggest output, less than target+threshold if (exact && amount + inputsAmount > targetAmount + fee + this.dust) continue; // skip if added value is bigger than dust // Negative: cost of using input is more than value provided (negative) // By default 'blackjack' mode in coinselect doesn't use that, which means // it will use negative output if sorted by 'smallest' if (skipNegative && value <= 0n) continue; weight = newWeight; if (estimate.hasWitnesses) hasWitnesses = true; num++; inputsAmount += amount; res.add(idx); // inputsAmount is enough to cover cost of tx if (!all && targetAmount + fee <= inputsAmount) return { indices: Array.from(res), fee, weight: totalWeight, total: inputsAmount }; } if (all) { const newWeight = weight + 4 * CompactSizeLen.encode(num).length; return { indices: Array.from(res), fee, weight: newWeight, total: inputsAmount }; } return undefined; } // Works like coinselect default method default(): Accumulated { const { biggest } = this; const exact = this.accumulate(biggest, true, false); if (exact) return exact; return this.accumulate(biggest); } private select(strategy: SelectionStrategy) { if (strategy === 'all') { return this.accumulate( this.normalizedInputs.map((_, j) => j), false, true, true ); } if (strategy === 'default') return this.default(); const data: Record<SortStrategy, () => number[]> = { Oldest: () => this.oldest, Newest: () => this.newest, Smallest: () => this.smallest, Biggest: () => this.biggest, }; if (strategy.startsWith('exact')) { const [exactData, left] = strategy.slice(5).split('/') as [SortStrategy, SelectionStrategy]; if (!data[exactData]) throw new Error(`Estimator.select: wrong strategy=${strategy}`); strategy = left; const exact = this.accumulate(data[exactData](), true, true); if (exact) return exact; } if (strategy.startsWith('accum')) { const accumData = strategy.slice(5) as SortStrategy; if (!data[accumData]) throw new Error(`Estimator.select: wrong strategy=${strategy}`); return this.accumulate(data[accumData]()); } throw new Error(`Estimator.select: wrong strategy=${strategy}`); } result(strategy: SelectionStrategy) { const s = this.select(strategy); if (!s) return; const { indices, weight, total } = s; let needChange = this.opts.alwaysChange; const changeWeight = this.opts.alwaysChange ? weight : weight + (this.changeWeight - this.baseWeight); const changeFee = this.getSatoshi(changeWeight); let fee = s.fee; const change = total - this.amount - changeFee; if (change > this.dust) needChange = true; let inputs = indices; let outputs = Array.from(this.outputs); if (needChange) { fee = changeFee; // this shouldn't happen! if (change < 0n) throw new Error(`Estimator.result: negative change=${change}`); outputs.push({ address: this.opts.changeAddress, amount: change }); } if (this.opts.bip69) { inputs = this.sortIndices(inputs); outputs = this.sortOutputs(outputs).map((i) => outputs[i]); } const res = { inputs: inputs.map((i) => this.normalizedInputs[i].normalized), outputs, fee, weight: this.opts.alwaysChange ? s.weight : changeWeight, change: !!needChange, }; let tx; if (this.opts.createTx) { const { inputs, outputs } = res; tx = new Transaction(this.opts); for (const i of inputs) tx.addInput(i); for (const o of outputs) tx.addOutput({ ...o, script: getScript(o, this.opts, this.opts.network) }); } return Object.assign(res, { tx }); // return { ...res, tx: tx }; } } export function selectUTXO( inputs: psbt.TransactionInputUpdate[], outputs: Output[], strategy: SelectionStrategy, opts: EstimatorOpts ) { // Defaults: do we want bip69 by default? const _opts = { createTx: true, bip69: true, ...opts }; const est = new _Estimator(inputs, outputs, _opts); return est.result(strategy); }