Skip to content

Commit

Permalink
implement new AggregateCoinSelector class
Browse files Browse the repository at this point in the history
  • Loading branch information
alejoacosta74 authored and rileystephens28 committed Nov 27, 2024
1 parent 5e5629e commit 880aa69
Show file tree
Hide file tree
Showing 7 changed files with 350 additions and 39 deletions.
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));
}
});
});
});
19 changes: 18 additions & 1 deletion 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 @@ -41,6 +41,7 @@ export interface CoinSelectionConfig {
target?: bigint;
fee?: bigint;
includeLocked?: boolean;
maxDenomination?: number;
// Any future parameters can be added here
}

Expand Down Expand Up @@ -113,4 +114,20 @@ export abstract class AbstractCoinSelector {
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

0 comments on commit 880aa69

Please sign in to comment.