From 7637e81408e2cf23c8ab2870097c80efcdf7d294 Mon Sep 17 00:00:00 2001 From: julian Date: Tue, 4 Jun 2024 15:07:31 -0600 Subject: [PATCH] clean up coin selection somewhat --- .../electrumx_interface.dart | 498 +++++++++--------- 1 file changed, 235 insertions(+), 263 deletions(-) diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index eda467a6a..03eb150ac 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:coinlib_flutter/coinlib_flutter.dart' as coinlib; import 'package:isar/isar.dart'; + import '../../../electrumx_rpc/cached_electrumx_client.dart'; import '../../../electrumx_rpc/client_manager.dart'; import '../../../electrumx_rpc/electrumx_client.dart'; @@ -64,7 +65,10 @@ mixin ElectrumXInterface } Future> - _helperRecipientsConvert(List addrs, List satValues) async { + _helperRecipientsConvert( + List addrs, + List satValues, + ) async { final List<({String address, Amount amount, bool isChange})> results = []; for (int i = 0; i < addrs.length; i++) { @@ -72,7 +76,7 @@ mixin ElectrumXInterface ( address: addrs[i], amount: Amount( - rawValue: BigInt.from(satValues[i]), + rawValue: satValues[i], fractionDigits: cryptoCurrency.fractionDigits, ), isChange: (await mainDB.isar.addresses @@ -105,44 +109,47 @@ mixin ElectrumXInterface // TODO: multiple recipients one day assert(txData.recipients!.length == 1); + if (coinControl && utxos == null) { + throw Exception("Coin control used where utxos is null!"); + } + final recipientAddress = txData.recipients!.first.address; - final satoshiAmountToSend = txData.amount!.raw.toInt(); + final satoshiAmountToSend = txData.amount!.raw; final int? satsPerVByte = txData.satsPerVByte; final selectedTxFeeRate = txData.feeRateAmount!; final List availableOutputs = utxos ?? await mainDB.getUTXOs(walletId).findAll(); final currentChainHeight = await chainHeight; - final List spendableOutputs = []; - int spendableSatoshiValue = 0; - // Build list of spendable outputs and totaling their satoshi amount - for (final utxo in availableOutputs) { - if (utxo.isBlocked == false && - utxo.isConfirmed(currentChainHeight, cryptoCurrency.minConfirms) && - utxo.used != true) { - spendableOutputs.add(utxo); - spendableSatoshiValue += utxo.value; - } + final spendableOutputs = availableOutputs + .where( + (e) => + !e.isBlocked && + (e.used != true) && + e.isConfirmed(currentChainHeight, cryptoCurrency.minConfirms), + ) + .toList(); + final spendableSatoshiValue = + spendableOutputs.fold(BigInt.zero, (p, e) => p + BigInt.from(e.value)); + + if (spendableSatoshiValue < satoshiAmountToSend) { + throw Exception("Insufficient balance"); + } else if (spendableSatoshiValue == satoshiAmountToSend && !isSendAll) { + throw Exception("Insufficient balance to pay transaction fee"); } if (coinControl) { if (spendableOutputs.length < availableOutputs.length) { throw ArgumentError("Attempted to use an unavailable utxo"); } - } - - // don't care about sorting if using all utxos - if (!coinControl) { + // don't care about sorting if using all utxos + } else { // sort spendable by age (oldest first) spendableOutputs.sort( (a, b) => (b.blockTime ?? currentChainHeight) .compareTo((a.blockTime ?? currentChainHeight)), ); - // Null check operator changed to null assignment in order to resolve a - // `Null check operator used on a null value` error. currentChainHeight - // used in order to sort these unconfirmed outputs as the youngest, but we - // could just as well use currentChainHeight + 1. } Logging.instance.log( @@ -161,26 +168,10 @@ mixin ElectrumXInterface ); Logging.instance .log("satoshiAmountToSend: $satoshiAmountToSend", level: LogLevel.Info); - // If the amount the user is trying to send is smaller than the amount that they have spendable, - // then return 1, which indicates that they have an insufficient balance. - if (spendableSatoshiValue < satoshiAmountToSend) { - // return 1; - throw Exception("Insufficient balance"); - // If the amount the user wants to send is exactly equal to the amount they can spend, then return - // 2, which indicates that they are not leaving enough over to pay the transaction fee - } else if (spendableSatoshiValue == satoshiAmountToSend && !isSendAll) { - throw Exception("Insufficient balance to pay transaction fee"); - // return 2; - } - // If neither of these statements pass, we assume that the user has a spendable balance greater - // than the amount they're attempting to send. Note that this value still does not account for - // the added transaction fee, which may require an extra input and will need to be checked for - // later on. - // Possible situation right here - int satoshisBeingUsed = 0; + BigInt satoshisBeingUsed = BigInt.zero; int inputsBeingConsumed = 0; - List utxoObjectsToUse = []; + final List utxoObjectsToUse = []; if (!coinControl) { for (var i = 0; @@ -188,7 +179,7 @@ mixin ElectrumXInterface i < spendableOutputs.length; i++) { utxoObjectsToUse.add(spendableOutputs[i]); - satoshisBeingUsed += spendableOutputs[i].value; + satoshisBeingUsed += BigInt.from(spendableOutputs[i].value); inputsBeingConsumed += 1; } for (int i = 0; @@ -196,12 +187,13 @@ mixin ElectrumXInterface inputsBeingConsumed < spendableOutputs.length; i++) { utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); - satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; + satoshisBeingUsed += + BigInt.from(spendableOutputs[inputsBeingConsumed].value); inputsBeingConsumed += 1; } } else { satoshisBeingUsed = spendableSatoshiValue; - utxoObjectsToUse = spendableOutputs; + utxoObjectsToUse.addAll(spendableOutputs); inputsBeingConsumed = spendableOutputs.length; } @@ -214,72 +206,20 @@ mixin ElectrumXInterface // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray final List recipientsArray = [recipientAddress]; - final List recipientsAmtArray = [satoshiAmountToSend]; + final List recipientsAmtArray = [satoshiAmountToSend]; // gather required signing data final utxoSigningData = await fetchBuildTxData(utxoObjectsToUse); if (isSendAll) { - Logging.instance - .log("Attempting to send all $cryptoCurrency", level: LogLevel.Info); - if (txData.recipients!.length != 1) { - throw Exception( - "Send all to more than one recipient not yet supported", - ); - } - - final int vSizeForOneOutput = (await buildTransaction( + return await _sendAllBuilder( + txData: txData, + recipientAddress: recipientAddress, + satoshiAmountToSend: satoshiAmountToSend, + satoshisBeingUsed: satoshisBeingUsed, utxoSigningData: utxoSigningData, - txData: txData.copyWith( - recipients: await _helperRecipientsConvert( - [recipientAddress], - [satoshisBeingUsed - 1], - ), - ), - )) - .vSize!; - int feeForOneOutput = satsPerVByte != null - ? (satsPerVByte * vSizeForOneOutput) - : estimateTxFee( - vSize: vSizeForOneOutput, - feeRatePerKB: selectedTxFeeRate, - ); - - if (satsPerVByte == null) { - final int roughEstimate = roughFeeEstimate( - spendableOutputs.length, - 1, - selectedTxFeeRate, - ).raw.toInt(); - if (feeForOneOutput < roughEstimate) { - feeForOneOutput = roughEstimate; - } - } - - final int amount = satoshiAmountToSend - feeForOneOutput; - - if (amount < 0) { - throw Exception( - "Estimated fee ($feeForOneOutput sats) is greater than balance!", - ); - } - - final data = await buildTransaction( - txData: txData.copyWith( - recipients: await _helperRecipientsConvert( - [recipientAddress], - [amount], - ), - ), - utxoSigningData: utxoSigningData, - ); - - return data.copyWith( - fee: Amount( - rawValue: BigInt.from(feeForOneOutput), - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: utxoSigningData.map((e) => e.utxo).toList(), + satsPerVByte: satsPerVByte, + feeRatePerKB: selectedTxFeeRate, ); } @@ -290,7 +230,7 @@ mixin ElectrumXInterface txData: txData.copyWith( recipients: await _helperRecipientsConvert( [recipientAddress], - [satoshisBeingUsed - 1], + [satoshisBeingUsed - BigInt.one], ), ), )) @@ -301,6 +241,9 @@ mixin ElectrumXInterface } final int vSizeForTwoOutPuts; + + BigInt maxBI(BigInt a, BigInt b) => a > b ? a : b; + try { vSizeForTwoOutPuts = (await buildTransaction( utxoSigningData: utxoSigningData, @@ -309,7 +252,10 @@ mixin ElectrumXInterface [recipientAddress, (await getCurrentChangeAddress())!.value], [ satoshiAmountToSend, - max(0, satoshisBeingUsed - satoshiAmountToSend - 1), + maxBI( + BigInt.zero, + satoshisBeingUsed - (satoshiAmountToSend + BigInt.one), + ), ], ), ), @@ -321,53 +267,112 @@ mixin ElectrumXInterface } // Assume 1 output, only for recipient and no change - final feeForOneOutput = satsPerVByte != null - ? (satsPerVByte * vSizeForOneOutput) - : estimateTxFee( - vSize: vSizeForOneOutput, - feeRatePerKB: selectedTxFeeRate, - ); + final feeForOneOutput = BigInt.from( + satsPerVByte != null + ? (satsPerVByte * vSizeForOneOutput) + : estimateTxFee( + vSize: vSizeForOneOutput, + feeRatePerKB: selectedTxFeeRate, + ), + ); // Assume 2 outputs, one for recipient and one for change - final feeForTwoOutputs = satsPerVByte != null - ? (satsPerVByte * vSizeForTwoOutPuts) - : estimateTxFee( - vSize: vSizeForTwoOutPuts, - feeRatePerKB: selectedTxFeeRate, - ); + final feeForTwoOutputs = BigInt.from( + satsPerVByte != null + ? (satsPerVByte * vSizeForTwoOutPuts) + : estimateTxFee( + vSize: vSizeForTwoOutPuts, + feeRatePerKB: selectedTxFeeRate, + ), + ); - Logging.instance - .log("feeForTwoOutputs: $feeForTwoOutputs", level: LogLevel.Info); - Logging.instance - .log("feeForOneOutput: $feeForOneOutput", level: LogLevel.Info); + Logging.instance.log( + "feeForTwoOutputs: $feeForTwoOutputs", + level: LogLevel.Info, + ); + Logging.instance.log( + "feeForOneOutput: $feeForOneOutput", + level: LogLevel.Info, + ); - if (satoshisBeingUsed - satoshiAmountToSend > feeForOneOutput) { - if (satoshisBeingUsed - satoshiAmountToSend > - feeForOneOutput + cryptoCurrency.dustLimit.raw.toInt()) { - // Here, we know that theoretically, we may be able to include another output(change) but we first need to - // factor in the value of this output in satoshis. - final int changeOutputSize = - satoshisBeingUsed - satoshiAmountToSend - feeForTwoOutputs; - // We check to see if the user can pay for the new transaction with 2 outputs instead of one. If they can and - // the second output's size > cryptoCurrency.dustLimit satoshis, we perform the mechanics required to properly generate and use a new - // change address. - if (changeOutputSize > cryptoCurrency.dustLimit.raw.toInt() && - satoshisBeingUsed - satoshiAmountToSend - changeOutputSize == - feeForTwoOutputs) { + final difference = satoshisBeingUsed - satoshiAmountToSend; + + Future singleOutputTxn() async { + Logging.instance.log( + 'Input size: $satoshisBeingUsed', + level: LogLevel.Info, + ); + Logging.instance.log( + 'Recipient output size: $satoshiAmountToSend', + level: LogLevel.Info, + ); + Logging.instance.log( + 'Fee being paid: $difference sats', + level: LogLevel.Info, + ); + Logging.instance.log( + 'Estimated fee: $feeForOneOutput', + level: LogLevel.Info, + ); + final txnData = await buildTransaction( + utxoSigningData: utxoSigningData, + txData: txData.copyWith( + recipients: await _helperRecipientsConvert( + recipientsArray, + recipientsAmtArray, + ), + ), + ); + return txnData.copyWith( + fee: Amount( + rawValue: feeForOneOutput, + fractionDigits: cryptoCurrency.fractionDigits, + ), + usedUTXOs: utxoSigningData.map((e) => e.utxo).toList(), + ); + } + + // no change output required + if (difference == feeForOneOutput) { + Logging.instance.log('1 output in tx', level: LogLevel.Info); + return await singleOutputTxn(); + } else if (difference < feeForOneOutput) { + Logging.instance.log( + 'Cannot pay tx fee - checking for more outputs and trying again', + level: LogLevel.Warning, + ); + // try adding more outputs + if (spendableOutputs.length > inputsBeingConsumed) { + return coinSelection( + txData: txData, + isSendAll: isSendAll, + additionalOutputs: additionalOutputs + 1, + utxos: utxos, + coinControl: coinControl, + ); + } + throw Exception("Insufficient balance to pay transaction fee"); + } else { + if (difference > (feeForOneOutput + cryptoCurrency.dustLimit.raw)) { + final changeOutputSize = difference - feeForTwoOutputs; + // check if possible to add the change output + if (changeOutputSize > cryptoCurrency.dustLimit.raw && + difference - changeOutputSize == feeForTwoOutputs) { // generate new change address if current change address has been used await checkChangeAddressForTransactions(); final String newChangeAddress = (await getCurrentChangeAddress())!.value; - int feeBeingPaid = - satoshisBeingUsed - satoshiAmountToSend - changeOutputSize; + BigInt feeBeingPaid = difference - changeOutputSize; + // add change output recipientsArray.add(newChangeAddress); recipientsAmtArray.add(changeOutputSize); - // At this point, we have the outputs we're going to use, the amounts to send along with which addresses - // we intend to send these amounts to. We have enough to send instructions to build the transaction. + Logging.instance.log('2 outputs in tx', level: LogLevel.Info); - Logging.instance - .log('Input size: $satoshisBeingUsed', level: LogLevel.Info); + Logging.instance.log( + 'Input size: $satoshisBeingUsed', + level: LogLevel.Info, + ); Logging.instance.log( 'Recipient output size: $satoshiAmountToSend', level: LogLevel.Info, @@ -380,10 +385,12 @@ mixin ElectrumXInterface 'Difference (fee being paid): $feeBeingPaid sats', level: LogLevel.Info, ); - Logging.instance - .log('Estimated fee: $feeForTwoOutputs', level: LogLevel.Info); + Logging.instance.log( + 'Estimated fee: $feeForTwoOutputs', + level: LogLevel.Info, + ); - var txn = await buildTransaction( + TxData txnData = await buildTransaction( utxoSigningData: utxoSigningData, txData: txData.copyWith( recipients: await _helperRecipientsConvert( @@ -394,13 +401,12 @@ mixin ElectrumXInterface ); // make sure minimum fee is accurate if that is being used - if (txn.vSize! - feeBeingPaid == 1) { - final int changeOutputSize = - satoshisBeingUsed - satoshiAmountToSend - txn.vSize!; - feeBeingPaid = - satoshisBeingUsed - satoshiAmountToSend - changeOutputSize; + if (BigInt.from(txnData.vSize!) - feeBeingPaid == BigInt.one) { + final changeOutputSize = difference - BigInt.from(txnData.vSize!); + feeBeingPaid = difference - changeOutputSize; recipientsAmtArray.removeLast(); recipientsAmtArray.add(changeOutputSize); + Logging.instance.log( 'Adjusted Input size: $satoshisBeingUsed', level: LogLevel.Info, @@ -421,7 +427,8 @@ mixin ElectrumXInterface 'Adjusted Estimated fee: $feeForTwoOutputs', level: LogLevel.Info, ); - txn = await buildTransaction( + + txnData = await buildTransaction( utxoSigningData: utxoSigningData, txData: txData.copyWith( recipients: await _helperRecipientsConvert( @@ -432,9 +439,9 @@ mixin ElectrumXInterface ); } - return txn.copyWith( + return txnData.copyWith( fee: Amount( - rawValue: BigInt.from(feeBeingPaid), + rawValue: feeBeingPaid, fractionDigits: cryptoCurrency.fractionDigits, ), usedUTXOs: utxoSigningData.map((e) => e.utxo).toList(), @@ -442,126 +449,91 @@ mixin ElectrumXInterface } else { // Something went wrong here. It either overshot or undershot the estimated fee amount or the changeOutputSize // is smaller than or equal to cryptoCurrency.dustLimit. Revert to single output transaction. - Logging.instance.log('1 output in tx', level: LogLevel.Info); - Logging.instance - .log('Input size: $satoshisBeingUsed', level: LogLevel.Info); Logging.instance.log( - 'Recipient output size: $satoshiAmountToSend', + 'Reverting to 1 output in tx', level: LogLevel.Info, ); - Logging.instance.log( - 'Difference (fee being paid): ${satoshisBeingUsed - satoshiAmountToSend} sats', - level: LogLevel.Info, - ); - Logging.instance - .log('Estimated fee: $feeForOneOutput', level: LogLevel.Info); - final txn = await buildTransaction( - utxoSigningData: utxoSigningData, - txData: txData.copyWith( - recipients: await _helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - ), - ); - return txn.copyWith( - fee: Amount( - rawValue: BigInt.from(satoshisBeingUsed - satoshiAmountToSend), - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: utxoSigningData.map((e) => e.utxo).toList(), - ); + return await singleOutputTxn(); } - } else { - // No additional outputs needed since adding one would mean that it'd be smaller than cryptoCurrency.dustLimit sats - // which makes it uneconomical to add to the transaction. Here, we pass data directly to instruct - // the wallet to begin crafting the transaction that the user requested. - Logging.instance.log('1 output in tx', level: LogLevel.Info); - Logging.instance - .log('Input size: $satoshisBeingUsed', level: LogLevel.Info); - Logging.instance.log( - 'Recipient output size: $satoshiAmountToSend', - level: LogLevel.Info, - ); - Logging.instance.log( - 'Difference (fee being paid): ${satoshisBeingUsed - satoshiAmountToSend} sats', - level: LogLevel.Info, - ); - Logging.instance - .log('Estimated fee: $feeForOneOutput', level: LogLevel.Info); - final txn = await buildTransaction( - utxoSigningData: utxoSigningData, - txData: txData.copyWith( - recipients: await _helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - ), - ); - - return txn.copyWith( - fee: Amount( - rawValue: BigInt.from(satoshisBeingUsed - satoshiAmountToSend), - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: utxoSigningData.map((e) => e.utxo).toList(), - ); } - } else if (satoshisBeingUsed - satoshiAmountToSend == feeForOneOutput) { - // In this scenario, no additional change output is needed since inputs - outputs equal exactly - // what we need to pay for fees. Here, we pass data directly to instruct the wallet to begin - // crafting the transaction that the user requested. - Logging.instance.log('1 output in tx', level: LogLevel.Info); - Logging.instance - .log('Input size: $satoshisBeingUsed', level: LogLevel.Info); - Logging.instance.log( - 'Recipient output size: $satoshiAmountToSend', - level: LogLevel.Info, - ); - Logging.instance.log( - 'Fee being paid: ${satoshisBeingUsed - satoshiAmountToSend} sats', - level: LogLevel.Info, - ); - Logging.instance - .log('Estimated fee: $feeForOneOutput', level: LogLevel.Info); - final txn = await buildTransaction( - utxoSigningData: utxoSigningData, - txData: txData.copyWith( - recipients: await _helperRecipientsConvert( - recipientsArray, - recipientsAmtArray, - ), - ), - ); - return txn.copyWith( - fee: Amount( - rawValue: BigInt.from(feeForOneOutput), - fractionDigits: cryptoCurrency.fractionDigits, - ), - usedUTXOs: utxoSigningData.map((e) => e.utxo).toList(), - ); - } else { - // Remember that returning 2 indicates that the user does not have a sufficient balance to - // pay for the transaction fee. Ideally, at this stage, we should check if the user has any - // additional outputs they're able to spend and then recalculate fees. - Logging.instance.log( - 'Cannot pay tx fee - checking for more outputs and trying again', - level: LogLevel.Warning, - ); - // try adding more outputs - if (spendableOutputs.length > inputsBeingConsumed) { - return coinSelection( - txData: txData, - isSendAll: isSendAll, - additionalOutputs: additionalOutputs + 1, - utxos: utxos, - coinControl: coinControl, - ); - } - throw Exception("Insufficient balance to pay transaction fee"); - // return 2; } + + return txData; + } + + Future _sendAllBuilder({ + required TxData txData, + required String recipientAddress, + required BigInt satoshiAmountToSend, + required BigInt satoshisBeingUsed, + required List utxoSigningData, + required int? satsPerVByte, + required int feeRatePerKB, + }) async { + Logging.instance + .log("Attempting to send all $cryptoCurrency", level: LogLevel.Info); + if (txData.recipients!.length != 1) { + throw Exception( + "Send all to more than one recipient not yet supported", + ); + } + + final int vSizeForOneOutput = (await buildTransaction( + utxoSigningData: utxoSigningData, + txData: txData.copyWith( + recipients: await _helperRecipientsConvert( + [recipientAddress], + [satoshisBeingUsed - BigInt.one], + ), + ), + )) + .vSize!; + BigInt feeForOneOutput = BigInt.from( + satsPerVByte != null + ? (satsPerVByte * vSizeForOneOutput) + : estimateTxFee( + vSize: vSizeForOneOutput, + feeRatePerKB: feeRatePerKB, + ), + ); + + if (satsPerVByte == null) { + final roughEstimate = roughFeeEstimate( + utxoSigningData.length, + 1, + feeRatePerKB, + ).raw; + if (feeForOneOutput < roughEstimate) { + feeForOneOutput = roughEstimate; + } + } + + final amount = satoshiAmountToSend - feeForOneOutput; + + if (amount.isNegative) { + throw Exception( + "Estimated fee ($feeForOneOutput sats) is greater than balance!", + ); + } + + final data = await buildTransaction( + txData: txData.copyWith( + recipients: await _helperRecipientsConvert( + [recipientAddress], + [amount], + ), + ), + utxoSigningData: utxoSigningData, + ); + + return data.copyWith( + fee: Amount( + rawValue: feeForOneOutput, + fractionDigits: cryptoCurrency.fractionDigits, + ), + usedUTXOs: utxoSigningData.map((e) => e.utxo).toList(), + ); } Future> fetchBuildTxData(