import 'dart:convert'; import 'package:isar/isar.dart'; import '../../../models/isar/models/blockchain_data/v2/transaction_v2.dart'; import '../../../models/isar/models/isar_models.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/enums/fee_rate_type_enum.dart'; import '../../../utilities/logger.dart'; import '../../crypto_currency/interfaces/electrumx_currency_interface.dart'; import '../../isar/models/wallet_info.dart'; import '../../models/tx_data.dart'; import 'electrumx_interface.dart'; typedef TxSize = ({int real, int virtual}); mixin RbfInterface on ElectrumXInterface { bool get flagOptInRBF => info.otherData[WalletInfoKeys.enableOptInRbf] as bool? ?? false; Future 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; } } Future 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; } Future prepareRbfSend({ required TransactionV2 oldTransaction, required int newRate, }) async { final note = await mainDB.isar.transactionNotes .where() .walletIdEqualTo(walletId) .filter() .txidEqualTo(oldTransaction.txid) .findFirst(); final Set 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); } final List 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; recipients.add( ( address: address, amount: Amount( rawValue: output.value, fractionDigits: cryptoCurrency.fractionDigits), isChange: isChange, ), ); } final oldFee = oldTransaction .getFee(fractionDigits: cryptoCurrency.fractionDigits) .raw; final inSum = utxos .map((e) => BigInt.from(e.value)) .fold(BigInt.zero, (p, e) => p + e); final noChange = recipients.map((e) => e.isChange).fold(false, (p, e) => p || e) == false; final otherAvailableUtxos = await mainDB .getUTXOs(walletId) .filter() .isBlockedEqualTo(false) .and() .group( (q) => q.usedIsNull().or().usedEqualTo(false), ) .findAll(); final height = await chainHeight; otherAvailableUtxos.removeWhere( (e) => !e.isConfirmed( height, cryptoCurrency.minConfirms, cryptoCurrency.minCoinbaseConfirms, ), ); TxData txData = TxData( recipients: recipients, feeRateType: FeeRateType.custom, satsPerVByte: newRate, utxos: utxos, ignoreCachedBalanceChecks: true, note: note?.value ?? "", ); 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 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}", ); } } }