clean up coin selection somewhat

This commit is contained in:
julian 2024-06-04 15:07:31 -06:00
parent 7cef4c45eb
commit 7637e81408

View file

@ -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<T extends ElectrumXCurrencyInterface>
}
Future<List<({String address, Amount amount, bool isChange})>>
_helperRecipientsConvert(List<String> addrs, List<int> satValues) async {
_helperRecipientsConvert(
List<String> addrs,
List<BigInt> 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<T extends ElectrumXCurrencyInterface>
(
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<T extends ElectrumXCurrencyInterface>
// 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<UTXO> availableOutputs =
utxos ?? await mainDB.getUTXOs(walletId).findAll();
final currentChainHeight = await chainHeight;
final List<UTXO> 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<T extends ElectrumXCurrencyInterface>
);
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<UTXO> utxoObjectsToUse = [];
final List<UTXO> utxoObjectsToUse = [];
if (!coinControl) {
for (var i = 0;
@ -188,7 +179,7 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
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<T extends ElectrumXCurrencyInterface>
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<T extends ElectrumXCurrencyInterface>
// numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray
final List<String> recipientsArray = [recipientAddress];
final List<int> recipientsAmtArray = [satoshiAmountToSend];
final List<BigInt> 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<T extends ElectrumXCurrencyInterface>
txData: txData.copyWith(
recipients: await _helperRecipientsConvert(
[recipientAddress],
[satoshisBeingUsed - 1],
[satoshisBeingUsed - BigInt.one],
),
),
))
@ -301,6 +241,9 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
}
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<T extends ElectrumXCurrencyInterface>
[recipientAddress, (await getCurrentChangeAddress())!.value],
[
satoshiAmountToSend,
max(0, satoshisBeingUsed - satoshiAmountToSend - 1),
maxBI(
BigInt.zero,
satoshisBeingUsed - (satoshiAmountToSend + BigInt.one),
),
],
),
),
@ -321,53 +267,112 @@ mixin ElectrumXInterface<T extends ElectrumXCurrencyInterface>
}
// 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<TxData> 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<T extends ElectrumXCurrencyInterface>
'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<T extends ElectrumXCurrencyInterface>
);
// 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<T extends ElectrumXCurrencyInterface>
'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<T extends ElectrumXCurrencyInterface>
);
}
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<T extends ElectrumXCurrencyInterface>
} 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<TxData> _sendAllBuilder({
required TxData txData,
required String recipientAddress,
required BigInt satoshiAmountToSend,
required BigInt satoshisBeingUsed,
required List<SigningData> 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<List<SigningData>> fetchBuildTxData(