diff --git a/README.md b/README.md index 2a39dd7..5129ae3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,15 @@ Module | Algorithm | Re-orders UTXOs? **Note:** Each algorithm will add a change output if the `input - output - fee` value difference is over a dust threshold. This is calculated independently by `utils.finalize`, irrespective of the algorithm chosen, for the purposes of safety. +**Dust:** an output is considered dust when it is not worth more than it costs to spend (`value <= 148 * feeRate`), or when it is below Bitcoin Core dust limit for its type. The latter is calculated the same way as `GetDustThreshold()` does with default `dustrelayfee` of 3 sat/vbyte: 546 sats for P2PKH, 540 for P2SH, 294 for P2WPKH, 330 for P2WSH and P2TR (output type is guessed from `script.length`, output without a script is treated as P2PKH; `script.length` has to be the exact scriptPubKey length - a padded one is not recognized as a witness program and gets the higher non-witness limit). This way a change output that would be rejected by the network as `dust` is never created, its value goes to the fee instead. The same applies to the outputs created by `coinselect/split`. Values of user defined outputs are not checked. + +**Options:** every algorithm accepts an optional 4th argument `options`: + +- `changeScript`: `{ length: number }`, length of the scriptPubKey change is going to (22 for P2WPKH, 23 for P2SH, 25 for P2PKH, 34 for P2WSH and P2TR). It is used to account for the actual size of the change output and to pick its dust limit. If omitted, P2PKH change is assumed. +- `txExtraBytes`: `number`, bytes of the transaction this library is not aware of. E.g. segwit marker & flag take 0.5 vbyte, so a wallet spending segwit inputs would pass `1`. + +Invalid options (wrong types, unknown keys) make algorithms return no solution (`{}`), as silently ignoring them would produce a transaction with a wrong fee. + **Pro-tip:** if you want to send-all inputs to an output address, `coinselect/split` with a partial output (`.address` defined, no `.value`) can be used to send-all, while leaving an appropriate amount for the `fee`. ## Example diff --git a/accumulative.js b/accumulative.js index 58f0247..be6e48c 100644 --- a/accumulative.js +++ b/accumulative.js @@ -2,10 +2,11 @@ var utils = require('./utils') // add inputs until we reach or surpass the target value (or deplete) // worst-case: O(n) -module.exports = function accumulative (utxos, outputs, feeRate) { +module.exports = function accumulative (utxos, outputs, feeRate, options) { if (!isFinite(utils.positiveNumOrNaN(feeRate))) return {} + if (!utils.checkOptions(options)) return {} - var bytesAccum = utils.transactionBytes([], outputs) + var bytesAccum = utils.transactionBytes([], outputs, options) var inAccum = 0 var inputs = [] @@ -32,7 +33,7 @@ module.exports = function accumulative (utxos, outputs, feeRate) { // go again? if (inAccum < outAccum + fee) continue - return utils.finalize(inputs, outputs, feeRate) + return utils.finalize(inputs, outputs, feeRate, options) } return { fee: feeRate * bytesAccum } diff --git a/blackjack.js b/blackjack.js index 40f5097..6904772 100644 --- a/blackjack.js +++ b/blackjack.js @@ -2,15 +2,18 @@ var utils = require('./utils') // only add inputs if they don't bust the target value (aka, exact match) // worst-case: O(n) -module.exports = function blackjack (utxos, outputs, feeRate) { +module.exports = function blackjack (utxos, outputs, feeRate, options) { if (!isFinite(utils.positiveNumOrNaN(feeRate))) return {} + if (!utils.checkOptions(options)) return {} - var bytesAccum = utils.transactionBytes([], outputs) + var bytesAccum = utils.transactionBytes([], outputs, options) var inAccum = 0 var inputs = [] var outAccum = utils.sumOrNaN(outputs) - var threshold = utils.dustThreshold({}, feeRate) + // how much we are fine to overpay to avoid a change output. this is intentionally not the dust threshold: that one can be + // way bigger on low fee rates, and a solution with change would be cheaper + var threshold = utils.inputBytes({}) * feeRate for (var i = 0; i < utxos.length; ++i) { var input = utxos[i] @@ -28,7 +31,7 @@ module.exports = function blackjack (utxos, outputs, feeRate) { // go again? if (inAccum < outAccum + fee) continue - return utils.finalize(inputs, outputs, feeRate) + return utils.finalize(inputs, outputs, feeRate, options) } return { fee: feeRate * bytesAccum } diff --git a/break.js b/break.js index 4a6a3c1..9c556c2 100644 --- a/break.js +++ b/break.js @@ -1,10 +1,11 @@ var utils = require('./utils') // break utxos into the maximum number of 'output' possible -module.exports = function broken (utxos, output, feeRate) { +module.exports = function broken (utxos, output, feeRate, options) { if (!isFinite(utils.positiveNumOrNaN(feeRate))) return {} + if (!utils.checkOptions(options)) return {} - var bytesAccum = utils.transactionBytes(utxos, []) + var bytesAccum = utils.transactionBytes(utxos, [], options) var value = utils.uintOrNaN(output.value) var inAccum = utils.sumOrNaN(utxos) if (!isFinite(value) || @@ -30,5 +31,5 @@ module.exports = function broken (utxos, output, feeRate) { outputs.push(output) } - return utils.finalize(utxos, outputs, feeRate) + return utils.finalize(utxos, outputs, feeRate, options) } diff --git a/index.d.ts b/index.d.ts index 4a3a5f3..583c1ae 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,15 +6,41 @@ export interface UTXO { witnessUtxo? : { script: Buffer, value: number - } + }, + /** size of the script spending this utxo (scriptSig, or witness in vbytes). p2pkh (107 bytes) is assumed if not set */ + script?: ScriptLength } export interface Target { address: string, - value?: number + value?: number, + /** size of the scriptPubKey of this output. p2pkh (25 bytes) is assumed if not set */ + script?: ScriptLength } export interface SelectedUTXO { inputs?: UTXO[], outputs?: Target[], fee: number } -export default function coinSelect(utxos: UTXO[], outputs: Target[], feeRate: number): SelectedUTXO; +export interface ScriptLength { + length: number +} +export interface Options { + /** length of the scriptPubKey change is going to. Used for the size of change output and its dust limit. p2pkh (25 bytes) is assumed if not set */ + changeScript?: ScriptLength, + /** bytes of the transaction this library is not aware of, e.g. 1 for segwit marker & flag (0.5 vbyte, rounded up) */ + txExtraBytes?: number +} +/** + * Selects utxos to fund the outputs, tries to avoid change output first (blackjack), then falls back to accumulative. + * + * @param utxos unspent outputs available for spending. `script.length` of utxo, if set, is used as input script size, + * p2pkh input is assumed otherwise + * @param outputs where coins are going. `script.length` of output, if set, is used as output script size, p2pkh output + * is assumed otherwise + * @param feeRate fee rate in satoshis per (virtual) byte, can be fractional + * @param options optional, see `Options`. Invalid options (wrong types, unknown keys) give no solution (`{}`) rather + * than a wrong fee + * @returns selected `inputs`, `outputs` (change, if any, is the last one and has no address) and `fee`. If no solution + * was found `inputs` and `outputs` are undefined, and `fee` is the fee that would be needed + */ +export default function coinSelect(utxos: UTXO[], outputs: Target[], feeRate: number, options?: Options): SelectedUTXO; diff --git a/index.js b/index.js index 19aa484..045e6e4 100644 --- a/index.js +++ b/index.js @@ -7,15 +7,16 @@ function utxoScore (x, feeRate) { return x.value - (feeRate * utils.inputBytes(x)) } -module.exports = function coinSelect (utxos, outputs, feeRate) { +// see utils.checkOptions for the optional `options` +module.exports = function coinSelect (utxos, outputs, feeRate, options) { utxos = utxos.concat().sort(function (a, b) { return utxoScore(b, feeRate) - utxoScore(a, feeRate) }) // attempt to use the blackjack strategy first (no change output) - var base = blackjack(utxos, outputs, feeRate) + var base = blackjack(utxos, outputs, feeRate, options) if (base.inputs) return base // else, try the accumulative strategy - return accumulative(utxos, outputs, feeRate) + return accumulative(utxos, outputs, feeRate, options) } diff --git a/split.js b/split.js index fd77aee..e39e06a 100644 --- a/split.js +++ b/split.js @@ -1,10 +1,11 @@ var utils = require('./utils') // split utxos between each output, ignores outputs with .value defined -module.exports = function split (utxos, outputs, feeRate) { +module.exports = function split (utxos, outputs, feeRate, options) { if (!isFinite(utils.positiveNumOrNaN(feeRate))) return {} + if (!utils.checkOptions(options)) return {} - var bytesAccum = utils.transactionBytes(utxos, outputs) + var bytesAccum = utils.transactionBytes(utxos, outputs, options) var fee = feeRate * bytesAccum if (outputs.length === 0) return { fee: fee } @@ -17,7 +18,7 @@ module.exports = function split (utxos, outputs, feeRate) { return a + !isFinite(x.value) }, 0) - if (remaining === 0 && unspecified === 0) return utils.finalize(utxos, outputs, feeRate) + if (remaining === 0 && unspecified === 0) return utils.finalize(utxos, outputs, feeRate, options) var splitOutputsCount = outputs.reduce(function (a, x) { if (x.value !== undefined) return a @@ -27,7 +28,7 @@ module.exports = function split (utxos, outputs, feeRate) { // ensure every output is either user defined, or over the threshold if (!outputs.every(function (x) { - return x.value !== undefined || (splitValue > utils.dustThreshold(x, feeRate)) + return x.value !== undefined || !utils.isDust(splitValue, x, feeRate) })) return { fee: fee } // assign splitValue to outputs not user defined @@ -41,5 +42,5 @@ module.exports = function split (utxos, outputs, feeRate) { return y }) - return utils.finalize(utxos, outputs, feeRate) + return utils.finalize(utxos, outputs, feeRate, options) } diff --git a/test/accumulative.js b/test/accumulative.js index 6f6edd7..557cf54 100644 --- a/test/accumulative.js +++ b/test/accumulative.js @@ -18,3 +18,13 @@ fixtures.forEach(function (f) { t.end() }) }) + +tape('accumulative: sub-dust remainder goes to fee, it does not add another input to get change', function (t) { + // 10700 covers 10000 + 192 fee, remainder of 508 is below p2pkh dust limit (546) so it can not be a change + var result = coinAccum([{ value: 10700 }, { value: 2000 }], [{ value: 10000 }], 1) + t.same(result.inputs, [{ value: 10700 }]) + t.same(result.outputs, [{ value: 10000 }]) + t.equal(result.fee, 700) + + t.end() +}) diff --git a/test/break.js b/test/break.js index 9748b6c..e746f64 100644 --- a/test/break.js +++ b/test/break.js @@ -13,3 +13,14 @@ fixtures.forEach(function (f) { t.end() }) }) + +tape('break: options', function (t) { + // 2 outputs of 4000: 10 + 148 + 34 * 2 = 226, + p2tr change 43 = 269, + 1 extra = 270 + var result = coinBreak([{ value: 10000 }], { value: 4000 }, 1, { changeScript: { length: 34 }, txExtraBytes: 1 }) + t.same(result.outputs, [{ value: 4000 }, { value: 4000 }, { value: 10000 - 8000 - 270 }]) + t.equal(result.fee, 270) + + t.same(coinBreak([{ value: 10000 }], { value: 4000 }, 1, { txExtraBytes: 1.5 }), {}) + + t.end() +}) diff --git a/test/index.js b/test/index.js index 3554363..5148a82 100644 --- a/test/index.js +++ b/test/index.js @@ -18,3 +18,67 @@ fixtures.forEach(function (f) { t.end() }) }) + +tape('change script is used for tx size and dust limit', function (t) { + var utxos = [{ value: 100000 }] + + // p2tr change (34 bytes script): tx is 10 + 148 + 34 + 43 = 235 bytes, dust limit is 330 + var result = coinSelect(utxos, [{ value: 100000 - 235 - 330 }], 1, { changeScript: { length: 34 } }) + t.same(result.outputs, [{ value: 100000 - 235 - 330 }, { value: 330 }]) + t.equal(result.fee, 235) + + // change of 329 would be rejected by the network as dust, so it goes to fee + result = coinSelect(utxos, [{ value: 100000 - 235 - 329 }], 1, { changeScript: { length: 34 } }) + t.same(result.outputs, [{ value: 100000 - 235 - 329 }]) + t.equal(result.fee, 235 + 329) + + // no change script, p2pkh is assumed: tx is 226 bytes, dust limit is 546 + result = coinSelect(utxos, [{ value: 100000 - 226 - 545 }], 1) + t.same(result.outputs, [{ value: 100000 - 226 - 545 }]) + t.equal(result.fee, 226 + 545) + + t.end() +}) + +tape('txExtraBytes is paid for', function (t) { + var utxos = [{ value: 100000 }] + + t.equal(coinSelect(utxos, [{ value: 50000 }], 3).fee, 3 * 226) + t.equal(coinSelect(utxos, [{ value: 50000 }], 3, { txExtraBytes: 1 }).fee, 3 * 227) + + // without change: tx is 10 + 148 + 34 = 192 bytes, 1 sat is not enough to pay for the extra byte + t.equal(coinSelect([{ value: 10192 }], [{ value: 10000 }], 1).fee, 192) + t.same(coinSelect([{ value: 10192 }], [{ value: 10000 }], 1, { txExtraBytes: 1 }), { fee: 193 }) + t.equal(coinSelect([{ value: 10193 }], [{ value: 10000 }], 1, { txExtraBytes: 1 }).fee, 193) + + t.end() +}) + +tape('invalid options give no solution instead of a wrong one', function (t) { + var utxos = [{ value: 100000 }] + var outputs = [{ value: 10000 }] + + t.same(coinSelect(utxos, outputs, 1, { changeScript: {} }), {}) + t.same(coinSelect(utxos, outputs, 1, { changeScript: { length: '34' } }), {}) + t.same(coinSelect(utxos, outputs, 1, { length: 34 }), {}) + t.same(coinSelect(utxos, outputs, 1, 'bc1qaddress'), {}) + t.same(coinSelect(utxos, outputs, 1, { txExtraBytes: -1 }), {}) + t.same(coinSelect(utxos, outputs, 1, { changeScript: { length: 1e6 } }), {}, 'would otherwise burn whole change as fee') + t.same(coinSelect(utxos, outputs, 1, false), {}) + t.same(coinSelect(utxos, outputs, 1, 0), {}) + t.same(coinSelect(utxos, outputs, 1, ''), {}) + t.same(coinSelect(utxos, outputs, 1, null), {}) + + t.end() +}) + +tape('does not overpay up to relay dust limit when there is a better solution with change', function (t) { + // spending only 10700 would leave 508 sats, which is below p2pkh dust limit so it can not be a change and would go to fee. + // using bigger utxo and getting change back is cheaper + var result = coinSelect([{ value: 10700 }, { value: 50000 }], [{ value: 10000 }], 1) + t.same(result.inputs, [{ value: 50000 }]) + t.same(result.outputs, [{ value: 10000 }, { value: 50000 - 10000 - 226 }]) + t.equal(result.fee, 226) + + t.end() +}) diff --git a/test/split.js b/test/split.js index 0cd8c4b..e6b000f 100644 --- a/test/split.js +++ b/test/split.js @@ -18,3 +18,35 @@ fixtures.forEach(function (f) { t.end() }) }) + +tape('split does not create outputs below dust limit of their type', function (t) { + // tx is 10 + 148 + 31 = 189 bytes + var p2wpkh = { script: { length: 22 } } + t.same(coinSplit([{ value: 189 + 294 }], [p2wpkh], 1).outputs, [{ script: { length: 22 }, value: 294 }]) + t.equal(coinSplit([{ value: 189 + 293 }], [p2wpkh], 1).outputs, undefined) + + // tx is 10 + 148 + 43 = 201 bytes + var p2tr = { script: { length: 34 } } + t.same(coinSplit([{ value: 201 + 330 }], [p2tr], 1).outputs, [{ script: { length: 34 }, value: 330 }]) + t.equal(coinSplit([{ value: 201 + 329 }], [p2tr], 1).outputs, undefined) + + // tx is 10 + 148 + 34 = 192 bytes + t.same(coinSplit([{ value: 192 + 546 }], [{}], 1).outputs, [{ value: 546 }]) + t.equal(coinSplit([{ value: 192 + 545 }], [{}], 1).outputs, undefined) + + t.end() +}) + +tape('split: options', function (t) { + // 1 sat/vB, tx is 192 bytes + 1 extra + t.same(coinSplit([{ value: 10000 }], [{}], 1, { txExtraBytes: 1 }), { inputs: [{ value: 10000 }], outputs: [{ value: 10000 - 193 }], fee: 193 }) + + // user defined output + change to p2wpkh: 10 + 148 + 34 + 31 = 223 bytes + var result = coinSplit([{ value: 10000 }], [{ value: 5000 }], 1, { changeScript: { length: 22 } }) + t.same(result.outputs, [{ value: 5000 }, { value: 10000 - 5000 - 223 }]) + t.equal(result.fee, 223) + + t.same(coinSplit([{ value: 10000 }], [{}], 1, { changeScript: {} }), {}) + + t.end() +}) diff --git a/test/utils.js b/test/utils.js index 6fd71be..6887cb7 100644 --- a/test/utils.js +++ b/test/utils.js @@ -28,5 +28,130 @@ tape('utils', function (t) { t.equal(isNaN(utils.positiveNumOrNaN(-1)), true) }) + t.test('dustThreshold follows Bitcoin Core dust limits', function (t) { + t.plan(8) + + // (output size + spending input size) * 3 sat/vB (default dustrelayfee) + t.equal(utils.dustThreshold({}, 1), 546, 'unknown output is treated as p2pkh') + t.equal(utils.dustThreshold({ script: { length: 25 } }, 1), 546, 'p2pkh') + t.equal(utils.dustThreshold({ script: { length: 23 } }, 1), 540, 'p2sh') + t.equal(utils.dustThreshold({ script: { length: 22 } }, 1), 294, 'p2wpkh') + t.equal(utils.dustThreshold({ script: { length: 34 } }, 1), 330, 'p2wsh / p2tr') + + // on high fee rates output should still be worth spending + t.equal(utils.dustThreshold({}, 10), 1480) + t.equal(utils.dustThreshold({ script: { length: 22 } }, 10), 1480) + t.equal(utils.dustThreshold({ script: { length: 22 } }, 1.5), 294) + }) + + t.test('isDust: relay limit is inclusive (as in Bitcoin Core), break-even check is strict', function (t) { + t.plan(14) + + t.equal(utils.isDust(546, {}, 1), false) + t.equal(utils.isDust(545, {}, 1), true) + t.equal(utils.isDust(540, { script: { length: 23 } }, 1), false) + t.equal(utils.isDust(539, { script: { length: 23 } }, 1), true) + t.equal(utils.isDust(294, { script: { length: 22 } }, 1), false) + t.equal(utils.isDust(293, { script: { length: 22 } }, 1), true) + t.equal(utils.isDust(330, { script: { length: 34 } }, 1), false) + t.equal(utils.isDust(329, { script: { length: 34 } }, 1), true) + + // no fee / low fee: relay limit still applies + t.equal(utils.isDust(546, {}, 0), false) + t.equal(utils.isDust(545, {}, 0), true) + t.equal(utils.isDust(294, { script: { length: 22 } }, 0.1), false) + + // high fee rate: output that costs as much as it is worth to spend is dust + t.equal(utils.isDust(1480, {}, 10), true) + t.equal(utils.isDust(1481, {}, 10), false) + t.equal(utils.isDust(NaN, {}, 1), true) + }) + + t.test('finalize does not create change below Bitcoin Core dust limit', function (t) { + t.plan(14) + + var inputs = [{ value: 100000 }] + // tx is 10 + 148 + 34 + 34 (change) = 226 bytes + var result = utils.finalize(inputs, [{ value: 100000 - 226 - 545 }], 1) + t.equal(result.outputs.length, 1, 'p2pkh change of 545 is not created') + t.equal(result.fee, 226 + 545) + + result = utils.finalize(inputs, [{ value: 100000 - 226 - 546 }], 1) + t.equal(result.outputs.length, 2, 'p2pkh change of 546 is created') + t.equal(result.outputs[1].value, 546) + + // p2wpkh change: tx is 10 + 148 + 34 + 31 = 223 bytes, dust limit is 294 + result = utils.finalize(inputs, [{ value: 100000 - 223 - 294 }], 1, { changeScript: { length: 22 } }) + t.same(result.outputs[1], { value: 294 }, 'change script length is used for both tx size and dust limit') + t.equal(result.fee, 223) + + // p2sh change: tx is 10 + 148 + 34 + 32 = 224 bytes, dust limit is 540 + result = utils.finalize(inputs, [{ value: 100000 - 224 - 539 }], 1, { changeScript: { length: 23 } }) + t.equal(result.outputs.length, 1) + + result = utils.finalize(inputs, [{ value: 100000 - 224 - 540 }], 1, { changeScript: { length: 23 } }) + t.same(result.outputs[1], { value: 540 }, 'p2sh change of 540 is created') + + // fractional fee rate: tx is 10 + 148 + 34 + 31 (p2wpkh change) + 1 (extra) = 224 bytes, fee is round(1.5 * 224) = 336 + result = utils.finalize(inputs, [{ value: 50000 }], 1.5, { changeScript: { length: 22 }, txExtraBytes: 1 }) + t.equal(result.fee, 336) + t.equal(result.outputs[1].value, 100000 - 50000 - 336) + + // p2tr change: tx is 10 + 148 + 34 + 43 = 235 bytes, dust limit is 330 + result = utils.finalize(inputs, [{ value: 100000 - 235 - 329 }], 1, { changeScript: { length: 34 } }) + t.equal(result.outputs.length, 1) + t.equal(result.fee, 235 + 329) + + // extra bytes (e.g. segwit marker & flag) are paid for + result = utils.finalize(inputs, [{ value: 50000 }], 2, { txExtraBytes: 1 }) + t.equal(result.fee, 2 * (226 + 1)) + + t.same(utils.finalize(inputs, [{ value: 50000 }], 2, { changeScript: {} }), {}, 'invalid options give no solution') + }) + + t.test('transactionBytes', function (t) { + t.plan(8) + + var inputs = [{}] + var outputs = [{}] + t.equal(utils.transactionBytes(inputs, outputs), 192) + t.equal(utils.transactionBytes(inputs, outputs, undefined), 192) + t.equal(utils.transactionBytes(inputs, outputs, {}), 192) + t.equal(utils.transactionBytes(inputs, outputs, { txExtraBytes: 1 }), 193) + t.equal(utils.transactionBytes(inputs, outputs, { changeScript: { length: 34 } }), 192, 'change is not a part of it') + + // invalid options should never end up as a plausible looking size + t.ok(isNaN(utils.transactionBytes(inputs, outputs, { txExtraBytes: '1' }))) + t.ok(isNaN(utils.transactionBytes(inputs, outputs, { txExtraBytes: -5 }))) + t.ok(isNaN(utils.transactionBytes(inputs, outputs, { txExtraBytes: 1.5 }))) + }) + + t.test('checkOptions', function (t) { + t.plan(21) + + t.equal(utils.checkOptions(undefined), true) + t.equal(utils.checkOptions({}), true) + t.equal(utils.checkOptions({ changeScript: { length: 34 } }), true) + t.equal(utils.checkOptions({ txExtraBytes: 0 }), true) + t.equal(utils.checkOptions({ changeScript: { length: 22 }, txExtraBytes: 1 }), true) + + t.equal(utils.checkOptions(null), false) + t.equal(utils.checkOptions(false), false) + t.equal(utils.checkOptions(0), false) + t.equal(utils.checkOptions(''), false) + t.equal(utils.checkOptions('bc1qaddress'), false) + t.equal(utils.checkOptions({ length: 34 }), false, 'unknown keys are rejected, so a typo can not silently change the result') + t.equal(utils.checkOptions({ changeScript: {} }), false) + t.equal(utils.checkOptions({ changeScript: null }), false) + t.equal(utils.checkOptions({ changeScript: 'bc1qaddress' }), false) + t.equal(utils.checkOptions({ changeScript: { length: '34' } }), false) + t.equal(utils.checkOptions({ changeScript: { length: 0 } }), false) + t.equal(utils.checkOptions({ changeScript: { length: 1.5 } }), false) + t.equal(utils.checkOptions({ changeScript: { length: 10000 } }), true) + t.equal(utils.checkOptions({ changeScript: { length: 10001 } }), false, 'bigger than any valid script') + t.equal(utils.checkOptions({ txExtraBytes: -1 }), false) + t.equal(utils.checkOptions({ txExtraBytes: '1' }), false) + }) + t.end() }) diff --git a/utils.js b/utils.js index dfd193b..d6429fc 100644 --- a/utils.js +++ b/utils.js @@ -13,13 +13,65 @@ function outputBytes (output) { return TX_OUTPUT_BASE + (output.script ? output.script.length : TX_OUTPUT_PUBKEYHASH) } +// default `dustrelayfee` of Bitcoin Core, sat/vbyte +var DUST_RELAY_FEE_RATE = 3 + +// size of the input spending the output, as Bitcoin Core assumes it when calculating dust +var DUST_INPUT_SIZE = 32 + 4 + 1 + 107 + 4 +var DUST_WITNESS_INPUT_SIZE = 32 + 4 + 1 + Math.floor(107 / 4) + 4 + +// witness programs we know of: p2wpkh (22 bytes), p2wsh & p2tr (34 bytes) +function isWitnessScriptLength (length) { + return length === 22 || length === 34 +} + +// minimal value of the output that is relayed by nodes with default policy, see GetDustThreshold() in Bitcoin Core. +// output without a script is treated as p2pkh +function relayDustThreshold (output) { + var inputSize = output.script && isWitnessScriptLength(output.script.length) ? DUST_WITNESS_INPUT_SIZE : DUST_INPUT_SIZE + return (outputBytes(output) + inputSize) * DUST_RELAY_FEE_RATE +} + function dustThreshold (output, feeRate) { - /* ... classify the output for input estimate */ - return inputBytes({}) * feeRate + return Math.max(inputBytes({}) * feeRate, relayDustThreshold(output)) +} + +// output should be worth more than it costs to spend it on current fee rate, and it should not be rejected by the network +// as dust. Bitcoin Core relays outputs with the value equal to its dust threshold, so that check is inclusive +function isDust (value, output, feeRate) { + return !(value > inputBytes({}) * feeRate && value >= relayDustThreshold(output)) } -function transactionBytes (inputs, outputs) { +// options are optional: +// changeScript: { length: number } - scriptPubKey change is going to, p2pkh is assumed by default +// txExtraBytes: number - bytes of the transaction this library is not aware of (e.g. segwit marker & flag) +// invalid options make algorithms return no solution, as silently ignoring them would produce a wrong fee +var KNOWN_OPTIONS = ['changeScript', 'txExtraBytes'] +// same as in Bitcoin Core. an absurdly big change script would make change unaffordable and silently turn it into fee +var MAX_SCRIPT_SIZE = 10000 + +function checkOptions (options) { + if (options === undefined) return true + if (typeof options !== 'object' || options === null) return false + if (!Object.keys(options).every(function (k) { return KNOWN_OPTIONS.indexOf(k) !== -1 })) return false + + if (options.changeScript !== undefined) { + var script = options.changeScript + if (typeof script !== 'object' || script === null) return false + if (!(uintOrNaN(script.length) > 0) || script.length > MAX_SCRIPT_SIZE) return false + } + + if (options.txExtraBytes !== undefined && !isFinite(uintOrNaN(options.txExtraBytes))) return false + + return true +} + +function transactionBytes (inputs, outputs, options) { + // invalid options should never end up as a plausible looking size + if (!checkOptions(options)) return NaN + return TX_EMPTY_SIZE + + ((options && options.txExtraBytes) || 0) + inputs.reduce(function (a, x) { return a + inputBytes(x) }, 0) + outputs.reduce(function (a, x) { return a + outputBytes(x) }, 0) } @@ -47,15 +99,16 @@ function sumOrNaN (range) { return range.reduce(function (a, x) { return a + uintOrNaN(x.value) }, 0) } -var BLANK_OUTPUT = outputBytes({}) +function finalize (inputs, outputs, feeRate, options) { + if (!checkOptions(options)) return {} -function finalize (inputs, outputs, feeRate) { - var bytesAccum = transactionBytes(inputs, outputs) - var feeAfterExtraOutput = Math.round(feeRate * (bytesAccum + BLANK_OUTPUT)) + var change = options && options.changeScript ? { script: options.changeScript } : {} + var bytesAccum = transactionBytes(inputs, outputs, options) + var feeAfterExtraOutput = Math.round(feeRate * (bytesAccum + outputBytes(change))) var remainderAfterExtraOutput = sumOrNaN(inputs) - (sumOrNaN(outputs) + feeAfterExtraOutput) // is it worth a change output? - if (remainderAfterExtraOutput > dustThreshold({}, feeRate)) { + if (!isDust(remainderAfterExtraOutput, change, feeRate)) { outputs = outputs.concat({ value: remainderAfterExtraOutput }) } @@ -70,9 +123,11 @@ function finalize (inputs, outputs, feeRate) { } module.exports = { + checkOptions: checkOptions, dustThreshold: dustThreshold, finalize: finalize, inputBytes: inputBytes, + isDust: isDust, outputBytes: outputBytes, sumOrNaN: sumOrNaN, sumForgiving: sumForgiving,