2024-06-16 16:33:54 +00:00
|
|
|
import 'dart:convert';
|
|
|
|
|
2024-06-16 19:25:07 +00:00
|
|
|
import 'package:isar/isar.dart';
|
|
|
|
|
2024-06-16 16:33:54 +00:00
|
|
|
import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart';
|
2024-06-16 19:25:07 +00:00
|
|
|
import '../../../models/isar/models/isar_models.dart';
|
|
|
|
import '../../../utilities/amount/amount.dart';
|
|
|
|
import '../../../utilities/enums/fee_rate_type_enum.dart';
|
2024-06-18 23:39:49 +00:00
|
|
|
import '../../../utilities/logger.dart';
|
2024-06-15 17:36:53 +00:00
|
|
|
import '../../crypto_currency/interfaces/electrumx_currency_interface.dart';
|
2024-06-19 20:42:08 +00:00
|
|
|
import '../../isar/models/wallet_info.dart';
|
2024-06-16 19:25:07 +00:00
|
|
|
import '../../models/tx_data.dart';
|
2024-06-15 17:36:53 +00:00
|
|
|
import 'electrumx_interface.dart';
|
|
|
|
|
|
|
|
typedef TxSize = ({int real, int virtual});
|
|
|
|
|
|
|
|
mixin RbfInterface<T extends ElectrumXCurrencyInterface>
|
|
|
|
on ElectrumXInterface<T> {
|
2024-06-19 20:42:08 +00:00
|
|
|
bool get flagOptInRBF =>
|
|
|
|
info.otherData[WalletInfoKeys.enableOptInRbf] as bool? ?? false;
|
|
|
|
|
2024-06-15 17:36:53 +00:00
|
|
|
Future<TxSize?> getVSize(String txid) async {
|
|
|
|
final tx = await electrumXCachedClient.getTransaction(
|
|
|
|
txHash: txid,
|
|
|
|
cryptoCurrency: cryptoCurrency,
|
|
|
|
);
|
|
|
|
|
|
|
|
try {
|
|
|
|
return (real: tx["size"] as int, virtual: tx["vsize"] as int);
|
|
|
|
} catch (_) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-16 16:33:54 +00:00
|
|
|
Future<TransactionV2> updateVSize(TransactionV2 transactionV2) async {
|
|
|
|
final size = await getVSize(transactionV2.txid);
|
|
|
|
final otherData = jsonDecode(transactionV2.otherData ?? "{}");
|
|
|
|
otherData[TxV2OdKeys.vSize] = size!.virtual;
|
|
|
|
otherData[TxV2OdKeys.size] = size.real;
|
|
|
|
final updatedTx = transactionV2.copyWith(otherData: jsonEncode(otherData));
|
|
|
|
await mainDB.updateOrPutTransactionV2s([updatedTx]);
|
|
|
|
return updatedTx;
|
|
|
|
}
|
|
|
|
|
2024-06-16 19:25:07 +00:00
|
|
|
Future<TxData> prepareRbfSend({
|
|
|
|
required TransactionV2 oldTransaction,
|
|
|
|
required int newRate,
|
|
|
|
}) async {
|
2024-06-18 23:39:49 +00:00
|
|
|
final note = await mainDB.isar.transactionNotes
|
|
|
|
.where()
|
|
|
|
.walletIdEqualTo(walletId)
|
|
|
|
.filter()
|
|
|
|
.txidEqualTo(oldTransaction.txid)
|
|
|
|
.findFirst();
|
|
|
|
|
2024-06-16 19:25:07 +00:00
|
|
|
final Set<UTXO> utxos = {};
|
|
|
|
for (final input in oldTransaction.inputs) {
|
|
|
|
final utxo = UTXO(
|
|
|
|
walletId: walletId,
|
|
|
|
txid: input.outpoint!.txid,
|
|
|
|
vout: input.outpoint!.vout,
|
|
|
|
value: input.value.toInt(),
|
|
|
|
name: "rbf",
|
|
|
|
isBlocked: false,
|
|
|
|
blockedReason: null,
|
|
|
|
isCoinbase: false,
|
|
|
|
blockHash: "rbf",
|
|
|
|
blockHeight: 1,
|
|
|
|
blockTime: 1,
|
|
|
|
used: false,
|
|
|
|
address: input.addresses.first,
|
|
|
|
);
|
|
|
|
|
|
|
|
utxos.add(utxo);
|
|
|
|
}
|
|
|
|
|
2024-06-18 23:39:49 +00:00
|
|
|
final List<TxRecipient> recipients = [];
|
|
|
|
for (final output in oldTransaction.outputs) {
|
|
|
|
if (output.addresses.length != 1) {
|
|
|
|
throw UnsupportedError(
|
|
|
|
"Unexpected output.addresses.length: ${output.addresses.length}",
|
|
|
|
);
|
|
|
|
}
|
|
|
|
final address = output.addresses.first;
|
|
|
|
final addressModel = await mainDB.getAddress(walletId, address);
|
|
|
|
final isChange = addressModel?.subType == AddressSubType.change;
|
2024-06-16 19:25:07 +00:00
|
|
|
|
2024-06-18 23:39:49 +00:00
|
|
|
recipients.add(
|
|
|
|
(
|
|
|
|
address: address,
|
|
|
|
amount: Amount(
|
|
|
|
rawValue: output.value,
|
|
|
|
fractionDigits: cryptoCurrency.fractionDigits),
|
|
|
|
isChange: isChange,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
2024-06-16 19:25:07 +00:00
|
|
|
|
2024-06-18 23:39:49 +00:00
|
|
|
final oldFee = oldTransaction
|
|
|
|
.getFee(fractionDigits: cryptoCurrency.fractionDigits)
|
|
|
|
.raw;
|
2024-06-16 19:25:07 +00:00
|
|
|
final inSum = utxos
|
|
|
|
.map((e) => BigInt.from(e.value))
|
|
|
|
.fold(BigInt.zero, (p, e) => p + e);
|
|
|
|
|
2024-06-18 23:39:49 +00:00
|
|
|
final noChange =
|
|
|
|
recipients.map((e) => e.isChange).fold(false, (p, e) => p || e) ==
|
|
|
|
false;
|
|
|
|
final otherAvailableUtxos = await mainDB
|
|
|
|
.getUTXOs(walletId)
|
2024-06-16 19:25:07 +00:00
|
|
|
.filter()
|
2024-06-19 16:15:39 +00:00
|
|
|
.isBlockedEqualTo(false)
|
|
|
|
.and()
|
|
|
|
.group(
|
|
|
|
(q) => q.usedIsNull().or().usedEqualTo(false),
|
|
|
|
)
|
2024-06-18 23:39:49 +00:00
|
|
|
.findAll();
|
2024-06-16 19:25:07 +00:00
|
|
|
|
2024-06-18 23:39:49 +00:00
|
|
|
final height = await chainHeight;
|
|
|
|
otherAvailableUtxos.removeWhere(
|
|
|
|
(e) => !e.isConfirmed(
|
|
|
|
height,
|
|
|
|
cryptoCurrency.minConfirms,
|
2024-11-21 15:40:40 +00:00
|
|
|
cryptoCurrency.minCoinbaseConfirms,
|
2024-06-18 23:39:49 +00:00
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
TxData txData = TxData(
|
|
|
|
recipients: recipients,
|
2024-06-16 19:25:07 +00:00
|
|
|
feeRateType: FeeRateType.custom,
|
|
|
|
satsPerVByte: newRate,
|
|
|
|
utxos: utxos,
|
|
|
|
ignoreCachedBalanceChecks: true,
|
|
|
|
note: note?.value ?? "",
|
|
|
|
);
|
|
|
|
|
2024-06-18 23:39:49 +00:00
|
|
|
if (otherAvailableUtxos.isEmpty && noChange && recipients.length == 1) {
|
|
|
|
// safe to assume send all?
|
|
|
|
txData = txData.copyWith(
|
|
|
|
recipients: [
|
|
|
|
(
|
|
|
|
address: recipients.first.address,
|
|
|
|
amount: Amount(
|
|
|
|
rawValue: inSum,
|
|
|
|
fractionDigits: cryptoCurrency.fractionDigits,
|
|
|
|
),
|
|
|
|
isChange: false,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
Logging.instance.log(
|
|
|
|
"RBF on assumed send all",
|
|
|
|
level: LogLevel.Debug,
|
|
|
|
);
|
|
|
|
return await prepareSend(txData: txData);
|
|
|
|
} else if (txData.recipients!.where((e) => e.isChange).length == 1) {
|
|
|
|
final newFee = BigInt.from(oldTransaction.vSize! * newRate);
|
|
|
|
final feeDifferenceRequired = newFee - oldFee;
|
|
|
|
if (feeDifferenceRequired < BigInt.zero) {
|
|
|
|
throw Exception("Negative new fee in RBF found");
|
|
|
|
} else if (feeDifferenceRequired == BigInt.zero) {
|
|
|
|
throw Exception("New fee in RBF has not changed at all");
|
|
|
|
}
|
|
|
|
|
|
|
|
final indexOfChangeOutput =
|
|
|
|
txData.recipients!.indexWhere((e) => e.isChange);
|
|
|
|
|
|
|
|
final removed = txData.recipients!.removeAt(indexOfChangeOutput);
|
|
|
|
|
|
|
|
BigInt newChangeAmount = removed.amount.raw - feeDifferenceRequired;
|
|
|
|
|
|
|
|
if (newChangeAmount >= BigInt.zero) {
|
|
|
|
if (newChangeAmount >= cryptoCurrency.dustLimit.raw) {
|
|
|
|
// yay we have enough
|
|
|
|
// update recipients
|
|
|
|
txData.recipients!.insert(
|
|
|
|
indexOfChangeOutput,
|
|
|
|
(
|
|
|
|
address: removed.address,
|
|
|
|
amount: Amount(
|
|
|
|
rawValue: newChangeAmount,
|
|
|
|
fractionDigits: cryptoCurrency.fractionDigits,
|
|
|
|
),
|
|
|
|
isChange: removed.isChange,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
Logging.instance.log(
|
|
|
|
"RBF with same utxo set with increased fee and reduced change",
|
|
|
|
level: LogLevel.Debug,
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
// new change amount is less than dust limit.
|
|
|
|
// TODO: check if worth adding another utxo?
|
|
|
|
// depending on several factors, it may be cheaper to just add]
|
|
|
|
// the dust to the fee...
|
|
|
|
// we'll do that for now... aka remove the change output entirely
|
|
|
|
// which now that I think about it, will reduce the size of the tx...
|
|
|
|
// oh well...
|
|
|
|
|
|
|
|
// do nothing here as we already removed the change output above
|
|
|
|
Logging.instance.log(
|
|
|
|
"RBF with same utxo set with increased fee and no change",
|
|
|
|
level: LogLevel.Debug,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return await buildTransaction(
|
|
|
|
txData: txData.copyWith(
|
|
|
|
usedUTXOs: txData.utxos!.toList(),
|
|
|
|
fee: Amount(
|
|
|
|
rawValue: newFee,
|
|
|
|
fractionDigits: cryptoCurrency.fractionDigits,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
utxoSigningData: await fetchBuildTxData(txData.utxos!.toList()),
|
|
|
|
);
|
|
|
|
|
|
|
|
// if change amount is negative
|
|
|
|
} else {
|
|
|
|
// we need more utxos
|
|
|
|
if (otherAvailableUtxos.isEmpty) {
|
|
|
|
throw Exception("Insufficient funds to pay for increased fee");
|
|
|
|
}
|
|
|
|
|
|
|
|
final List<UTXO> extraUtxos = [];
|
|
|
|
for (int i = 0; i < otherAvailableUtxos.length; i++) {
|
|
|
|
final utxoToAdd = otherAvailableUtxos[i];
|
|
|
|
newChangeAmount += BigInt.from(utxoToAdd.value);
|
|
|
|
extraUtxos.add(utxoToAdd);
|
|
|
|
|
|
|
|
if (newChangeAmount >= cryptoCurrency.dustLimit.raw) {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (newChangeAmount < cryptoCurrency.dustLimit.raw) {
|
|
|
|
throw Exception("Insufficient funds to pay for increased fee");
|
|
|
|
}
|
|
|
|
txData.recipients!.insert(
|
|
|
|
indexOfChangeOutput,
|
|
|
|
(
|
|
|
|
address: removed.address,
|
|
|
|
amount: Amount(
|
|
|
|
rawValue: newChangeAmount,
|
|
|
|
fractionDigits: cryptoCurrency.fractionDigits,
|
|
|
|
),
|
|
|
|
isChange: removed.isChange,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
final newUtxoSet = {
|
|
|
|
...txData.utxos!,
|
|
|
|
...extraUtxos,
|
|
|
|
};
|
|
|
|
|
|
|
|
// TODO: remove assert
|
|
|
|
assert(newUtxoSet.length == txData.utxos!.length + extraUtxos.length);
|
|
|
|
|
|
|
|
Logging.instance.log(
|
|
|
|
"RBF with ${extraUtxos.length} extra utxo(s)"
|
|
|
|
" added to pay for the new fee",
|
|
|
|
level: LogLevel.Debug,
|
|
|
|
);
|
|
|
|
|
|
|
|
return await buildTransaction(
|
|
|
|
txData: txData.copyWith(
|
|
|
|
utxos: newUtxoSet,
|
|
|
|
usedUTXOs: newUtxoSet.toList(),
|
|
|
|
fee: Amount(
|
|
|
|
rawValue: newFee,
|
|
|
|
fractionDigits: cryptoCurrency.fractionDigits,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
utxoSigningData: await fetchBuildTxData(newUtxoSet.toList()),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// TODO handle building a tx here in this case
|
|
|
|
throw Exception(
|
|
|
|
"Unexpected number of change outputs found:"
|
|
|
|
" ${txData.recipients!.where((e) => e.isChange).length}",
|
|
|
|
);
|
|
|
|
}
|
2024-06-16 19:25:07 +00:00
|
|
|
}
|
2024-06-15 17:36:53 +00:00
|
|
|
}
|