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

Implement AggregateCoinSelector class #365

Merged
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: 17 additions & 14 deletions src/_tests/unit/coinselection.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([1, 2, 3]); // .065 Qi
const targetSpend = denominations[3]; // .05 Qi
const selector = new FewestCoinSelector(availableUTXOs);
const result = selector.performSelection(targetSpend);
const result = selector.performSelection({ target: targetSpend });

// A single 0.05 Qi UTXO should have been selected
assert.strictEqual(result.inputs.length, 1);
Expand All @@ -40,7 +40,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([1, 2, 2, 3]); // .075 Qi
const targetSpend = denominations[2] + denominations[3]; // .06 Qi
const selector = new FewestCoinSelector(availableUTXOs);
const result = selector.performSelection(targetSpend);
const result = selector.performSelection({ target: targetSpend });

// 2 UTXOs should have been selected for a total of .06 Qi
assert.strictEqual(result.inputs.length, 2);
Expand All @@ -63,7 +63,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([2, 4]); // .11 Qi
const targetSpend = denominations[3]; // .05 Qi
const selector = new FewestCoinSelector(availableUTXOs);
const result = selector.performSelection(targetSpend);
const result = selector.performSelection({ target: targetSpend });

// A single 0.1 Qi UTXO should have been selected
assert.strictEqual(result.inputs.length, 1);
Expand All @@ -82,7 +82,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([2, 4, 4, 4, 5]); // .56 Qi
const targetSpend = denominations[6]; // .5 Qi
const selector = new FewestCoinSelector(availableUTXOs);
const result = selector.performSelection(targetSpend);
const result = selector.performSelection({ target: targetSpend });

