Implemented simple greedy coin selection

Added tests for current coin selection algorithm options

Responded to code review

Implemented fee estimation calculation in coin selection

Updated tests after rebase

Fixed CoinSelectorTest

Fixed CoinSelectorTest after rebase
This commit is contained in:
nkohen 2019-04-18 16:05:52 -05:00 committed by Torkel Rogstad
parent 1f363b4586
commit 269d3c8f21
3 changed files with 201 additions and 9 deletions

View File

@ -0,0 +1,94 @@
package org.bitcoins.wallet.api
import org.bitcoins.core.currency.{CurrencyUnits, Satoshis}
import org.bitcoins.core.number.Int64
import org.bitcoins.core.protocol.script.ScriptPubKey
import org.bitcoins.core.protocol.transaction.TransactionOutput
import org.bitcoins.core.wallet.fee.{FeeUnit, SatoshisPerByte}
import org.bitcoins.testkit.core.gen.{TransactionGenerators, WitnessGenerators}
import org.bitcoins.wallet.models.{
NativeV0UTXOSpendingInfoDb,
UTXOSpendingInfoDb
}
import org.bitcoins.wallet.util.{BitcoinSWalletTest, WalletTestUtil}
import org.scalatest.FutureOutcome
class CoinSelectorTest extends BitcoinSWalletTest {
case class CoinSelectionFixture(
output: TransactionOutput,
feeRate: FeeUnit,
utxo1: UTXOSpendingInfoDb,
utxo2: UTXOSpendingInfoDb,
utxo3: UTXOSpendingInfoDb) {
val utxoSet: Vector[UTXOSpendingInfoDb] = Vector(utxo1, utxo2, utxo3)
}
override type FixtureParam = CoinSelectionFixture
override def withFixture(test: OneArgAsyncTest): FutureOutcome = {
val output = TransactionOutput(Satoshis(Int64(99L)), ScriptPubKey.empty)
val feeRate = SatoshisPerByte(CurrencyUnits.zero)
val utxo1 = NativeV0UTXOSpendingInfoDb(
id = Some(1),
outPoint = TransactionGenerators.outPoint.sample.get,
output = TransactionOutput(Satoshis(Int64(10)), ScriptPubKey.empty),
privKeyPath = WalletTestUtil.sampleSegwitPath,
scriptWitness = WitnessGenerators.scriptWitness.sample.get
)
val utxo2 = NativeV0UTXOSpendingInfoDb(
id = Some(2),
outPoint = TransactionGenerators.outPoint.sample.get,
output = TransactionOutput(Satoshis(Int64(90)), ScriptPubKey.empty),
privKeyPath = WalletTestUtil.sampleSegwitPath,
scriptWitness = WitnessGenerators.scriptWitness.sample.get
)
val utxo3 = NativeV0UTXOSpendingInfoDb(
id = Some(3),
outPoint = TransactionGenerators.outPoint.sample.get,
output = TransactionOutput(Satoshis(Int64(20)), ScriptPubKey.empty),
privKeyPath = WalletTestUtil.sampleSegwitPath,
scriptWitness = WitnessGenerators.scriptWitness.sample.get
)
test(CoinSelectionFixture(output, feeRate, utxo1, utxo2, utxo3))
}
behavior of "CoinSelector"
it must "accumulate largest outputs" in { fixture =>
val selection =
CoinSelector.accumulateLargest(walletUtxos = fixture.utxoSet,
outputs = Vector(fixture.output),
feeRate = fixture.feeRate)
assert(selection == Vector(fixture.utxo2, fixture.utxo3))
}
it must "accumulate smallest outputs" in { fixture =>
val selection =
CoinSelector.accumulateSmallestViable(walletUtxos = fixture.utxoSet,
outputs = Vector(fixture.output),
feeRate = fixture.feeRate)
assert(selection == Vector(fixture.utxo1, fixture.utxo3, fixture.utxo2))
}
it must "accumulate outputs in order" in { fixture =>
val selection = CoinSelector.accumulate(walletUtxos = fixture.utxoSet,
outputs = Vector(fixture.output),
feeRate = fixture.feeRate)
assert(selection == Vector(fixture.utxo1, fixture.utxo2))
}
it must "correctly approximate transaction input size" in { fixture =>
val expected1 = 32 + 4 + 1 + 4 + fixture.utxo1.scriptWitnessOpt.get.bytes.length
val expected2 = 32 + 4 + 1 + 4 + fixture.utxo2.scriptWitnessOpt.get.bytes.length
val expected3 = 32 + 4 + 1 + 4 + fixture.utxo3.scriptWitnessOpt.get.bytes.length
assert(CoinSelector.approximateUtxoSize(fixture.utxo1) == expected1)
assert(CoinSelector.approximateUtxoSize(fixture.utxo2) == expected2)
assert(CoinSelector.approximateUtxoSize(fixture.utxo3) == expected3)
}
}

View File

