From 395a5ba91ce48aa3b122ef9cbfdb42d1ebb448f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loun=C3=A8s=20Ksouri?= Date: Wed, 3 Jan 2024 12:25:44 +0100 Subject: [PATCH 1/2] feat: force utxo to be included in selection --- src/algos/addUntilReach.ts | 31 +++++++++++++++++++++++++++---- src/algos/avoidChange.ts | 31 +++++++++++++++++++++++++++---- src/coinselect.ts | 17 ++--------------- src/index.ts | 1 + 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/algos/addUntilReach.ts b/src/algos/addUntilReach.ts index 0a4a10f..ac20c19 100644 --- a/src/algos/addUntilReach.ts +++ b/src/algos/addUntilReach.ts @@ -1,5 +1,5 @@ import type { OutputInstance } from '@bitcoinerlab/descriptors'; -import { DUST_RELAY_FEE_RATE, OutputWithValue } from '../index'; +import { DUST_RELAY_FEE_RATE, Input, OutputWithValue } from '../index'; import { validateFeeRate, validateOutputWithValues, @@ -29,7 +29,7 @@ export function addUntilReach({ feeRate, dustRelayFeeRate = DUST_RELAY_FEE_RATE }: { - utxos: Array; + utxos: Array; targets: Array; remainder: OutputInstance; feeRate: number; @@ -42,9 +42,32 @@ export function addUntilReach({ validateFeeRate(dustRelayFeeRate); const targetsValue = targets.reduce((a, target) => a + target.value, 0); - const utxosSoFar: Array = []; + const utxosSoFar = utxos.filter(utxo => utxo.forceSelection); - for (const candidate of utxos) { + // First check if the force included utxos are enough + if (utxosSoFar.length > 0) { + const utxosValue = utxosSoFar.reduce((a, utxo) => a + utxo.value, 0); + const txSize = vsize( + utxosSoFar.map(utxo => utxo.output), + [remainder, ...targets.map(target => target.output)] + ); + const txFee = Math.ceil(txSize * feeRate); + const remainderValue = utxosValue - (targetsValue + txFee); + const targetsResult = isDust(remainder, remainderValue, dustRelayFeeRate) + ? targets + : [...targets, { output: remainder, value: remainderValue }]; + + if (utxosValue >= targetsValue + txFee) { + return { + utxos: utxosSoFar, + targets: targetsResult, + ...validatedFeeAndVsize(utxosSoFar, targetsResult, feeRate) + }; + } + } + + const notforceSelectiondUtxos = utxos.filter(utxo => !utxo.forceSelection); + for (const candidate of notforceSelectiondUtxos) { const txSizeSoFar = vsize( utxosSoFar.map(utxo => utxo.output), targets.map(target => target.output) diff --git a/src/algos/avoidChange.ts b/src/algos/avoidChange.ts index 141a6e4..c5c794a 100644 --- a/src/algos/avoidChange.ts +++ b/src/algos/avoidChange.ts @@ -1,5 +1,5 @@ import type { OutputInstance } from '@bitcoinerlab/descriptors'; -import { DUST_RELAY_FEE_RATE, OutputWithValue } from '../index'; +import { DUST_RELAY_FEE_RATE, Input, OutputWithValue } from '../index'; import { validateFeeRate, validateOutputWithValues, @@ -30,7 +30,7 @@ export function avoidChange({ feeRate, dustRelayFeeRate = DUST_RELAY_FEE_RATE }: { - utxos: Array; + utxos: Array; targets: Array; /** * This is the hypotetical change that this algo will check it would @@ -47,9 +47,32 @@ export function avoidChange({ validateFeeRate(dustRelayFeeRate); const targetsValue = targets.reduce((a, target) => a + target.value, 0); - const utxosSoFar: Array = []; + const utxosSoFar = utxos.filter(utxo => utxo.forceSelection); - for (const candidate of utxos) { + // First check if the force included utxos are enough + if (utxosSoFar.length > 0) { + const utxosValue = utxosSoFar.reduce((a, utxo) => a + utxo.value, 0); + const txSize = vsize( + utxosSoFar.map(utxo => utxo.output), + targets.map(target => target.output) + ); + const txFee = Math.ceil(txSize * feeRate); + const remainderValue = utxosValue - (targetsValue + txFee); + + if ( + utxosValue >= targetsValue + txFee && + isDust(remainder, remainderValue, dustRelayFeeRate) + ) { + return { + utxos: utxosSoFar, + targets, + ...validatedFeeAndVsize(utxosSoFar, targets, feeRate) + }; + } + } + + const notforceSelectiondUtxos = utxos.filter(utxo => !utxo.forceSelection); + for (const candidate of notforceSelectiondUtxos) { const utxosSoFarValue = utxosSoFar.reduce((a, utxo) => a + utxo.value, 0); const txSizeWithCandidateAndChange = vsize( diff --git a/src/coinselect.ts b/src/coinselect.ts index 498a436..b186bed 100644 --- a/src/coinselect.ts +++ b/src/coinselect.ts @@ -1,11 +1,6 @@ //TODO: docs: add a reference to the API import type { OutputInstance } from '@bitcoinerlab/descriptors'; -import { OutputWithValue, DUST_RELAY_FEE_RATE } from './index'; -import { - validateFeeRate, - validateOutputWithValues, - validateDust -} from './validation'; +import { OutputWithValue, DUST_RELAY_FEE_RATE, Input } from './index'; import { addUntilReach } from './algos/addUntilReach'; import { avoidChange } from './algos/avoidChange'; import { isSegwitTx } from './vsize'; @@ -81,7 +76,7 @@ export function coinselect({ * Array of UTXOs for the transaction. Each UTXO includes an `OutputInstance` * and its value. */ - utxos: Array; + utxos: Array; /** * Array of transaction targets. If specified, `remainder` is used * as the change address. @@ -103,14 +98,6 @@ export function coinselect({ */ dustRelayFeeRate?: number; }) { - validateOutputWithValues(utxos); - if (targets) { - validateOutputWithValues(targets); - validateDust(targets); - } - validateFeeRate(feeRate); - validateFeeRate(dustRelayFeeRate); - //We will assume that the tx is segwit if there is at least one segwit //utxo for computing the utxo ordering. This is an approximation. //Note that having one segwit utxo does not mean the final tx will be segwit diff --git a/src/index.ts b/src/index.ts index 05e8262..85ac424 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,3 +15,4 @@ export { maxFunds } from './algos/maxFunds'; export { addUntilReach } from './algos/addUntilReach'; export { avoidChange } from './algos/avoidChange'; export type OutputWithValue = { output: OutputInstance; value: number }; +export type Input = OutputWithValue & { forceSelection?: boolean }; From ab89f0b6e3692066d40a7a706a2651639a3e9481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Loun=C3=A8s=20Ksouri?= Date: Wed, 3 Jan 2024 12:27:41 +0100 Subject: [PATCH 2/2] test: add tests for forced selection --- test/coinselect.test.ts | 40 +++++---- test/fixtures/addUntilReach.json | 137 ++++++++++++++++++++++++++++++- test/fixtures/coinselect.json | 88 +++++++++++++++++++- 3 files changed, 246 insertions(+), 19 deletions(-) diff --git a/test/coinselect.test.ts b/test/coinselect.test.ts index b97709d..083e5d9 100644 --- a/test/coinselect.test.ts +++ b/test/coinselect.test.ts @@ -1,4 +1,4 @@ -import { coinselect, addUntilReach, maxFunds } from '../dist'; +import { coinselect, addUntilReach, maxFunds, Input } from '../dist'; import * as secp256k1 from '@bitcoinerlab/secp256k1'; import { DescriptorsFactory } from '@bitcoinerlab/descriptors'; const { Output } = DescriptorsFactory(secp256k1); @@ -16,9 +16,10 @@ for (const fixturesWithDescription of [ describe(setDescription, () => { for (const fixture of fixtures) { test(fixture.description, () => { - const utxos = fixture.utxos.map(utxo => ({ + const utxos: Input[] = fixture.utxos.map(utxo => ({ value: utxo.value, - output: new Output({ descriptor: utxo.descriptor }) + output: new Output({ descriptor: utxo.descriptor }), + forceSelection: 'forceSelection' in utxo ? utxo.forceSelection : false })); const targets = fixture.targets.map(target => ({ value: target.value, @@ -71,9 +72,28 @@ for (const fixturesWithDescription of [ // 2 // ) //); + + // Check if the selected UTXOs match the expected indices + if (coinselected && fixture.expected.inputs) { + const selectedUtxoIndices = coinselected.utxos.map(selectedUtxo => { + const index = utxos.indexOf(selectedUtxo); + if (index === -1) { + throw new Error('Selected UTXO not found in original list'); + } + return index; + }); + + const expectedIndices = fixture.expected.inputs.map(input => input.i); + + expect(selectedUtxoIndices.sort()).toEqual(expectedIndices.sort()); + } + + // Check the number of targets expect(coinselected ? coinselected.targets.length : 0).toBe( fixture.expected?.outputs?.length || 0 ); + + // Check the remainder value let expectedRemainderValue: number | undefined; if ( fixture.expected.outputs && @@ -91,20 +111,6 @@ for (const fixturesWithDescription of [ coinselected.targets[coinselected.targets.length - 1]!.value ).toBe(expectedRemainderValue); } - // Check if the selected UTXOs match the expected indices - if (coinselected && fixture.expected.inputs) { - const selectedUtxoIndices = coinselected.utxos.map(selectedUtxo => { - const index = utxos.indexOf(selectedUtxo); - if (index === -1) { - throw new Error('Selected UTXO not found in original list'); - } - return index; - }); - - const expectedIndices = fixture.expected.inputs.map(input => input.i); - - expect(selectedUtxoIndices.sort()).toEqual(expectedIndices.sort()); - } }); } }); diff --git a/test/fixtures/addUntilReach.json b/test/fixtures/addUntilReach.json index 3743d69..8f63c38 100644 --- a/test/fixtures/addUntilReach.json +++ b/test/fixtures/addUntilReach.json @@ -179,6 +179,141 @@ } ] }, + { + "description": "1 output, forcing bigger utxo, change expected", + "remainder": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "feeRate": 5, + "expected": { + "inputs": [ + { + "i": 2, + "value": 80000 + } + ], + "outputs": [ + { + "value": 4700 + }, + { + "value": 74170 + } + ] + }, + "utxos": [ + { + "value": 10000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + }, + { + "value": 40000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + }, + { + "value": 80000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "forceSelection": true + } + ], + "targets": [ + { + "value": 4700, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + } + ] + }, + { + "description": "1 output, forcing multiple utxos, change expected", + "remainder": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "feeRate": 5, + "expected": { + "inputs": [ + { + "i": 0, + "value": 10000 + }, + { + "i": 2, + "value": 80000 + } + ], + "outputs": [ + { + "value": 4700 + }, + { + "value": 83430 + } + ] + }, + "utxos": [ + { + "value": 10000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "forceSelection": true + }, + { + "value": 40000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + }, + { + "value": 80000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "forceSelection": true + } + ], + "targets": [ + { + "value": 4700, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + } + ] + }, + { + "description": "1 output, forcing small utxo, change expected", + "remainder": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "feeRate": 5, + "expected": { + "inputs": [ + { + "i": 0, + "value": 2000 + }, + { + "i": 1, + "value": 10000 + } + ], + "outputs": [ + { + "value": 4700 + }, + { + "value": 5430 + } + ] + }, + "utxos": [ + { + "value": 2000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "forceSelection": true + }, + { + "value": 10000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + }, + { + "value": 80000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + } + ], + "targets": [ + { + "value": 4700, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + } + ] + }, { "description": "1 output, fails, skips (and finishes on) detrimental input", "remainder": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", @@ -639,4 +774,4 @@ } ] } -] \ No newline at end of file +] diff --git a/test/fixtures/coinselect.json b/test/fixtures/coinselect.json index 731b3b3..25daaf3 100644 --- a/test/fixtures/coinselect.json +++ b/test/fixtures/coinselect.json @@ -179,6 +179,92 @@ } ] }, + { + "description": "1 output, forcing utxo, no change", + "remainder": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "feeRate": 5, + "expected": { + "inputs": [ + { + "i": 0, + "value": 10000 + } + ], + "outputs": [ + { + "value": 8400 + } + ] + }, + "utxos": [ + { + "value": 10000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "forceSelection": true + }, + { + "value": 40000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + }, + { + "value": 40000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + } + ], + "targets": [ + { + "value": 8400, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + } + ] + }, + { + "description": "1 output, forcing multiple utxos, change expected", + "remainder": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "feeRate": 5, + "expected": { + "inputs": [ + { + "i": 1, + "value": 40000 + }, + { + "i": 2, + "value": 40000 + } + ], + "outputs": [ + { + "value": 4700 + }, + { + "value": 73430 + } + ] + }, + "utxos": [ + { + "value": 10000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + }, + { + "value": 40000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "forceSelection": true + }, + { + "value": 40000, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", + "forceSelection": true + } + ], + "targets": [ + { + "value": 4700, + "descriptor": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)" + } + ] + }, { "description": "1 output, passes, poor ordering but still good", "remainder": "addr(12higDjoCCNXSA95xZMWUdPvXNmkAduhWv)", @@ -606,4 +692,4 @@ } ] } -] \ No newline at end of file +]