From b30f1db45bc72747a27380d670c333033affc2c4 Mon Sep 17 00:00:00 2001 From: julian Date: Wed, 8 Mar 2023 16:11:46 -0600 Subject: [PATCH] enable coin control for selected other coins --- .../coins/bitcoincash/bitcoincash_wallet.dart | 194 +++++++++++------- .../coins/dogecoin/dogecoin_wallet.dart | 194 +++++++++++------- .../coins/litecoin/litecoin_wallet.dart | 188 ++++++++++------- .../coins/namecoin/namecoin_wallet.dart | 188 ++++++++++------- .../coins/particl/particl_wallet.dart | 190 ++++++++++------- 5 files changed, 587 insertions(+), 367 deletions(-) diff --git a/lib/services/coins/bitcoincash/bitcoincash_wallet.dart b/lib/services/coins/bitcoincash/bitcoincash_wallet.dart index 2ab18c4e1..c395ac16b 100644 --- a/lib/services/coins/bitcoincash/bitcoincash_wallet.dart +++ b/lib/services/coins/bitcoincash/bitcoincash_wallet.dart @@ -24,6 +24,7 @@ import 'package:stackwallet/services/event_bus/events/global/refresh_percent_cha import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/services/mixins/coin_control_interface.dart'; import 'package:stackwallet/services/mixins/wallet_cache.dart'; import 'package:stackwallet/services/mixins/wallet_db.dart'; import 'package:stackwallet/services/node_service.dart'; @@ -98,7 +99,8 @@ String constructDerivePath({ return "m/$purpose'/$coinType'/$account'/$chain/$index"; } -class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { +class BitcoinCashWallet extends CoinServiceAPI + with WalletCache, WalletDB, CoinControlInterface { BitcoinCashWallet({ required String walletId, required String walletName, @@ -118,6 +120,17 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { _secureStore = secureStore; initCache(walletId, coin); initWalletDB(mockableOverride: mockableOverride); + initCoinControlInterface( + walletId: walletId, + walletName: walletName, + coin: coin, + db: db, + getChainHeight: () => chainHeight, + refreshedBalanceCallback: (balance) async { + _balance = balance; + await updateCachedBalance(_balance!); + }, + ); } static const integrationTestFlag = @@ -1041,6 +1054,7 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { try { final feeRateType = args?["feeRate"]; final feeRateAmount = args?["feeRateAmount"]; + final utxos = args?["UTXOs"] as Set?; if (feeRateType is FeeRateType || feeRateAmount is int) { late final int rate; if (feeRateType is FeeRateType) { @@ -1067,9 +1081,17 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { isSendAll = true; } - final result = - await coinSelection(satoshiAmount, rate, address, isSendAll); - Logging.instance.log("SEND RESULT: $result", level: LogLevel.Info); + final result = await coinSelection( + satoshiAmountToSend: satoshiAmount, + selectedTxFeeRate: rate, + recipientAddress: address, + isSendAll: isSendAll, + utxos: utxos?.toList(), + coinControl: utxos is List, + ); + + Logging.instance + .log("PREPARE SEND RESULT: $result", level: LogLevel.Info); if (result is int) { switch (result) { case 1: @@ -1115,6 +1137,12 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { final txHash = await _electrumXClient.broadcastTransaction( rawTx: txData["hex"] as String); Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + + final utxos = txData["usedUTXOs"] as List; + + // mark utxos as used + await db.putUTXOs(utxos.map((e) => e.copyWith(used: true)).toList()); + return txHash; } catch (e, s) { Logging.instance.log("Exception rethrown from confirmSend(): $e\n$s", @@ -1719,49 +1747,47 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { } } - final currentChainHeight = await chainHeight; - final List outputArray = []; - int satoshiBalanceTotal = 0; - int satoshiBalancePending = 0; - int satoshiBalanceSpendable = 0; - int satoshiBalanceBlocked = 0; for (int i = 0; i < fetchedUtxoList.length; i++) { for (int j = 0; j < fetchedUtxoList[i].length; j++) { + final jsonUTXO = fetchedUtxoList[i][j]; + final txn = await cachedElectrumXClient.getTransaction( - txHash: fetchedUtxoList[i][j]["tx_hash"] as String, + txHash: jsonUTXO["tx_hash"] as String, verbose: true, coin: coin, ); - // todo check here if we should mark as blocked + final vout = jsonUTXO["tx_pos"] as int; + + final outputs = txn["vout"] as List; + + String? utxoOwnerAddress; + // get UTXO owner address + for (final output in outputs) { + if (output["n"] == vout) { + utxoOwnerAddress = + output["scriptPubKey"]?["addresses"]?[0] as String? ?? + output["scriptPubKey"]?["address"] as String?; + } + } + final utxo = isar_models.UTXO( walletId: walletId, txid: txn["txid"] as String, - vout: fetchedUtxoList[i][j]["tx_pos"] as int, - value: fetchedUtxoList[i][j]["value"] as int, + vout: vout, + value: jsonUTXO["value"] as int, name: "", isBlocked: false, blockedReason: null, isCoinbase: txn["is_coinbase"] as bool? ?? false, blockHash: txn["blockhash"] as String?, - blockHeight: fetchedUtxoList[i][j]["height"] as int?, + blockHeight: jsonUTXO["height"] as int?, blockTime: txn["blocktime"] as int?, + address: utxoOwnerAddress, ); - satoshiBalanceTotal += utxo.value; - - if (utxo.isBlocked) { - satoshiBalanceBlocked += utxo.value; - } else { - if (utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS)) { - satoshiBalanceSpendable += utxo.value; - } else { - satoshiBalancePending += utxo.value; - } - } - outputArray.add(utxo); } } @@ -1769,27 +1795,20 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { Logging.instance .log('Outputs fetched: $outputArray', level: LogLevel.Info); - // TODO move this out of here and into IDB - await db.isar.writeTxn(() async { - await db.isar.utxos.where().walletIdEqualTo(walletId).deleteAll(); - await db.isar.utxos.putAll(outputArray); - }); + await db.updateUTXOs(walletId, outputArray); // finally update balance - _balance = Balance( - coin: coin, - total: satoshiBalanceTotal, - spendable: satoshiBalanceSpendable, - blockedTotal: satoshiBalanceBlocked, - pendingSpendable: satoshiBalancePending, - ); - await updateCachedBalance(_balance!); + await _updateBalance(); } catch (e, s) { Logging.instance .log("Output fetch unsuccessful: $e\n$s", level: LogLevel.Error); } } + Future _updateBalance() async { + await refreshBalance(); + } + @override Balance get balance => _balance ??= getCachedBalance(); Balance? _balance; @@ -2322,11 +2341,12 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { /// with [satoshiAmountToSend] and [selectedTxFeeRate]. If so, it will call buildTrasaction() and return /// a map containing the tx hex along with other important information. If not, then it will return /// an integer (1 or 2) - dynamic coinSelection( - int satoshiAmountToSend, - int selectedTxFeeRate, - String _recipientAddress, - bool isSendAll, { + dynamic coinSelection({ + required int satoshiAmountToSend, + required int selectedTxFeeRate, + required String recipientAddress, + required bool coinControl, + required bool isSendAll, int additionalOutputs = 0, List? utxos, }) async { @@ -2338,18 +2358,26 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { int spendableSatoshiValue = 0; // Build list of spendable outputs and totaling their satoshi amount - for (var i = 0; i < availableOutputs.length; i++) { - if (availableOutputs[i].isBlocked == false && - availableOutputs[i] - .isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) == - true) { - spendableOutputs.add(availableOutputs[i]); - spendableSatoshiValue += availableOutputs[i].value; + for (final utxo in availableOutputs) { + if (utxo.isBlocked == false && + utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) && + utxo.used != true) { + spendableOutputs.add(utxo); + spendableSatoshiValue += utxo.value; } } - // sort spendable by age (oldest first) - spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + 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) { + // sort spendable by age (oldest first) + spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + } Logging.instance.log("spendableOutputs.length: ${spendableOutputs.length}", level: LogLevel.Info); @@ -2378,19 +2406,26 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { int inputsBeingConsumed = 0; List utxoObjectsToUse = []; - for (var i = 0; - satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[i]); - satoshisBeingUsed += spendableOutputs[i].value; - inputsBeingConsumed += 1; - } - for (int i = 0; - i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); - satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; - inputsBeingConsumed += 1; + if (!coinControl) { + for (var i = 0; + satoshisBeingUsed < satoshiAmountToSend && + i < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[i]); + satoshisBeingUsed += spendableOutputs[i].value; + inputsBeingConsumed += 1; + } + for (int i = 0; + i < additionalOutputs && + inputsBeingConsumed < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); + satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; + inputsBeingConsumed += 1; + } + } else { + satoshisBeingUsed = spendableSatoshiValue; + utxoObjectsToUse = spendableOutputs; } Logging.instance @@ -2403,7 +2438,7 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { .log('satoshiAmountToSend $satoshiAmountToSend', level: LogLevel.Info); // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray - List recipientsArray = [_recipientAddress]; + List recipientsArray = [recipientAddress]; List recipientsAmtArray = [satoshiAmountToSend]; // gather required signing data @@ -2416,7 +2451,7 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; int feeForOneOutput = estimateTxFee( @@ -2440,6 +2475,7 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": amount, "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2447,14 +2483,14 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; final int vSizeForTwoOutPuts = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, recipients: [ - _recipientAddress, + recipientAddress, await _getCurrentAddressForChain(1, DerivePathTypeExt.primaryFor(coin)), ], satoshiAmounts: [ @@ -2572,6 +2608,7 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": recipientsAmtArray[0], "fee": feeBeingPaid, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2599,6 +2636,7 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2628,6 +2666,7 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2657,6 +2696,7 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": recipientsAmtArray[0], "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2668,9 +2708,15 @@ class BitcoinCashWallet extends CoinServiceAPI with WalletCache, WalletDB { level: LogLevel.Warning); // try adding more outputs if (spendableOutputs.length > inputsBeingConsumed) { - return coinSelection(satoshiAmountToSend, selectedTxFeeRate, - _recipientAddress, isSendAll, - additionalOutputs: additionalOutputs + 1, utxos: utxos); + return coinSelection( + satoshiAmountToSend: satoshiAmountToSend, + selectedTxFeeRate: selectedTxFeeRate, + recipientAddress: recipientAddress, + isSendAll: isSendAll, + additionalOutputs: additionalOutputs + 1, + utxos: utxos, + coinControl: coinControl, + ); } return 2; } diff --git a/lib/services/coins/dogecoin/dogecoin_wallet.dart b/lib/services/coins/dogecoin/dogecoin_wallet.dart index f1eb0ab20..ae185ce2d 100644 --- a/lib/services/coins/dogecoin/dogecoin_wallet.dart +++ b/lib/services/coins/dogecoin/dogecoin_wallet.dart @@ -24,6 +24,7 @@ import 'package:stackwallet/services/event_bus/events/global/refresh_percent_cha import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/services/mixins/coin_control_interface.dart'; import 'package:stackwallet/services/mixins/electrum_x_parsing.dart'; import 'package:stackwallet/services/mixins/wallet_cache.dart'; import 'package:stackwallet/services/mixins/wallet_db.dart'; @@ -85,7 +86,7 @@ String constructDerivePath({ } class DogecoinWallet extends CoinServiceAPI - with WalletCache, WalletDB, ElectrumXParsing { + with WalletCache, WalletDB, ElectrumXParsing, CoinControlInterface { DogecoinWallet({ required String walletId, required String walletName, @@ -105,6 +106,17 @@ class DogecoinWallet extends CoinServiceAPI _secureStore = secureStore; initCache(walletId, coin); initWalletDB(mockableOverride: mockableOverride); + initCoinControlInterface( + walletId: walletId, + walletName: walletName, + coin: coin, + db: db, + getChainHeight: () => chainHeight, + refreshedBalanceCallback: (balance) async { + _balance = balance; + await updateCachedBalance(_balance!); + }, + ); // paynym stuff // initPaynymWalletInterface( @@ -907,6 +919,7 @@ class DogecoinWallet extends CoinServiceAPI try { final feeRateType = args?["feeRate"]; final feeRateAmount = args?["feeRateAmount"]; + final utxos = args?["UTXOs"] as Set?; if (feeRateType is FeeRateType || feeRateAmount is int) { late final int rate; if (feeRateType is FeeRateType) { @@ -933,9 +946,17 @@ class DogecoinWallet extends CoinServiceAPI isSendAll = true; } - final result = - await coinSelection(satoshiAmount, rate, address, isSendAll); - Logging.instance.log("SEND RESULT: $result", level: LogLevel.Info); + final result = await coinSelection( + satoshiAmountToSend: satoshiAmount, + selectedTxFeeRate: rate, + recipientAddress: address, + isSendAll: isSendAll, + utxos: utxos?.toList(), + coinControl: utxos is List, + ); + + Logging.instance + .log("PREPARE SEND RESULT: $result", level: LogLevel.Info); if (result is int) { switch (result) { case 1: @@ -981,6 +1002,12 @@ class DogecoinWallet extends CoinServiceAPI final txHash = await _electrumXClient.broadcastTransaction( rawTx: txData["hex"] as String); Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + + final utxos = txData["usedUTXOs"] as List; + + // mark utxos as used + await db.putUTXOs(utxos.map((e) => e.copyWith(used: true)).toList()); + return txHash; } catch (e, s) { Logging.instance.log("Exception rethrown from confirmSend(): $e\n$s", @@ -1537,18 +1564,14 @@ class DogecoinWallet extends CoinServiceAPI } } - final currentChainHeight = await chainHeight; - final List outputArray = []; - int satoshiBalanceTotal = 0; - int satoshiBalancePending = 0; - int satoshiBalanceSpendable = 0; - int satoshiBalanceBlocked = 0; for (int i = 0; i < fetchedUtxoList.length; i++) { for (int j = 0; j < fetchedUtxoList[i].length; j++) { + final jsonUTXO = fetchedUtxoList[i][j]; + final txn = await cachedElectrumXClient.getTransaction( - txHash: fetchedUtxoList[i][j]["tx_hash"] as String, + txHash: jsonUTXO["tx_hash"] as String, verbose: true, coin: coin, ); @@ -1556,7 +1579,7 @@ class DogecoinWallet extends CoinServiceAPI // fetch stored tx to see if paynym notification tx and block utxo final storedTx = await db.getTransaction( walletId, - fetchedUtxoList[i][j]["tx_hash"] as String, + jsonUTXO["tx_hash"] as String, ); bool shouldBlock = false; @@ -1573,32 +1596,35 @@ class DogecoinWallet extends CoinServiceAPI blockReason = "Incoming paynym notification transaction."; } + final vout = jsonUTXO["tx_pos"] as int; + + final outputs = txn["vout"] as List; + + String? utxoOwnerAddress; + // get UTXO owner address + for (final output in outputs) { + if (output["n"] == vout) { + utxoOwnerAddress = + output["scriptPubKey"]?["addresses"]?[0] as String? ?? + output["scriptPubKey"]?["address"] as String?; + } + } + final utxo = isar_models.UTXO( walletId: walletId, txid: txn["txid"] as String, - vout: fetchedUtxoList[i][j]["tx_pos"] as int, - value: fetchedUtxoList[i][j]["value"] as int, + vout: vout, + value: jsonUTXO["value"] as int, name: "", isBlocked: shouldBlock, blockedReason: blockReason, isCoinbase: txn["is_coinbase"] as bool? ?? false, blockHash: txn["blockhash"] as String?, - blockHeight: fetchedUtxoList[i][j]["height"] as int?, + blockHeight: jsonUTXO["height"] as int?, blockTime: txn["blocktime"] as int?, + address: utxoOwnerAddress, ); - satoshiBalanceTotal += utxo.value; - - if (utxo.isBlocked) { - satoshiBalanceBlocked += utxo.value; - } else { - if (utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS)) { - satoshiBalanceSpendable += utxo.value; - } else { - satoshiBalancePending += utxo.value; - } - } - outputArray.add(utxo); } } @@ -1606,27 +1632,20 @@ class DogecoinWallet extends CoinServiceAPI Logging.instance .log('Outputs fetched: $outputArray', level: LogLevel.Info); - // TODO move this out of here and into IDB - await db.isar.writeTxn(() async { - await db.isar.utxos.where().walletIdEqualTo(walletId).deleteAll(); - await db.isar.utxos.putAll(outputArray); - }); + await db.updateUTXOs(walletId, outputArray); // finally update balance - _balance = Balance( - coin: coin, - total: satoshiBalanceTotal, - spendable: satoshiBalanceSpendable, - blockedTotal: satoshiBalanceBlocked, - pendingSpendable: satoshiBalancePending, - ); - await updateCachedBalance(_balance!); + await _updateBalance(); } catch (e, s) { Logging.instance .log("Output fetch unsuccessful: $e\n$s", level: LogLevel.Error); } } + Future _updateBalance() async { + await refreshBalance(); + } + @override Balance get balance => _balance ??= getCachedBalance(); Balance? _balance; @@ -2022,11 +2041,12 @@ class DogecoinWallet extends CoinServiceAPI /// with [satoshiAmountToSend] and [selectedTxFeeRate]. If so, it will call buildTrasaction() and return /// a map containing the tx hex along with other important information. If not, then it will return /// an integer (1 or 2) - dynamic coinSelection( - int satoshiAmountToSend, - int selectedTxFeeRate, - String _recipientAddress, - bool isSendAll, { + dynamic coinSelection({ + required int satoshiAmountToSend, + required int selectedTxFeeRate, + required String recipientAddress, + required bool coinControl, + required bool isSendAll, int additionalOutputs = 0, List? utxos, }) async { @@ -2038,18 +2058,26 @@ class DogecoinWallet extends CoinServiceAPI int spendableSatoshiValue = 0; // Build list of spendable outputs and totaling their satoshi amount - for (var i = 0; i < availableOutputs.length; i++) { - if (availableOutputs[i].isBlocked == false && - availableOutputs[i] - .isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) == - true) { - spendableOutputs.add(availableOutputs[i]); - spendableSatoshiValue += availableOutputs[i].value; + for (final utxo in availableOutputs) { + if (utxo.isBlocked == false && + utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) && + utxo.used != true) { + spendableOutputs.add(utxo); + spendableSatoshiValue += utxo.value; } } - // sort spendable by age (oldest first) - spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + 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) { + // sort spendable by age (oldest first) + spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + } Logging.instance.log("spendableOutputs.length: ${spendableOutputs.length}", level: LogLevel.Info); @@ -2078,19 +2106,26 @@ class DogecoinWallet extends CoinServiceAPI int inputsBeingConsumed = 0; List utxoObjectsToUse = []; - for (var i = 0; - satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[i]); - satoshisBeingUsed += spendableOutputs[i].value; - inputsBeingConsumed += 1; - } - for (int i = 0; - i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); - satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; - inputsBeingConsumed += 1; + if (!coinControl) { + for (var i = 0; + satoshisBeingUsed < satoshiAmountToSend && + i < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[i]); + satoshisBeingUsed += spendableOutputs[i].value; + inputsBeingConsumed += 1; + } + for (int i = 0; + i < additionalOutputs && + inputsBeingConsumed < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); + satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; + inputsBeingConsumed += 1; + } + } else { + satoshisBeingUsed = spendableSatoshiValue; + utxoObjectsToUse = spendableOutputs; } Logging.instance @@ -2103,7 +2138,7 @@ class DogecoinWallet extends CoinServiceAPI .log('satoshiAmountToSend $satoshiAmountToSend', level: LogLevel.Info); // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray - List recipientsArray = [_recipientAddress]; + List recipientsArray = [recipientAddress]; List recipientsAmtArray = [satoshiAmountToSend]; // gather required signing data @@ -2116,7 +2151,7 @@ class DogecoinWallet extends CoinServiceAPI final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; int feeForOneOutput = estimateTxFee( @@ -2140,6 +2175,7 @@ class DogecoinWallet extends CoinServiceAPI "recipientAmt": amount, "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2147,14 +2183,14 @@ class DogecoinWallet extends CoinServiceAPI final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; final int vSizeForTwoOutPuts = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, recipients: [ - _recipientAddress, + recipientAddress, await _getCurrentAddressForChain(1, DerivePathTypeExt.primaryFor(coin)), ], satoshiAmounts: [ @@ -2272,6 +2308,7 @@ class DogecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": feeBeingPaid, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2299,6 +2336,7 @@ class DogecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2328,6 +2366,7 @@ class DogecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2357,6 +2396,7 @@ class DogecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2368,9 +2408,15 @@ class DogecoinWallet extends CoinServiceAPI level: LogLevel.Warning); // try adding more outputs if (spendableOutputs.length > inputsBeingConsumed) { - return coinSelection(satoshiAmountToSend, selectedTxFeeRate, - _recipientAddress, isSendAll, - additionalOutputs: additionalOutputs + 1, utxos: utxos); + return coinSelection( + satoshiAmountToSend: satoshiAmountToSend, + selectedTxFeeRate: selectedTxFeeRate, + recipientAddress: recipientAddress, + isSendAll: isSendAll, + additionalOutputs: additionalOutputs + 1, + utxos: utxos, + coinControl: coinControl, + ); } return 2; } diff --git a/lib/services/coins/litecoin/litecoin_wallet.dart b/lib/services/coins/litecoin/litecoin_wallet.dart index 46aa56f46..98f6aad86 100644 --- a/lib/services/coins/litecoin/litecoin_wallet.dart +++ b/lib/services/coins/litecoin/litecoin_wallet.dart @@ -23,6 +23,7 @@ import 'package:stackwallet/services/event_bus/events/global/refresh_percent_cha import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/services/mixins/coin_control_interface.dart'; import 'package:stackwallet/services/mixins/electrum_x_parsing.dart'; import 'package:stackwallet/services/mixins/wallet_cache.dart'; import 'package:stackwallet/services/mixins/wallet_db.dart'; @@ -90,7 +91,7 @@ String constructDerivePath({ } class LitecoinWallet extends CoinServiceAPI - with WalletCache, WalletDB, ElectrumXParsing { + with WalletCache, WalletDB, ElectrumXParsing, CoinControlInterface { LitecoinWallet({ required String walletId, required String walletName, @@ -110,6 +111,17 @@ class LitecoinWallet extends CoinServiceAPI _secureStore = secureStore; initCache(walletId, coin); initWalletDB(mockableOverride: mockableOverride); + initCoinControlInterface( + walletId: walletId, + walletName: walletName, + coin: coin, + db: db, + getChainHeight: () => chainHeight, + refreshedBalanceCallback: (balance) async { + _balance = balance; + await updateCachedBalance(_balance!); + }, + ); } static const integrationTestFlag = @@ -1018,6 +1030,7 @@ class LitecoinWallet extends CoinServiceAPI try { final feeRateType = args?["feeRate"]; final feeRateAmount = args?["feeRateAmount"]; + final utxos = args?["UTXOs"] as Set?; if (feeRateType is FeeRateType || feeRateAmount is int) { late final int rate; if (feeRateType is FeeRateType) { @@ -1045,8 +1058,14 @@ class LitecoinWallet extends CoinServiceAPI isSendAll = true; } - final txData = - await coinSelection(satoshiAmount, rate, address, isSendAll); + final txData = await coinSelection( + satoshiAmountToSend: satoshiAmount, + selectedTxFeeRate: rate, + recipientAddress: address, + isSendAll: isSendAll, + utxos: utxos?.toList(), + coinControl: utxos is List, + ); Logging.instance.log("prepare send: $txData", level: LogLevel.Info); try { @@ -1109,6 +1128,11 @@ class LitecoinWallet extends CoinServiceAPI final txHash = await _electrumXClient.broadcastTransaction(rawTx: hex); Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + final utxos = txData["usedUTXOs"] as List; + + // mark utxos as used + await db.putUTXOs(utxos.map((e) => e.copyWith(used: true)).toList()); + return txHash; } catch (e, s) { Logging.instance.log("Exception rethrown from confirmSend(): $e\n$s", @@ -1724,49 +1748,47 @@ class LitecoinWallet extends CoinServiceAPI } } - final currentChainHeight = await chainHeight; - final List outputArray = []; - int satoshiBalanceTotal = 0; - int satoshiBalancePending = 0; - int satoshiBalanceSpendable = 0; - int satoshiBalanceBlocked = 0; for (int i = 0; i < fetchedUtxoList.length; i++) { for (int j = 0; j < fetchedUtxoList[i].length; j++) { + final jsonUTXO = fetchedUtxoList[i][j]; + final txn = await cachedElectrumXClient.getTransaction( - txHash: fetchedUtxoList[i][j]["tx_hash"] as String, + txHash: jsonUTXO["tx_hash"] as String, verbose: true, coin: coin, ); - // todo check here if we should mark as blocked + final vout = jsonUTXO["tx_pos"] as int; + + final outputs = txn["vout"] as List; + + String? utxoOwnerAddress; + // get UTXO owner address + for (final output in outputs) { + if (output["n"] == vout) { + utxoOwnerAddress = + output["scriptPubKey"]?["addresses"]?[0] as String? ?? + output["scriptPubKey"]?["address"] as String?; + } + } + final utxo = isar_models.UTXO( walletId: walletId, txid: txn["txid"] as String, - vout: fetchedUtxoList[i][j]["tx_pos"] as int, - value: fetchedUtxoList[i][j]["value"] as int, + vout: vout, + value: jsonUTXO["value"] as int, name: "", isBlocked: false, blockedReason: null, isCoinbase: txn["is_coinbase"] as bool? ?? false, blockHash: txn["blockhash"] as String?, - blockHeight: fetchedUtxoList[i][j]["height"] as int?, + blockHeight: jsonUTXO["height"] as int?, blockTime: txn["blocktime"] as int?, + address: utxoOwnerAddress, ); - satoshiBalanceTotal += utxo.value; - - if (utxo.isBlocked) { - satoshiBalanceBlocked += utxo.value; - } else { - if (utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS)) { - satoshiBalanceSpendable += utxo.value; - } else { - satoshiBalancePending += utxo.value; - } - } - outputArray.add(utxo); } } @@ -1774,27 +1796,20 @@ class LitecoinWallet extends CoinServiceAPI Logging.instance .log('Outputs fetched: $outputArray', level: LogLevel.Info); - // TODO move this out of here and into IDB - await db.isar.writeTxn(() async { - await db.isar.utxos.where().walletIdEqualTo(walletId).deleteAll(); - await db.isar.utxos.putAll(outputArray); - }); + await db.updateUTXOs(walletId, outputArray); // finally update balance - _balance = Balance( - coin: coin, - total: satoshiBalanceTotal, - spendable: satoshiBalanceSpendable, - blockedTotal: satoshiBalanceBlocked, - pendingSpendable: satoshiBalancePending, - ); - await updateCachedBalance(_balance!); + await _updateBalance(); } catch (e, s) { Logging.instance .log("Output fetch unsuccessful: $e\n$s", level: LogLevel.Error); } } + Future _updateBalance() async { + await refreshBalance(); + } + @override Balance get balance => _balance ??= getCachedBalance(); Balance? _balance; @@ -2214,11 +2229,12 @@ class LitecoinWallet extends CoinServiceAPI /// with [satoshiAmountToSend] and [selectedTxFeeRate]. If so, it will call buildTrasaction() and return /// a map containing the tx hex along with other important information. If not, then it will return /// an integer (1 or 2) - dynamic coinSelection( - int satoshiAmountToSend, - int selectedTxFeeRate, - String _recipientAddress, - bool isSendAll, { + dynamic coinSelection({ + required int satoshiAmountToSend, + required int selectedTxFeeRate, + required String recipientAddress, + required bool coinControl, + required bool isSendAll, int additionalOutputs = 0, List? utxos, }) async { @@ -2230,18 +2246,26 @@ class LitecoinWallet extends CoinServiceAPI int spendableSatoshiValue = 0; // Build list of spendable outputs and totaling their satoshi amount - for (var i = 0; i < availableOutputs.length; i++) { - if (availableOutputs[i].isBlocked == false && - availableOutputs[i] - .isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) == - true) { - spendableOutputs.add(availableOutputs[i]); - spendableSatoshiValue += availableOutputs[i].value; + for (final utxo in availableOutputs) { + if (utxo.isBlocked == false && + utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) && + utxo.used != true) { + spendableOutputs.add(utxo); + spendableSatoshiValue += utxo.value; } } - // sort spendable by age (oldest first) - spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + 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) { + // sort spendable by age (oldest first) + spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + } Logging.instance.log("spendableOutputs.length: ${spendableOutputs.length}", level: LogLevel.Info); @@ -2270,19 +2294,26 @@ class LitecoinWallet extends CoinServiceAPI int inputsBeingConsumed = 0; List utxoObjectsToUse = []; - for (var i = 0; - satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[i]); - satoshisBeingUsed += spendableOutputs[i].value; - inputsBeingConsumed += 1; - } - for (int i = 0; - i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); - satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; - inputsBeingConsumed += 1; + if (!coinControl) { + for (var i = 0; + satoshisBeingUsed < satoshiAmountToSend && + i < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[i]); + satoshisBeingUsed += spendableOutputs[i].value; + inputsBeingConsumed += 1; + } + for (int i = 0; + i < additionalOutputs && + inputsBeingConsumed < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); + satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; + inputsBeingConsumed += 1; + } + } else { + satoshisBeingUsed = spendableSatoshiValue; + utxoObjectsToUse = spendableOutputs; } Logging.instance @@ -2293,7 +2324,7 @@ class LitecoinWallet extends CoinServiceAPI .log('utxoObjectsToUse: $utxoObjectsToUse', level: LogLevel.Info); // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray - List recipientsArray = [_recipientAddress]; + List recipientsArray = [recipientAddress]; List recipientsAmtArray = [satoshiAmountToSend]; // gather required signing data @@ -2306,7 +2337,7 @@ class LitecoinWallet extends CoinServiceAPI final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; int feeForOneOutput = estimateTxFee( @@ -2333,6 +2364,7 @@ class LitecoinWallet extends CoinServiceAPI "recipientAmt": amount, "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2340,14 +2372,14 @@ class LitecoinWallet extends CoinServiceAPI final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; final int vSizeForTwoOutPuts = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, recipients: [ - _recipientAddress, + recipientAddress, await _getCurrentAddressForChain(1, DerivePathTypeExt.primaryFor(coin)), ], satoshiAmounts: [ @@ -2451,6 +2483,7 @@ class LitecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": feeBeingPaid, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2478,6 +2511,7 @@ class LitecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2507,6 +2541,7 @@ class LitecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2536,6 +2571,7 @@ class LitecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2547,9 +2583,15 @@ class LitecoinWallet extends CoinServiceAPI level: LogLevel.Warning); // try adding more outputs if (spendableOutputs.length > inputsBeingConsumed) { - return coinSelection(satoshiAmountToSend, selectedTxFeeRate, - _recipientAddress, isSendAll, - additionalOutputs: additionalOutputs + 1, utxos: utxos); + return coinSelection( + satoshiAmountToSend: satoshiAmountToSend, + selectedTxFeeRate: selectedTxFeeRate, + recipientAddress: recipientAddress, + isSendAll: isSendAll, + additionalOutputs: additionalOutputs + 1, + utxos: utxos, + coinControl: coinControl, + ); } return 2; } diff --git a/lib/services/coins/namecoin/namecoin_wallet.dart b/lib/services/coins/namecoin/namecoin_wallet.dart index 4277a2974..e3693c2b6 100644 --- a/lib/services/coins/namecoin/namecoin_wallet.dart +++ b/lib/services/coins/namecoin/namecoin_wallet.dart @@ -23,6 +23,7 @@ import 'package:stackwallet/services/event_bus/events/global/refresh_percent_cha import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/services/mixins/coin_control_interface.dart'; import 'package:stackwallet/services/mixins/electrum_x_parsing.dart'; import 'package:stackwallet/services/mixins/wallet_cache.dart'; import 'package:stackwallet/services/mixins/wallet_db.dart'; @@ -87,7 +88,7 @@ String constructDerivePath({ } class NamecoinWallet extends CoinServiceAPI - with WalletCache, WalletDB, ElectrumXParsing { + with WalletCache, WalletDB, ElectrumXParsing, CoinControlInterface { NamecoinWallet({ required String walletId, required String walletName, @@ -107,6 +108,17 @@ class NamecoinWallet extends CoinServiceAPI _secureStore = secureStore; initCache(walletId, coin); initWalletDB(mockableOverride: mockableOverride); + initCoinControlInterface( + walletId: walletId, + walletName: walletName, + coin: coin, + db: db, + getChainHeight: () => chainHeight, + refreshedBalanceCallback: (balance) async { + _balance = balance; + await updateCachedBalance(_balance!); + }, + ); } static const integrationTestFlag = @@ -1009,6 +1021,7 @@ class NamecoinWallet extends CoinServiceAPI try { final feeRateType = args?["feeRate"]; final feeRateAmount = args?["feeRateAmount"]; + final utxos = args?["UTXOs"] as Set?; if (feeRateType is FeeRateType || feeRateAmount is int) { late final int rate; if (feeRateType is FeeRateType) { @@ -1036,8 +1049,14 @@ class NamecoinWallet extends CoinServiceAPI isSendAll = true; } - final txData = - await coinSelection(satoshiAmount, rate, address, isSendAll); + final txData = await coinSelection( + satoshiAmountToSend: satoshiAmount, + selectedTxFeeRate: rate, + recipientAddress: address, + isSendAll: isSendAll, + utxos: utxos?.toList(), + coinControl: utxos is List, + ); Logging.instance.log("prepare send: $txData", level: LogLevel.Info); try { @@ -1100,6 +1119,11 @@ class NamecoinWallet extends CoinServiceAPI final txHash = await _electrumXClient.broadcastTransaction(rawTx: hex); Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + final utxos = txData["usedUTXOs"] as List; + + // mark utxos as used + await db.putUTXOs(utxos.map((e) => e.copyWith(used: true)).toList()); + return txHash; } catch (e, s) { Logging.instance.log("Exception rethrown from confirmSend(): $e\n$s", @@ -1709,49 +1733,47 @@ class NamecoinWallet extends CoinServiceAPI } } - final currentChainHeight = await chainHeight; - final List outputArray = []; - int satoshiBalanceTotal = 0; - int satoshiBalancePending = 0; - int satoshiBalanceSpendable = 0; - int satoshiBalanceBlocked = 0; for (int i = 0; i < fetchedUtxoList.length; i++) { for (int j = 0; j < fetchedUtxoList[i].length; j++) { + final jsonUTXO = fetchedUtxoList[i][j]; + final txn = await cachedElectrumXClient.getTransaction( - txHash: fetchedUtxoList[i][j]["tx_hash"] as String, + txHash: jsonUTXO["tx_hash"] as String, verbose: true, coin: coin, ); - // todo check here if we should mark as blocked + final vout = jsonUTXO["tx_pos"] as int; + + final outputs = txn["vout"] as List; + + String? utxoOwnerAddress; + // get UTXO owner address + for (final output in outputs) { + if (output["n"] == vout) { + utxoOwnerAddress = + output["scriptPubKey"]?["addresses"]?[0] as String? ?? + output["scriptPubKey"]?["address"] as String?; + } + } + final utxo = isar_models.UTXO( walletId: walletId, txid: txn["txid"] as String, - vout: fetchedUtxoList[i][j]["tx_pos"] as int, - value: fetchedUtxoList[i][j]["value"] as int, + vout: vout, + value: jsonUTXO["value"] as int, name: "", isBlocked: false, blockedReason: null, isCoinbase: txn["is_coinbase"] as bool? ?? false, blockHash: txn["blockhash"] as String?, - blockHeight: fetchedUtxoList[i][j]["height"] as int?, + blockHeight: jsonUTXO["height"] as int?, blockTime: txn["blocktime"] as int?, + address: utxoOwnerAddress, ); - satoshiBalanceTotal += utxo.value; - - if (utxo.isBlocked) { - satoshiBalanceBlocked += utxo.value; - } else { - if (utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS)) { - satoshiBalanceSpendable += utxo.value; - } else { - satoshiBalancePending += utxo.value; - } - } - outputArray.add(utxo); } } @@ -1759,27 +1781,20 @@ class NamecoinWallet extends CoinServiceAPI Logging.instance .log('Outputs fetched: $outputArray', level: LogLevel.Info); - // TODO move this out of here and into IDB - await db.isar.writeTxn(() async { - await db.isar.utxos.where().walletIdEqualTo(walletId).deleteAll(); - await db.isar.utxos.putAll(outputArray); - }); + await db.updateUTXOs(walletId, outputArray); // finally update balance - _balance = Balance( - coin: coin, - total: satoshiBalanceTotal, - spendable: satoshiBalanceSpendable, - blockedTotal: satoshiBalanceBlocked, - pendingSpendable: satoshiBalancePending, - ); - await updateCachedBalance(_balance!); + await _updateBalance(); } catch (e, s) { Logging.instance .log("Output fetch unsuccessful: $e\n$s", level: LogLevel.Error); } } + Future _updateBalance() async { + await refreshBalance(); + } + @override Balance get balance => _balance ??= getCachedBalance(); Balance? _balance; @@ -2207,11 +2222,12 @@ class NamecoinWallet extends CoinServiceAPI /// with [satoshiAmountToSend] and [selectedTxFeeRate]. If so, it will call buildTrasaction() and return /// a map containing the tx hex along with other important information. If not, then it will return /// an integer (1 or 2) - dynamic coinSelection( - int satoshiAmountToSend, - int selectedTxFeeRate, - String _recipientAddress, - bool isSendAll, { + dynamic coinSelection({ + required int satoshiAmountToSend, + required int selectedTxFeeRate, + required String recipientAddress, + required bool coinControl, + required bool isSendAll, int additionalOutputs = 0, List? utxos, }) async { @@ -2223,18 +2239,26 @@ class NamecoinWallet extends CoinServiceAPI int spendableSatoshiValue = 0; // Build list of spendable outputs and totaling their satoshi amount - for (var i = 0; i < availableOutputs.length; i++) { - if (availableOutputs[i].isBlocked == false && - availableOutputs[i] - .isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) == - true) { - spendableOutputs.add(availableOutputs[i]); - spendableSatoshiValue += availableOutputs[i].value; + for (final utxo in availableOutputs) { + if (utxo.isBlocked == false && + utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) && + utxo.used != true) { + spendableOutputs.add(utxo); + spendableSatoshiValue += utxo.value; } } - // sort spendable by age (oldest first) - spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + 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) { + // sort spendable by age (oldest first) + spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + } Logging.instance.log("spendableOutputs.length: ${spendableOutputs.length}", level: LogLevel.Info); @@ -2263,19 +2287,26 @@ class NamecoinWallet extends CoinServiceAPI int inputsBeingConsumed = 0; List utxoObjectsToUse = []; - for (var i = 0; - satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[i]); - satoshisBeingUsed += spendableOutputs[i].value; - inputsBeingConsumed += 1; - } - for (int i = 0; - i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); - satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; - inputsBeingConsumed += 1; + if (!coinControl) { + for (var i = 0; + satoshisBeingUsed < satoshiAmountToSend && + i < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[i]); + satoshisBeingUsed += spendableOutputs[i].value; + inputsBeingConsumed += 1; + } + for (int i = 0; + i < additionalOutputs && + inputsBeingConsumed < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); + satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; + inputsBeingConsumed += 1; + } + } else { + satoshisBeingUsed = spendableSatoshiValue; + utxoObjectsToUse = spendableOutputs; } Logging.instance @@ -2286,7 +2317,7 @@ class NamecoinWallet extends CoinServiceAPI .log('utxoObjectsToUse: $utxoObjectsToUse', level: LogLevel.Info); // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray - List recipientsArray = [_recipientAddress]; + List recipientsArray = [recipientAddress]; List recipientsAmtArray = [satoshiAmountToSend]; // gather required signing data @@ -2299,7 +2330,7 @@ class NamecoinWallet extends CoinServiceAPI final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; int feeForOneOutput = estimateTxFee( @@ -2326,6 +2357,7 @@ class NamecoinWallet extends CoinServiceAPI "recipientAmt": amount, "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2333,14 +2365,14 @@ class NamecoinWallet extends CoinServiceAPI final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; final int vSizeForTwoOutPuts = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, recipients: [ - _recipientAddress, + recipientAddress, await _getCurrentAddressForChain(1, DerivePathTypeExt.primaryFor(coin)), ], satoshiAmounts: [ @@ -2444,6 +2476,7 @@ class NamecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": feeBeingPaid, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2471,6 +2504,7 @@ class NamecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2500,6 +2534,7 @@ class NamecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2529,6 +2564,7 @@ class NamecoinWallet extends CoinServiceAPI "recipientAmt": recipientsAmtArray[0], "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2540,9 +2576,15 @@ class NamecoinWallet extends CoinServiceAPI level: LogLevel.Warning); // try adding more outputs if (spendableOutputs.length > inputsBeingConsumed) { - return coinSelection(satoshiAmountToSend, selectedTxFeeRate, - _recipientAddress, isSendAll, - additionalOutputs: additionalOutputs + 1, utxos: utxos); + return coinSelection( + satoshiAmountToSend: satoshiAmountToSend, + selectedTxFeeRate: selectedTxFeeRate, + recipientAddress: recipientAddress, + isSendAll: isSendAll, + additionalOutputs: additionalOutputs + 1, + utxos: utxos, + coinControl: coinControl, + ); } return 2; } diff --git a/lib/services/coins/particl/particl_wallet.dart b/lib/services/coins/particl/particl_wallet.dart index 7b32ac54f..8f8cd0bd3 100644 --- a/lib/services/coins/particl/particl_wallet.dart +++ b/lib/services/coins/particl/particl_wallet.dart @@ -23,6 +23,7 @@ import 'package:stackwallet/services/event_bus/events/global/refresh_percent_cha import 'package:stackwallet/services/event_bus/events/global/updated_in_background_event.dart'; import 'package:stackwallet/services/event_bus/events/global/wallet_sync_status_changed_event.dart'; import 'package:stackwallet/services/event_bus/global_event_bus.dart'; +import 'package:stackwallet/services/mixins/coin_control_interface.dart'; import 'package:stackwallet/services/mixins/wallet_cache.dart'; import 'package:stackwallet/services/mixins/wallet_db.dart'; import 'package:stackwallet/services/node_service.dart'; @@ -81,7 +82,8 @@ String constructDerivePath({ return "m/$purpose'/$coinType'/$account'/$chain/$index"; } -class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { +class ParticlWallet extends CoinServiceAPI + with WalletCache, WalletDB, CoinControlInterface { ParticlWallet({ required String walletId, required String walletName, @@ -101,6 +103,17 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { _secureStore = secureStore; initCache(walletId, coin); initWalletDB(mockableOverride: mockableOverride); + initCoinControlInterface( + walletId: walletId, + walletName: walletName, + coin: coin, + db: db, + getChainHeight: () => chainHeight, + refreshedBalanceCallback: (balance) async { + _balance = balance; + await updateCachedBalance(_balance!); + }, + ); } static const integrationTestFlag = @@ -935,6 +948,7 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { try { final feeRateType = args?["feeRate"]; final feeRateAmount = args?["feeRateAmount"]; + final utxos = args?["UTXOs"] as Set?; if (feeRateType is FeeRateType || feeRateAmount is int) { late final int rate; if (feeRateType is FeeRateType) { @@ -962,8 +976,14 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { isSendAll = true; } - final txData = - await coinSelection(satoshiAmount, rate, address, isSendAll); + final txData = await coinSelection( + satoshiAmountToSend: satoshiAmount, + selectedTxFeeRate: rate, + recipientAddress: address, + isSendAll: isSendAll, + utxos: utxos?.toList(), + coinControl: utxos is List, + ); Logging.instance.log("prepare send: $txData", level: LogLevel.Info); try { @@ -1026,6 +1046,11 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { final txHash = await _electrumXClient.broadcastTransaction(rawTx: hex); Logging.instance.log("Sent txHash: $txHash", level: LogLevel.Info); + final utxos = txData["usedUTXOs"] as List; + + // mark utxos as used + await db.putUTXOs(utxos.map((e) => e.copyWith(used: true)).toList()); + return txHash; } catch (e, s) { Logging.instance.log("Exception rethrown from confirmSend(): $e\n$s", @@ -1595,49 +1620,47 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { } } - final currentChainHeight = await chainHeight; - final List outputArray = []; - int satoshiBalanceTotal = 0; - int satoshiBalancePending = 0; - int satoshiBalanceSpendable = 0; - int satoshiBalanceBlocked = 0; for (int i = 0; i < fetchedUtxoList.length; i++) { for (int j = 0; j < fetchedUtxoList[i].length; j++) { + final jsonUTXO = fetchedUtxoList[i][j]; + final txn = await cachedElectrumXClient.getTransaction( - txHash: fetchedUtxoList[i][j]["tx_hash"] as String, + txHash: jsonUTXO["tx_hash"] as String, verbose: true, coin: coin, ); - // todo check here if we should mark as blocked + final vout = jsonUTXO["tx_pos"] as int; + + final outputs = txn["vout"] as List; + + String? utxoOwnerAddress; + // get UTXO owner address + for (final output in outputs) { + if (output["n"] == vout) { + utxoOwnerAddress = + output["scriptPubKey"]?["addresses"]?[0] as String? ?? + output["scriptPubKey"]?["address"] as String?; + } + } + final utxo = isar_models.UTXO( walletId: walletId, txid: txn["txid"] as String, - vout: fetchedUtxoList[i][j]["tx_pos"] as int, - value: fetchedUtxoList[i][j]["value"] as int, + vout: vout, + value: jsonUTXO["value"] as int, name: "", isBlocked: false, blockedReason: null, isCoinbase: txn["is_coinbase"] as bool? ?? false, blockHash: txn["blockhash"] as String?, - blockHeight: fetchedUtxoList[i][j]["height"] as int?, + blockHeight: jsonUTXO["height"] as int?, blockTime: txn["blocktime"] as int?, + address: utxoOwnerAddress, ); - satoshiBalanceTotal += utxo.value; - - if (utxo.isBlocked) { - satoshiBalanceBlocked += utxo.value; - } else { - if (utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS)) { - satoshiBalanceSpendable += utxo.value; - } else { - satoshiBalancePending += utxo.value; - } - } - outputArray.add(utxo); } } @@ -1645,27 +1668,20 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { Logging.instance .log('Outputs fetched: $outputArray', level: LogLevel.Info); - // TODO move this out of here and into IDB - await db.isar.writeTxn(() async { - await db.isar.utxos.where().walletIdEqualTo(walletId).deleteAll(); - await db.isar.utxos.putAll(outputArray); - }); + await db.updateUTXOs(walletId, outputArray); // finally update balance - _balance = Balance( - coin: coin, - total: satoshiBalanceTotal, - spendable: satoshiBalanceSpendable, - blockedTotal: satoshiBalanceBlocked, - pendingSpendable: satoshiBalancePending, - ); - await updateCachedBalance(_balance!); + await _updateBalance(); } catch (e, s) { Logging.instance .log("Output fetch unsuccessful: $e\n$s", level: LogLevel.Error); } } + Future _updateBalance() async { + await refreshBalance(); + } + @override Balance get balance => _balance ??= getCachedBalance(); Balance? _balance; @@ -2367,11 +2383,12 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { /// with [satoshiAmountToSend] and [selectedTxFeeRate]. If so, it will call buildTrasaction() and return /// a map containing the tx hex along with other important information. If not, then it will return /// an integer (1 or 2) - dynamic coinSelection( - int satoshiAmountToSend, - int selectedTxFeeRate, - String _recipientAddress, - bool isSendAll, { + dynamic coinSelection({ + required int satoshiAmountToSend, + required int selectedTxFeeRate, + required String recipientAddress, + required bool coinControl, + required bool isSendAll, int additionalOutputs = 0, List? utxos, }) async { @@ -2381,19 +2398,28 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { final currentChainHeight = await chainHeight; final List spendableOutputs = []; int spendableSatoshiValue = 0; + // Build list of spendable outputs and totaling their satoshi amount - for (var i = 0; i < availableOutputs.length; i++) { - if (availableOutputs[i].isBlocked == false && - availableOutputs[i] - .isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) == - true) { - spendableOutputs.add(availableOutputs[i]); - spendableSatoshiValue += availableOutputs[i].value; + for (final utxo in availableOutputs) { + if (utxo.isBlocked == false && + utxo.isConfirmed(currentChainHeight, MINIMUM_CONFIRMATIONS) && + utxo.used != true) { + spendableOutputs.add(utxo); + spendableSatoshiValue += utxo.value; } } - // sort spendable by age (oldest first) - spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + 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) { + // sort spendable by age (oldest first) + spendableOutputs.sort((a, b) => b.blockTime!.compareTo(a.blockTime!)); + } Logging.instance.log("spendableOutputs.length: ${spendableOutputs.length}", level: LogLevel.Info); @@ -2422,19 +2448,26 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { int inputsBeingConsumed = 0; List utxoObjectsToUse = []; - for (var i = 0; - satoshisBeingUsed < satoshiAmountToSend && i < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[i]); - satoshisBeingUsed += spendableOutputs[i].value; - inputsBeingConsumed += 1; - } - for (int i = 0; - i < additionalOutputs && inputsBeingConsumed < spendableOutputs.length; - i++) { - utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); - satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; - inputsBeingConsumed += 1; + if (!coinControl) { + for (var i = 0; + satoshisBeingUsed < satoshiAmountToSend && + i < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[i]); + satoshisBeingUsed += spendableOutputs[i].value; + inputsBeingConsumed += 1; + } + for (int i = 0; + i < additionalOutputs && + inputsBeingConsumed < spendableOutputs.length; + i++) { + utxoObjectsToUse.add(spendableOutputs[inputsBeingConsumed]); + satoshisBeingUsed += spendableOutputs[inputsBeingConsumed].value; + inputsBeingConsumed += 1; + } + } else { + satoshisBeingUsed = spendableSatoshiValue; + utxoObjectsToUse = spendableOutputs; } Logging.instance @@ -2445,7 +2478,7 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { .log('utxoObjectsToUse: $utxoObjectsToUse', level: LogLevel.Info); // numberOfOutputs' length must always be equal to that of recipientsArray and recipientsAmtArray - List recipientsArray = [_recipientAddress]; + List recipientsArray = [recipientAddress]; List recipientsAmtArray = [satoshiAmountToSend]; // gather required signing data @@ -2458,7 +2491,7 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; int feeForOneOutput = estimateTxFee( @@ -2485,6 +2518,7 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": amount, "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2492,14 +2526,14 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { final int vSizeForOneOutput = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, - recipients: [_recipientAddress], + recipients: [recipientAddress], satoshiAmounts: [satoshisBeingUsed - 1], ))["vSize"] as int; final int vSizeForTwoOutPuts = (await buildTransaction( utxosToUse: utxoObjectsToUse, utxoSigningData: utxoSigningData, recipients: [ - _recipientAddress, + recipientAddress, await _getCurrentAddressForChain(1, DerivePathTypeExt.primaryFor(coin)), ], satoshiAmounts: [ @@ -2603,6 +2637,7 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": recipientsAmtArray[0], "fee": feeBeingPaid, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2630,6 +2665,7 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2659,6 +2695,7 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": recipientsAmtArray[0], "fee": satoshisBeingUsed - satoshiAmountToSend, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } @@ -2688,6 +2725,7 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { "recipientAmt": recipientsAmtArray[0], "fee": feeForOneOutput, "vSize": txn["vSize"], + "usedUTXOs": utxoObjectsToUse, }; return transactionObject; } else { @@ -2699,9 +2737,15 @@ class ParticlWallet extends CoinServiceAPI with WalletCache, WalletDB { level: LogLevel.Warning); // try adding more outputs if (spendableOutputs.length > inputsBeingConsumed) { - return coinSelection(satoshiAmountToSend, selectedTxFeeRate, - _recipientAddress, isSendAll, - additionalOutputs: additionalOutputs + 1, utxos: utxos); + return coinSelection( + satoshiAmountToSend: satoshiAmountToSend, + selectedTxFeeRate: selectedTxFeeRate, + recipientAddress: recipientAddress, + isSendAll: isSendAll, + additionalOutputs: additionalOutputs + 1, + utxos: utxos, + coinControl: coinControl, + ); } return 2; }