diff --git a/build.gradle b/build.gradle index 4426f8d..317ce36 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ group = 'com.coinselection' -version = '0.0.3' +version = '0.0.4' buildscript { ext.kotlin_version = '1.3.11' ext.spring_boot_version = '2.0.6.RELEASE' diff --git a/src/main/kotlin/com.coinselection/BtcCoinSelectionProvider.kt b/src/main/kotlin/com.coinselection/BtcCoinSelectionProvider.kt index 9bc9f11..5d1cbdb 100644 --- a/src/main/kotlin/com.coinselection/BtcCoinSelectionProvider.kt +++ b/src/main/kotlin/com.coinselection/BtcCoinSelectionProvider.kt @@ -15,18 +15,41 @@ class BtcCoinSelectionProvider( private val transactionSize: TransactionSize = SegwitLegacyCompatibleSizeProvider.provide() ) { - fun provide(utxoList: List, targetValue: BigDecimal, feeRatePerByte: BigDecimal, numberOfOutputs: Int = 1): CoinSelectionResult? { - val cumulativeHolder = CumulativeHolder.defaultInit() - val costCalculator = CostCalculator(transactionSize = transactionSize, feePerByte = feeRatePerByte, numberOfOutputs = numberOfOutputs) - val selectedUtxoList = select(utxoList, targetValue, costCalculator, cumulativeHolder) + private class UtxoSumCalculationData( + val utxoList: List, + val cumulativeHolder: CumulativeHolder + ) + + + fun provide(utxoList: List, + targetValue: BigDecimal, + feeRatePerByte: BigDecimal, + numberOfOutputs: Int = 1, + compulsoryUtxoList: List = listOf() + ): CoinSelectionResult? { + + val costCalculator = CostCalculator(transactionSize, feeRatePerByte, numberOfOutputs) + + val dataPair = selectUntilSumIsLessThanTarget( + utxoList.shuffled(), targetValue, costCalculator, compulsoryUtxoList) + ?: fallbackLargestFirstSelection( + utxoListRearanged = utxoList.sortedByDescending { it.amount }, + costCalculator = costCalculator, + targetValue = targetValue, + compulsoryUtxoList = compulsoryUtxoList) ?: return null - val improvedUtxoList = improve(getRemainingUtxoList(selectedUtxoList = selectedUtxoList, originalUtxoList = utxoList), targetValue = targetValue, costCalculator = costCalculator, cumulativeHolder = cumulativeHolder) - return CoinSelectionResult(selectedUtxos = mergeUtxoLists(selectedUtxoList = selectedUtxoList, improvedUtxoList = improvedUtxoList), totalFee = cumulativeHolder.getFee()) - } - private fun select(utxoList: List, targetValue: BigDecimal, costCalculator: CostCalculator, cumulativeHolder: CumulativeHolder): List? { - return selectUntilSumIsLessThanTarget(utxoListRearanged = utxoList.shuffled(), cumulativeHolder = cumulativeHolder, targetValue = targetValue, costCalculator = costCalculator) - ?: fallbackLargestFirstSelection(utxoListRearanged = utxoList.sortedByDescending { it.amount }, cumulativeHolder = cumulativeHolder, costCalculator = costCalculator, targetValue = targetValue) + val selectedUtxoList = dataPair.utxoList + val cumulativeHolder = dataPair.cumulativeHolder + + val remainingUtxoList = utxoList.subtract(selectedUtxoList).toList() + val improvedUtxoList = improve(remainingUtxoList, targetValue, costCalculator, cumulativeHolder) + + val utxResult = selectedUtxoList.union(improvedUtxoList).toList() + + return CoinSelectionResult( + selectedUtxos = utxResult, + totalFee = cumulativeHolder.getFee()) } private fun improve(remainingUtxoList: List, targetValue: BigDecimal, costCalculator: CostCalculator, cumulativeHolder: CumulativeHolder): List { @@ -37,10 +60,10 @@ class BtcCoinSelectionProvider( .shuffled() .asSequence() .take(maxNumberOfInputs) - .takeWhile { sumIsLessThanTarget(cumulativeHolder = cumulativeHolder, targetValue = maxTargetValue) } + .takeWhile { sumIsLessThanTarget(cumulativeHolder, targetValue = maxTargetValue) } .onEach { delta.getAndSet((cumulativeHolder.getSum() - (optimalTargetValue + cumulativeHolder.getFee())).abs()) } .takeWhile { (cumulativeHolder.getSum() + it.amount - (optimalTargetValue + cumulativeHolder.getFee() + costCalculator.getCostPerInput())).abs() < delta.get() } - .onEach { appendSumAndFee(cumulativeHolder = cumulativeHolder, costCalculator = costCalculator, sum = it.amount) } + .onEach { appendSumAndFee(cumulativeHolder, costCalculator, sum = it.amount) } .toList() } @@ -48,30 +71,36 @@ class BtcCoinSelectionProvider( return cumulativeHolder.getSum() < targetValue + cumulativeHolder.getFee() } - private fun selectUntilSumIsLessThanTarget(utxoListRearanged: List, cumulativeHolder: CumulativeHolder, targetValue: BigDecimal, costCalculator: CostCalculator): List? { + private fun selectUntilSumIsLessThanTarget(utxoListRearanged: List, + targetValue: BigDecimal, + costCalculator: CostCalculator, + compulsoryUtxoList: List): UtxoSumCalculationData? { + val cumulativeHolder = CumulativeHolder.defaultInit() cumulativeHolder.appendFee(costCalculator.getBaseFee()) val selectedUtxoList = utxoListRearanged .asSequence() - .takeWhile { sumIsLessThanTarget(cumulativeHolder = cumulativeHolder, targetValue = targetValue) } + .takeWhile { sumIsLessThanTarget(cumulativeHolder, targetValue) } .take(maxNumberOfInputs) - .onEach { appendSumAndFee(cumulativeHolder = cumulativeHolder, costCalculator = costCalculator, sum = it.amount) } + .onEach { appendSumAndFee(cumulativeHolder, costCalculator, sum = it.amount) } .toList() - if (sumIsLessThanTarget(cumulativeHolder = cumulativeHolder, targetValue = targetValue)) { + if (sumIsLessThanTarget(cumulativeHolder, targetValue)) { return null } - return selectedUtxoList + return UtxoSumCalculationData(compulsoryUtxoList + selectedUtxoList, cumulativeHolder) } - private fun fallbackLargestFirstSelection(utxoListRearanged: List, cumulativeHolder: CumulativeHolder, costCalculator: CostCalculator, targetValue: BigDecimal): List? { - cumulativeHolder.reset() - cumulativeHolder.appendFee(costCalculator.getBaseFee()) - val selectedUtxoList = selectUntilSumIsLessThanTarget(utxoListRearanged = utxoListRearanged, - cumulativeHolder = cumulativeHolder, costCalculator = costCalculator, targetValue = targetValue) -// Return null utxo list if total amount is still not enough - if (sumIsLessThanTarget(cumulativeHolder = cumulativeHolder, targetValue = targetValue)) { - return null + private fun fallbackLargestFirstSelection(utxoListRearanged: List, + costCalculator: CostCalculator, + targetValue: BigDecimal, + compulsoryUtxoList: List): UtxoSumCalculationData? { + val dataPair = selectUntilSumIsLessThanTarget(utxoListRearanged, targetValue, costCalculator, compulsoryUtxoList) + return if (dataPair == null || sumIsLessThanTarget(dataPair.cumulativeHolder, targetValue)) { + null + } else { + val cumulativeHolder = dataPair.cumulativeHolder + cumulativeHolder.appendFee(costCalculator.getBaseFee()) + UtxoSumCalculationData(compulsoryUtxoList + dataPair.utxoList, cumulativeHolder) } - return selectedUtxoList } private fun appendSumAndFee(cumulativeHolder: CumulativeHolder, costCalculator: CostCalculator, sum: BigDecimal) { @@ -79,11 +108,4 @@ class BtcCoinSelectionProvider( cumulativeHolder.appendFee(costCalculator.getCostPerInput()) } - private fun getRemainingUtxoList(selectedUtxoList: List, originalUtxoList: List): List { - return originalUtxoList.subtract(selectedUtxoList).toList() - } - - private fun mergeUtxoLists(selectedUtxoList: List, improvedUtxoList: List): List { - return selectedUtxoList.union(improvedUtxoList).toList() - } } \ No newline at end of file diff --git a/src/test/kotlin/com/coinselection/BtcCoinSelectionProviderTest.kt b/src/test/kotlin/com/coinselection/BtcCoinSelectionProviderTest.kt index 3f37f56..cb64f53 100644 --- a/src/test/kotlin/com/coinselection/BtcCoinSelectionProviderTest.kt +++ b/src/test/kotlin/com/coinselection/BtcCoinSelectionProviderTest.kt @@ -62,6 +62,18 @@ class BtcCoinSelectionProviderTest { Assertions.assertNull(coinSelectionResult) } + @Test + fun `should include all compulsory utxos`() { + val targetValue = BigDecimal(5) + val rangeMin = 1.1 + val rangeMax = 1.2 + val utxoList = (1..50).map { rangeMin + (rangeMax - rangeMin) * random.nextDouble() }.map { createUnspentOutput(it) } + val compulsoryUtxoList = (1..20).map { rangeMin + (rangeMax - rangeMin) * random.nextDouble() }.map { createUnspentOutput(it) } + val coinSelectionResult = coinSelectionProvider.provide(utxoList, targetValue, smartFeePerByte, compulsoryUtxoList = compulsoryUtxoList) + Assertions.assertNotNull(coinSelectionResult!!.selectedUtxos) + Assertions.assertTrue(coinSelectionResult.selectedUtxos!!.containsAll(compulsoryUtxoList)) + } + private fun createUnspentOutput(value: Double): UnspentOutput { return UnspentOutput(amount = BigDecimal(value)) }