Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions accumulative.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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 }
Expand Down
11 changes: 7 additions & 4 deletions blackjack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 }
Expand Down
7 changes: 4 additions & 3 deletions break.js
Original file line number Diff line number Diff line change
@@ -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) ||
Expand All @@ -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)
}
32 changes: 29 additions & 3 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
7 changes: 4 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
11 changes: 6 additions & 5 deletions split.js
Original file line number Diff line number Diff line change
@@ -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 }

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}
10 changes: 10 additions & 0 deletions test/accumulative.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
11 changes: 11 additions & 0 deletions test/break.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
64 changes: 64 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
32 changes: 32 additions & 0 deletions test/split.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Loading
Loading