Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to force the selection of utxos #4

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
31 changes: 27 additions & 4 deletions src/algos/addUntilReach.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -29,7 +29,7 @@ export function addUntilReach({
feeRate,
dustRelayFeeRate = DUST_RELAY_FEE_RATE
}: {
utxos: Array<OutputWithValue>;
utxos: Array<Input>;
targets: Array<OutputWithValue>;
remainder: OutputInstance;
feeRate: number;
Expand All @@ -42,9 +42,32 @@ export function addUntilReach({
validateFeeRate(dustRelayFeeRate);

const targetsValue = targets.reduce((a, target) => a + target.value, 0);
const utxosSoFar: Array<OutputWithValue> = [];
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)
Expand Down
31 changes: 27 additions & 4 deletions src/algos/avoidChange.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -30,7 +30,7 @@ export function avoidChange({
feeRate,
dustRelayFeeRate = DUST_RELAY_FEE_RATE
}: {
utxos: Array<OutputWithValue>;
utxos: Array<Input>;
targets: Array<OutputWithValue>;
/**
* This is the hypotetical change that this algo will check it would
Expand All @@ -47,9 +47,32 @@ export function avoidChange({
validateFeeRate(dustRelayFeeRate);

const targetsValue = targets.reduce((a, target) => a + target.value, 0);
const utxosSoFar: Array<OutputWithValue> = [];
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(
Expand Down
17 changes: 2 additions & 15 deletions src/coinselect.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -81,7 +76,7 @@ export function coinselect({
* Array of UTXOs for the transaction. Each UTXO includes an `OutputInstance`
* and its value.
*/
utxos: Array<OutputWithValue>;
utxos: Array<Input>;
/**
* Array of transaction targets. If specified, `remainder` is used
* as the change address.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
40 changes: 23 additions & 17 deletions test/coinselect.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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 &&
Expand All @@ -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());
}
});
}
});
Expand Down
137 changes: 136 additions & 1 deletion test/fixtures/addUntilReach.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -639,4 +774,4 @@
}
]
}
]
]
Loading