@ -50,17 +50,14 @@ sealed abstract class Wallet
change <- getNewChangeAddress(fromAccount)
walletUtxos <- listUtxos()
txBuilder <- {
val destinations: Seq[TransactionOutput] = List(
val destinations = Vector(
TransactionOutput(amount, address.scriptPubKey))
// currencly just grabs one utxos, throws if can't find big enough
// todo: implement coin selection
val utxos: List[BitcoinUTXOSpendingInfo] =
List(
walletUtxos
.find(_.value >= amount)
.get
.toUTXOSpendingInfo(fromAccount, seed))
// currencly just grabs the biggest utxos until it finds enough
val utxos: Vector[BitcoinUTXOSpendingInfo] =
CoinSelector
.accumulateLargest(walletUtxos, destinations, feeRate)
.map(_.toUTXOSpendingInfo(fromAccount, seed))
logger.info({
val utxosStr = utxos

View File

@ -0,0 +1,101 @@
package org.bitcoins.wallet.api
import org.bitcoins.core.currency.{CurrencyUnit, CurrencyUnits}
import org.bitcoins.core.protocol.transaction.TransactionOutput
import org.bitcoins.core.wallet.fee.FeeUnit
import org.bitcoins.wallet.models.UTXOSpendingInfoDb
import scala.annotation.tailrec
/** Implements algorithms for selecting from a UTXO set to spend to an output set at a given fee rate. */
trait CoinSelector {
/**
* Greedily selects from walletUtxos starting with the largest outputs, skipping outputs with values
* below their fees. Better for high fee environments than accumulateSmallestViable.
*/
def accumulateLargest(
walletUtxos: Vector[UTXOSpendingInfoDb],
outputs: Vector[TransactionOutput],
feeRate: FeeUnit): Vector[UTXOSpendingInfoDb] = {
val sortedUtxos =
walletUtxos.sortBy(_.value.satoshis.toLong).reverse
accumulate(sortedUtxos, outputs, feeRate)
}
/**
* Greedily selects from walletUtxos starting with the smallest outputs, skipping outputs with values
* below their fees. Good for low fee environments to consolidate UTXOs.
*
* Has the potential privacy breach of connecting a ton of UTXOs to one address.
*/
def accumulateSmallestViable(
walletUtxos: Vector[UTXOSpendingInfoDb],
outputs: Vector[TransactionOutput],
feeRate: FeeUnit): Vector[UTXOSpendingInfoDb] = {
val sortedUtxos = walletUtxos.sortBy(_.value.satoshis.toLong)
accumulate(sortedUtxos, outputs, feeRate)
}
/** Greedily selects from walletUtxos in order, skipping outputs with values below their fees */
def accumulate(
walletUtxos: Vector[UTXOSpendingInfoDb],
outputs: Vector[TransactionOutput],
feeRate: FeeUnit): Vector[UTXOSpendingInfoDb] = {
val totalValue = outputs.foldLeft(CurrencyUnits.zero) {
case (totVal, output) => totVal + output.value
}
@tailrec
def addUtxos(
alreadyAdded: Vector[UTXOSpendingInfoDb],
valueSoFar: CurrencyUnit,
bytesSoFar: Long,
utxosLeft: Vector[UTXOSpendingInfoDb]): Vector[UTXOSpendingInfoDb] = {
val fee = feeRate.currencyUnit * bytesSoFar
if (valueSoFar > totalValue + fee) {
alreadyAdded
} else if (utxosLeft.isEmpty) {
throw new RuntimeException(
s"Not enough value in given outputs ($valueSoFar) to make transaction spending $totalValue")
} else {
val nextUtxo = utxosLeft.head
val approxUtxoSize = CoinSelector.approximateUtxoSize(nextUtxo)
val nextUtxoFee = feeRate.currencyUnit * approxUtxoSize
if (nextUtxo.value < nextUtxoFee) {
addUtxos(alreadyAdded, valueSoFar, bytesSoFar, utxosLeft.tail)
} else {
val newAdded = alreadyAdded.:+(nextUtxo)
val newValue = valueSoFar + nextUtxo.value
addUtxos(newAdded,
newValue,
bytesSoFar + approxUtxoSize,
utxosLeft.tail)
}
}
}
addUtxos(Vector.empty, CurrencyUnits.zero, bytesSoFar = 0L, walletUtxos)
}
}
object CoinSelector extends CoinSelector {
/** Cribbed from [[https://github.com/bitcoinjs/coinselect/blob/master/utils.js]] */
def approximateUtxoSize(utxo: UTXOSpendingInfoDb): Long = {
val inputBase = 32 + 4 + 1 + 4
val scriptSize = utxo.redeemScriptOpt match {
case Some(script) => script.bytes.length
case None =>
utxo.scriptWitnessOpt match {
case Some(script) => script.bytes.length
case None => 25 // PUBKEYHASH
}
}
inputBase + scriptSize
}
}