UNPKG

@scure/btc-signer

Version:

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

467 lines 21.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports._Estimator = exports._cmpBig = void 0; exports.selectUTXO = selectUTXO; const base_1 = require("@scure/base"); const P = require("micro-packed"); const payment_ts_1 = require("./payment.js"); const psbt = require("./psbt.js"); const script_ts_1 = require("./script.js"); const transaction_ts_1 = require("./transaction.js"); const utils_ts_1 = require("./utils.js"); const encodeTapBlock = (item) => psbt.TaprootControlBlock.encode(item); function iterLeafs(tapLeafScript, sigSize, customScripts) { 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 = payment_ts_1.OutScript.decode(script); let signatures = []; 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 = (0, payment_ts_1.tapLeafHash)(script, ver); for (const c of customScripts) { if (!c.finalizeTaproot) continue; const scriptDecoded = script_ts_1.Script.decode(script); const csEncoded = c.encode(scriptDecoded); if (csEncoded === undefined) continue; const pubKeys = scriptDecoded.filter((i) => { if (!(0, utils_ts_1.isBytes)(i)) return false; try { (0, utils_ts_1.validatePubkey)(i, utils_ts_1.PubT.schnorr); return true; } catch (e) { return false; } }); 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, input, opts) { let script = P.EMPTY; let witness; // schnorr sig is always 64 bytes. except for cases when sighash is not default! if (inputType.txType === 'taproot') { const SCHNORR_SIG_SIZE = inputType.sighash !== transaction_ts_1.SignatureHash.DEFAULT ? 65 : 64; if (input.tapInternalKey && !(0, utils_ts_1.equalBytes)(input.tapInternalKey, utils_ts_1.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 = []; const ltype = inputType.last.type; if (ltype === 'ms') { const m = inputType.last.m; const sig = [0]; for (let i = 0; i < m; i++) sig.push(empty()); inputScript = script_ts_1.Script.encode(sig); } else if (ltype === 'pk') { // 71 sig + 1 sighash inputScript = script_ts_1.Script.encode([empty()]); } else if (ltype === 'pkh') { inputScript = script_ts_1.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_ts_1.Script.decode(inputScript).map((i) => { if (i === 0) return P.EMPTY; if ((0, utils_ts_1.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_ts_1.Script.encode([script_ts_1.Script.encode([0, new Uint8Array(utils_ts_1.sha256.outputLen)])]); } else if (inputType.type.startsWith('sh-')) { script = script_ts_1.Script.encode([...script_ts_1.Script.decode(inputScript), inputType.lastScript]); } else if (inputType.type.startsWith('wsh-')) { } else if (inputType.txType !== 'segwit') script = inputScript; } let weight = 160 + 4 * script_ts_1.VarBytes.encode(script).length; let hasWitnesses = false; if (witness) { weight += script_ts_1.RawWitness.encode(witness).length; hasWitnesses = true; } return { weight, hasWitnesses }; } // Exported for tests, internal method const _cmpBig = (a, b) => { const n = a - b; if (n < 0n) return -1; else if (n > 0n) return 1; return 0; }; exports._cmpBig = _cmpBig; function getScript(o, opts = {}, network = utils_ts_1.NETWORK) { let script; if ('script' in o && (0, utils_ts_1.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 = payment_ts_1.OutScript.encode((0, payment_ts_1.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 && payment_ts_1.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) (0, payment_ts_1.checkScript)(script); return script; } // class, because we need to re-use normalized inputs, instead of parsing each time // internal stuff, exported for tests only class _Estimator { constructor(inputs, outputs, opts) { this.requiredIndices = []; 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 || utils_ts_1.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 * script_ts_1.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 * script_ts_1.VarBytes.encode(payment_ts_1.OutScript.encode((0, payment_ts_1.Address)(network).decode(opts.changeAddress))).length; baseWeight += 4 * script_ts_1.CompactSizeLen.encode(outputs.length).length; // If there a lot of outputs change can change fee changeWeight += 4 * script_ts_1.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 = (0, transaction_ts_1.normalizeInput)(i, undefined, undefined, opts.disableScriptCheck, opts.allowUnknown); (0, transaction_ts_1.inputBeforeSign)(normalized); // check fields const key = `${base_1.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 = (0, transaction_ts_1.getInputType)(normalized, opts.allowLegacyWitnessUtxo); const prev = (0, transaction_ts_1.getPrevOut)(normalized); const estimate = estimateInput(inputType, normalized, this.opts); const value = prev.amount - opts.feePerByte * BigInt((0, transaction_ts_1.toVsize)(estimate.weight)); // value = amount-fee return { inputType, normalized, amount: prev.amount, value, estimate }; }); } checkInputIdx(idx) { if (!Number.isSafeInteger(idx) || 0 > idx || idx >= this.normalizedInputs.length) throw new Error(`Wrong input index=${idx}`); return idx; } sortIndices(indices) { return indices.slice().sort((a, b) => { const ai = this.normalizedInputs[this.checkInputIdx(a)]; const bi = this.normalizedInputs[this.checkInputIdx(b)]; const out = (0, utils_ts_1.compareBytes)(ai.normalized.txid, bi.normalized.txid); if (out !== 0) return out; return ai.normalized.index - bi.normalized.index; }); } sortOutputs(outputs) { 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 = (0, exports._cmpBig)(aa, ba); if (out !== 0) return out; return (0, utils_ts_1.compareBytes)(scripts[a], scripts[b]); }); } getSatoshi(weigth) { return this.opts.feePerByte * BigInt((0, transaction_ts_1.toVsize)(weigth)); } // Sort by value instead of amount get biggest() { return this.normalizedInputs .map((_i, j) => j) .sort((a, b) => (0, exports._cmpBig)(this.normalizedInputs[b].value, this.normalizedInputs[a].value)); } get smallest() { 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() { return this.normalizedInputs.map((_i, j) => j); } get newest() { 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, exact = false, skipNegative = true, all = false) { // 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 = 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 * script_ts_1.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 && num >= this.requiredIndices.length) 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 * script_ts_1.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 * script_ts_1.CompactSizeLen.encode(num).length; return { indices: Array.from(res), fee, weight: newWeight, total: inputsAmount }; } return undefined; } // Works like coinselect default method default() { const { biggest } = this; const exact = this.accumulate(biggest, true, false); if (exact) return exact; return this.accumulate(biggest); } select(strategy) { if (strategy === 'all') { return this.accumulate(this.normalizedInputs.map((_, j) => j), false, true, true); } if (strategy === 'default') return this.default(); const data = { Oldest: () => this.oldest, Newest: () => this.newest, Smallest: () => this.smallest, Biggest: () => this.biggest, }; if (strategy.startsWith('exact')) { const [exactData, left] = strategy.slice(5).split('/'); 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); 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) { 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_ts_1.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 }; } } exports._Estimator = _Estimator; function selectUTXO(inputs, outputs, strategy, opts) { // 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); } //# sourceMappingURL=utxo.js.map