From 45845e236d306f650ad448df8088d6b62755a102 Mon Sep 17 00:00:00 2001 From: Alejo Acosta Date: Thu, 14 Nov 2024 14:29:49 -0300 Subject: [PATCH 1/2] add JSDoc to qi hdwallet methods --- src/transaction/abstract-coinselector.ts | 22 +++++++++++++++++++-- src/transaction/coinselector-fewest.ts | 17 ++++------------ src/wallet/qi-hdwallet.ts | 25 ++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/src/transaction/abstract-coinselector.ts b/src/transaction/abstract-coinselector.ts index 9c94bcf1..f65e568e 100644 --- a/src/transaction/abstract-coinselector.ts +++ b/src/transaction/abstract-coinselector.ts @@ -37,6 +37,13 @@ export type SelectedCoinsResult = { * @category Transaction * @abstract */ +export interface CoinSelectionConfig { + target?: bigint; + fee?: bigint; + includeLocked?: boolean; + // Any future parameters can be added here +} + export abstract class AbstractCoinSelector { public availableUTXOs: UTXO[]; public totalInputValue: bigint = BigInt(0); @@ -65,10 +72,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 @@ -95,4 +102,15 @@ 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'); + } + } } diff --git a/src/transaction/coinselector-fewest.ts b/src/transaction/coinselector-fewest.ts index cfa45932..21c12747 100644 --- a/src/transaction/coinselector-fewest.ts +++ b/src/transaction/coinselector-fewest.ts @@ -1,5 +1,5 @@ // import { bigIntAbs } from '../utils/maths.js'; -import { AbstractCoinSelector, SelectedCoinsResult } from './abstract-coinselector.js'; +import { AbstractCoinSelector, CoinSelectionConfig, SelectedCoinsResult } from './abstract-coinselector.js'; import { UTXO, denominate, denominations } from './utxo.js'; /** @@ -21,7 +21,9 @@ export class FewestCoinSelector extends AbstractCoinSelector { * @param {bigint} fee - The fee amount to include in the selection. * @returns {SelectedCoinsResult} The selected UTXOs and outputs. */ - performSelection(target: bigint, fee: bigint = BigInt(0)): SelectedCoinsResult { + performSelection(config: CoinSelectionConfig): SelectedCoinsResult { + const { target = BigInt(0), fee = BigInt(0) } = config; + if (target <= BigInt(0)) { throw new Error('Target amount must be greater than 0'); } @@ -353,15 +355,4 @@ export class FewestCoinSelector extends AbstractCoinSelector { return diff > BigInt(0) ? 1 : diff < BigInt(0) ? -1 : 0; }); } - - /** - * Validates the available UTXOs. - * - * @throws Will throw an error if there are no available UTXOs. - */ - private validateUTXOs() { - if (this.availableUTXOs.length === 0) { - throw new Error('No UTXOs available'); - } - } } diff --git a/src/wallet/qi-hdwallet.ts b/src/wallet/qi-hdwallet.ts index d244f591..1776348b 100644 --- a/src/wallet/qi-hdwallet.ts +++ b/src/wallet/qi-hdwallet.ts @@ -631,7 +631,7 @@ export class QiHDWallet extends AbstractHDWallet { const fewestCoinSelector = new FewestCoinSelector(unlockedUTXOs); const spendTarget: bigint = amount; - let selection = fewestCoinSelector.performSelection(spendTarget); + let selection = fewestCoinSelector.performSelection({ target: spendTarget }); // 3. Generate as many unused addresses as required to populate the spend outputs const sendAddresses = await getDestinationAddresses(selection.spendOutputs.length); @@ -702,7 +702,7 @@ export class QiHDWallet extends AbstractHDWallet { finalFee = await this.provider.estimateFeeForQi(feeEstimationTx); // Get new selection with updated fee 2x - selection = fewestCoinSelector.performSelection(spendTarget, finalFee * 3n); + selection = fewestCoinSelector.performSelection({ target: spendTarget, fee: finalFee * 3n }); // Determine if new addresses are needed for the change outputs const changeAddressesNeeded = selection.changeOutputs.length - changeAddresses.length; @@ -862,6 +862,17 @@ export class QiHDWallet extends AbstractHDWallet { return this.prepareAndSendTransaction(amount, originZone, getDestinationAddresses); } + /** + * Prepares a transaction with the specified parameters. + * + * @private + * @param {SelectedCoinsResult} selection - The selected coins result. + * @param {string[]} inputPubKeys - The public keys of the inputs. + * @param {string[]} sendAddresses - The addresses to send to. + * @param {string[]} changeAddresses - The addresses to change to. + * @param {number} chainId - The chain ID. + * @returns {Promise} A promise that resolves to the prepared transaction. + */ private async prepareTransaction( selection: SelectedCoinsResult, inputPubKeys: string[], @@ -895,6 +906,16 @@ export class QiHDWallet extends AbstractHDWallet { return tx; } + /** + * Prepares a fee estimation transaction with the specified parameters. + * + * @private + * @param {SelectedCoinsResult} selection - The selected coins result. + * @param {string[]} inputPubKeys - The public keys of the inputs. + * @param {string[]} sendAddresses - The addresses to send to. + * @param {string[]} changeAddresses - The addresses to change to. + * @returns {QiPerformActionTransaction} The prepared transaction. + */ private prepareFeeEstimationTransaction( selection: SelectedCoinsResult, inputPubKeys: string[], From 541de6be15eb272230af52ace6713f2889b3bd83 Mon Sep 17 00:00:00 2001 From: Alejo Acosta Date: Thu, 14 Nov 2024 14:17:50 -0300 Subject: [PATCH 2/2] implement new AggregateCoinSelector class --- src/_tests/unit/coinselection.unit.test.ts | 31 +-- .../unit/coinselector-aggregate.unit.test.ts | 133 +++++++++++++ src/transaction/abstract-coinselector.ts | 19 +- src/transaction/coinselector-aggregate.ts | 181 ++++++++++++++++++ src/transaction/coinselector-fewest.ts | 24 --- src/transaction/index.ts | 1 + testcases/qi-coin-aggregation.json.gz | Bin 0 -> 758 bytes 7 files changed, 350 insertions(+), 39 deletions(-) create mode 100644 src/_tests/unit/coinselector-aggregate.unit.test.ts create mode 100644 src/transaction/coinselector-aggregate.ts create mode 100644 testcases/qi-coin-aggregation.json.gz diff --git a/src/_tests/unit/coinselection.unit.test.ts b/src/_tests/unit/coinselection.unit.test.ts index 9a30800b..a6166db5 100644 --- a/src/_tests/unit/coinselection.unit.test.ts +++ b/src/_tests/unit/coinselection.unit.test.ts @@ -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); @@ -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); @@ -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); @@ -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); @@ -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/, + ); }); }); @@ -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 @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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); diff --git a/src/_tests/unit/coinselector-aggregate.unit.test.ts b/src/_tests/unit/coinselector-aggregate.unit.test.ts new file mode 100644 index 00000000..bb1b9988 --- /dev/null +++ b/src/_tests/unit/coinselector-aggregate.unit.test.ts @@ -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('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)); + } + }); + }); +}); diff --git a/src/transaction/abstract-coinselector.ts b/src/transaction/abstract-coinselector.ts index f65e568e..51d148fb 100644 --- a/src/transaction/abstract-coinselector.ts +++ b/src/transaction/abstract-coinselector.ts @@ -1,4 +1,4 @@ -import { UTXO } from './utxo.js'; +import { denominations, UTXO } from './utxo.js'; /** * Represents a target for spending. @@ -41,6 +41,7 @@ export interface CoinSelectionConfig { target?: bigint; fee?: bigint; includeLocked?: boolean; + maxDenomination?: number; // Any future parameters can be added here } @@ -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; + }); + } } diff --git a/src/transaction/coinselector-aggregate.ts b/src/transaction/coinselector-aggregate.ts new file mode 100644 index 00000000..5525ecbd --- /dev/null +++ b/src/transaction/coinselector-aggregate.ts @@ -0,0 +1,181 @@ +import { AbstractCoinSelector, CoinSelectionConfig, SelectedCoinsResult } from './abstract-coinselector.js'; +import { UTXO, denominations } from './utxo.js'; + +/** + * A coin selector that aggregates multiple UTXOs into larger denominations. It attempts to combine smaller denomination + * UTXOs into the largest possible denominations. + */ +export class AggregateCoinSelector extends AbstractCoinSelector { + /** + * Performs coin selection by aggregating UTXOs into larger denominations. This implementation combines smaller + * denomination UTXOs into the largest possible denominations up to maxDenomination, while ensuring enough value + * remains to cover the transaction fee. + * + * @param {CoinSelectionConfig} config - The configuration object containing: + * @param {boolean} [config.includeLocked=false] - Whether to include locked UTXOs in the selection. Default is + * `false` + * @param {bigint} [config.fee=0n] - The fee amount to account for. Default is `0n` + * @param {number} [config.maxDenomination=6] - The maximum denomination to aggregate up to (default 6 = 1 Qi). + * Default is `6` + * @returns {SelectedCoinsResult} The selected UTXOs and aggregated outputs + * @throws {Error} If no eligible UTXOs are available for aggregation + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + performSelection(config: CoinSelectionConfig): SelectedCoinsResult { + const { + includeLocked = false, + fee = BigInt(0), + maxDenomination = 6, // Default to denomination[6] (1 Qi) + } = config; + + this.validateUTXOs(); + + // Filter UTXOs based on lock status if needed + const eligibleUTXOs = includeLocked + ? this.availableUTXOs + : this.availableUTXOs.filter((utxo) => utxo.lock === null || utxo.lock === 0); + + // totalInputValue is the sum of the denominations of the eligible UTXOsregardless of maxDenomination + this.totalInputValue = eligibleUTXOs.reduce( + (sum, utxo) => sum + BigInt(denominations[utxo.denomination!]), + BigInt(0), + ); + + // get the UTXOs that are below maxDenomination + const smallDenominationsUTXOs = eligibleUTXOs.filter((utxo) => utxo.denomination! < maxDenomination); + + if (smallDenominationsUTXOs.length === 0) { + throw new Error('No eligible UTXOs available for aggregation'); + } + + // get the UTXOs that are above or equal to maxDenomination + const bigDenominationUTXOs = eligibleUTXOs.filter((utxo) => utxo.denomination! >= maxDenomination); + + // calculate the sum of the denominations of the big denomination UTXOs + const totalInputValueAboveMaxDenomination = bigDenominationUTXOs.reduce( + (sum, utxo) => sum + BigInt(denominations[utxo.denomination!]), + BigInt(0), + ); + + // calculate the sum of the denominations of the small denomination UTXOs + const totalInputValueBelowMaxDenomination = this.totalInputValue - totalInputValueAboveMaxDenomination; + + // The valueToAggregate value is calculated as: + // 1. If the total value of bigDenominationsUTXOs is greater than the fee, then the fee is covered by bigDenominationsUTXOs, and + // the valueToAggregate is the value of small denomination UTXOs, i.e.: + // valueToAggregate = totalInputValueBelowMaxDenomination + // 2. Otherwise, the valueToAggregate equals the value of small denomination UTXOs minus + // the difference between the fee and the value of the big denomination UTXOs, i.e.: + // valueToAggregate = totalInputValueBelowMaxDenomination - (fee - totalInputValueAboveMaxDenomination) + const valueToAggregate = + totalInputValueAboveMaxDenomination >= fee + ? totalInputValueBelowMaxDenomination + : totalInputValueBelowMaxDenomination - (fee - totalInputValueAboveMaxDenomination); + + if (valueToAggregate <= BigInt(0)) { + throw new Error('Insufficient funds to cover fee'); + } + + this.spendOutputs = this.createOptimalDenominations(valueToAggregate); + + // get the inputs to cover the valueToAggregate + const inputsToAggregate = this.getInputsToAggregate(smallDenominationsUTXOs, valueToAggregate); + + // get UTXOs inputs not included in inputsToAggregate to cover the fee. + const feeInputs = this.getInputsForFee(inputsToAggregate, eligibleUTXOs, fee); + + // calculate the value of the feeInputs + const feeInputsValue = feeInputs.reduce( + (sum, utxo) => sum + BigInt(denominations[utxo.denomination!]), + BigInt(0), + ); + + // if the feeInputs value is higher than the fee, add the difference to the outputs to compensate the fee + if (feeInputsValue > fee) { + const difference = feeInputsValue - fee; + const additionalOutputs = this.createOptimalDenominations(difference); + this.spendOutputs.push(...additionalOutputs); + } + + this.selectedUTXOs = [...feeInputs, ...inputsToAggregate]; + + // if the number of outputs is greater than or equal to the number of inputs to aggregate, throw an error + if (this.spendOutputs.length >= inputsToAggregate.length) { + throw new Error('Aggregation would not reduce number of UTXOs'); + } + + this.changeOutputs = []; + + return { + inputs: this.selectedUTXOs, + spendOutputs: this.spendOutputs, + changeOutputs: this.changeOutputs, + }; + } + + /** + * Helper method to calculate the optimal denomination distribution for a given value. + * + * @param {bigint} value - The value to optimize denominations for + * @returns {UTXO[]} Array of UTXOs with optimal denomination distribution + */ + private createOptimalDenominations(value: bigint): UTXO[] { + const outputs: UTXO[] = []; + let remaining = value; + + for (let i = denominations.length - 1; i >= 0 && remaining > BigInt(0); i--) { + const denomination = denominations[i]; + while (remaining >= denomination) { + const output = new UTXO(); + output.denomination = i; + outputs.push(output); + remaining -= denomination; + } + } + + if (remaining > BigInt(0)) { + throw new Error('Unable to create optimal denominations'); + } + + return outputs; + } + + // gets the input UTXOs to cover the fee + private getInputsForFee(inputsToAggregate: UTXO[], eligibleUTXOs: UTXO[], fee: bigint): UTXO[] { + // get the input UTXOs that are not included in inputsToAggregate + const eligiblefeeInputs = eligibleUTXOs.filter( + (utxo) => !inputsToAggregate.some((input) => input.txhash === utxo.txhash && input.index === utxo.index), + ); + + const sortedUTXOs = this.sortUTXOsByDenomination(eligiblefeeInputs, 'asc'); + + // loop through sortedUTXOs and sum the denominations until the sum is greater than or equal to the fee + let sum = BigInt(0); + const feeInputs: UTXO[] = []; + for (const utxo of sortedUTXOs) { + sum += BigInt(denominations[utxo.denomination!]); + feeInputs.push(utxo); + if (sum >= fee) { + return feeInputs; + } + } + + throw new Error('Unable to find inputs to cover fee'); + } + + // gets the input UTXOs whose value equals the amount to aggregate, i.e. valueToAggregate + private getInputsToAggregate(smallDenominationsUTXOs: UTXO[], valueToAggregate: bigint): UTXO[] { + const sortedUTXOs = this.sortUTXOsByDenomination(smallDenominationsUTXOs, 'asc'); + const inputsToAggregate: UTXO[] = []; + for (const utxo of sortedUTXOs) { + inputsToAggregate.push(utxo); + if ( + inputsToAggregate.reduce((sum, utxo) => sum + BigInt(denominations[utxo.denomination!]), BigInt(0)) === + valueToAggregate + ) { + return inputsToAggregate; + } + } + throw new Error('Unable to find inputs to aggregate'); + } +} diff --git a/src/transaction/coinselector-fewest.ts b/src/transaction/coinselector-fewest.ts index 21c12747..6e27ff82 100644 --- a/src/transaction/coinselector-fewest.ts +++ b/src/transaction/coinselector-fewest.ts @@ -331,28 +331,4 @@ export class FewestCoinSelector extends AbstractCoinSelector { this.changeOutputs = this.createChangeOutputs(changeAmount); } - - /** - * Sorts UTXOs by their denomination. - * - * @param {UTXO[]} utxos - The UTXOs to sort. - * @param {'asc' | 'desc'} direction - The direction to sort ('asc' for ascending, 'desc' for descending). - * @returns {UTXO[]} The sorted UTXOs. - */ - private sortUTXOsByDenomination(utxos: UTXO[], direction: 'asc' | 'desc'): UTXO[] { - if (direction === 'asc') { - return [...utxos].sort((a, b) => { - const diff = - BigInt(a.denomination !== null ? denominations[a.denomination] : 0) - - BigInt(b.denomination !== null ? denominations[b.denomination] : 0); - return diff > BigInt(0) ? 1 : diff < BigInt(0) ? -1 : 0; - }); - } - return [...utxos].sort((a, b) => { - const diff = - BigInt(b.denomination !== null ? denominations[b.denomination] : 0) - - BigInt(a.denomination !== null ? denominations[a.denomination] : 0); - return diff > BigInt(0) ? 1 : diff < BigInt(0) ? -1 : 0; - }); - } } diff --git a/src/transaction/index.ts b/src/transaction/index.ts index 552018d3..8e643aa7 100644 --- a/src/transaction/index.ts +++ b/src/transaction/index.ts @@ -29,6 +29,7 @@ export { accessListify } from './accesslist.js'; export { AbstractTransaction } from './abstract-transaction.js'; export { FewestCoinSelector } from './coinselector-fewest.js'; +export { AggregateCoinSelector } from './coinselector-aggregate.js'; export type { SpendTarget } from './abstract-coinselector.js'; export type { TransactionLike } from './abstract-transaction.js'; diff --git a/testcases/qi-coin-aggregation.json.gz b/testcases/qi-coin-aggregation.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..dfd2637328ba33a3bc7f406e0722258483e82f7a GIT binary patch literal 758 zcmV>bZKvHE^2dcZUF6CO>f#T7(TDR z;#E$Y#37+TyWJ*jhf3Re*d|S?dNA>u4=u4P$0?bn{rANwpQZ(Zfh5!%DpC7+19_hh z6Z`H0%GclOuX;I*r09GAX8>7Bk_0F+p2m#Gn6nHd2NH$?Ln09fqu}#L6o3WsB?O30 zZ&4{`54p_BT6gpRRo4P%xn?;&?13YTh-8tvuv>UePoXIm<$D+Y^=jh#hWJZE{6NFc z;rB~-N8PM)w&%L;>P_>LoZ=Vm|3+F)2uPCQ(s)XWPpelSEsbLFfWAa}o68;V{pf{{ zH>-4Go4BEgS;X^%e$Ra$t8Ph=V>LRx+%vlR8W%`Ur!CL0EjqpvZE1&DU8S0LaMWR?k9PQa zb|Ks#PY$E>O6J*8s$0b$)3fX1M~3(-L;STN{=FgIb88m3(hKMeY_sQH?c}*9n_|t~ zTEeTfggxT5zZS0A6X^%PUQ^^~^8^fSML zDaT9#c2PN` zg#7_N6{3xm`b5>22Iser(xxfaoEzh;ZcaKmXI=O#1Q)9A$LR?TRsYm47 o7LpiqY@J|`@JTs3+hekHJ9%s$`&mxobiKI$4_wI~nFlif01NSSSpWb4 literal 0 HcmV?d00001