// 4 UTXOs should have been selected for a total of .55 Qi
assert.strictEqual(result.inputs.length, 4);
Expand All @@ -107,17 +107,20 @@ describe('FewestCoinSelector', function () {
describe('Error cases', function () {
it('throws an error when there are insufficient funds', function () {
const selector = new FewestCoinSelector(createUTXOs([0, 0]));
assert.throws(() => selector.performSelection(denominations[3]), /Insufficient funds/);
assert.throws(() => selector.performSelection({ target: denominations[3] }), /Insufficient funds/);
});

it('throws an error when no UTXOs are available', function () {
const selector = new FewestCoinSelector([]);
assert.throws(() => selector.performSelection(denominations[2]), /No UTXOs available/);
assert.throws(() => selector.performSelection({ target: denominations[2] }), /No UTXOs available/);
});

it('throws an error when the target amount is negative', function () {
const selector = new FewestCoinSelector(createUTXOs([2, 2]));
assert.throws(() => selector.performSelection(-denominations[1]), /Target amount must be greater than 0/);
assert.throws(
() => selector.performSelection({ target: -denominations[1] }),
/Target amount must be greater than 0/,
);
});
});

Expand All @@ -127,7 +130,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([3]); // Denomination index 3 (50 units)
const targetSpend = denominations[2]; // 10 units
const selector = new FewestCoinSelector(availableUTXOs);
selector.performSelection(targetSpend);
selector.performSelection({ target: targetSpend });

// Calculate expected initial change amount
const initialChangeAmount = denominations[3] - denominations[2]; // 50 - 10 = 40 units
Expand Down Expand Up @@ -178,7 +181,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([2, 2, 2]); // Denomination index 2 (10 units each)
const targetSpend = denominations[2] * BigInt(2); // 20 units
const selector = new FewestCoinSelector(availableUTXOs);
selector.performSelection(targetSpend);
selector.performSelection({ target: targetSpend });

// Initially, no change outputs (total input = 20 units)
assert.strictEqual(selector.changeOutputs.length, 0);
Expand Down Expand Up @@ -206,7 +209,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([2, 2]); // Two .01 Qi UTXOs
const targetSpend = denominations[2] * BigInt(2); // .02 Qi
const selector = new FewestCoinSelector(availableUTXOs);
selector.performSelection(targetSpend);
selector.performSelection({ target: targetSpend });

// No change outputs expected
assert.strictEqual(selector.changeOutputs.length, 0);
Expand All @@ -227,7 +230,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([3, 2]); // .05 Qi and .01 Qi
const targetSpend = denominations[3]; // .05 Qi
const selector = new FewestCoinSelector(availableUTXOs);
selector.performSelection(targetSpend);
selector.performSelection({ target: targetSpend });

// No change outputs expected
assert.strictEqual(selector.changeOutputs.length, 0);
Expand All @@ -248,7 +251,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([3, 2]); // Denomination indices 3 (50 units) and 2 (10 units)
const targetSpend = denominations[1]; // 20 units
const selector = new FewestCoinSelector(availableUTXOs);
selector.performSelection(targetSpend);
selector.performSelection({ target: targetSpend });

// Initially, selects the 50-unit UTXO for the target spend
assert.strictEqual(selector.selectedUTXOs.length, 1);
Expand Down Expand Up @@ -285,7 +288,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([3]); // .05 Qi
const targetSpend = denominations[3]; // .05 Qi
const selector = new FewestCoinSelector(availableUTXOs);
selector.performSelection(targetSpend);
selector.performSelection({ target: targetSpend });

// No change outputs expected
assert.strictEqual(selector.changeOutputs.length, 0);
Expand All @@ -308,7 +311,7 @@ describe('FewestCoinSelector', function () {
const availableUTXOs = createUTXOs([2, 2]); // Denomination indices 2 (10 units each)
const targetSpend = denominations[2]; // 10 units
const selector = new FewestCoinSelector(availableUTXOs);
selector.performSelection(targetSpend);
selector.performSelection({ target: targetSpend });

// Initially, selects one UTXO, change expected
assert.strictEqual(selector.selectedUTXOs.length, 1);
Expand Down
133 changes: 133 additions & 0 deletions src/_tests/unit/coinselector-aggregate.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// import { describe, expect, test } from '@jest/globals';
import assert from 'assert';
import { loadTests } from '../utils.js';
import { AggregateCoinSelector } from '../../transaction/coinselector-aggregate.js';
import { UTXO, denominations } from '../../transaction/utxo.js';

interface AggregationTestCase {
name: string;
inputs: Array<{
denomination: number;
txhash: string;
index: number;
lock?: number;
}>;
fee: string;
includeLocked: boolean;
maxDenomination?: number;
expectedOutputs: Array<{
denomination: number;
}>;
expectedInputs: Array<{
denomination: number;
txhash: string;
index: number;
lock?: number;
}>;
shouldSucceed: boolean;
expectedError?: string;
}

// Helper function to sort denomination arrays for comparison
function sortByDenomination(a: { denomination: number }, b: { denomination: number }): number {
return a.denomination - b.denomination;
}

describe('AggregateCoinSelector', () => {
const testCases = loadTests<AggregationTestCase>('qi-coin-aggregation');

testCases.forEach((testCase) => {
it(testCase.name, () => {
// Create UTXOs from test inputs
const utxos = testCase.inputs.map((input) => {
const utxo = new UTXO();
utxo.denomination = input.denomination;
utxo.txhash = input.txhash;
utxo.index = input.index;
if (input.lock !== undefined) {
utxo.lock = input.lock;
}
return utxo;
});

// Create coin selector instance
const selector = new AggregateCoinSelector(utxos);

if (testCase.shouldSucceed) {
// Test successful case
const result = selector.performSelection({
includeLocked: testCase.includeLocked,
fee: BigInt(testCase.fee),
maxDenomination: testCase.maxDenomination ?? undefined,
});

// Map UTXOs to same format as expected outputs before sorting
const sortedExpectedOutputs = [...testCase.expectedOutputs].sort(sortByDenomination);
const sortedActualOutputs = [...result.spendOutputs]
.map((utxo) => ({ denomination: utxo.denomination ?? 0 }))
.sort(sortByDenomination);

// Verify number of outputs matches expected
assert.strictEqual(
sortedActualOutputs.length,
sortedExpectedOutputs.length,
`Outputs length: Expected ${sortedExpectedOutputs.length} but got ${sortedActualOutputs.length}`,
);

// Verify each output denomination matches expected
sortedActualOutputs.forEach((output, index) => {
assert.strictEqual(
output.denomination,
sortedExpectedOutputs[index].denomination,
`Outputs: Expected ${JSON.stringify(sortedExpectedOutputs[index], null, 2)} but got ${JSON.stringify(output, null, 2)}`,
);
});

// Verify no change outputs
assert.strictEqual(result.changeOutputs.length, 0);

// Verify expected inputs match selectedCoinResults inputs
const sortedExpectedInputs = [...testCase.expectedInputs].sort(sortByDenomination);
const sortedActualInputs = [...result.inputs]
.map((input) => ({
denomination: input.denomination ?? 0,
txhash: input.txhash,
index: input.index,
}))
.sort(sortByDenomination);

sortedExpectedInputs.forEach((input, index) => {
assert.strictEqual(
sortedActualInputs[index].denomination,
input.denomination,
`Inputs: Expected ${JSON.stringify(input, null, 2)} but got ${JSON.stringify(sortedActualInputs[index], null, 2)}`,
);
});

// Verify total value conservation
const inputValue = result.inputs.reduce(
(sum, input) => sum + BigInt(denominations[input.denomination!]),
BigInt(0),
);
const outputValue = result.spendOutputs.reduce(
(sum, output) => sum + BigInt(denominations[output.denomination!]),
BigInt(0),
);
assert.strictEqual(
inputValue,
outputValue + BigInt(testCase.fee),
`Input value: Expected ${inputValue} but got ${outputValue + BigInt(testCase.fee)}`,
);
} else {
// Test error case
assert.throws(() => {
selector.performSelection({
includeLocked: testCase.includeLocked,
fee: BigInt(testCase.fee),
maxDenomination: testCase.maxDenomination ?? 6,
});
}, new Error(testCase.expectedError));
}
});
});
});
41 changes: 38 additions & 3 deletions src/transaction/abstract-coinselector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UTXO } from './utxo.js';
import { denominations, UTXO } from './utxo.js';

/**
* Represents a target for spending.
Expand Down Expand Up @@ -37,6 +37,14 @@ export type SelectedCoinsResult = {
* @category Transaction
* @abstract
*/
export interface CoinSelectionConfig {
target?: bigint;
fee?: bigint;
includeLocked?: boolean;
maxDenomination?: number;
// Any future parameters can be added here
}

export abstract class AbstractCoinSelector {
public availableUTXOs: UTXO[];
public totalInputValue: bigint = BigInt(0);
Expand Down Expand Up @@ -65,10 +73,10 @@ export abstract class AbstractCoinSelector {
* and change outputs.
*
* @abstract
* @param {SpendTarget} target - The target address and value to spend.
* @param {CoinSelectionConfig} config - The configuration for coin selection.
* @returns {SelectedCoinsResult} The selected UTXOs and outputs.
*/
abstract performSelection(target: bigint, fee: bigint): SelectedCoinsResult;
abstract performSelection(config: CoinSelectionConfig): SelectedCoinsResult;

/**
* Validates the provided UTXO instance. In order to be valid for coin selection, the UTXO must have a valid address
Expand All @@ -95,4 +103,31 @@ export abstract class AbstractCoinSelector {
throw new Error('UTXO index is required');
}
}

/**
* Validates the available UTXOs.
*
* @throws Will throw an error if there are no available UTXOs.
*/
protected validateUTXOs() {
if (this.availableUTXOs.length === 0) {
throw new Error('No UTXOs available');
}
}

/**
* Sorts UTXOs by their denomination.
*
* @param {UTXO[]} utxos - The UTXOs to sort.
* @param {'asc' | 'desc'} order - The direction to sort ('asc' for ascending, 'desc' for descending).
* @returns {UTXO[]} The sorted UTXOs.
*/
protected sortUTXOsByDenomination(utxos: UTXO[], order: 'asc' | 'desc' = 'asc'): UTXO[] {
return [...utxos].sort((a, b) => {
const aValue = BigInt(a.denomination !== null ? denominations[a.denomination] : 0);
const bValue = BigInt(b.denomination !== null ? denominations[b.denomination] : 0);
const diff = order === 'asc' ? aValue - bValue : bValue - aValue;
return diff > BigInt(0) ? 1 : diff < BigInt(0) ? -1 : 0;
});
}
}
Loading
Loading