diff --git a/package-lock.json b/package-lock.json index 028e4bf..814eb99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@bitcoinerlab/coinselect", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@bitcoinerlab/coinselect", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { - "@bitcoinerlab/descriptors": "^2.0.3" + "@bitcoinerlab/descriptors": "^2.1.0" }, "devDependencies": { "@bitcoinerlab/configs": "github:bitcoinerlab/configs", @@ -712,15 +712,17 @@ } }, "node_modules/@bitcoinerlab/descriptors": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.0.3.tgz", - "integrity": "sha512-HIzypdjnvG361JUh7Or7LeawVCrdjuW5SRry6AcUxC8FTeDe3/vQhvD49QDwl6lURM12g0bSRitBK6oXznhsrw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.1.0.tgz", + "integrity": "sha512-VSdw07ASLfs3HZzTW4D65ONpSUOstGVhYuEq6bGTW0RexVHwgh90Cr0L53e/Uh+U7uwhjPgUhCXcTgSlvoXPeA==", "dependencies": { "@bitcoinerlab/miniscript": "^1.2.1", "@bitcoinerlab/secp256k1": "^1.0.5", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.3", - "ecpair": "^2.1.0" + "ecpair": "^2.1.0", + "lodash.memoize": "^4.1.2", + "varuint-bitcoin": "^1.1.2" }, "peerDependencies": { "ledger-bitcoin": "^0.2.2" @@ -4351,6 +4353,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6396,15 +6403,17 @@ } }, "@bitcoinerlab/descriptors": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.0.3.tgz", - "integrity": "sha512-HIzypdjnvG361JUh7Or7LeawVCrdjuW5SRry6AcUxC8FTeDe3/vQhvD49QDwl6lURM12g0bSRitBK6oXznhsrw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.1.0.tgz", + "integrity": "sha512-VSdw07ASLfs3HZzTW4D65ONpSUOstGVhYuEq6bGTW0RexVHwgh90Cr0L53e/Uh+U7uwhjPgUhCXcTgSlvoXPeA==", "requires": { "@bitcoinerlab/miniscript": "^1.2.1", "@bitcoinerlab/secp256k1": "^1.0.5", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.3", - "ecpair": "^2.1.0" + "ecpair": "^2.1.0", + "lodash.memoize": "^4.1.2", + "varuint-bitcoin": "^1.1.2" } }, "@bitcoinerlab/miniscript": { @@ -9040,6 +9049,11 @@ "p-locate": "^5.0.0" } }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index 884d483..4d1f3fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitcoinerlab/coinselect", - "version": "1.1.0", + "version": "1.2.0", "author": "Jose-Luis Landabaso", "license": "MIT", "description": "A TypeScript library for Bitcoin transaction management, based on Bitcoin Descriptors for defining inputs and outputs. It facilitates optimal UTXO selection and transaction size calculation.", @@ -52,6 +52,6 @@ "regtest-client": "^0.2.1" }, "dependencies": { - "@bitcoinerlab/descriptors": "^2.0.3" + "@bitcoinerlab/descriptors": "^2.1.0" } } diff --git a/src/algos/maxFunds.ts b/src/algos/maxFunds.ts index a12b8d8..afb925d 100644 --- a/src/algos/maxFunds.ts +++ b/src/algos/maxFunds.ts @@ -3,6 +3,7 @@ import { DUST_RELAY_FEE_RATE, OutputWithValue } from '../index'; import { validateFeeRate, validateOutputWithValues, + validateDust, validatedFeeAndVsize } from '../validation'; import { vsize } from '../vsize'; @@ -38,7 +39,8 @@ export function maxFunds({ dustRelayFeeRate?: number; }) { validateOutputWithValues(utxos); - targets.length === 0 || validateOutputWithValues(targets); + if (targets.length) validateOutputWithValues(targets); + validateDust(targets); validateFeeRate(feeRate); validateFeeRate(dustRelayFeeRate); diff --git a/src/coinselect.ts b/src/coinselect.ts index 3ca64f9..498a436 100644 --- a/src/coinselect.ts +++ b/src/coinselect.ts @@ -8,8 +8,7 @@ import { } from './validation'; import { addUntilReach } from './algos/addUntilReach'; import { avoidChange } from './algos/avoidChange'; -import { inputWeight } from './vsize'; -import { isSegwitTx } from './segwit'; +import { isSegwitTx } from './vsize'; // order by descending value, minus the inputs approximate fee function utxoTransferredValue( @@ -19,7 +18,12 @@ function utxoTransferredValue( ) { return ( outputAndValue.value - - (feeRate * inputWeight(outputAndValue.output, isSegwitTx)) / 4 + (feeRate * + outputAndValue.output.inputWeight( + isSegwitTx, + 'DANGEROUSLY_USE_FAKE_SIGNATURES' + )) / + 4 ); } diff --git a/src/dust.ts b/src/dust.ts index 06d9171..ec1c338 100644 --- a/src/dust.ts +++ b/src/dust.ts @@ -1,6 +1,4 @@ import type { OutputInstance } from '@bitcoinerlab/descriptors'; -import { inputWeight, outputWeight } from './vsize'; -import { isSegwit } from './segwit'; import { DUST_RELAY_FEE_RATE } from './index'; /** @@ -52,11 +50,17 @@ export function dustThreshold( */ dustRelayFeeRate: number = DUST_RELAY_FEE_RATE ) { - const isSegwitOutput = isSegwit(output); + const isSegwitOutput = output.isSegwit(); + if (isSegwitOutput === undefined) throw new Error(`Unknown output type`); return Math.ceil( dustRelayFeeRate * Math.ceil( - (outputWeight(output) + inputWeight(output, isSegwitOutput)) / 4 + (output.outputWeight() + + output.inputWeight( + isSegwitOutput, + 'DANGEROUSLY_USE_FAKE_SIGNATURES' + )) / + 4 ) ); } diff --git a/src/index.ts b/src/index.ts index 2b29435..05e8262 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,6 @@ import type { OutputInstance } from '@bitcoinerlab/descriptors'; export { coinselect } from './coinselect'; export { vsize } from './vsize'; export { dustThreshold } from './dust'; -export { inputWeight, outputWeight } from './vsize'; export { maxFunds } from './algos/maxFunds'; export { addUntilReach } from './algos/addUntilReach'; export { avoidChange } from './algos/avoidChange'; diff --git a/src/segwit.ts b/src/segwit.ts deleted file mode 100644 index 2e3c75a..0000000 --- a/src/segwit.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { OutputInstance } from '@bitcoinerlab/descriptors'; -import { payments } from 'bitcoinjs-lib'; - -export function guessOutput(output: OutputInstance) { - function guessSH(output: Buffer) { - try { - payments.p2sh({ output }); - return true; - } catch (err) { - return false; - } - } - function guessWPKH(output: Buffer) { - try { - payments.p2wpkh({ output }); - return true; - } catch (err) { - return false; - } - } - function guessPKH(output: Buffer) { - try { - payments.p2pkh({ output }); - return true; - } catch (err) { - return false; - } - } - const isPKH = guessPKH(output.getScriptPubKey()); - const isWPKH = guessWPKH(output.getScriptPubKey()); - const isSH = guessSH(output.getScriptPubKey()); - - if ([isPKH, isWPKH, isSH].filter(Boolean).length > 1) - throw new Error('Cannot have multiple output types.'); - - return { isPKH, isWPKH, isSH }; -} - -/** - * It assumes that an addr(SH_ADDRESS) is always a add(SH_WPKH) address - */ -export function isSegwit(output: OutputInstance) { - let isSegwit = output.isSegwit(); - const expansion = output.expand().expandedExpression; - const { isPKH, isWPKH, isSH } = guessOutput(output); - //expansion is not generated for addr() descriptors: - if (!expansion && isPKH) isSegwit = false; - if (!expansion && isWPKH) isSegwit = true; - if (!expansion && isSH) isSegwit = true; //Assume PSH-P2WPKH - if (isSegwit === undefined) - throw new Error('Cannot guess whether the Output is Segwit or not'); - //we will assume that any addr(SH_TYPE_ADDRESS) is in fact SH_WPKH. - return isSegwit; -} - -export const isSegwitTx = (inputs: Array) => - inputs.some(input => isSegwit(input)); diff --git a/src/validation.ts b/src/validation.ts index e8ba651..e528b2b 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -39,9 +39,13 @@ export function validatedFeeAndVsize( targets.map(t => t.output) ); const finalFeeRate = fee / vsizeResult; - if (finalFeeRate < feeRate) + // Don't compare fee rates because values are picked based on comparing fees (multiplications) + // Don't mix * / operators: + // F.ex.: 100/27 !== 100*(1/27) + // Instead, compare final fee + if (fee < Math.ceil(vsizeResult * feeRate)) throw new Error( - `Final fee rate ${finalFeeRate} lower than required ${feeRate}` + `Final fee ${fee} lower than required ${Math.ceil(vsizeResult * feeRate)}` ); validateFeeRate(finalFeeRate); return { fee, vsize: vsizeResult }; diff --git a/src/vsize.ts b/src/vsize.ts index 33d8534..de728d7 100644 --- a/src/vsize.ts +++ b/src/vsize.ts @@ -10,199 +10,9 @@ import type { PartialSig } from 'bip174/src/lib/interfaces'; import type { OutputInstance } from '@bitcoinerlab/descriptors'; import { encodingLength } from 'varuint-bitcoin'; -import { payments } from 'bitcoinjs-lib'; -import { guessOutput, isSegwit, isSegwitTx } from './segwit'; -function varSliceSize(someScript: Buffer): number { - const length = someScript.length; - - return encodingLength(length) + length; -} - -function vectorSize(someVector: Buffer[]): number { - const length = someVector.length; - - return ( - encodingLength(length) + - someVector.reduce((sum, witness) => { - return sum + varSliceSize(witness); - }, 0) - ); -} - -/** - * This function will typically return 73; since it assumes a signature size of - * 72 bytes (this is the max size of a DER encoded signature) and it adds 1 - * extra byte for encoding its length - */ -function signatureSize(signature?: PartialSig) { - const length = signature?.signature?.length || 72; - return encodingLength(length) + length; -} - -/** - * Computes the Weight Unit contributions of an input. - * - * *NOTE:* When the descriptor in an input is `addr(address)`, it is assumed - * that any `addr(SH_TYPE_ADDRESS)` is in fact a Segwit `SH_WPKH` - * (Script Hash-Witness Public Key Hash). - * For inputs using arbitrary scripts (not standard addresses), - * use a descriptor in the format `sh(MINISCRIPT)`. - */ -export function inputWeight( - input: OutputInstance, - /** - * Indicates if the transaction is a Segwit transaction. - * If a transaction isSegwitTx, a single byte is then also required for - * non-witness inputs to encode the length of the empty witness stack: - * encodeLength(0) + 0 = 1 - * Read more: - * https://gist.github.com/junderw/b43af3253ea5865ed52cb51c200ac19c?permalink_comment_id=4760512#gistcomment-4760512 - */ - isSegwitTx: boolean, - /* - * Optional array of `PartialSig`. Each `PartialSig` includes - * a public key and its corresponding signature. This parameter - * enables the accurate calculation of signature sizes. If omitted, - * signatures are assumed to be 72 bytes in length. - * Mainly used for testing. - */ - signatures?: Array -) { - if (isSegwit(input) && !isSegwitTx) - throw new Error(`a tx is segwit if at least one input is segwit`); - const errorMsg = - 'Input type not implemented. Currently supported: pkh(KEY), wpkh(KEY), \ - sh(wpkh(KEY)), sh(wsh(MINISCRIPT)), sh(MINISCRIPT), wsh(MINISCRIPT), \ - addr(PKH_ADDRESS), addr(WPKH_ADDRESS), addr(SH_WPKH_ADDRESS).'; - - //expand any miniscript-based descriptor. It not miniscript-based, then it's - //an addr() descriptor. For those, we can only guess their type. - const expansion = input.expand().expandedExpression; - const { isPKH, isWPKH, isSH } = guessOutput(input); - if (!expansion && !isPKH && !isWPKH && !isSH) throw new Error(errorMsg); - - if (expansion ? expansion.startsWith('pkh(') : isPKH) { - return ( - // Non-segwit: (txid:32) + (vout:4) + (sequence:4) + (script_len:1) + (sig:73) + (pubkey:34) - (32 + 4 + 4 + 1 + signatureSize(signatures?.[0]) + 34) * 4 + - //Segwit: - (isSegwitTx ? 1 : 0) - ); - } else if (expansion ? expansion.startsWith('wpkh(') : isWPKH) { - if (!isSegwitTx) throw new Error('Should be SegwitTx'); - return ( - // Non-segwit: (txid:32) + (vout:4) + (sequence:4) + (script_len:1) - 41 * 4 + - // Segwit: (push_count:1) + (sig:73) + (pubkey:34) - (1 + signatureSize(signatures?.[0]) + 34) - ); - } else if (expansion ? expansion.startsWith('sh(wpkh(') : isSH) { - if (!isSegwitTx) throw new Error('Should be SegwitTx'); - return ( - // Non-segwit: (txid:32) + (vout:4) + (sequence:4) + (script_len:1) + (p2wpkh:23) - // -> p2wpkh_script: OP_0 OP_PUSH20 - // -> p2wpkh: (script_len:1) + (script:22) - 64 * 4 + - // Segwit: (push_count:1) + (sig:73) + (pubkey:34) - (1 + signatureSize(signatures?.[0]) + 34) - ); - } else if (expansion?.startsWith('sh(wsh(')) { - if (!isSegwitTx) throw new Error('Should be SegwitTx'); - const witnessScript = input.getWitnessScript(); - if (!witnessScript) throw new Error('sh(wsh) must provide witnessScript'); - const payment = payments.p2sh({ - redeem: payments.p2wsh({ - redeem: { - input: input.getScriptSatisfaction( - signatures || 'DANGEROUSLY_USE_FAKE_SIGNATURES' - ), - output: witnessScript - } - }) - }); - if (!payment || !payment.input || !payment.witness) - throw new Error('Could not create payment'); - return ( - //Non-segwit - 4 * (40 + varSliceSize(payment.input)) + - //Segwit - vectorSize(payment.witness) - ); - } else if (expansion?.startsWith('sh(')) { - const redeemScript = input.getRedeemScript(); - if (!redeemScript) throw new Error('sh() must provide redeemScript'); - const payment = payments.p2sh({ - redeem: { - input: input.getScriptSatisfaction( - signatures || 'DANGEROUSLY_USE_FAKE_SIGNATURES' - ), - output: redeemScript - } - }); - if (!payment || !payment.input) throw new Error('Could not create payment'); - if (payment.witness?.length) - throw new Error('A legacy p2sh payment should not cointain a witness'); - return ( - //Non-segwit - 4 * (40 + varSliceSize(payment.input)) + - //Segwit: - (isSegwitTx ? 1 : 0) - ); - } else if (expansion?.startsWith('wsh(')) { - const witnessScript = input.getWitnessScript(); - if (!witnessScript) throw new Error('wsh must provide witnessScript'); - const payment = payments.p2wsh({ - redeem: { - input: input.getScriptSatisfaction( - signatures || 'DANGEROUSLY_USE_FAKE_SIGNATURES' - ), - output: witnessScript - } - }); - if (!payment || !payment.input || !payment.witness) - throw new Error('Could not create payment'); - return ( - //Non-segwit - 4 * (40 + varSliceSize(payment.input)) + - //Segwit - vectorSize(payment.witness) - ); - } else { - throw new Error(errorMsg); - } -} - -/** - * Computes the Weight Unit contributions of an output. - */ -export function outputWeight(output: OutputInstance) { - const errorMsg = - 'Output type not implemented. Currently supported: pkh(KEY), wpkh(KEY), \ - sh(ANYTHING), wsh(ANYTHING), addr(PKH_ADDRESS), addr(WPKH_ADDRESS), \ - addr(SH_WPKH_ADDRESS)'; - - //expand any miniscript-based descriptor. It not miniscript-based, then it's - //an addr() descriptor. For those, we can only guess their type. - const expansion = output.expand().expandedExpression; - const { isPKH, isWPKH, isSH } = guessOutput(output); - if (!expansion && !isPKH && !isWPKH && !isSH) throw new Error(errorMsg); - if (expansion ? expansion.startsWith('pkh(') : isPKH) { - // (p2pkh:26) + (amount:8) - return 34 * 4; - } else if (expansion ? expansion.startsWith('wpkh(') : isWPKH) { - // (p2wpkh:23) + (amount:8) - return 31 * 4; - } else if (expansion ? expansion.startsWith('sh(') : isSH) { - // (p2sh:24) + (amount:8) - return 32 * 4; - } else if (expansion?.startsWith('wsh(')) { - // (p2wsh:35) + (amount:8) - return 43 * 4; - } else { - throw new Error(errorMsg); - } -} +export const isSegwitTx = (inputs: Array) => + inputs.some(input => input.isSegwit()); /** * Computes the virtual size (vsize) of a Bitcoin transaction based on specified @@ -254,16 +64,19 @@ export function vsize( let totalWeight = 0; inputs.forEach(function (input, index) { - if (signaturesPerInput) - totalWeight += inputWeight( - input, + if (signaturesPerInput) { + const signatures = signaturesPerInput[index]; + if (!signatures) + throw new Error(`signaturesPerInput not defined for ${index}`); + totalWeight += input.inputWeight(isSegwitTxValue, signatures); + } else + totalWeight += input.inputWeight( isSegwitTxValue, - signaturesPerInput[index] + 'DANGEROUSLY_USE_FAKE_SIGNATURES' ); - else totalWeight += inputWeight(input, isSegwitTxValue); }); outputs.forEach(function (output) { - totalWeight += outputWeight(output); + totalWeight += output.outputWeight(); }); if (isSegwitTxValue) totalWeight += 2; diff --git a/test/vsize.test.ts b/test/vsize.test.ts index 279b6ae..9b006ed 100644 --- a/test/vsize.test.ts +++ b/test/vsize.test.ts @@ -1,62 +1,14 @@ // Copyright (c) 2023 Jose-Luis Landabaso - https://bitcoinerlab.com // Distributed under the MIT software license -import { networks, Psbt, payments } from 'bitcoinjs-lib'; -import { DescriptorsFactory, OutputInstance } from '@bitcoinerlab/descriptors'; +import { networks, Psbt } from 'bitcoinjs-lib'; +import { DescriptorsFactory } from '@bitcoinerlab/descriptors'; import fixturesVsize from './fixtures/vsize.json'; import * as secp256k1 from '@bitcoinerlab/secp256k1'; const { Output } = DescriptorsFactory(secp256k1); import { vsize } from '../dist'; -function guessOutput(output: OutputInstance) { - function guessSH(output: Buffer) { - try { - payments.p2sh({ output }); - return true; - } catch (err) { - return false; - } - } - function guessWPKH(output: Buffer) { - try { - payments.p2wpkh({ output }); - return true; - } catch (err) { - return false; - } - } - function guessPKH(output: Buffer) { - try { - payments.p2pkh({ output }); - return true; - } catch (err) { - return false; - } - } - const isPKH = guessPKH(output.getScriptPubKey()); - const isWPKH = guessWPKH(output.getScriptPubKey()); - const isSH = guessSH(output.getScriptPubKey()); - - if ([isPKH, isWPKH, isSH].filter(Boolean).length > 1) - throw new Error('Cannot have multiple output types.'); - - return { isPKH, isWPKH, isSH }; -} - -/** - * It assumes that an addr(SH_ADDRESS) is always a add(SH_WPKH) address - */ -function isSegwit(output: OutputInstance) { - const isSegwit = output.isSegwit(); - const expansion = output.expand().expandedExpression; - const { isPKH, isWPKH, isSH } = guessOutput(output); - if (!expansion && !isPKH && !isWPKH && !isSH) - throw new Error('Incompatible expansion and output'); - //we will assume that any addr(SH_TYPE_ADDRESS) is in fact SH_WPKH. - return isSegwit !== undefined ? isSegwit : isWPKH || (isSH && !expansion); -} - const network = networks.regtest; interface TransactionFixture { @@ -137,7 +89,7 @@ describe('vsize', () => { //larger txs. Discount it to test for the upper limt: const upperLimit = inputs.reduce((accumulator, input, index) => { const signaturesCount = signaturesPerInput[index]!.length; - return accumulator + signaturesCount * (isSegwit(input) ? 0.25 : 1); + return accumulator + signaturesCount * (input.isSegwit() ? 0.25 : 1); }, 0); expect(bestGuessTxSize).toBeLessThanOrEqual(