diff --git a/branchandbound.js b/branchandbound.js new file mode 100644 index 0000000..90f192d --- /dev/null +++ b/branchandbound.js @@ -0,0 +1,133 @@ +var utils = require('./utils') + +var maxTries = 1000000 + +function calculateEffectiveValues (utxos, feeRate) { + return utxos.map(function (utxo) { + if (isNaN(utils.uintOrNaN(utxo.value))) { + return { + utxo: utxo, + effectiveValue: 0 + } + } + + var effectiveFee = utils.inputBytes(utxo) * feeRate + var effectiveValue = utxo.value - effectiveFee + return { + utxo: utxo, + effectiveValue: effectiveValue + } + }) +} + +module.exports = function branchAndBound (utxos, outputs, feeRate, factor) { + if (!isFinite(utils.uintOrNaN(feeRate))) return {} + + // TODO: segwit cost + var costPerOutput = utils.outputBytes({}) * feeRate + var costPerInput = utils.inputBytes({}) * feeRate + var costOfChange = Math.floor((costPerInput + costPerOutput) * factor) + + var outAccum = utils.sumOrNaN(outputs) + utils.transactionBytes([], outputs) * feeRate + + if (isNaN(outAccum)) { + return { + fee: 0 + } + } + + var effectiveUtxos = calculateEffectiveValues(utxos, feeRate).filter(function (x) { + return x.effectiveValue > 0 + }).sort(function (a, b) { + return b.effectiveValue - a.effectiveValue + }) + + var selected = search(effectiveUtxos, outAccum, costOfChange) + if (selected != null) { + var inputs = [] + + for (var i = 0; i < effectiveUtxos.length; i++) { + if (selected[i]) { + inputs.push(effectiveUtxos[i].utxo) + } + } + + return utils.finalize(inputs, outputs, feeRate) + } else { + return { + fee: 0 + } + } +} + +// Depth first search +// Inclusion branch first (Largest First Exploration), then exclusion branch +function search (effectiveUtxos, target, costOfChange) { + if (effectiveUtxos.length === 0) { + return + } + + var tries = maxTries + + var selected = [] // true -> select the utxo at this index + var selectedAccum = 0 // sum of effective values + + var done = false + var backtrack = false + + var remaining = effectiveUtxos.reduce(function (a, x) { + return a + x.effectiveValue + }, 0) + + var depth = 0 + while (!done) { + if (tries <= 0) { // Too many tries, exit + return + } else if (selectedAccum > target + costOfChange) { // Selected value is out of range, go back and try other branch + backtrack = true + } else if (selectedAccum >= target) { // Selected value is within range + done = true + } else if (depth >= effectiveUtxos.length) { // Reached a leaf node, no solution here + backtrack = true + } else if (selectedAccum + remaining < target) { // Cannot possibly reach target with amount remaining + if (depth === 0) { // At the first utxo, no possible selections, so exit + return + } else { + backtrack = true + } + } else { // Continue down this branch + // Remove this utxo from the remaining utxo amount + remaining -= effectiveUtxos[depth].effectiveValue + // Inclusion branch first (Largest First Exploration) + selected[depth] = true + selectedAccum += effectiveUtxos[depth].effectiveValue + depth++ + } + + // Step back to the previous utxo and try the other branch + if (backtrack) { + backtrack = false // Reset + depth-- + + // Walk backwards to find the first utxo which has not has its second branch traversed + while (!selected[depth]) { + remaining += effectiveUtxos[depth].effectiveValue + + // Step back one + depth-- + + if (depth < 0) { // We have walked back to the first utxo and no branch is untraversed. No solution, exit. + return + } + } + + // Now traverse the second branch of the utxo we have arrived at. + selected[depth] = false + selectedAccum -= effectiveUtxos[depth].effectiveValue + depth++ + } + tries-- + } + + return selected +} diff --git a/stats/strategies.js b/stats/strategies.js index bf86280..5564c26 100644 --- a/stats/strategies.js +++ b/stats/strategies.js @@ -1,4 +1,5 @@ let accumulative = require('../accumulative') +let branchandbound = require('../branchandbound') let blackjack = require('../blackjack') let shuffle = require('fisher-yates') let shuffleInplace = require('fisher-yates/inplace') @@ -40,6 +41,68 @@ function blackrand (utxos, outputs, feeRate) { return accumulative(utxos, outputs, feeRate) } +function bnbrand (utxos, outputs, feeRate, factor) { + // attempt to use the bnb strategy first (no change output) + let base = branchandbound(utxos, outputs, feeRate, factor) + if (base.inputs) return base + + utxos = shuffle(utxos) + + // else, try the accumulative strategy + return accumulative(utxos, outputs, feeRate) +} + +function bnbmin (utxos, outputs, feeRate, factor) { + // attempt to use the blackjack strategy first (no change output) + let base = branchandbound(utxos, outputs, feeRate, factor) + if (base.inputs) return base + + // order by descending value + utxos = utxos.concat().sort((a, b) => b.value - a.value) + + // else, try the accumulative strategy + return accumulative(utxos, outputs, feeRate) +} + +function bnbmax (utxos, outputs, feeRate, factor) { + // attempt to use the bnb strategy first (no change output) + let base = branchandbound(utxos, outputs, feeRate, factor) + if (base.inputs) return base + + // order by ascending value + utxos = utxos.concat().sort((a, b) => a.value - b.value) + + // else, try the accumulative strategy + return accumulative(utxos, outputs, feeRate) +} + +function bnbcs (utxos, outputs, feeRate, factor) { + // attempt to use the bnb strategy first (no change output) + let base = branchandbound(utxos, outputs, feeRate, factor) + if (base.inputs) return base + + // else, try the current default + return coinSelect(utxos, outputs, feeRate) +} + +function bnbus (utxos, outputs, feeRate, factor) { + // order by descending value, minus the inputs approximate fee + function utxoScore (x, feeRate) { + return x.value - (feeRate * utils.inputBytes(x)) + } + + // attempt to use the blackjack strategy first (no change output) + let base = branchandbound(utxos, outputs, feeRate, factor) + if (base.inputs) return base + + utxos = utxos.concat().sort(function (a, b) { + return utxoScore(b, feeRate) - utxoScore(a, feeRate) + }) + + // else, try the accumulative strategy + return accumulative(utxos, outputs, feeRate) +} + function maximal (utxos, outputs, feeRate) { utxos = utxos.concat().sort((a, b) => a.value - b.value) @@ -125,9 +188,19 @@ function privet (utxos, outputs, feeRate) { return accumulative(utxos, outputs, feeRate) } +function useBnbWithFactor (strategy, factor) { + return (utxos, outputs, feeRate) => strategy(utxos, outputs, feeRate, factor) +} + module.exports = { accumulative, bestof, + bnb: useBnbWithFactor(branchandbound, 0.5), + bnbrand: useBnbWithFactor(bnbrand, 0.5), + bnbmin: useBnbWithFactor(bnbmin, 0.5), + bnbmax: useBnbWithFactor(bnbmax, 0.5), + bnbcs: useBnbWithFactor(bnbcs, 0.5), + bnbus: useBnbWithFactor(bnbus, 0.5), blackjack, blackmax, blackmin, @@ -140,3 +213,15 @@ module.exports = { proximal, random } + +// uncomment for benchmarking bnb parameters +// let res = {} +// +// for (let i = 0; i <= 200; i+=1) { +// let factor = i / 100; +// res['M' + i] = useBnbWithFactor(bnbmin, factor) +// res['R' + i] = useBnbWithFactor(bnbrand, factor) +// res['R' + i] = (u, o, f) => bnbrand(u, o, f, factor) +// } +// +// module.exports = res diff --git a/test/bnb.js b/test/bnb.js new file mode 100644 index 0000000..a2e1141 --- /dev/null +++ b/test/bnb.js @@ -0,0 +1,20 @@ +var bnb = require('../branchandbound') +var fixtures = require('./fixtures/bnb') +var tape = require('tape') +var utils = require('./_utils') + +fixtures.forEach(function (f) { + tape(f.description, function (t) { + var inputs = utils.expand(f.inputs, true) + var outputs = utils.expand(f.outputs) + var actual = bnb(inputs, outputs, f.feeRate, 0.5) + + t.same(actual, f.expected) + if (actual.inputs) { + var feedback = bnb(actual.inputs, actual.outputs, f.feeRate, 0.5) + t.same(feedback, f.expected) + } + + t.end() + }) +}) diff --git a/test/fixtures/bnb.json b/test/fixtures/bnb.json new file mode 100644 index 0000000..31f89b1 --- /dev/null +++ b/test/fixtures/bnb.json @@ -0,0 +1,592 @@ +[ + { + "description": "1 output, no change", + "feeRate": 10, + "inputs": [ + 102001 + ], + "outputs": [ + 100000 + ], + "expected": { + "inputs": [ + { + "i": 0, + "value": 102001 + } + ], + "outputs": [ + { + "value": 100000 + } + ], + "fee": 2001 + } + }, + { + "description": "1 output, no change, value > 2^32", + "feeRate": 10, + "inputs": [ + 5000002001 + ], + "outputs": [ + 5000000000 + ], + "expected": { + "inputs": [ + { + "i": 0, + "value": 5000002001 + } + ], + "outputs": [ + { + "value": 5000000000 + } + ], + "fee": 2001 + } + }, + + + { + "description": "1 output, change rejected, value > 2^32", + "feeRate": 5, + "inputs": [ + 5000000000 + ], + "outputs": [ + 1 + ], + "expected": { + "fee": 0 + } + }, + + { + "description": "1 output, only possibility with change, rejects", + "feeRate": 5, + "inputs": [ + 106001 + ], + "outputs": [ + 100000 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "1 output, sub-optimal inputs (if re-ordered), direct possible", + "feeRate": 10, + "inputs": [ + 10000, + 40000, + 40000 + ], + "outputs": [ + 7700 + ], + "expected": { + "inputs": [ + { + "i": 0, + "value": 10000 + } + ], + "outputs": [ + { + "value": 7700 + } + ], + "fee": 2300 + } + }, + { + "description": "1 output, sub-optimal inputs (if re-ordered), direct possible, but slightly higher fee, rejecte", + "feeRate": 10, + "inputs": [ + 10000, + 40000, + 40000 + ], + "outputs": [ + 6800 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "1 output, passes, skipped detrimental input", + "feeRate": 5, + "inputs": [ + { + "script": { + "length": 1000 + }, + "value": 3000 + }, + { + "value": 3000 + }, + { + "value": 3000 + } + ], + "outputs": [ + 4000 + ], + "expected": { + "fee": 2000, + "inputs": [ + { + "i": 1, + "value": 3000 + }, + { + "i": 2, + "value": 3000 + } + ], + "outputs": [ + { + "value": 4000 + } + ] + } + }, + { + "description": "1 output, fails, skips (and finishes on) detrimental input", + "feeRate": 55, + "inputs": [ + { + "value": 44000 + }, + { + "value": 800 + } + ], + "outputs": [ + 38000 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "1 output, passes, good match despite bad ordering", + "feeRate": 5, + "inputs": [ + { + "script": { + "length": 500 + }, + "value": 3000 + }, + { + "value": 3000 + }, + { + "value": 3000 + } + ], + "outputs": [ + 4000 + ], + "expected": { + "inputs": [ + { + "i": 1, + "value": 3000 + }, + { + "i": 2, + "value": 3000 + } + ], + "outputs": [ + { + "value": 4000 + } + ], + "fee": 2000 + } + }, + { + "description": "1 output, optimal inputs, no change", + "feeRate": 10, + "inputs": [ + 10000 + ], + "outputs": [ + 7700 + ], + "expected": { + "inputs": [ + { + "i": 0, + "value": 10000 + } + ], + "outputs": [ + { + "value": 7700 + } + ], + "fee": 2300 + } + }, + { + "description": "1 output, no fee, no match", + "feeRate": 0, + "inputs": [ + 5000, + 5000, + 5000, + 5000, + 5000, + 5000 + ], + "outputs": [ + 28000 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "1 output, 2 inputs (related), no change", + "feeRate": 10, + "inputs": [ + { + "address": "a", + "value": 100000 + }, + { + "address": "a", + "value": 2000 + } + ], + "outputs": [ + 98000 + ], + "expected": { + "inputs": [ + { + "i": 0, + "address": "a", + "value": 100000 + } + ], + "outputs": [ + { + "value": 98000 + } + ], + "fee": 2000 + } + }, + { + "description": "many outputs, no change", + "feeRate": 10, + "inputs": [ + 30000, + 12220, + 10001 + ], + "outputs": [ + 35000, + 5000, + 5000, + 1000 + ], + "expected": { + "inputs": [ + { + "i": 0, + "value": 30000 + }, + { + "i": 1, + "value": 12220 + }, + { + "i": 2, + "value": 10001 + } + ], + "outputs": [ + { + "value": 35000 + }, + { + "value": 5000 + }, + { + "value": 5000 + }, + { + "value": 1000 + } + ], + "fee": 6221 + } + }, + { + "description": "many outputs, no match", + "feeRate": 10, + "inputs": [ + 30000, + 14220, + 10001 + ], + "outputs": [ + 35000, + 5000, + 5000, + 1000 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "many outputs, no match", + "feeRate": 0, + "inputs": [ + 5000, + 5000, + 5000, + 5000, + 5000, + 5000 + ], + "outputs": [ + 28000, + 1000 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "no outputs, no change", + "feeRate": 10, + "inputs": [ + 1900 + ], + "outputs": [], + "expected": { + "inputs": [ + { + "i": 0, + "value": 1900 + } + ], + "outputs": [], + "fee": 1900 + } + }, + { + "description": "no outputs, no match", + "feeRate": 10, + "inputs": [ + 20000 + ], + "outputs": [], + "expected": { + "fee": 0 + } + }, + { + "description": "not enough funds, empty result", + "feeRate": 10, + "inputs": [ + 20000 + ], + "outputs": [ + 40000 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "not enough funds (w/ fee), empty result", + "feeRate": 10, + "inputs": [ + 40000 + ], + "outputs": [ + 40000 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "not enough funds (no inputs), empty result", + "feeRate": 10, + "inputs": [], + "outputs": [], + "expected": { + "fee": 0 + } + }, + { + "description": "not enough funds (no inputs), empty result (>1KiB)", + "feeRate": 10, + "inputs": [], + "outputs": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "2 outputs, some with missing value (NaN)", + "feeRate": 10, + "inputs": [ + 20000 + ], + "outputs": [ + 1000, + {} + ], + "expected": { + "fee": 0 + } + }, + { + "description": "input with float values (NaN)", + "feeRate": 10, + "inputs": [ + 20000.5 + ], + "outputs": [ + 10000, + 1200 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "2 outputs, with float values (NaN)", + "feeRate": 10, + "inputs": [ + 20000 + ], + "outputs": [ + 10000.25, + 1200.5 + ], + "expected": { + "fee": 0 + } + }, + { + "description": "2 outputs, string values (NaN)", + "feeRate": 10, + "inputs": [ + 20000 + ], + "outputs": [ + { + "value": "100" + }, + { + "value": "204" + } + ], + "expected": { + "fee": 0 + } + }, + { + "description": "inputs and outputs, bad feeRate (NaN)", + "feeRate": "1", + "inputs": [ + 20000 + ], + "outputs": [ + 10000 + ], + "expected": {} + }, + { + "description": "inputs and outputs, bad feeRate (NaN)", + "feeRate": 1.5, + "inputs": [ + 20000 + ], + "outputs": [ + 10000 + ], + "expected": {} + }, + { + "description": "exhausting BnB", + "feeRate": 10, + "inputs": [ + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, + 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000 + ], + "outputs": [ + 1000000 + ], + "expected": { + "fee": 0 + } + } +]