From 06e9688d11553293c0dc2020bd343e1bb93c140d Mon Sep 17 00:00:00 2001 From: rileystephens28 Date: Wed, 27 Nov 2024 17:30:21 -0600 Subject: [PATCH] Create new conversion tx coin selector class and add few minor optimizations to syncing logic --- src/transaction/coinselector-conversion.ts | 31 ++++++++ src/transaction/coinselector-fewest.ts | 16 ++-- src/wallet/qi-hdwallet.ts | 91 ++++++++++------------ 3 files changed, 82 insertions(+), 56 deletions(-) create mode 100644 src/transaction/coinselector-conversion.ts diff --git a/src/transaction/coinselector-conversion.ts b/src/transaction/coinselector-conversion.ts new file mode 100644 index 00000000..442910bd --- /dev/null +++ b/src/transaction/coinselector-conversion.ts @@ -0,0 +1,31 @@ +import { FewestCoinSelector } from './coinselector-fewest.js'; +import { UTXO, denominate, denominations } from './utxo.js'; + +/** + * The ConversionSelector class provides a coin selection algorithm that selects the fewest UTXOs required to meet the + * target amount. This algorithm is useful for minimizing the size of the transaction and the fees associated with it. + * + * This class is a modified version of {@link FewestCoinSelector | **FewestCoinSelector** } and implements the + * {@link FewestCoinSelector.createSpendOutputs | **createSpendOutputs** } method to provide the actual coin selection + * logic. + * + * @category Transaction + */ +export class ConversionCoinSelector extends FewestCoinSelector { + /** + * Creates spend outputs based on the target amount and input denominations. + * + * @param {bigint} amount - The target amount to spend. + * @returns {UTXO[]} The spend outputs. + */ + protected override createSpendOutputs(amount: bigint): UTXO[] { + // Spend outpoints are not limited to max input denomination + const spendDenominations = denominate(amount); + + return spendDenominations.map((denominationValue) => { + const utxo = new UTXO(); + utxo.denomination = denominations.indexOf(denominationValue); + return utxo; + }); + } +} diff --git a/src/transaction/coinselector-fewest.ts b/src/transaction/coinselector-fewest.ts index 6e27ff82..0c971917 100644 --- a/src/transaction/coinselector-fewest.ts +++ b/src/transaction/coinselector-fewest.ts @@ -102,7 +102,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { * @param {bigint} totalRequired - The total amount required (target + fee). * @returns {UTXO[]} The minimal set of UTXOs. */ - private findMinimalUTXOSet(sortedUTXOs: UTXO[], totalRequired: bigint): UTXO[] { + protected findMinimalUTXOSet(sortedUTXOs: UTXO[], totalRequired: bigint): UTXO[] { // First, try to find the smallest single UTXO that covers the total required amount const singleUTXO = sortedUTXOs.find((utxo) => BigInt(denominations[utxo.denomination!]) >= totalRequired); if (singleUTXO) { @@ -136,7 +136,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { * @param {UTXO[]} inputs - The selected inputs. * @returns {UTXO[]} The spend outputs. */ - private createSpendOutputs(amount: bigint): UTXO[] { + protected createSpendOutputs(amount: bigint): UTXO[] { const maxInputDenomination = this.getMaxInputDenomination(); // Denominate the amount using available denominations up to the max input denomination @@ -156,7 +156,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { * @param {UTXO[]} inputs - The selected inputs. * @returns {UTXO[]} The change outputs. */ - private createChangeOutputs(change: bigint): UTXO[] { + protected createChangeOutputs(change: bigint): UTXO[] { if (change <= BigInt(0)) { return []; } @@ -178,7 +178,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { * * @returns {bigint} The total output value. */ - private calculateTotalOutputValue(): bigint { + protected calculateTotalOutputValue(): bigint { const spendValue = this.spendOutputs.reduce( (sum, output) => sum + BigInt(denominations[output.denomination!]), BigInt(0), @@ -197,7 +197,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { * * @returns {bigint} The maximum input denomination value. */ - private getMaxInputDenomination(): bigint { + protected getMaxInputDenomination(): bigint { const inputs = [...this.selectedUTXOs]; return this.getMaxDenomination(inputs); } @@ -207,7 +207,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { * * @returns {bigint} The maximum output denomination value. */ - private getMaxOutputDenomination(): bigint { + protected getMaxOutputDenomination(): bigint { const outputs = [...this.spendOutputs, ...this.changeOutputs]; return this.getMaxDenomination(outputs); } @@ -218,7 +218,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { * @param {UTXO[]} utxos - The list of UTXOs. * @returns {bigint} The maximum denomination value. */ - private getMaxDenomination(utxos: UTXO[]): bigint { + protected getMaxDenomination(utxos: UTXO[]): bigint { return utxos.reduce((max, utxo) => { const denomValue = BigInt(denominations[utxo.denomination!]); return denomValue > max ? denomValue : max; @@ -323,7 +323,7 @@ export class FewestCoinSelector extends AbstractCoinSelector { * * @param {bigint} changeAmount - The amount to adjust change outputs by. */ - private adjustChangeOutputs(changeAmount: bigint): void { + protected adjustChangeOutputs(changeAmount: bigint): void { if (changeAmount <= BigInt(0)) { this.changeOutputs = []; return; diff --git a/src/wallet/qi-hdwallet.ts b/src/wallet/qi-hdwallet.ts index 9093abc2..1a3d0011 100644 --- a/src/wallet/qi-hdwallet.ts +++ b/src/wallet/qi-hdwallet.ts @@ -31,6 +31,7 @@ import { type BIP32API, HDNodeBIP32Adapter } from './bip32/types.js'; import ecc from '@bitcoinerlab/secp256k1'; import { SelectedCoinsResult } from '../transaction/abstract-coinselector.js'; import { QiPerformActionTransaction } from '../providers/abstract-provider.js'; +import { ConversionCoinSelector } from '../transaction/coinselector-conversion.js'; /** * @property {Outpoint} outpoint - The outpoint object. @@ -576,22 +577,10 @@ export class QiHDWallet extends AbstractHDWallet { * Converts outpoints for a specific zone to UTXO format. * * @param {Zone} zone - The zone to filter outpoints for. - * @param {number} [minDenominationToUse] - The minimum denomination to allow for the UTXOs. * @returns {UTXO[]} An array of UTXO objects. */ - private outpointsToUTXOs(zone: Zone, minDenominationToUse?: number): UTXO[] { - this.validateZone(zone); - let zoneOutpoints = this.getOutpoints(zone); - - // Filter outpoints by minimum denomination if specified - // This will likely only be used for converting to Quai - // as the min denomination for converting is 10 (100 Qi) - if (minDenominationToUse !== undefined) { - zoneOutpoints = zoneOutpoints.filter( - (outpointInfo) => outpointInfo.outpoint.denomination >= minDenominationToUse, - ); - } - return zoneOutpoints.map((outpointInfo) => { + private outpointsToUTXOs(zone: Zone): UTXO[] { + return this.getOutpoints(zone).map((outpointInfo) => { const utxo = new UTXO(); utxo.txhash = outpointInfo.outpoint.txhash; utxo.index = outpointInfo.outpoint.index; @@ -628,7 +617,12 @@ export class QiHDWallet extends AbstractHDWallet { return Array(count).fill(destinationAddress); }; - return this.prepareAndSendTransaction(amount, zone, getDestinationAddresses, 10); + return this.prepareAndSendTransaction( + amount, + zone, + getDestinationAddresses, + (utxos) => new ConversionCoinSelector(utxos), + ); } /** @@ -668,7 +662,12 @@ export class QiHDWallet extends AbstractHDWallet { return addresses; }; - return this.prepareAndSendTransaction(amount, originZone, getDestinationAddresses); + return this.prepareAndSendTransaction( + amount, + originZone, + getDestinationAddresses, + (utxos) => new FewestCoinSelector(utxos), + ); } /** @@ -730,7 +729,6 @@ export class QiHDWallet extends AbstractHDWallet { * @param {Zone} originZone - The zone where the transaction originates. * @param {Function} getDestinationAddresses - A function that returns a promise resolving to an array of * destination addresses. - * @param {number} [minDenominationToUse] - Optional minimum denomination of Qi to use for the transaction. * @returns {Promise} A promise that resolves to the transaction response. * @throws {Error} If provider is not set, insufficient balance, no available UTXOs, or insufficient spendable * balance. @@ -739,7 +737,7 @@ export class QiHDWallet extends AbstractHDWallet { amount: bigint, originZone: Zone, getDestinationAddresses: (count: number) => Promise, - minDenominationToUse?: number, + coinSelectorCreator: (utxos: UTXO[]) => FewestCoinSelector | ConversionCoinSelector, ): Promise { if (!this.provider) { throw new Error('Provider is not set'); @@ -755,15 +753,10 @@ export class QiHDWallet extends AbstractHDWallet { } // 2. Select the UXTOs from the specified zone to use as inputs, and generate the spend and change outputs - const zoneUTXOs = this.outpointsToUTXOs(originZone, minDenominationToUse); + const zoneUTXOs = this.outpointsToUTXOs(originZone); if (zoneUTXOs.length === 0) { - if (minDenominationToUse === 10) { - throw new Error('Qi denominations too small to convert.'); - } else { - throw new Error('No Qi available in zone.'); - } + throw new Error('No Qi available in zone.'); } - const unlockedUTXOs = zoneUTXOs.filter( (utxo) => utxo.lock === 0 || utxo.lock! < currentBlock?.woHeader.number!, ); @@ -771,10 +764,10 @@ export class QiHDWallet extends AbstractHDWallet { throw new Error('Insufficient spendable balance in zone.'); } - const fewestCoinSelector = new FewestCoinSelector(unlockedUTXOs); + const coinSelector = coinSelectorCreator(unlockedUTXOs); const spendTarget: bigint = amount; - let selection = fewestCoinSelector.performSelection({ target: spendTarget }); + let selection = coinSelector.performSelection({ target: spendTarget }); // 3. Generate as many unused addresses as required to populate the spend outputs const sendAddresses = await getDestinationAddresses(selection.spendOutputs.length); @@ -844,8 +837,7 @@ export class QiHDWallet extends AbstractHDWallet { const estimatedFee = await this.provider.estimateFeeForQi(feeEstimationTx); // Get new selection with updated fee 2x - selection = fewestCoinSelector.performSelection({ target: spendTarget, fee: estimatedFee * 3n }); - + selection = coinSelector.performSelection({ target: spendTarget, fee: estimatedFee * 3n }); // Determine if new addresses are needed for the change outputs const changeAddressesNeeded = selection.changeOutputs.length - changeAddresses.length; if (changeAddressesNeeded > 0) { @@ -904,7 +896,6 @@ export class QiHDWallet extends AbstractHDWallet { // Sign the transaction const signedTx = await this.signTransaction(tx); - // Broadcast the transaction to the network using the provider return this.provider.broadcastTransaction(originZone, signedTx); } @@ -1223,29 +1214,30 @@ export class QiHDWallet extends AbstractHDWallet { const derivationPaths: DerivationPath[] = ['BIP44:external', 'BIP44:change', ...this.openChannels]; const currentBlock = (await this.provider!.getBlock(toShard(zone), 'latest')) as Block; - - await Promise.all([ - ...derivationPaths.map((path) => - this._scanDerivationPath( - path, - zone, - account, - currentBlock, - false, - onOutpointsCreated, - onOutpointsDeleted, - ), - ), - this._scanDerivationPath( - QiHDWallet.PRIVATE_KEYS_PATH, + for (const path of derivationPaths) { + await this._scanDerivationPath( + path, zone, account, currentBlock, - true, + false, onOutpointsCreated, onOutpointsDeleted, - ), - ]); + ); + + // Yield control back to the event loop + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + await this._scanDerivationPath( + QiHDWallet.PRIVATE_KEYS_PATH, + zone, + account, + currentBlock, + true, + onOutpointsCreated, + onOutpointsDeleted, + ); } /** @@ -1459,6 +1451,9 @@ export class QiHDWallet extends AbstractHDWallet { break; } } + + // Yield control back to the event loop after each iteration + await new Promise((resolve) => setTimeout(resolve, 0)); } }