diff --git a/cw_bitcoin/lib/bitcoin_transaction_credentials.dart b/cw_bitcoin/lib/bitcoin_transaction_credentials.dart index 7d6894e149..ebbcea4a7b 100644 --- a/cw_bitcoin/lib/bitcoin_transaction_credentials.dart +++ b/cw_bitcoin/lib/bitcoin_transaction_credentials.dart @@ -1,6 +1,7 @@ -import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; -import 'package:cw_core/output_info.dart'; -import 'package:cw_core/unspent_coin_type.dart'; +import "package:cw_bitcoin/bitcoin_transaction_priority.dart"; +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/output_info.dart"; +import "package:cw_core/unspent_coin_type.dart"; class BitcoinTransactionCredentials { BitcoinTransactionCredentials( @@ -8,12 +9,14 @@ class BitcoinTransactionCredentials { required this.priority, this.feeRate, this.coinTypeToSpendFrom = UnspentCoinType.any, + this.coinSelection = const AllCoinSelection(), this.payjoinUri, }); final List outputs; final BitcoinTransactionPriority? priority; final int? feeRate; + final CoinSelection coinSelection; final UnspentCoinType coinTypeToSpendFrom; final String? payjoinUri; } diff --git a/cw_bitcoin/lib/bitcoin_unspent.dart b/cw_bitcoin/lib/bitcoin_unspent.dart index 53700a2722..564fb48a2a 100644 --- a/cw_bitcoin/lib/bitcoin_unspent.dart +++ b/cw_bitcoin/lib/bitcoin_unspent.dart @@ -1,5 +1,6 @@ -import 'package:cw_bitcoin/bitcoin_address_record.dart'; -import 'package:cw_core/unspent_transaction_output.dart'; +import "package:bitcoin_base/bitcoin_base.dart"; +import "package:cw_bitcoin/bitcoin_address_record.dart"; +import "package:cw_core/unspent_transaction_output.dart"; class BitcoinUnspent extends Unspent { BitcoinUnspent(BaseBitcoinAddressRecord addressRecord, String hash, int value, int vout) @@ -26,6 +27,9 @@ class BitcoinUnspent extends Unspent { final BaseBitcoinAddressRecord bitcoinAddressRecord; bool? isPegOut; + + @override + String get id => bitcoinAddressRecord.type == SegwitAddresType.mweb ? hash : "$hash:$vout"; } class BitcoinSilentPaymentsUnspent extends BitcoinUnspent { @@ -63,6 +67,9 @@ class BitcoinSilentPaymentsUnspent extends BitcoinUnspent { return json; } + @override + bool get isSilentPayment => true; + String? silentPaymentTweak; String? silentPaymentLabel; } diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 132227f274..8357d4fde2 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -37,7 +37,7 @@ import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import "package:cw_core/receive_page_option.dart"; import 'package:cw_core/unspent_coin_type.dart'; -import 'package:cw_core/unspent_coins_info.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/zpub.dart'; import 'package:cw_core/wallet_info.dart'; @@ -59,7 +59,6 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required Box payjoinBox, required EncryptionFileUtils encryptionFileUtils, Uint8List? seedBytes, @@ -85,7 +84,6 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, network: networkParam == null ? BitcoinNetwork.mainnet : networkParam == BitcoinNetwork.mainnet @@ -183,7 +181,6 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required String mnemonic, required String password, required WalletInfo walletInfo, - required Box unspentCoinsInfo, required Box payjoinBox, required EncryptionFileUtils encryptionFileUtils, String? passphrase, @@ -219,7 +216,6 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, initialSilentAddresses: initialSilentAddresses, initialSilentAddressIndex: initialSilentAddressIndex, @@ -238,7 +234,6 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { static Future open({ required String name, required WalletInfo walletInfo, - required Box unspentCoinsInfo, required Box payjoinBox, required String password, required EncryptionFileUtils encryptionFileUtils, @@ -322,7 +317,6 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { passphrase: passphrase, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: snp?.addresses, initialSilentAddresses: snp?.silentAddresses, initialSilentAddressIndex: snp?.silentAddressIndex ?? 0, @@ -430,9 +424,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { @override bool get hasLightningSupport => seed != null && LightningWallet.isAvailable; - bool get isPayjoinAvailable => unspentCoinsInfo.values - .where((element) => element.walletId == id && element.isSending && !element.isFrozen) - .isNotEmpty; + Future get isPayjoinAvailable async => + (await spendableCoins()).isNotEmpty; Future buildPsbt({ required List outputs, @@ -606,7 +599,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { } final originalPsbt = - await signPsbt(base64.encode(transaction.asPsbtV0()), getUtxoWithPrivateKeys()); + await signPsbt(base64.encode(transaction.asPsbtV0()), await getUtxoWithPrivateKeys()); tx.commitOverride = () async { final sender = @@ -617,10 +610,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return tx; } - List getUtxoWithPrivateKeys({bool confirmedOnly = false}) => - unspentCoinsForCurrentAccount - .where( - (e) => e.isSending && !e.isFrozen && (!confirmedOnly || (e.confirmations ?? 0) > 0)) + Future> getUtxoWithPrivateKeys({bool confirmedOnly = false}) async => + (await spendableCoins()) + .cast() + .where((e) => !confirmedOnly || (e.confirmations ?? 0) > 0) .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) .toList(); @@ -747,4 +740,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return true; } + + @override + Uri coinControlUrl(String txId) => Uri.https("ordinals.com", "/tx/${txId}"); } diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index 836b006d26..480ef7fddf 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -4,9 +4,10 @@ import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_bitcoin/mnemonic_is_incorrect_exception.dart'; import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart'; +import "package:cw_core/coin_control/coin_notes_store.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/payjoin_session.dart'; -import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/zpub.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_bitcoin/bitcoin_wallet.dart'; @@ -21,9 +22,8 @@ class BitcoinWalletService extends WalletService< BitcoinRestoreWalletFromSeedCredentials, BitcoinWalletFromKeysCredentials, BitcoinRestoreWalletFromHardware> { - BitcoinWalletService(this.unspentCoinsInfoSource, this.payjoinSessionSource, this.isDirect); + BitcoinWalletService(this.payjoinSessionSource, this.isDirect); - final Box unspentCoinsInfoSource; final Box payjoinSessionSource; final bool isDirect; @@ -63,7 +63,6 @@ class BitcoinWalletService extends WalletService< password: credentials.password!, passphrase: credentials.passphrase, walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource, payjoinBox: payjoinSessionSource, network: network, encryptionFileUtils: encryptionFileUtilsFor(isDirect), @@ -90,7 +89,6 @@ class BitcoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, payjoinBox: payjoinSessionSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -103,7 +101,6 @@ class BitcoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, payjoinBox: payjoinSessionSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -121,15 +118,8 @@ class BitcoinWalletService extends WalletService< } await WalletInfo.delete(walletInfo); - final unspentCoinsToDelete = unspentCoinsInfoSource.values - .where((unspentCoin) => unspentCoin.walletId == walletInfo.id) - .toList(); - - final keysToDelete = unspentCoinsToDelete.map((unspentCoin) => unspentCoin.key).toList(); - - if (keysToDelete.isNotEmpty) { - await unspentCoinsInfoSource.deleteAll(keysToDelete); - } + await FrozenCoinsStore.instance.deleteWallet(walletInfo.internalId); + await CoinNotesStore.instance.deleteWallet(walletInfo.internalId); } @override @@ -148,7 +138,6 @@ class BitcoinWalletService extends WalletService< xpub: xpub, walletInfo: credentials.walletInfo!, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfoSource, networkParam: network, encryptionFileUtils: encryptionFileUtilsFor(isDirect), payjoinBox: payjoinSessionSource, @@ -172,7 +161,6 @@ class BitcoinWalletService extends WalletService< xpub: xpub, walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, networkParam: network, encryptionFileUtils: encryptionFileUtilsFor(isDirect), payjoinBox: payjoinSessionSource, @@ -199,7 +187,6 @@ class BitcoinWalletService extends WalletService< passphrase: credentials.passphrase, mnemonic: credentials.mnemonic, walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource, payjoinBox: payjoinSessionSource, network: network, encryptionFileUtils: encryptionFileUtilsFor(isDirect), diff --git a/cw_bitcoin/lib/electrum_balance.dart b/cw_bitcoin/lib/electrum_balance.dart index e8a39420c9..60f43e4905 100644 --- a/cw_bitcoin/lib/electrum_balance.dart +++ b/cw_bitcoin/lib/electrum_balance.dart @@ -36,6 +36,14 @@ class ElectrumBalance extends Balance { @override Money frozen; + ElectrumBalance copyWith({Money? frozen}) => ElectrumBalance( + confirmed: confirmed, + unconfirmed: unconfirmed, + frozen: frozen ?? this.frozen, + secondConfirmed: secondConfirmed, + secondUnconfirmed: secondUnconfirmed, + ); + ElectrumBalance copy() => ElectrumBalance( confirmed: confirmed, unconfirmed: unconfirmed, diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index b04e490e12..d8cad06a0e 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -42,8 +42,10 @@ import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; +import "package:cw_core/coin_control/coin_control_wallet.dart"; +import "package:cw_core/unspent_transaction_output.dart"; +import "package:cw_core/coin_control/coin_selection.dart"; import 'package:cw_core/unspent_coin_type.dart'; -import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/socket_health_logger.dart'; import 'package:cw_core/utils/tor/abstract.dart'; import 'package:cw_core/wallet_base.dart'; @@ -52,7 +54,6 @@ import 'package:cw_core/wallet_keys_file.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/foundation.dart'; import 'package:hex/hex.dart'; -import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; import 'package:sp_scanner/sp_scanner.dart'; @@ -63,12 +64,11 @@ class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; abstract class ElectrumWalletBase extends WalletBase - with Store, WalletKeysFile { + with Store, WalletKeysFile, CoinControlWallet { ElectrumWalletBase({ required String password, required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required this.network, required this.encryptionFileUtils, String? xpub, @@ -95,7 +95,6 @@ abstract class ElectrumWalletBase silentPaymentsScanningActive = alwaysScan ?? false, balance = ObservableMap.of( currency != null ? {currency: initialBalance ?? ElectrumBalance.zero(currency)} : {}), - this.unspentCoinsInfo = unspentCoinsInfo, this.isTestnet = !network.isMainnet, this._mnemonic = mnemonic, this.electrumClient = electrumClient ?? electrum.ElectrumClient(), @@ -283,8 +282,6 @@ abstract class ElectrumWalletBase static const int maxProbAccounts = 3; - final Map> _appliedCoinKeysByAddress = {}; - @observable bool? alwaysScan; @@ -320,7 +317,6 @@ abstract class ElectrumWalletBase bool isEnabledAutoGenerateSubaddress; late electrum.ElectrumClient electrumClient; - Box unspentCoinsInfo; @override late ElectrumWalletAddresses walletAddresses; @@ -385,9 +381,10 @@ abstract class ElectrumWalletBase ElectrumBalance balanceForAccount(int accountIndex) => accountBalances[accountIndex] ?? ElectrumBalance.zero(currency); - void _updateAccountBalancesFromUnspents() { + Future _updateAccountBalancesFromUnspents() async { if (!_hasCompleteUnspentSet) return; + final frozen = await frozenIds(); final newBalances = {}; for (final coin in unspentCoins) { @@ -395,7 +392,7 @@ abstract class ElectrumWalletBase final current = newBalances[accountIndex] ?? ElectrumBalance.zero(currency); final amount = Money.fromInt(coin.value, currency); - if (coin.isFrozen) { + if (frozen.contains(coin.id)) { current.frozen += amount; } @@ -697,6 +694,13 @@ abstract class ElectrumWalletBase String _password; List unspentCoins; + + @override + List get unspents => unspentCoinsForCurrentAccount.toList(); + + @override + Future refreshUnspents() => updateAllUnspents(); + List _feeRates; // ignore: prefer_final_fields @@ -728,7 +732,6 @@ abstract class ElectrumWalletBase await walletAddresses.init(accountIndexes: accountIndexes); await transactionHistory.init(); - await cleanUpDuplicateUnspentCoins(); _seedAccountBalancesFromAddressRecords(); await save(); @@ -1142,11 +1145,11 @@ abstract class ElectrumWalletBase UtxoDetails _createUTXOS({ required bool sendAll, required bool paysToSilentPayment, + required List candidates, int credentialsAmount = 0, int? inputsCount, int feeRate = 0, int? outputsVBytes, - UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) { List utxos = []; List vinOutpoints = []; @@ -1157,21 +1160,7 @@ abstract class ElectrumWalletBase bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; - var availableInputs = unspentCoinsForCurrentAccount.where((utx) { - if (!utx.isSending || utx.isFrozen) { - return false; - } - - switch (coinTypeToSpendFrom) { - case UnspentCoinType.mweb: - return utx.bitcoinAddressRecord.type == SegwitAddresType.mweb; - case UnspentCoinType.nonMweb: - return utx.bitcoinAddressRecord.type != SegwitAddresType.mweb; - case UnspentCoinType.any: - case UnspentCoinType.lightning: - return true; - } - }).toList(); + var availableInputs = List.from(candidates); final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); // Single Random Draw: order the pool by each coin's random priority so selection is @@ -1323,12 +1312,12 @@ abstract class ElectrumWalletBase int feeRate, { String? memo, bool hasSilentPayment = false, - UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + required List candidates, }) async { final utxoDetails = _createUTXOS( sendAll: true, paysToSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); int fee = await calcFee( @@ -1388,6 +1377,7 @@ abstract class ElectrumWalletBase String? memo, bool? useUnconfirmed, bool hasSilentPayment = false, + required List candidates, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { // Attempting to send less than the dust limit @@ -1395,14 +1385,6 @@ abstract class ElectrumWalletBase throw BitcoinTransactionNoDustException(); } - // if mweb isn't enabled, don't consider spending mweb coins: - if (this is LitecoinWallet) { - var mwebEnabled = (this as LitecoinWallet).mwebEnabled; - if (!mwebEnabled) { - coinTypeToSpendFrom = UnspentCoinType.nonMweb; - } - } - // If there is only one output, and the amount to send is more than the max spendable amount // then it is actually a send all transaction @@ -1412,7 +1394,7 @@ abstract class ElectrumWalletBase feeRate: feeRate, memo: memo, hasSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); if (credentialsAmount > maxSpendable) { throw BitcoinTransactionWrongBalanceException(); @@ -1430,7 +1412,7 @@ abstract class ElectrumWalletBase feeRate, memo: memo, hasSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); } } @@ -1455,7 +1437,7 @@ abstract class ElectrumWalletBase feeRate: feeRate, outputsVBytes: outputsVBytes, paysToSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; @@ -1476,6 +1458,7 @@ abstract class ElectrumWalletBase inputsCount: utxoDetails.utxos.length + 1, memo: memo, hasSilentPayment: hasSilentPayment, + candidates: candidates, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } @@ -1597,6 +1580,7 @@ abstract class ElectrumWalletBase memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, hasSilentPayment: hasSilentPayment, + candidates: candidates, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } else { @@ -1655,12 +1639,12 @@ abstract class ElectrumWalletBase required int feeRate, String? memo, bool hasSilentPayment = false, - UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + required List candidates, }) async { final utxoDetailsAll = _createUTXOS( sendAll: true, paysToSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); final output = [ @@ -1733,6 +1717,16 @@ abstract class ElectrumWalletBase final memo = transactionCredentials.outputs.first.memo; final coinTypeToSpendFrom = transactionCredentials.coinTypeToSpendFrom; + final candidates = (await spendableCoins( + selection: transactionCredentials.coinSelection, + coinType: coinTypeToSpendFrom, + )) + .cast(); + + if (candidates.isEmpty) { + throw BitcoinTransactionNoInputsException(); + } + var credentialsAmount = Money.zero(currency); var hasSilentPayment = false; @@ -1795,7 +1789,7 @@ abstract class ElectrumWalletBase feeRateInt, memo: memo, hasSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); } else { estimatedTx = await estimateTxForAmount( @@ -1805,6 +1799,7 @@ abstract class ElectrumWalletBase feeRateInt, memo: memo, hasSilentPayment: hasSilentPayment, + candidates: candidates, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } @@ -2019,17 +2014,28 @@ abstract class ElectrumWalletBase feeRate * (size ?? estimatedTransactionSize(inputsCount, outputsCount)); @override - int calculateEstimatedFee(TransactionPriority? priority, int? amount, - {int? outputsCount, int? size}) { + Future calculateEstimatedFee(TransactionPriority? priority, int? amount, + {int? outputsCount, int? size, CoinSelection selection = const AllCoinSelection()}) async { if (priority is BitcoinTransactionPriority) { - return calculateEstimatedFeeWithFeeRate(feeRate(priority), amount, - outputsCount: outputsCount, size: size); + return calculateEstimatedFeeWithFeeRate( + feeRate(priority), + amount, + outputsCount: outputsCount, + size: size, + candidates: await spendableCoins(selection: selection), + ); } return 0; } - int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount, int? size}) { + int calculateEstimatedFeeWithFeeRate( + int feeRate, + int? amount, { + required List candidates, + int? outputsCount, + int? size, + }) { if (size != null) { return feeAmountWithFeeRate(feeRate, 0, 0, size: size); } @@ -2039,24 +2045,18 @@ abstract class ElectrumWalletBase if (amount != null) { int totalValue = 0; - for (final input in unspentCoinsForCurrentAccount) { + for (final input in candidates) { if (totalValue >= amount) { break; } - if (input.isSending) { - totalValue += input.value; - inputsCount += 1; - } + totalValue += input.value; + inputsCount += 1; } if (totalValue < amount) return 0; } else { - for (final input in unspentCoinsForCurrentAccount) { - if (input.isSending) { - inputsCount += 1; - } - } + inputsCount = candidates.length; } // If send all, then we have no change value @@ -2133,7 +2133,6 @@ abstract class ElectrumWalletBase for (final addr in targetAddresses) { if (addr is! BitcoinSilentPaymentAddressRecord) { addr.balance = 0; - _appliedCoinKeysByAddress[addr.address]?.clear(); } } @@ -2162,16 +2161,13 @@ abstract class ElectrumWalletBase } } - final currentWalletUnspentCoins = - unspentCoinsInfo.values.where((element) => element.walletId == id); - - if (currentWalletUnspentCoins.length != updatedUnspentCoins.length) { - unspentCoins.forEach((coin) => addCoinInfo(coin)); + for (final coin in unspentCoins) { + if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) { + coin.bitcoinAddressRecord.balance += coin.value; + } } - await updateCoins(unspentCoins); - _updateAccountBalancesFromUnspents(); - await _refreshUnspentCoinsInfo(); + await _updateAccountBalancesFromUnspents(); } Future?>> _fetchUnspentsRegular( @@ -2276,45 +2272,16 @@ abstract class ElectrumWalletBase return updatedUnspentCoins; } - Future updateCoins(List newUnspentCoins) async { - if (newUnspentCoins.isEmpty) { - return; - } - - newUnspentCoins.forEach((coin) { - final coinInfoList = unspentCoinsInfo.values.where( - (element) => - element.walletId.contains(id) && - element.hash.contains(coin.hash) && - element.vout == coin.vout, - ); - - if (coinInfoList.isNotEmpty) { - final coinInfo = coinInfoList.first; - - coin.isFrozen = coinInfo.isFrozen; - coin.isSending = coinInfo.isSending; - coin.note = coinInfo.note; - - if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) { - final addr = coin.bitcoinAddressRecord.address; - final coinKey = '${coin.hash}:${coin.vout}'; - final appliedKeys = _appliedCoinKeysByAddress.putIfAbsent(addr, () => {}); - - if (appliedKeys.add(coinKey)) { - coin.bitcoinAddressRecord.balance += coinInfo.value; - } - } - } else { - addCoinInfo(coin); - } - }); - } - @action Future updateUnspentsForAddress(BitcoinAddressRecord address) async { - final newUnspentCoins = await fetchUnspent(address); - await updateCoins(newUnspentCoins ?? []); + final fetched = await fetchUnspent(address); + if (fetched == null || fetched.isEmpty) return; + + unspentCoins = { + // this doubles as deduplication + for (final coin in unspentCoins) coin.id: coin, + for (final coin in fetched) coin.id: coin, + }.values.toList(); } @action @@ -2341,77 +2308,6 @@ abstract class ElectrumWalletBase return updatedUnspentCoins; } - @action - Future addCoinInfo(BitcoinUnspent coin) async { - // Check if the coin is already in the unspentCoinsInfo for the wallet - final existingCoinInfo = unspentCoinsInfo.values.firstWhereOrNull( - (element) => - element.walletId == walletInfo.id && - element.hash == coin.hash && - element.vout == coin.vout, - ); - - if (existingCoinInfo == null) { - final newInfo = UnspentCoinsInfo( - walletId: id, - hash: coin.hash, - isFrozen: coin.isFrozen, - isSending: coin.isSending, - noteRaw: coin.note, - address: coin.bitcoinAddressRecord.address, - value: coin.value, - vout: coin.vout, - isChange: coin.isChange, - isSilentPayment: coin is BitcoinSilentPaymentsUnspent, - ); - - await unspentCoinsInfo.add(newInfo); - } - } - - Future _refreshUnspentCoinsInfo() async { - try { - final List keys = []; - final currentWalletUnspentCoins = - unspentCoinsInfo.values.where((record) => record.walletId == id); - - for (final element in currentWalletUnspentCoins) { - if (element.isFrozen) continue; - if (RegexUtils.addressTypeFromStr(element.address, network) is MwebAddress) continue; - - final existUnspentCoins = unspentCoins.where((coin) => element == coin); - - if (existUnspentCoins.isEmpty) { - keys.add(element.key); - } - } - - if (keys.isNotEmpty) { - await unspentCoinsInfo.deleteAll(keys); - } - } catch (e) { - printV("refreshUnspentCoinsInfo $e"); - } - } - - Future cleanUpDuplicateUnspentCoins() async { - final currentWalletUnspentCoins = - unspentCoinsInfo.values.where((element) => element.walletId == id); - final Map uniqueUnspentCoins = {}; - final List duplicateKeys = []; - - for (final unspentCoin in currentWalletUnspentCoins) { - final key = '${unspentCoin.hash}:${unspentCoin.vout}'; - if (!uniqueUnspentCoins.containsKey(key)) { - uniqueUnspentCoins[key] = unspentCoin; - } else { - duplicateKeys.add(unspentCoin.key); - } - } - - if (duplicateKeys.isNotEmpty) await unspentCoinsInfo.deleteAll(duplicateKeys); - } - void _seedAccountBalancesFromAddressRecords() { if (type != WalletType.bitcoin) return; if (accountBalances.isNotEmpty) return; @@ -2475,9 +2371,8 @@ abstract class ElectrumWalletBase throw Exception("Receiver output not found."); } - final availableInputs = - unspentCoinsForCurrentAccount.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); - int totalBalance = availableInputs.fold( + final availableInputs = await spendableCoins(); + final totalBalance = availableInputs.fold( 0, (previousValue, element) => previousValue + element.value.toInt()); int allInputsAmount = 0; @@ -2604,8 +2499,9 @@ abstract class ElectrumWalletBase // If still not enough, add UTXOs until the fee is covered, drawing them at // random instead of in the predictable wallet scan order (address, then age). if (remainingFee > BigInt.zero) { - final unusedUtxos = unspentCoinsForCurrentAccount - .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0) + final unusedUtxos = (await spendableCoins(selection: const AllCoinSelection())) + .cast() + .where((utxo) => (utxo.confirmations ?? 0) > 0) .toList() ..shuffle(Random.secure()); @@ -3007,7 +2903,7 @@ abstract class ElectrumWalletBase _missingHistoryQueue.putIfAbsent(txid, () => item); } } - + Future resolveQueuedTransactionDetails() { final inProgress = _resolveMissingHistoryFuture; if (inProgress != null) return inProgress; @@ -4044,6 +3940,15 @@ abstract class ElectrumWalletBase final unconfirmedByAccount = {}; final frozenByAccount = {}; + final frozen = await frozenIds(); + for (final coin in unspentCoins) { + if (coin.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) continue; + if (!frozen.contains(coin.id)) continue; + final account = coin.bitcoinAddressRecord.accountIndex; + totalFrozen += coin.value; + frozenByAccount[account] = (frozenByAccount[account] ?? 0) + coin.value; + } + if (hasSilentPaymentsScanning) { // Add values from unspent coins that are not fetched by the address list // i.e. scanned silent payments @@ -4052,7 +3957,7 @@ abstract class ElectrumWalletBase tx.unspents!.forEach((unspent) { if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { final account = unspent.bitcoinAddressRecord.accountIndex; - if (unspent.isFrozen) { + if (frozen.contains(unspent.id)) { totalFrozen += unspent.value; frozenByAccount[account] = (frozenByAccount[account] ?? 0) + unspent.value; } @@ -4064,24 +3969,6 @@ abstract class ElectrumWalletBase }); } - unspentCoinsInfo.values.forEach((info) { - unspentCoins.forEach((element) { - if (element.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) return; - - if (element.hash == info.hash && - element.vout == info.vout && - element.bitcoinAddressRecord.address == info.address && - element.value == info.value) { - if (info.isFrozen) { - totalFrozen += element.value; - final account = element.bitcoinAddressRecord.accountIndex; - frozenByAccount[account] = (frozenByAccount[account] ?? 0) + element.value; - } - } - }); - }); - - // Check if ALL returned balances have null confirmed — indicates server is unreachable. final allNull = balances.every((b) => b['confirmed'] == null); if (allNull) { // if we got null balance responses from the server, set our connection status to lost and return our last known balance: @@ -4199,6 +4086,17 @@ abstract class ElectrumWalletBase } } + @override + Future refreshBalanceAfterFreeze() async { + final frozen = Money.fromInt(await frozenBalance(), currency); + balance[currency] = balance[currency]!.copyWith(frozen: frozen); + + if (type == WalletType.bitcoin) { + accountBalances[currentAccountIndex] = + balanceForAccount(currentAccountIndex).copyWith(frozen: frozen); + } + } + @override Future checkNodeHealth() async { try { diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index d07f4b8f6b..4a88230d6b 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -9,6 +9,7 @@ import 'package:cw_core/amount/money.dart'; import 'package:cw_core/cake_hive.dart'; import 'package:cw_core/mweb_utxo.dart'; import 'package:cw_core/unspent_coin_type.dart'; +import 'package:cw_core/unspent_transaction_output.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/node.dart'; import 'package:cw_mweb/mwebd.pbgrpc.dart'; @@ -41,7 +42,6 @@ import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; -import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; import 'package:cw_core/wallet_type.dart'; @@ -66,7 +66,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, Uint8List? seedBytes, String? mnemonic, @@ -89,7 +88,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { xpub: xpub, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, network: LitecoinNetwork.mainnet, initialAddresses: initialAddresses, initialBalance: initialBalance, @@ -192,7 +190,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, String? passphrase, String? addressPageType, @@ -220,7 +217,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, initialMwebAddresses: initialMwebAddresses, initialBalance: initialBalance, @@ -244,7 +240,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { static Future open({ required String name, required WalletInfo walletInfo, - required Box unspentCoinsInfo, required String password, required EncryptionFileUtils encryptionFileUtils, }) async { @@ -320,7 +315,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: snp?.addresses, initialMwebAddresses: snp?.mwebAddresses, initialBalance: snp?.balance, @@ -858,6 +852,26 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { await updateAllUnspents(); } + @override + bool allowsCoinType(Unspent coin, UnspentCoinType coinType) { + final isMweb = coin is BitcoinUnspent && + coin.bitcoinAddressRecord.type == SegwitAddresType.mweb; + + if (isMweb && !mwebEnabled) { + return false; + } + + switch (coinType) { + case UnspentCoinType.mweb: + return isMweb; + case UnspentCoinType.nonMweb: + return !isMweb; + case UnspentCoinType.any: + case UnspentCoinType.lightning: + return true; + } + } + @override @action Future updateAllUnspents() async { @@ -901,8 +915,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { mwebUnspentCoins.add(unspent); }); - // copy coin control attributes to mwebCoins: - await updateCoins(mwebUnspentCoins); // get regular ltc unspents (this resets unspentCoins): await super.updateAllUnspents(); // add the mwebCoins: @@ -947,27 +959,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { addressRecord.txCount = 0; } - unspentCoins.forEach((coin) { - final coinInfoList = unspentCoinsInfo.values.where( - (element) => - element.walletId.contains(id) && - element.hash.contains(coin.hash) && - element.vout == coin.vout, - ); - - if (coinInfoList.isNotEmpty) { - final coinInfo = coinInfoList.first; - - coin.isFrozen = coinInfo.isFrozen; - coin.isSending = coinInfo.isSending; - coin.note = coinInfo.note; - if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) - coin.bitcoinAddressRecord.balance += coinInfo.value; - } else { - super.addCoinInfo(coin); - } - }); - // update the txCount for each address using the tx history, since we can't rely on mwebd // to have an accurate count, we should just keep it in sync with what we know from the tx history: for (final tx in transactionHistory.transactions.values) { @@ -1616,4 +1607,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { return BtcTransaction.fromRaw(rawHex); } + + @override + Uri coinControlUrl(String txId) => Uri.https("blockchair.com", "litecoin/transaction/${txId}"); } diff --git a/cw_bitcoin/lib/litecoin_wallet_addresses.dart b/cw_bitcoin/lib/litecoin_wallet_addresses.dart index 3c66dd63d5..31d634fd5b 100644 --- a/cw_bitcoin/lib/litecoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/litecoin_wallet_addresses.dart @@ -196,9 +196,6 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with } inputs.forEach((element) { - if (!element.isSending || element.isFrozen) { - return; - } if (element.address.startsWith("ltcmweb")) { comesFromMweb = true; } diff --git a/cw_bitcoin/lib/litecoin_wallet_service.dart b/cw_bitcoin/lib/litecoin_wallet_service.dart index 09ab924d1f..26a6a9e453 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -2,9 +2,9 @@ import 'dart:io'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_bitcoin/mnemonic_is_incorrect_exception.dart'; +import "package:cw_core/coin_control/coin_notes_store.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; import 'package:cw_core/encryption_file_utils.dart'; -import 'package:cw_core/unspent_coins_info.dart'; -import 'package:hive/hive.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_bitcoin/bitcoin_wallet_creation_credentials.dart'; import 'package:cw_bitcoin/litecoin_wallet.dart'; @@ -20,9 +20,8 @@ class LitecoinWalletService extends WalletService< BitcoinRestoreWalletFromSeedCredentials, LitecoinWalletFromKeysCredentials, BitcoinRestoreWalletFromHardware> { - LitecoinWalletService(this.unspentCoinsInfoSource, this.isDirect); + LitecoinWalletService(this.isDirect); - final Box unspentCoinsInfoSource; final bool isDirect; @override @@ -50,7 +49,6 @@ class LitecoinWalletService extends WalletService< walletInfo: credentials.walletInfo!, derivationInfo: credentials.derivationInfo ?? (await credentials.walletInfo!.getDerivationInfo()), - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.save(); @@ -75,7 +73,6 @@ class LitecoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -87,7 +84,6 @@ class LitecoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -125,15 +121,8 @@ class LitecoinWalletService extends WalletService< } } - final unspentCoinsToDelete = unspentCoinsInfoSource.values - .where((unspentCoin) => unspentCoin.walletId == walletInfo.id) - .toList(); - - final keysToDelete = unspentCoinsToDelete.map((unspentCoin) => unspentCoin.key).toList(); - - if (keysToDelete.isNotEmpty) { - await unspentCoinsInfoSource.deleteAll(keysToDelete); - } + await FrozenCoinsStore.instance.deleteWallet(walletInfo.internalId); + await CoinNotesStore.instance.deleteWallet(walletInfo.internalId); } @override @@ -160,7 +149,6 @@ class LitecoinWalletService extends WalletService< xpub: credentials.hwAccountData.xpub, walletInfo: credentials.walletInfo!, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.save(); @@ -181,7 +169,6 @@ class LitecoinWalletService extends WalletService< spendPubkeyOverride: credentials.spendPubkey, walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -204,7 +191,6 @@ class LitecoinWalletService extends WalletService< walletInfo: credentials.walletInfo!, derivationInfo: credentials.derivationInfo ?? (await credentials.walletInfo!.getDerivationInfo()), - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.save(); diff --git a/cw_bitcoin/lib/payjoin/manager.dart b/cw_bitcoin/lib/payjoin/manager.dart index ba0d616ad3..49579d7672 100644 --- a/cw_bitcoin/lib/payjoin/manager.dart +++ b/cw_bitcoin/lib/payjoin/manager.dart @@ -157,7 +157,7 @@ class PayjoinManager { final proposalPsbt = message['psbt'] as String; writePayjoinLog("Sender($pjUri) proposedPSBT: $proposalPsbt"); - final utxos = _wallet.getUtxoWithPrivateKeys(); + final utxos = await _wallet.getUtxoWithPrivateKeys(); final finalizedPsbt = await _wallet.signPsbt(proposalPsbt, utxos); writePayjoinLog("Sender($pjUri) finalizedPsbt: $finalizedPsbt"); @@ -290,10 +290,10 @@ class PayjoinManager { break; case PayjoinReceiverRequestTypes.getCandidateInputs: - utxos = _wallet.getUtxoWithPrivateKeys(confirmedOnly: true); + utxos = await _wallet.getUtxoWithPrivateKeys(confirmedOnly: true); if (utxos.isEmpty) { await _wallet.updateAllUnspents(); - utxos = _wallet.getUtxoWithPrivateKeys(confirmedOnly: true); + utxos = await _wallet.getUtxoWithPrivateKeys(confirmedOnly: true); } // Candidates arrive in wallet scan order (address, then age), which is // predictable; shuffle so the receiver's input choice can't mirror it. diff --git a/cw_bitcoin/test/bitcoin_unspent_id_test.dart b/cw_bitcoin/test/bitcoin_unspent_id_test.dart new file mode 100644 index 0000000000..f733be5866 --- /dev/null +++ b/cw_bitcoin/test/bitcoin_unspent_id_test.dart @@ -0,0 +1,73 @@ +import "package:bitcoin_base/bitcoin_base.dart"; +import "package:cw_bitcoin/bitcoin_address_record.dart"; +import "package:cw_bitcoin/bitcoin_unspent.dart"; +import "package:flutter_test/flutter_test.dart"; + +BitcoinAddressRecord record(String address, int index, {BitcoinAddressType? type}) => + BitcoinAddressRecord( + address, + index: index, + type: type ?? P2pkhAddressType.p2pkh, + network: LitecoinNetwork.mainnet, + ); + +void main() { + group("BitcoinUnspent.id", () { + test("is the transaction hash and output index", () { + final coin = BitcoinUnspent(record("addr", 0), "abc123", 1000, 2); + expect(coin.id, "abc123:2"); + }); + + test("distinguishes two outputs of the same transaction", () { + final first = BitcoinUnspent(record("addr", 0), "abc123", 1000, 0); + final second = BitcoinUnspent(record("addr", 0), "abc123", 500, 1); + + // Two outputs of one transaction is the ordinary case, and the previous + // implementation matched stored records on the hash alone. + expect(first.id, isNot(second.id)); + }); + + test("does not change with the value", () { + final cheap = BitcoinUnspent(record("addr", 0), "abc123", 1, 0); + final rich = BitcoinUnspent(record("addr", 0), "abc123", 999999, 0); + expect(cheap.id, rich.id); + }); + + test("does not change with the address record's index", () { + final early = BitcoinUnspent(record("addr", 0), "abc123", 1000, 0); + final late_ = BitcoinUnspent(record("addr", 41), "abc123", 1000, 0); + expect(early.id, late_.id); + }); + }); + + group("MWEB outputs", () { + // MWEB coins are built with the index of their address in the wallet's MWEB + // address list where the output index would go, and that list grows. + BitcoinUnspent mweb(String outputId, int addressIndex) => BitcoinUnspent( + record("ltcmweb1qq...", addressIndex, type: SegwitAddresType.mweb), + outputId, + 1000, + addressIndex, + ); + + test("are identified by their output id alone", () { + expect(mweb("output-abc", 3).id, "output-abc"); + }); + + test("keep their identity when the address list grows", () { + // The bug this closes: the same output, seen after more MWEB addresses + // were generated, used to hash to a different id and silently lose the + // user's frozen flag and note. + expect(mweb("output-abc", 3).id, mweb("output-abc", 57).id); + }); + + test("two different outputs stay distinct", () { + expect(mweb("output-abc", 0).id, isNot(mweb("output-def", 0).id)); + }); + + test("do not collide with a regular output's id shape", () { + final regular = BitcoinUnspent(record("ltc1q...", 0), "output-abc", 1000, 0); + expect(mweb("output-abc", 0).id, isNot(regular.id)); + }); + }); +} diff --git a/cw_bitcoin/test/electrum_wallet_accounts_coin_control_test.dart b/cw_bitcoin/test/electrum_wallet_accounts_coin_control_test.dart new file mode 100644 index 0000000000..b578ca4b74 --- /dev/null +++ b/cw_bitcoin/test/electrum_wallet_accounts_coin_control_test.dart @@ -0,0 +1,134 @@ +import "dart:typed_data"; + +import "package:bitcoin_base/bitcoin_base.dart"; +import "package:cw_bitcoin/bitcoin_address_record.dart"; +import "package:cw_bitcoin/bitcoin_unspent.dart"; +import "package:cw_bitcoin/electrum_wallet.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/encryption_file_utils.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:shared_preferences/shared_preferences.dart"; + +class _FakeFrozenCoinsStore implements FrozenCoinsStore { + final Map> _byWallet = {}; + + @override + Future> frozenIds(int walletInfoId) async => + (_byWallet[walletInfoId] ?? const {}) + .entries + .where((entry) => entry.value) + .map((entry) => entry.key) + .toSet(); + + @override + Future setFrozen(int walletInfoId, String id, bool frozen) async => + _byWallet.putIfAbsent(walletInfoId, () => {})[id] = frozen; + + @override + Future deleteWallet(int walletInfoId) async => _byWallet.remove(walletInfoId); +} + +ElectrumWallet buildWallet() => ElectrumWallet( + password: "", + walletInfo: WalletInfo.external( + id: "bitcoin_accounts_test", + name: "accounts_test", + type: WalletType.bitcoin, + isRecovery: false, + restoreHeight: 0, + date: DateTime(2026), + dirPath: "", + path: "", + address: "", + ), + derivationInfo: DerivationInfo( + derivationType: DerivationType.bip39, + derivationPath: "m/84'/0'/0'", + ), + network: BitcoinNetwork.mainnet, + encryptionFileUtils: encryptionFileUtilsFor(false), + seedBytes: Uint8List(64), + currency: CryptoCurrency.btc, + ); + +BitcoinUnspent coin(String hash, {required int accountIndex, int value = 1000}) => BitcoinUnspent( + BitcoinAddressRecord( + "addr-$hash", + index: 0, + accountIndex: accountIndex, + type: SegwitAddresType.p2wpkh, + network: BitcoinNetwork.mainnet, + ), + hash, + value, + 0, + ); + +void main() { + final originalStore = FrozenCoinsStore.instance; + + late ElectrumWallet wallet; + + final first = coin("aa", accountIndex: 0, value: 100); + final second = coin("bb", accountIndex: 0, value: 200); + final other = coin("cc", accountIndex: 1, value: 400); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + FrozenCoinsStore.instance = _FakeFrozenCoinsStore(); + wallet = buildWallet()..unspentCoins = [first, second, other]; + }); + + tearDown(() => FrozenCoinsStore.instance = originalStore); + + group("unspents", () { + test("lists only the current account's coins", () { + expect(wallet.unspents.map((coin) => coin.id), [first.id, second.id]); + + wallet.walletInfo.currentAccountIndex = 1; + + expect(wallet.unspents.map((coin) => coin.id), [other.id]); + }); + }); + + group("spendableCoins", () { + test("never offers another account's coins", () async { + final spendable = await wallet.spendableCoins(); + + expect(spendable.map((coin) => coin.id), [first.id, second.id]); + }); + + test("excludes a frozen coin of the current account", () async { + await wallet.setFrozen(second.id, true); + + final spendable = await wallet.spendableCoins(); + + expect(spendable.map((coin) => coin.id), [first.id]); + }); + }); + + group("freezing", () { + test("counts only the current account's frozen coins", () async { + await wallet.setFrozen(other.id, true); + await wallet.setFrozen(first.id, true); + + expect(await wallet.frozenBalance(), first.value); + expect(wallet.balance[CryptoCurrency.btc]!.frozen, Money.fromInt(first.value, CryptoCurrency.btc)); + }); + + test("keeps each account's frozen amount after switching accounts", () async { + await wallet.setFrozen(first.id, true); + + wallet.walletInfo.currentAccountIndex = 1; + await wallet.setFrozen(other.id, true); + + expect(wallet.balanceForAccount(0).frozen, Money.fromInt(first.value, CryptoCurrency.btc)); + expect(wallet.balanceForAccount(1).frozen, Money.fromInt(other.value, CryptoCurrency.btc)); + expect(wallet.balance[CryptoCurrency.btc]!.frozen, Money.fromInt(other.value, CryptoCurrency.btc)); + }); + }); +} diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart index 2f4a4dc491..9a30be9d1a 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart @@ -10,11 +10,10 @@ import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/transaction_priority.dart'; -import 'package:cw_core/unspent_coins_info.dart'; +import "package:cw_core/unspent_transaction_output.dart"; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; import 'package:flutter/foundation.dart'; -import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'bitcoin_cash_base.dart'; @@ -29,7 +28,6 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required Uint8List seedBytes, required EncryptionFileUtils encryptionFileUtils, String? passphrase, @@ -43,7 +41,6 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, network: BitcoinCashNetwork.mainnet, initialAddresses: initialAddresses, initialBalance: initialBalance, @@ -75,7 +72,6 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { {required String mnemonic, required String password, required WalletInfo walletInfo, - required Box unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, String? passphrase, String? addressPageType, @@ -88,7 +84,6 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, initialBalance: initialBalance, seedBytes: MnemonicBip39.toSeed(mnemonic, passphrase: passphrase), @@ -103,7 +98,6 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { static Future open({ required String name, required WalletInfo walletInfo, - required Box unspentCoinsInfo, required String password, required EncryptionFileUtils encryptionFileUtils, }) async { @@ -142,7 +136,6 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: snp?.addresses.map((addr) { try { BitcoinCashAddress(addr.address); @@ -182,15 +175,20 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { Uint8List.fromList(hd.childKey(Bip32KeyIndex(index)).privateKey.raw), ); - int calculateEstimatedFeeWithFeeRate(int feeRate, int? amount, {int? outputsCount, int? size}) { + @override + int calculateEstimatedFeeWithFeeRate( + int feeRate, + int? amount, { + required List candidates, + int? outputsCount, + int? size, + }) { int inputsCount = 0; int totalValue = 0; - for (final input in unspentCoins) { - if (input.isSending) { - inputsCount++; - totalValue += input.value; - } + for (final input in candidates) { + inputsCount++; + totalValue += input.value; if (amount != null && totalValue >= amount) { break; } @@ -234,4 +232,7 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { ); return priv.signMessage(StringUtils.encode(message)); } + + @override + Uri coinControlUrl(String txId) => Uri.https("blockchair.com", "/bitcoin-cash/transaction/${txId}"); } diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart index b8888c5eaf..bd24687cf3 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart @@ -3,22 +3,21 @@ import 'dart:io'; import 'package:bip39/bip39.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; import 'package:cw_bitcoin_cash/cw_bitcoin_cash.dart'; +import "package:cw_core/coin_control/coin_notes_store.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/pathForWallet.dart'; -import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; -import 'package:hive/hive.dart'; class BitcoinCashWalletService extends WalletService< BitcoinCashNewWalletCredentials, BitcoinCashRestoreWalletFromSeedCredentials, BitcoinCashRestoreWalletFromWIFCredentials, BitcoinCashNewWalletCredentials> { - BitcoinCashWalletService(this.unspentCoinsInfoSource, this.isDirect); + BitcoinCashWalletService(this.isDirect); - final Box unspentCoinsInfoSource; final bool isDirect; @override @@ -36,7 +35,6 @@ class BitcoinCashWalletService extends WalletService< mnemonic: credentials.mnemonic ?? MnemonicBip39.generate(strength: strength), password: credentials.password!, walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), passphrase: credentials.passphrase, ); @@ -58,7 +56,6 @@ class BitcoinCashWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -70,7 +67,6 @@ class BitcoinCashWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -87,15 +83,8 @@ class BitcoinCashWalletService extends WalletService< } await WalletInfo.delete(walletInfo); - final unspentCoinsToDelete = unspentCoinsInfoSource.values - .where((unspentCoin) => unspentCoin.walletId == walletInfo.id) - .toList(); - - final keysToDelete = unspentCoinsToDelete.map((unspentCoin) => unspentCoin.key).toList(); - - if (keysToDelete.isNotEmpty) { - await unspentCoinsInfoSource.deleteAll(keysToDelete); - } + await FrozenCoinsStore.instance.deleteWallet(walletInfo.internalId); + await CoinNotesStore.instance.deleteWallet(walletInfo.internalId); } @override @@ -121,7 +110,6 @@ class BitcoinCashWalletService extends WalletService< password: credentials.password!, mnemonic: credentials.mnemonic, walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), passphrase: credentials.passphrase); await wallet.save(); diff --git a/cw_core/lib/amount/money.dart b/cw_core/lib/amount/money.dart index a4c258b543..e2319b783a 100644 --- a/cw_core/lib/amount/money.dart +++ b/cw_core/lib/amount/money.dart @@ -36,6 +36,14 @@ class Money implements Comparable { return Money(amount, currency, decimals); } + factory Money.safeParse(source, Currency currency, {bool isBaseUnit = false}) { + final amount = isBaseUnit + ? BigInt.parse(source.toString()) + : parseFixed(source.toString().withDecimals(currency.decimals), currency.decimals); + + return Money(amount, currency); + } + /// Parse the [source] and turn it into [Money] if possible trimming trailing 0s /// /// As [parse] except that this method returns `null` if the input is not diff --git a/cw_core/lib/coin_control/coin_control_wallet.dart b/cw_core/lib/coin_control/coin_control_wallet.dart new file mode 100644 index 0000000000..c09221df95 --- /dev/null +++ b/cw_core/lib/coin_control/coin_control_wallet.dart @@ -0,0 +1,52 @@ +import "package:cw_core/balance.dart"; +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; +import "package:cw_core/transaction_history.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/unspent_coin_type.dart"; +import "package:cw_core/unspent_transaction_output.dart"; +import "package:cw_core/wallet_base.dart"; + +mixin CoinControlWallet + on WalletBase { + List get unspents; + + Future refreshUnspents(); + + FrozenCoinsStore get frozenCoinsStore => FrozenCoinsStore.instance; + + Future> frozenIds() => frozenCoinsStore.frozenIds(walletInfo.internalId); + + Future setFrozen(String coinId, bool frozen) async { + await frozenCoinsStore.setFrozen(walletInfo.internalId, coinId, frozen); + await refreshBalanceAfterFreeze(); + } + + Future refreshBalanceAfterFreeze(); + + bool allowsCoinType(Unspent coin, UnspentCoinType coinType) => true; + + Future> spendableCoins({ + CoinSelection selection = const AllCoinSelection(), + UnspentCoinType coinType = UnspentCoinType.any, + }) async { + final frozen = await frozenIds(); + + return unspents + .where( + (coin) => + !frozen.contains(coin.id) && selection.allows(coin) && allowsCoinType(coin, coinType), + ) + .toList(); + } + + Future frozenBalance() async { + final frozen = await frozenIds(); + return unspents + .where((coin) => frozen.contains(coin.id)) + .fold(0, (sum, coin) => sum + coin.value); + } + + Uri? coinControlUrl(String txId) => null; +} diff --git a/cw_core/lib/coin_control/coin_notes_store.dart b/cw_core/lib/coin_control/coin_notes_store.dart new file mode 100644 index 0000000000..ee711c1a2e --- /dev/null +++ b/cw_core/lib/coin_control/coin_notes_store.dart @@ -0,0 +1,24 @@ +import "package:cw_core/db/sqlite.dart"; +import "package:sqflite/sqflite.dart"; + +class CoinNotesStore { + static CoinNotesStore instance = CoinNotesStore(); + + static const tableName = "CoinNote"; + + Future> forWallet(int walletInfoId) async { + final rows = await db!.query(tableName, where: "walletInfoId = ?", whereArgs: [walletInfoId]); + return { + for (final row in rows) row["id"]! as String: row["note"] as String? ?? "", + }; + } + + Future save(int walletInfoId, String id, String note) => db!.insert( + tableName, + {"walletInfoId": walletInfoId, "id": id, "note": note}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + Future deleteWallet(int walletInfoId) => + db!.delete(tableName, where: "walletInfoId = ?", whereArgs: [walletInfoId]); +} diff --git a/cw_core/lib/coin_control/coin_selection.dart b/cw_core/lib/coin_control/coin_selection.dart new file mode 100644 index 0000000000..2ed50009c3 --- /dev/null +++ b/cw_core/lib/coin_control/coin_selection.dart @@ -0,0 +1,23 @@ +import "package:cw_core/unspent_transaction_output.dart"; + +abstract class CoinSelection { + const CoinSelection(); + + bool allows(Unspent coin); +} + +class AllCoinSelection extends CoinSelection { + const AllCoinSelection(); + + @override + bool allows(Unspent coin) => true; +} + +class SpecificCoinSelection extends CoinSelection { + SpecificCoinSelection(Iterable ids) : ids = Set.unmodifiable(ids); + + final Set ids; + + @override + bool allows(Unspent coin) => ids.contains(coin.id); +} diff --git a/cw_core/lib/coin_control/frozen_coins_store.dart b/cw_core/lib/coin_control/frozen_coins_store.dart new file mode 100644 index 0000000000..49f18dd334 --- /dev/null +++ b/cw_core/lib/coin_control/frozen_coins_store.dart @@ -0,0 +1,27 @@ +import "package:cw_core/db/sqlite.dart"; +import "package:sqflite/sqflite.dart"; + +class FrozenCoinsStore { + static FrozenCoinsStore instance = FrozenCoinsStore(); + + static const tableName = "FrozenCoin"; + + Future> frozenIds(int walletInfoId) async { + final rows = await db!.query( + tableName, + columns: ["id"], + where: "walletInfoId = ? AND frozen = 1", + whereArgs: [walletInfoId], + ); + return rows.map((row) => row["id"]! as String).toSet(); + } + + Future setFrozen(int walletInfoId, String id, bool frozen) => db!.insert( + tableName, + {"walletInfoId": walletInfoId, "id": id, "frozen": frozen ? 1 : 0}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + Future deleteWallet(int walletInfoId) => + db!.delete(tableName, where: "walletInfoId = ?", whereArgs: [walletInfoId]); +} diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart index 30b1be318a..828fcd4c8e 100644 --- a/cw_core/lib/db/sqlite.dart +++ b/cw_core/lib/db/sqlite.dart @@ -66,7 +66,7 @@ Future _initDb({String? pathOverride}) async { await db?.close(); db = await openDatabase( dbFile.path, - version: 15, + version: 16, onUpgrade: (db, oldVersion, newVersion) async { printV("migrating: $oldVersion, $newVersion"); if (oldVersion <= 1) { @@ -201,6 +201,10 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings ( definition: "BOOLEAN DEFAULT FALSE", ); } + + if (oldVersion <= 15) { + await _createCoinControlTables(db); + } }, onCreate: (Database db, int version) async { await db.execute(''' @@ -308,6 +312,8 @@ CREATE TABLE BalanceCardStyleSettings ( await _createTronTokenTable(db); await _createImportedNFTTable(db); await _createWalletInfoAccountTable(db); + await _createCoinControlTables(db); + }, ); } @@ -330,6 +336,25 @@ CREATE TABLE ChartsAssets ( """); } +Future _createCoinControlTables(Database db) async { + await db.execute(""" +CREATE TABLE IF NOT EXISTS FrozenCoin ( + walletInfoId INTEGER NOT NULL, + id TEXT NOT NULL, + frozen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (walletInfoId, id) +); +"""); + await db.execute(""" +CREATE TABLE IF NOT EXISTS CoinNote ( + walletInfoId INTEGER NOT NULL, + id TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + PRIMARY KEY (walletInfoId, id) +); +"""); +} + Future _createTradeTable(Database db) async { await db.execute(""" CREATE TABLE IF NOT EXISTS Trade ( diff --git a/cw_core/lib/unspent_coins_info.dart b/cw_core/lib/unspent_coins_info.dart index 8e0ec7883a..8eb22f03d8 100644 --- a/cw_core/lib/unspent_coins_info.dart +++ b/cw_core/lib/unspent_coins_info.dart @@ -1,9 +1,32 @@ +import 'package:cw_core/cake_hive.dart'; +import 'package:cw_core/coin_control/coin_notes_store.dart'; +import 'package:cw_core/coin_control/frozen_coins_store.dart'; import 'package:cw_core/hive_type_ids.dart'; import 'package:cw_core/unspent_comparable_mixin.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:hive/hive.dart'; part 'unspent_coins_info.part.dart'; +Future performUnspentCoinsInfoHiveMigration() async { + try { + if (!CakeHive.isAdapterRegistered(UnspentCoinsInfo.typeId)) { + CakeHive.registerAdapter(UnspentCoinsInfoAdapter()); + } + + if (!await CakeHive.boxExists(UnspentCoinsInfo.boxName)) { + return; + } + + final wallets = await WalletInfo.getAll(); + await UnspentCoinsInfo.migrateAllToSqlite(wallets); + } catch (e) { + printV("Error performing UnspentCoinsInfo Hive migration: $e, continuing anyway"); + } +} + // @HiveType(typeId: UnspentCoinsInfo.typeId) class UnspentCoinsInfo extends HiveObject with UnspentComparable { UnspentCoinsInfo({ @@ -64,4 +87,51 @@ class UnspentCoinsInfo extends HiveObject with UnspentComparable { String get note => noteRaw ?? ''; set note(String value) => noteRaw = value; + + static Future migrateAllToSqlite(List wallets) async { + final box = await CakeHive.openBox(boxName); + + for (final record in box.values.toList()) { + final wallet = wallets + .cast() + .firstWhere((item) => item!.id == record.walletId, orElse: () => null); + + if (wallet == null) { + continue; + } + + try { + await record.migrateToSqlite(wallet); + await record.delete(); + } catch (e) { + printV("Error migrating unspent record ${record.walletId}: $e, continuing anyway"); + } + } + } + + Future migrateToSqlite(WalletInfo wallet) async { + final id = _outputId(wallet.type); + + if (note.isNotEmpty) { + await CoinNotesStore.instance.save(wallet.internalId, id, note); + } + if (wallet.type != WalletType.monero) { + await FrozenCoinsStore.instance.setFrozen(wallet.internalId, id, isFrozen); + } + } + + String _outputId(WalletType walletType) { + if (keyImage != null && keyImage!.isNotEmpty) { + return keyImage!; + } + + if (walletType == WalletType.litecoin && _isMwebAddress(address)) { + return hash; + } + + return "$hash:$vout"; + } + + static bool _isMwebAddress(String address) => + address.startsWith("ltcmweb1") || address.startsWith("tmweb1"); } diff --git a/cw_core/lib/unspent_transaction_output.dart b/cw_core/lib/unspent_transaction_output.dart index 31820e3a11..67e74dd4a6 100644 --- a/cw_core/lib/unspent_transaction_output.dart +++ b/cw_core/lib/unspent_transaction_output.dart @@ -1,11 +1,6 @@ -import 'package:cw_core/unspent_comparable_mixin.dart'; -class Unspent with UnspentComparable { - Unspent(this.address, this.hash, this.value, this.vout, this.keyImage) - : isSending = true, - isFrozen = false, - isChange = false, - note = ''; +class Unspent { + Unspent(this.address, this.hash, this.value, this.vout, this.keyImage) : isChange = false; final String address; final String hash; @@ -14,16 +9,15 @@ class Unspent with UnspentComparable { final String? keyImage; bool isChange; - bool isSending; - bool isFrozen; int? confirmations; - String note; + + String get id => keyImage ?? "$hash:$vout"; + + bool get isSilentPayment => false; bool get isP2wpkh => - address.startsWith('bc') || address.startsWith('tb') || address.startsWith('ltc'); + address.startsWith("bc") || address.startsWith("tb") || address.startsWith("ltc"); @override - String toString() { - return 'Unspent(address: $address, hash: $hash, value: $value, vout: $vout, keyImage: $keyImage, isSending: $isSending, isFrozen: $isFrozen, isChange: $isChange, note: $note)'; - } + String toString() => "Unspent(id: $id, address: $address, value: $value, isChange: $isChange)"; } diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 03c438a45a..b1e84aa5cd 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -3,6 +3,7 @@ import 'package:mobx/mobx.dart'; import 'package:cw_core/balance.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/transaction_history.dart'; +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/wallet_addresses.dart'; import 'package:flutter/foundation.dart'; @@ -92,7 +93,11 @@ abstract class WalletBase createTransaction(Object credentials); - int calculateEstimatedFee(TransactionPriority priority, int? amount); + Future calculateEstimatedFee( + TransactionPriority priority, + int? amount, { + CoinSelection selection = const AllCoinSelection(), + }); Future updateEstimatedFeesParams(TransactionPriority? priority) async {} diff --git a/cw_core/test/coin_control/coin_control_wallet_test.dart b/cw_core/test/coin_control/coin_control_wallet_test.dart new file mode 100644 index 0000000000..7be24569ce --- /dev/null +++ b/cw_core/test/coin_control/coin_control_wallet_test.dart @@ -0,0 +1,271 @@ +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; +import "package:cw_core/unspent_coin_type.dart"; +import "package:cw_core/unspent_transaction_output.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "fake_coin_control_stores.dart"; +import "fake_coin_control_wallet.dart"; + +Unspent coin(String hash, int vout, {int value = 1000}) => + Unspent("addr-$hash", hash, value, vout, null); + +void main() { + late FakeFrozenCoinsStore store; + late FakeCoinControlWallet wallet; + + final a = coin("aa", 0, value: 100); + final b = coin("bb", 0, value: 200); + final c = coin("cc", 0, value: 300); + + setUp(() { + store = FakeFrozenCoinsStore(); + wallet = FakeCoinControlWallet( + id: "wallet-a", + unspents: [a, b, c], + frozenCoinsStore: store, + ); + }); + + group("spendableCoins", () { + test("defaults to an all-outputs selection when none is given", () async { + final spendable = await wallet.spendableCoins(); + expect(spendable.map((coin) => coin.id), [a.id, b.id, c.id]); + }); + + test("returns only the selected outputs", () async { + final spendable = await wallet.spendableCoins(selection: SpecificCoinSelection({a.id, c.id})); + expect(spendable.map((coin) => coin.id), [a.id, c.id]); + }); + + test("returns nothing for an empty selection", () async { + final spendable = + await wallet.spendableCoins(selection: SpecificCoinSelection(const {})); + expect(spendable, isEmpty); + }); + + test("excludes a frozen output even under an all-outputs selection", () async { + // The rule the previous implementation dropped in two fee estimators. + await wallet.setFrozen(b.id, true); + + final spendable = await wallet.spendableCoins(); + expect(spendable.map((coin) => coin.id), [a.id, c.id]); + }); + + test("excludes a frozen output even when it is explicitly selected", () async { + await wallet.setFrozen(b.id, true); + + final spendable = await wallet.spendableCoins(selection: SpecificCoinSelection({a.id, b.id})); + expect(spendable.map((coin) => coin.id), [a.id]); + }); + + test("unfreezing makes an output spendable again", () async { + await wallet.setFrozen(b.id, true); + await wallet.setFrozen(b.id, false); + + final spendable = await wallet.spendableCoins(); + expect(spendable.map((coin) => coin.id), [a.id, b.id, c.id]); + }); + + test("reads the frozen set once per call, not once per output", () async { + final counting = _CountingStore(store); + final counted = FakeCoinControlWallet( + id: "wallet-a", + unspents: List.generate(50, (i) => coin("tx$i", 0)), + frozenCoinsStore: counting, + ); + + await counted.spendableCoins(); + + // Transaction building calls this repeatedly per transaction, so a read + // per output would be n * passes queries against the store. + expect(counting.frozenIdsCalls, 1); + }); + + test("another wallet's frozen record does not affect this one", () async { + await store.setFrozen(otherWalletId, b.id, true); + + final spendable = await wallet.spendableCoins(); + expect(spendable, hasLength(3)); + }); + + test("survives a refresh that replaces every Unspent instance", () async { + await wallet.setFrozen(b.id, true); + wallet.unspents = [coin("aa", 0), coin("bb", 0), coin("cc", 0)]; + + final spendable = await wallet.spendableCoins(selection: SpecificCoinSelection({a.id, b.id})); + expect(spendable.map((coin) => coin.id), [a.id]); + }); + }); + + group("spendableCoins with a coin type", () { + late FakeCoinControlWallet mixedWallet; + final mweb = coin("mweb-out", 0); + final regular = coin("regular-out", 0); + + setUp(() { + mixedWallet = FakeCoinControlWallet( + id: "wallet-a", + unspents: [regular, mweb], + frozenCoinsStore: store, + coinTypeOf: (coin) => + coin.hash.startsWith("mweb") ? UnspentCoinType.mweb : UnspentCoinType.nonMweb, + ); + }); + + test("any accepts every kind", () async { + final spendable = await mixedWallet.spendableCoins(coinType: UnspentCoinType.any); + expect(spendable, hasLength(2)); + }); + + test("narrows to the requested kind", () async { + final spendable = await mixedWallet.spendableCoins( + coinType: UnspentCoinType.nonMweb, + ); + expect(spendable.map((coin) => coin.id), [regular.id]); + }); + + test("an all-outputs selection cannot defeat the coin type", () async { + // Select-all under a constraint must still not produce a transaction the + // flow cannot make: the constraint is applied by this method, not by the + // selection, and not by filtering the list the user was shown. + final spendable = await mixedWallet.spendableCoins( + coinType: UnspentCoinType.mweb, + ); + expect(spendable.map((coin) => coin.id), [mweb.id]); + }); + + test("an explicit selection of the wrong kind yields nothing", () async { + final spendable = await mixedWallet.spendableCoins( + selection: SpecificCoinSelection({mweb.id}), + coinType: UnspentCoinType.nonMweb, + ); + expect(spendable, isEmpty); + }); + + test("frozen still wins over a matching coin type", () async { + await mixedWallet.setFrozen(regular.id, true); + + final spendable = await mixedWallet.spendableCoins( + coinType: UnspentCoinType.nonMweb, + ); + expect(spendable, isEmpty); + }); + }); + + group("republishing the balance", () { + test("freezing republishes it", () async { + await wallet.setFrozen(a.id, true); + + // Without this the dashboard keeps the total it last computed, and the + // change only appears once something else triggers a balance update. + expect(wallet.balanceRefreshCount, 1); + }); + + test("thawing republishes it too", () async { + await wallet.setFrozen(a.id, true); + await wallet.setFrozen(a.id, false); + + expect(wallet.balanceRefreshCount, 2); + }); + + test("it happens after the write, so the new total is already visible", () async { + await wallet.setFrozen(a.id, true); + + expect(wallet.frozenAtRefresh, [ + {a.id} + ]); + }); + + test("a failed write does not republish", () async { + final failing = FakeCoinControlWallet( + id: "wallet-a", + unspents: [a], + frozenCoinsStore: _FailingStore(), + ); + + await expectLater(failing.setFrozen(a.id, true), throwsException); + + // The balance still matches what is stored, which is what it should show. + expect(failing.balanceRefreshCount, 0); + }); + + test("nothing else republishes it", () async { + await wallet.spendableCoins(); + await wallet.frozenBalance(); + await wallet.refreshUnspents(); + + expect(wallet.balanceRefreshCount, 0); + }); + }); + + group("frozenBalance", () { + test("is zero when nothing is frozen", () async { + expect(await wallet.frozenBalance(), 0); + }); + + test("sums only the frozen outputs", () async { + await wallet.setFrozen(a.id, true); + await wallet.setFrozen(c.id, true); + + expect(await wallet.frozenBalance(), 400); + }); + + test("does not double count", () async { + await wallet.setFrozen(a.id, true); + await wallet.setFrozen(a.id, true); + + expect(await wallet.frozenBalance(), 100); + }); + + test("ignores a record for an output that has since been spent", () async { + // Derived by matching records against the live output list, so a leftover + // record contributes nothing and pruning is not a correctness concern. + await wallet.setFrozen(a.id, true); + wallet.unspents = [b, c]; + + expect(await wallet.frozenBalance(), 0); + }); + + test("ignores another wallet's frozen records", () async { + await store.setFrozen(otherWalletId, a.id, true); + expect(await wallet.frozenBalance(), 0); + }); + }); +} + +/// A second wallet's rows, which must never reach the wallet under test. +const otherWalletId = 2; + +class _CountingStore implements FrozenCoinsStore { + _CountingStore(this._inner); + + final FrozenCoinsStore _inner; + int frozenIdsCalls = 0; + + @override + Future> frozenIds(int walletInfoId) { + frozenIdsCalls++; + return _inner.frozenIds(walletInfoId); + } + + @override + Future setFrozen(int walletInfoId, String id, bool frozen) => + _inner.setFrozen(walletInfoId, id, frozen); + + @override + Future deleteWallet(int walletInfoId) => _inner.deleteWallet(walletInfoId); +} + +/// Fails every write, to prove the balance is only republished once one lands. +class _FailingStore implements FrozenCoinsStore { + @override + Future> frozenIds(int walletInfoId) async => {}; + + @override + Future setFrozen(int walletInfoId, String id, bool frozen) async => + throw Exception("database is locked"); + + @override + Future deleteWallet(int walletInfoId) async {} +} diff --git a/cw_core/test/coin_control/coin_notes_store_test.dart b/cw_core/test/coin_control/coin_notes_store_test.dart new file mode 100644 index 0000000000..c9976b6d0d --- /dev/null +++ b/cw_core/test/coin_control/coin_notes_store_test.dart @@ -0,0 +1,151 @@ +import "package:cw_core/coin_control/coin_notes_store.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "fake_coin_control_stores.dart"; +import "sqlite_test_harness.dart"; + +// The tables key on WalletInfo.internalId, so these stand in for two saved +// wallets and one used only by the raw-sql checks. +const walletA = 1; +const walletB = 2; +const walletDup = 3; + +/// Run against both implementations so the in-memory one used in other +/// packages' tests cannot drift from the sqlite one used in the app. +void contractTests(String label, CoinNotesStore Function() build) { + group(label, () { + late CoinNotesStore store; + + setUp(() async { + store = build(); + await store.deleteWallet(walletA); + await store.deleteWallet(walletB); + }); + + test("a wallet with nothing stored has no notes", () async { + expect(await store.forWallet(walletA), isEmpty); + }); + + test("an output that was never annotated is absent", () async { + await store.save(walletA, "aa:0", "rent"); + + expect((await store.forWallet(walletA))["bb:0"], isNull); + }); + + test("a note is written and read back", () async { + await store.save(walletA, "aa:0", "rent"); + + expect((await store.forWallet(walletA))["aa:0"], "rent"); + }); + + test("saving over a note replaces it", () async { + await store.save(walletA, "aa:0", "first"); + await store.save(walletA, "aa:0", "second"); + + final all = await store.forWallet(walletA); + expect(all["aa:0"], "second"); + expect(all, hasLength(1)); + }); + + test("clearing a note keeps the record and reads as empty", () async { + await store.save(walletA, "aa:0", "rent"); + await store.save(walletA, "aa:0", ""); + + // Kept rather than deleted: an empty note and an absent one both render + // as no note, so a write never has to decide whether to drop the row. + expect((await store.forWallet(walletA))["aa:0"], isEmpty); + }); + + test("notes for several outputs are all returned", () async { + await store.save(walletA, "aa:0", "one"); + await store.save(walletA, "bb:0", "two"); + + expect(await store.forWallet(walletA), {"aa:0": "one", "bb:0": "two"}); + }); + + test("a note is scoped to the wallet", () async { + await store.save(walletA, "shared:0", "wallet a note"); + + expect(await store.forWallet(walletB), isEmpty); + }); + + test("the same id can be annotated differently in two wallets", () async { + await store.save(walletA, "shared:0", "mine"); + await store.save(walletB, "shared:0", "theirs"); + + expect((await store.forWallet(walletA))["shared:0"], "mine"); + expect((await store.forWallet(walletB))["shared:0"], "theirs"); + }); + + test("deleting a wallet leaves other wallets untouched", () async { + await store.save(walletA, "aa:0", "mine"); + await store.save(walletB, "bb:0", "theirs"); + + await store.deleteWallet(walletA); + + expect(await store.forWallet(walletA), isEmpty); + expect((await store.forWallet(walletB))["bb:0"], "theirs"); + }); + + test("a note the user typed is stored verbatim", () async { + // Bound as a parameter, so quotes and newlines are content rather than + // anything the database has to interpret. + const note = "it's \"cold\" storage;\nDROP TABLE CoinNote; -- 🥶"; + await store.save(walletA, "aa:0", note); + + expect((await store.forWallet(walletA))["aa:0"], note); + expect(await store.forWallet(walletB), isEmpty); + }); + }); +} + +void main() { + contractTests("FakeCoinNotesStore", FakeCoinNotesStore.new); + + useSqliteDatabase("coin_notes_store"); + + contractTests("CoinNotesStore (sqlite)", CoinNotesStore.new); + + group("CoinNotesStore schema", () { + test("the table exists after a fresh initDb", () async { + final rows = await db!.query( + "sqlite_master", + where: "type = ? AND name = ?", + whereArgs: ["table", CoinNotesStore.tableName], + ); + expect(rows, hasLength(1)); + }); + + test("(walletId, id) is the primary key", () async { + final info = await db!.rawQuery("PRAGMA table_info(${CoinNotesStore.tableName})"); + final keyColumns = info + .where((column) => (column["pk"]! as int) > 0) + .map((column) => column["name"] as String) + .toSet(); + expect(keyColumns, {"walletInfoId", "id"}); + }); + + test("it carries no frozen column, so freezing cannot be written here", () async { + // Monero's frozen state never reaches the database, and no chain's frozen + // state reaches this table -- there is only one place to look for it. + final info = await db!.rawQuery("PRAGMA table_info(${CoinNotesStore.tableName})"); + expect(info.map((column) => column["name"]), isNot(contains("frozen"))); + }); + + test("saving the same key twice replaces rather than duplicates", () async { + final store = CoinNotesStore(); + await store.deleteWallet(walletDup); + await store.save(walletDup, "aa:0", "first"); + await store.save(walletDup, "aa:0", "second"); + + final rows = await db!.query( + CoinNotesStore.tableName, + where: "walletInfoId = ?", + whereArgs: [walletDup], + ); + expect(rows, hasLength(1)); + expect(rows.first["note"], "second"); + }); + }); +} diff --git a/cw_core/test/coin_control/coin_selection_test.dart b/cw_core/test/coin_control/coin_selection_test.dart new file mode 100644 index 0000000000..0fd69582fc --- /dev/null +++ b/cw_core/test/coin_control/coin_selection_test.dart @@ -0,0 +1,115 @@ +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/unspent_transaction_output.dart"; +import "package:flutter_test/flutter_test.dart"; + +Unspent coin(String hash, int vout, {int value = 1000, String? keyImage}) => + Unspent("addr", hash, value, vout, keyImage); + +void main() { + group("Unspent.id", () { + test("is the hash and index when there is no key image", () { + expect(coin("aa", 1).id, "aa:1"); + }); + + test("is the key image when there is one", () { + expect(coin("aa", 0, keyImage: "ki-1").id, "ki-1"); + }); + + test("distinguishes two outputs of the same transaction", () { + expect(coin("aa", 0).id, isNot(coin("aa", 1).id)); + }); + + test("does not change when the value changes", () { + // The previous implementation compared stored records to outputs on value + // and address as well as position, so any drift detached the two. + expect(coin("aa", 1, value: 1000).id, coin("aa", 1, value: 9999).id); + }); + + test("does not change when the address changes", () { + final a = Unspent("addr-one", "aa", 1000, 1, null); + final b = Unspent("addr-two", "aa", 1000, 1, null); + expect(a.id, b.id); + }); + + test("does not change when isChange or confirmations change", () { + final c = coin("aa", 1); + final before = c.id; + c.isChange = true; + c.confirmations = 7; + expect(c.id, before); + }); + }); + + group("AllCoinSelection", () { + const selection = AllCoinSelection(); + + test("allows every output", () { + expect(selection.allows(coin("aa", 0)), isTrue); + expect(selection.allows(coin("bb", 3)), isTrue); + }); + + test("allows an output it has never seen", () { + // This is the whole difference from an enumerated selection: a coin + // received after the selection was made must still be spendable. + expect(selection.allows(coin("received-later", 0)), isTrue); + }); + + test("every const instance is the same instance", () { + // It carries no state, so callers can compare against a const literal -- + // which several of them do -- without the class needing value equality. + expect(const AllCoinSelection(), same(const AllCoinSelection())); + }); + }); + + group("SpecificCoinSelection", () { + test("allows only the listed ids", () { + final selection = SpecificCoinSelection({"aa:0", "bb:1"}); + expect(selection.allows(coin("aa", 0)), isTrue); + expect(selection.allows(coin("bb", 1)), isTrue); + expect(selection.allows(coin("cc", 2)), isFalse); + }); + + test("allows nothing when empty", () { + expect(SpecificCoinSelection(const {}).allows(coin("aa", 0)), isFalse); + }); + + test("excludes an output it has never seen", () { + final selection = SpecificCoinSelection({"aa:0"}); + expect(selection.allows(coin("received-later", 0)), isFalse); + }); + + test("survives a wholesale replacement of every Unspent instance", () { + // Refreshing the output list replaces every instance, and transaction + // creation triggers that refresh after the selection was made. Holding + // ids rather than instances is what makes the selection outlive it. + final before = coin("aa", 0); + final selection = SpecificCoinSelection({before.id}); + + final after = coin("aa", 0); + expect(identical(before, after), isFalse); + expect(selection.allows(after), isTrue); + }); + + test("matches a Monero output on its key image", () { + final selection = SpecificCoinSelection({"ki-1"}); + expect(selection.allows(coin("aa", 0, keyImage: "ki-1")), isTrue); + expect(selection.allows(coin("aa", 0, keyImage: "ki-2")), isFalse); + }); + + test("holds exactly the ids it was given, whatever the order", () { + // Asserted on the set rather than through ==, which this class does not + // define: what anything downstream reads is the ids, and comparing two + // selections is not something the flow does. + expect(SpecificCoinSelection(["aa:0", "bb:1"]).ids, {"bb:1", "aa:0"}); + }); + + test("drops a duplicated id", () { + expect(SpecificCoinSelection(["aa:0", "aa:0"]).ids, hasLength(1)); + }); + + test("rejects mutation of the id set after construction", () { + final selection = SpecificCoinSelection({"aa:0"}); + expect(() => selection.ids.add("bb:1"), throwsUnsupportedError); + }); + }); +} diff --git a/cw_core/test/coin_control/fake_coin_control_stores.dart b/cw_core/test/coin_control/fake_coin_control_stores.dart new file mode 100644 index 0000000000..6d25f15c97 --- /dev/null +++ b/cw_core/test/coin_control/fake_coin_control_stores.dart @@ -0,0 +1,45 @@ +import "package:cw_core/coin_control/coin_notes_store.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; + +/// An in-memory [FrozenCoinsStore] for tests. +/// +/// Lets the coin control logic be exercised without a database. Not for +/// production use: nothing here survives the process. +class FakeFrozenCoinsStore implements FrozenCoinsStore { + final Map> _byWallet = {}; + + /// Number of stored records, across all wallets. + int get recordCount => _byWallet.values.fold(0, (sum, rows) => sum + rows.length); + + @override + Future> frozenIds(int walletInfoId) async { + final rows = _byWallet[walletInfoId] ?? const {}; + return rows.entries.where((entry) => entry.value).map((entry) => entry.key).toSet(); + } + + @override + Future setFrozen(int walletInfoId, String id, bool frozen) async => + _byWallet.putIfAbsent(walletInfoId, () => {})[id] = frozen; + + @override + Future deleteWallet(int walletInfoId) async => _byWallet.remove(walletInfoId); +} + +/// An in-memory [CoinNotesStore] for tests. See [FakeFrozenCoinsStore]. +class FakeCoinNotesStore implements CoinNotesStore { + final Map> _byWallet = {}; + + /// Number of stored records, across all wallets. + int get recordCount => _byWallet.values.fold(0, (sum, rows) => sum + rows.length); + + @override + Future> forWallet(int walletInfoId) async => + Map.of(_byWallet[walletInfoId] ?? const {}); + + @override + Future save(int walletInfoId, String id, String note) async => + _byWallet.putIfAbsent(walletInfoId, () => {})[id] = note; + + @override + Future deleteWallet(int walletInfoId) async => _byWallet.remove(walletInfoId); +} diff --git a/cw_core/test/coin_control/fake_coin_control_wallet.dart b/cw_core/test/coin_control/fake_coin_control_wallet.dart new file mode 100644 index 0000000000..79f10f78ba --- /dev/null +++ b/cw_core/test/coin_control/fake_coin_control_wallet.dart @@ -0,0 +1,154 @@ +import "package:cw_core/balance.dart"; +import "package:cw_core/coin_control/coin_control_wallet.dart"; +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/node.dart"; +import "package:cw_core/pending_transaction.dart"; +import "package:cw_core/sync_status.dart"; +import "package:cw_core/transaction_history.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/transaction_priority.dart"; +import "package:cw_core/unspent_coin_type.dart"; +import "package:cw_core/unspent_transaction_output.dart"; +import "package:cw_core/wallet_addresses.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:mobx/mobx.dart"; + +typedef _History = TransactionHistoryBase; + +/// A wallet double for the coin control mixin. +/// +/// The mixin is declared `on WalletBase`, so a double has to be one. Only the +/// members coin control reaches are implemented; the rest throw, so a test that +/// starts depending on one fails loudly instead of quietly reading a stub. +class FakeCoinControlWallet extends WalletBase + with CoinControlWallet { + FakeCoinControlWallet({ + required String id, + required this.unspents, + required this.frozenCoinsStore, + this.coinTypeOf, + int internalId = 1, + WalletType type = WalletType.bitcoin, + }) : super( + WalletInfo.external( + id: id, + name: id, + type: type, + isRecovery: false, + restoreHeight: 0, + date: DateTime(2026), + dirPath: "", + path: "", + address: "", + ), + DerivationInfo(), + ) { + // WalletInfo.external cannot take one, as sqlite assigns it on first save. + walletInfo.internalId = internalId; + } + + @override + List unspents; + + @override + final FrozenCoinsStore frozenCoinsStore; + + /// Stands in for a chain that has more than one kind of output. + final UnspentCoinType Function(Unspent coin)? coinTypeOf; + + int refreshCount = 0; + + /// How many times the balance was republished, and what the frozen total was + /// each time, so the ordering against the write can be asserted. + int balanceRefreshCount = 0; + final List> frozenAtRefresh = []; + + @override + Future refreshUnspents() async => refreshCount++; + + @override + Future refreshBalanceAfterFreeze() async { + balanceRefreshCount++; + frozenAtRefresh.add(await frozenIds()); + } + + @override + bool allowsCoinType(Unspent coin, UnspentCoinType coinType) { + if (coinTypeOf == null || coinType == UnspentCoinType.any) { + return true; + } + return coinTypeOf!(coin) == coinType; + } + + // WalletBase's remaining surface, none of which coin control touches. + + @override + ObservableMap get balance => throw UnimplementedError(); + + @override + SyncStatus get syncStatus => throw UnimplementedError(); + + @override + set syncStatus(SyncStatus status) => throw UnimplementedError(); + + @override + String? get seed => throw UnimplementedError(); + + @override + Object get keys => throw UnimplementedError(); + + @override + WalletAddresses get walletAddresses => throw UnimplementedError(); + + @override + String get password => throw UnimplementedError(); + + @override + Future connectToNode({required Node node}) => throw UnimplementedError(); + + @override + Future startSync() => throw UnimplementedError(); + + @override + Future createTransaction(Object credentials) => throw UnimplementedError(); + + @override + Future calculateEstimatedFee( + TransactionPriority priority, + int? amount, { + CoinSelection selection = const AllCoinSelection(), + }) => + throw UnimplementedError(); + + @override + Future> fetchTransactions() => throw UnimplementedError(); + + @override + Future save() => throw UnimplementedError(); + + @override + Future rescan({required int height}) => throw UnimplementedError(); + + @override + Future close({bool shouldCleanup = false}) => throw UnimplementedError(); + + @override + Future changePassword(String password) => throw UnimplementedError(); + + @override + Future? updateBalance() => throw UnimplementedError(); + + @override + Future signMessage(String message, {String? address}) => throw UnimplementedError(); + + @override + Future verifyMessage(String message, String signature, {String? address}) => + throw UnimplementedError(); + + @override + Future checkNodeHealth() => throw UnimplementedError(); +} diff --git a/cw_core/test/coin_control/frozen_coins_store_test.dart b/cw_core/test/coin_control/frozen_coins_store_test.dart new file mode 100644 index 0000000000..9c0b4bd83a --- /dev/null +++ b/cw_core/test/coin_control/frozen_coins_store_test.dart @@ -0,0 +1,173 @@ +import "package:cw_core/coin_control/frozen_coins_store.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "fake_coin_control_stores.dart"; +import "sqlite_test_harness.dart"; + +// The tables key on WalletInfo.internalId, so these stand in for two saved +// wallets and one used only by the raw-sql checks. +const walletA = 1; +const walletB = 2; +const walletDup = 3; + +/// Every behaviour the wallet mixin and the coin control Bloc rely on, run +/// against both implementations so the in-memory one used in other packages' +/// tests cannot drift from the sqlite one used in the app. +void contractTests(String label, FrozenCoinsStore Function() build) { + group(label, () { + late FrozenCoinsStore store; + + setUp(() async { + store = build(); + await store.deleteWallet(walletA); + await store.deleteWallet(walletB); + }); + + test("a wallet with nothing stored has no frozen ids", () async { + expect(await store.frozenIds(walletA), isEmpty); + }); + + test("freezing then reading back", () async { + await store.setFrozen(walletA, "aa:0", true); + + expect(await store.frozenIds(walletA), {"aa:0"}); + }); + + test("freezing the same output twice does not report it twice", () async { + await store.setFrozen(walletA, "aa:0", true); + await store.setFrozen(walletA, "aa:0", true); + + expect(await store.frozenIds(walletA), {"aa:0"}); + }); + + test("thawing clears the flag", () async { + await store.setFrozen(walletA, "aa:0", true); + await store.setFrozen(walletA, "aa:0", false); + + expect(await store.frozenIds(walletA), isEmpty); + }); + + test("thawing an output that was never frozen is not an error", () async { + await store.setFrozen(walletA, "never-touched", false); + + expect(await store.frozenIds(walletA), isEmpty); + }); + + test("re-freezing after a thaw works", () async { + await store.setFrozen(walletA, "aa:0", true); + await store.setFrozen(walletA, "aa:0", false); + await store.setFrozen(walletA, "aa:0", true); + + expect(await store.frozenIds(walletA), {"aa:0"}); + }); + + test("only the frozen ones are reported", () async { + await store.setFrozen(walletA, "frozen:0", true); + await store.setFrozen(walletA, "thawed:0", false); + + expect(await store.frozenIds(walletA), {"frozen:0"}); + }); + + test("a record for one wallet is invisible to another", () async { + // Two wallets restored from the same seed see the same output ids. If the + // wallet were not part of the key, freezing in one would freeze in both + // and the frozen amount would be attributed twice. + await store.setFrozen(walletA, "shared:0", true); + + expect(await store.frozenIds(walletB), isEmpty); + }); + + test("the same id can be frozen independently in two wallets", () async { + await store.setFrozen(walletA, "shared:0", true); + await store.setFrozen(walletB, "shared:0", false); + + expect(await store.frozenIds(walletA), {"shared:0"}); + expect(await store.frozenIds(walletB), isEmpty); + }); + + test("deleting a wallet leaves other wallets untouched", () async { + await store.setFrozen(walletA, "aa:0", true); + await store.setFrozen(walletB, "bb:0", true); + + await store.deleteWallet(walletA); + + expect(await store.frozenIds(walletA), isEmpty); + expect(await store.frozenIds(walletB), {"bb:0"}); + }); + + test("a Monero-style key image id round-trips", () async { + const keyImage = "9a1f0c3b7e5d2a48f6b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c"; + await store.setFrozen(walletA, keyImage, true); + + expect(await store.frozenIds(walletA), {keyImage}); + }); + }); +} + +void main() { + contractTests("FakeFrozenCoinsStore", FakeFrozenCoinsStore.new); + + group("FakeFrozenCoinsStore bookkeeping", () { + test("holds no records until the user freezes, then keeps one per output", () async { + final store = FakeFrozenCoinsStore(); + expect(store.recordCount, 0); + + await store.setFrozen(walletA, "aa:0", true); + expect(store.recordCount, 1); + + // Kept, not dropped: the record now holds the default. + await store.setFrozen(walletA, "aa:0", false); + expect(store.recordCount, 1); + + await store.setFrozen(walletA, "bb:0", true); + expect(store.recordCount, 2); + }); + }); + + // The sqlite store is exercised against a real database so the composite key + // is proven in SQL, not just in the Dart fake. + useSqliteDatabase("frozen_coins_store"); + + contractTests("FrozenCoinsStore (sqlite)", FrozenCoinsStore.new); + + group("FrozenCoinsStore schema", () { + test("the table exists after a fresh initDb", () async { + final rows = await db!.query( + "sqlite_master", + where: "type = ? AND name = ?", + whereArgs: ["table", FrozenCoinsStore.tableName], + ); + expect(rows, hasLength(1)); + }); + + test("(walletId, id) is the primary key", () async { + final info = await db!.rawQuery("PRAGMA table_info(${FrozenCoinsStore.tableName})"); + final keyColumns = info + .where((column) => (column["pk"]! as int) > 0) + .map((column) => column["name"] as String) + .toSet(); + expect(keyColumns, {"walletInfoId", "id"}); + }); + + test("it carries no note column, so notes cannot be written here", () async { + final info = await db!.rawQuery("PRAGMA table_info(${FrozenCoinsStore.tableName})"); + expect(info.map((column) => column["name"]), isNot(contains("note"))); + }); + + test("setting the same key twice replaces rather than duplicates", () async { + final store = FrozenCoinsStore(); + await store.deleteWallet(walletDup); + await store.setFrozen(walletDup, "aa:0", true); + await store.setFrozen(walletDup, "aa:0", false); + + final rows = await db!.query( + FrozenCoinsStore.tableName, + where: "walletInfoId = ?", + whereArgs: [walletDup], + ); + expect(rows, hasLength(1), reason: "a thawed record is kept, not deleted"); + expect(rows.first["frozen"], 0); + }); + }); +} diff --git a/cw_core/test/coin_control/sqlite_test_harness.dart b/cw_core/test/coin_control/sqlite_test_harness.dart new file mode 100644 index 0000000000..6d11b0b14e --- /dev/null +++ b/cw_core/test/coin_control/sqlite_test_harness.dart @@ -0,0 +1,49 @@ +import "dart:io"; + +import "package:cw_core/db/sqlite.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path_provider_platform_interface/path_provider_platform_interface.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +// Faking the documents dir keeps getAppDir() off the platform channel, so the +// tests run with a plain `flutter test` on any host and in CI. +class _FakePathProviderPlatform extends PathProviderPlatform { + _FakePathProviderPlatform(this.root); + + final String root; + + @override + Future getApplicationDocumentsPath() async => root; + + @override + Future getApplicationSupportPath() async => root; +} + +/// Opens a real database, built by the app's own [initDb], for the whole test +/// file. Using the real schema rather than hand-written DDL is the point: it +/// proves the migration created what the store queries. +void useSqliteDatabase(String scratchDirName) { + final dataRoot = Directory("./test/data/$scratchDirName"); + + setUpAll(() async { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + dataRoot.createSync(recursive: true); + + PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path); + Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true); + + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await initDb(); + }); + + tearDownAll(() async { + await db?.close(); + db = null; + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + }); +} diff --git a/cw_core/test/unspent_coins_info_migration_test.dart b/cw_core/test/unspent_coins_info_migration_test.dart new file mode 100644 index 0000000000..dd68d45926 --- /dev/null +++ b/cw_core/test/unspent_coins_info_migration_test.dart @@ -0,0 +1,364 @@ +import "dart:io"; + +import "package:cw_core/cake_hive.dart"; +import "package:cw_core/coin_control/coin_notes_store.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:cw_core/root_dir.dart"; +import "package:cw_core/unspent_coins_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path_provider_platform_interface/path_provider_platform_interface.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +// Faking the documents dir keeps getAppDir() off the platform channel, so the +// test runs with a plain `flutter test` on any host and in CI. +class _FakePathProviderPlatform extends PathProviderPlatform { + _FakePathProviderPlatform(this.root); + + final String root; + + @override + Future getApplicationDocumentsPath() async => root; + + @override + Future getApplicationSupportPath() async => root; +} + +Future main() async { + final dataRoot = Directory("./test/data/unspent_coins_info_migration"); + + final frozen = FrozenCoinsStore(); + final notes = CoinNotesStore(); + + late _Wallet btc; + late _Wallet ltc; + late _Wallet xmr; + late _Wallet dcr; + + String legacyWalletId(String name, WalletType type) => + "${walletTypeToString(type).toLowerCase()}_$name"; + + Future<_Wallet> insertWalletInfoRow(String name, WalletType type) async { + final internalId = await db!.insert("WalletInfo", { + "id": legacyWalletId(name, type), + "name": name, + "type": type.index, + "isRecovery": 0, + "restoreHeight": 0, + "timestamp": 0, + "dirPath": "", + "path": "", + "address": "", + "showIntroCakePayCard": 0, + "walletInfoDerivationInfoId": 0, + "isNonSeedWallet": 0, + "sortOrder": 0, + "receiveInfoboxDismissed": 0, + "showCombinedBalance": 1, + }); + + return _Wallet(legacyWalletId(name, type), internalId); + } + + UnspentCoinsInfo record({ + required String walletId, + required String hash, + int vout = 0, + bool isFrozen = false, + bool isSending = true, + String note = "", + String address = "addr", + String? keyImage, + }) => + UnspentCoinsInfo( + walletId: walletId, + hash: hash, + isFrozen: isFrozen, + isSending: isSending, + noteRaw: note, + address: address, + vout: vout, + value: 1000, + keyImage: keyImage, + ); + + /// Fills the legacy box and runs the migration over it, as startup does. + Future migrate(List records) async { + final box = await CakeHive.openBox(UnspentCoinsInfo.boxName); + await box.addAll(records); + await box.close(); + + await performUnspentCoinsInfoHiveMigration(); + } + + setUpAll(() async { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + dataRoot.createSync(recursive: true); + + PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path); + + // On linux getAppDir() appends /cake_wallet to the documents dir and picks the + // first existing candidate, so create it up front to keep CI on the faked path. + Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true); + + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await initDb(); + + // Everything must share the dir initDb resolved so boxExists finds the box. + final appDir = await getAppDir(); + CakeHive.init(appDir.path); + + if (!CakeHive.isAdapterRegistered(UnspentCoinsInfo.typeId)) { + CakeHive.registerAdapter(UnspentCoinsInfoAdapter()); + } + + btc = await insertWalletInfoRow("btc", WalletType.bitcoin); + ltc = await insertWalletInfoRow("ltc", WalletType.litecoin); + xmr = await insertWalletInfoRow("xmr", WalletType.monero); + dcr = await insertWalletInfoRow("dcr", WalletType.decred); + }); + + tearDownAll(() async { + await db?.close(); + db = null; + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + }); + + setUp(() async { + // The migration removes what it migrated, but a record it deliberately + // skipped stays, so the box is emptied here to keep the tests isolated. + await CakeHive.deleteBoxFromDisk(UnspentCoinsInfo.boxName); + + for (final id in [btc.internalId, ltc.internalId, xmr.internalId, dcr.internalId, _deletedId]) { + await frozen.deleteWallet(id); + await notes.deleteWallet(id); + } + }); + + group("unspent coins info migration", () { + test("a frozen bitcoin output keeps its freeze, keyed by hash and index", () async { + await migrate([record(walletId: btc.id, hash: "aabb", vout: 2, isFrozen: true)]); + + expect(await frozen.frozenIds(btc.internalId), {"aabb:2"}); + }); + + test("a note is carried over", () async { + await migrate([record(walletId: btc.id, hash: "aabb", note: "rent")]); + + expect((await notes.forWallet(btc.internalId))["aabb:0"], "rent"); + }); + + test("a record carrying both keeps both", () async { + await migrate([record(walletId: btc.id, hash: "aabb", isFrozen: true, note: "cold")]); + + expect(await frozen.frozenIds(btc.internalId), {"aabb:0"}); + expect((await notes.forWallet(btc.internalId))["aabb:0"], "cold"); + }); + + test("an untouched output gets a row holding the default", () async { + // The legacy box mirrored the whole output list, so most of its records + // said nothing. Each still becomes a row, which reads identically to an + // absent one -- so the outcome is right, and the cost is one row per + // output the wallet had ever seen rather than one per output the user + // touched. + await migrate([ + record(walletId: btc.id, hash: "aa"), + record(walletId: btc.id, hash: "bb", vout: 1), + record(walletId: btc.id, hash: "cc", isFrozen: true), + ]); + + expect(await frozen.frozenIds(btc.internalId), {"cc:0"}); + expect(await notes.forWallet(btc.internalId), isEmpty, reason: "an empty note is not stored"); + + final rows = await db!.query( + FrozenCoinsStore.tableName, + where: "walletInfoId = ?", + whereArgs: [btc.internalId], + ); + expect(rows, hasLength(3)); + expect(rows.where((row) => row["frozen"] == 1), hasLength(1)); + }); + + test("an unselected output does not become a frozen one", () async { + // isSending was the selection, which is not durable state. Treating it as + // frozen would silently freeze outputs the user had merely unticked. + await migrate([record(walletId: btc.id, hash: "aabb", isSending: false)]); + + expect(await frozen.frozenIds(btc.internalId), isEmpty); + expect(await notes.forWallet(btc.internalId), isEmpty); + }); + + test("a Monero output keeps its note but not its freeze", () async { + // wallet2 persists the freeze itself, so a row here would be a second + // source of truth for it. The note has no equivalent in the wallet file. + await migrate([ + record( + walletId: xmr.id, + hash: "xmrhash", + isFrozen: true, + note: "savings", + keyImage: "ki-1", + ), + ]); + + expect(await frozen.frozenIds(xmr.internalId), isEmpty); + expect((await notes.forWallet(xmr.internalId))["ki-1"], "savings", + reason: "Monero outputs are keyed by key image"); + }); + + test("a frozen-only Monero output produces nothing", () async { + await migrate([record(walletId: xmr.id, hash: "h", isFrozen: true, keyImage: "ki-2")]); + + expect(await frozen.frozenIds(xmr.internalId), isEmpty); + expect(await notes.forWallet(xmr.internalId), isEmpty); + }); + + test("an MWEB output is keyed by hash alone", () async { + // Its vout is an index into the wallet's MWEB address list, which shifts + // as that list grows, so it cannot be part of the identity. + await migrate([ + record( + walletId: ltc.id, + hash: "mwebhash", + vout: 7, + isFrozen: true, + address: "ltcmweb1qqf${"0" * 90}", + ), + ]); + + expect(await frozen.frozenIds(ltc.internalId), {"mwebhash"}); + }); + + test("a regular Litecoin output still uses hash and index", () async { + await migrate([ + record( + walletId: ltc.id, + hash: "ltchash", + vout: 3, + isFrozen: true, + address: "ltc1qsomeregularaddress", + ), + ]); + + expect(await frozen.frozenIds(ltc.internalId), {"ltchash:3"}); + }); + + test("a Decred output uses hash and index, as it has no key image", () async { + await migrate([record(walletId: dcr.id, hash: "dcrhash", vout: 1, isFrozen: true)]); + + expect(await frozen.frozenIds(dcr.internalId), {"dcrhash:1"}); + }); + + test("a record for a wallet that no longer exists contributes nothing", () async { + await migrate([record(walletId: _deletedLegacyId, hash: "aa", isFrozen: true, note: "n")]); + + final rows = await db!.query(FrozenCoinsStore.tableName); + expect(rows.where((row) => row["walletInfoId"] == _deletedId), isEmpty); + expect(await notes.forWallet(_deletedId), isEmpty); + expect(await frozen.frozenIds(btc.internalId), isEmpty); + }); + + test("a stale record does not stop the ones after it", () async { + // It is skipped rather than treated as the end of the box, so one leftover + // from a deleted wallet cannot silently drop every later record. + await migrate([ + record(walletId: _deletedLegacyId, hash: "stale", isFrozen: true), + record(walletId: btc.id, hash: "mine", isFrozen: true, note: "kept"), + ]); + + expect(await frozen.frozenIds(btc.internalId), {"mine:0"}); + expect((await notes.forWallet(btc.internalId))["mine:0"], "kept"); + }); + + test("the same output id in two wallets stays separate", () async { + // Two wallets restored from the same seed see the same ids, which is why + // the wallet is part of the key in both new tables. + await migrate([ + record(walletId: btc.id, hash: "shared", isFrozen: true), + record(walletId: dcr.id, hash: "shared", note: "theirs"), + ]); + + expect(await frozen.frozenIds(btc.internalId), {"shared:0"}); + expect(await frozen.frozenIds(dcr.internalId), isEmpty); + expect(await notes.forWallet(btc.internalId), isEmpty); + expect((await notes.forWallet(dcr.internalId))["shared:0"], "theirs"); + }); + + test("a migrated record is removed from the box", () async { + await migrate([record(walletId: btc.id, hash: "aabb", isFrozen: true, note: "n")]); + + final box = await CakeHive.openBox(UnspentCoinsInfo.boxName); + expect(box.values, isEmpty); + }); + + test("a record it skipped is left in the box", () async { + // Only what actually reached the new tables is removed. A wallet list + // that came back short for any reason must not cost the user a freeze. + await migrate([record(walletId: _deletedLegacyId, hash: "aa", isFrozen: true)]); + + final box = await CakeHive.openBox(UnspentCoinsInfo.boxName); + expect(box.values, hasLength(1)); + }); + + test("a change made after the migration is not undone by a later run", () async { + // The reason the records are removed rather than re-read: the box still + // says frozen, so a second pass over it would resurrect a freeze the user + // had cleared in the app. + await migrate([record(walletId: btc.id, hash: "aabb", isFrozen: true)]); + expect(await frozen.frozenIds(btc.internalId), {"aabb:0"}); + + await frozen.setFrozen(btc.internalId, "aabb:0", false); + await performUnspentCoinsInfoHiveMigration(); + + expect(await frozen.frozenIds(btc.internalId), isEmpty); + }); + + test("a record whose write fails is kept for the next launch", () async { + CoinNotesStore.instance = _FailingNotesStore(); + addTearDown(() => CoinNotesStore.instance = CoinNotesStore()); + + await migrate([record(walletId: btc.id, hash: "aabb", isFrozen: true, note: "n")]); + + final box = await CakeHive.openBox(UnspentCoinsInfo.boxName); + expect(box.values, hasLength(1), reason: "still there to retry"); + }); + + test("with no legacy box there is nothing to do and nothing throws", () async { + await CakeHive.deleteBoxFromDisk(UnspentCoinsInfo.boxName); + expect(await CakeHive.boxExists(UnspentCoinsInfo.boxName), isFalse); + + await performUnspentCoinsInfoHiveMigration(); + + expect(await db!.query(FrozenCoinsStore.tableName), isEmpty); + expect(await db!.query(CoinNotesStore.tableName), isEmpty); + }); + }); +} + +/// Fails every note write, to prove a record is only dropped once it landed. +class _FailingNotesStore extends CoinNotesStore { + @override + Future save(int walletInfoId, String id, String note) async => + throw Exception("database is locked"); +} + +/// A wallet under test, by both of the ids that matter here. +class _Wallet { + _Wallet(this.id, this.internalId); + + /// What the legacy records carry, derived from the wallet name. + final String id; + + /// What the new tables key on, stable across a rename. + final int internalId; +} + +/// A wallet that no longer exists, so nothing resolves it. +const _deletedLegacyId = "bitcoin_deleted"; +const _deletedId = 99; diff --git a/cw_decred/lib/transaction_credentials.dart b/cw_decred/lib/transaction_credentials.dart index 5ace384f47..7db995d989 100644 --- a/cw_decred/lib/transaction_credentials.dart +++ b/cw_decred/lib/transaction_credentials.dart @@ -1,10 +1,17 @@ +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_decred/transaction_priority.dart'; import 'package:cw_core/output_info.dart'; class DecredTransactionCredentials { - DecredTransactionCredentials(this.outputs, {required this.priority, this.feeRate}); + DecredTransactionCredentials( + this.outputs, { + required this.priority, + this.feeRate, + this.coinSelection = const AllCoinSelection(), + }); final List outputs; final DecredTransactionPriority? priority; final int? feeRate; + final CoinSelection coinSelection; } diff --git a/cw_decred/lib/wallet.dart b/cw_decred/lib/wallet.dart index b5398b7a2c..ab25cfac46 100644 --- a/cw_decred/lib/wallet.dart +++ b/cw_decred/lib/wallet.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:cw_core/amount/money.dart'; import 'package:path/path.dart' as p; +import "package:cw_core/coin_control/coin_control_wallet.dart"; import 'package:cw_core/exceptions.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/utils/print_verbose.dart'; @@ -24,13 +25,13 @@ import 'package:cw_decred/transaction_info.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/wallet_info.dart'; +import "package:cw_core/coin_control/coin_selection.dart"; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_keys_file.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/node.dart'; -import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/unspent_transaction_output.dart'; part 'wallet.g.dart'; @@ -39,15 +40,14 @@ class DecredWallet = DecredWalletBase with _$DecredWallet; abstract class DecredWalletBase extends WalletBase - with Store, WalletKeysFile { + with Store, WalletKeysFile, CoinControlWallet { DecredWalletBase(WalletInfo walletInfo, DerivationInfo derivationInfo, String password, - Box unspentCoinsInfo, Libwallet libwallet, Function() closeLibwallet, + Libwallet libwallet, Function() closeLibwallet, {this.passphrase, required this.encryptionFileUtils}) : _password = password, _libwallet = libwallet, _closeLibwallet = closeLibwallet, this.syncStatus = NotConnectedSyncStatus(), - this.unspentCoinsInfo = unspentCoinsInfo, this.watchingOnly = derivationInfo.derivationPath == DecredWalletService.pubkeyRestorePath || derivationInfo.derivationPath == DecredWalletService.pubkeyRestorePathTestnet, @@ -92,7 +92,6 @@ abstract class DecredWalletBase FeeCache feeRateMedium = FeeCache(defaultFeeRate); FeeCache feeRateSlow = FeeCache(defaultFeeRate); Timer? syncTimer; - Box unspentCoinsInfo; @override @observable @@ -369,18 +368,16 @@ abstract class DecredWalletBase send: () async => throw "unable to send with watching only wallet", ); } - var totalIn = 0; - final ignoreInputs = []; - this.unspentCoinsInfo.values.forEach((unspent) { - if (unspent.isFrozen || !unspent.isSending) { - final input = {"txid": unspent.hash, "vout": unspent.vout}; - ignoreInputs.add(input); - return; - } - totalIn += unspent.value; - }); - final creds = credentials as DecredTransactionCredentials; + + final spendable = await spendableCoins(selection: creds.coinSelection); + final spendableIds = spendable.map((coin) => coin.id).toSet(); + + var totalIn = spendable.fold(0, (sum, coin) => sum + coin.value); + final ignoreInputs = _unspents + .where((coin) => !spendableIds.contains(coin.id)) + .map((coin) => {"txid": coin.hash, "vout": coin.vout}) + .toList(); var totalAmt = 0; var sendAll = false; final outputs = []; @@ -404,7 +401,7 @@ abstract class DecredWalletBase // throw exception if no selected coins under coin control // or if the total coins selected, is less than the amount the user wants to spend - if (ignoreInputs.length == unspentCoinsInfo.values.length || totalIn < totalAmt) { + if (spendable.isEmpty || totalIn < totalAmt) { throw TransactionNoInputsException(); } @@ -457,7 +454,8 @@ abstract class DecredWalletBase } @override - int calculateEstimatedFee(TransactionPriority priority, int? amount) { + Future calculateEstimatedFee(TransactionPriority priority, int? amount, + {CoinSelection selection = const AllCoinSelection()}) async { if (priority is DecredTransactionPriority) { final P2PKHOutputSize = 36; // 8 bytes value + 2 bytes version + at least 1 byte varint script size + P2PKHPkScriptSize @@ -589,18 +587,7 @@ abstract class DecredWalletBase Future updateBalance() async { final balanceMap = await _libwallet.balance(walletInfo.name); - var totalFrozen = 0; - - unspentCoinsInfo.values.forEach((info) { - _unspents.forEach((element) { - if (element.hash == info.hash && - element.vout == info.vout && - info.isFrozen && - element.value == info.value) { - totalFrozen += element.value; - } - }); - }); + final totalFrozen = await frozenBalance(); balance[CryptoCurrency.dcr] = DecredBalance( confirmed: Money.fromInt(balanceMap["confirmed"] ?? 0, currency), @@ -609,6 +596,9 @@ abstract class DecredWalletBase ); } + @override + Future refreshBalanceAfterFreeze() => updateBalance(); + @override Future checkNodeHealth() async => await checkSync(); @@ -689,70 +679,11 @@ abstract class DecredWalletBase } } - List unspents() { - this.updateUnspents(_unspents); - return _unspents; - } - - void updateUnspents(List unspentCoins) { - if (this.unspentCoinsInfo.isEmpty) { - unspentCoins.forEach((coin) => this.addCoinInfo(coin)); - return; - } - - if (unspentCoins.isEmpty) { - this.unspentCoinsInfo.clear(); - return; - } - - final walletID = idPrefix + walletInfo.name; - if (unspentCoins.isNotEmpty) { - unspentCoins.forEach((coin) { - final coinInfoList = this.unspentCoinsInfo.values.where((element) => - element.walletId == walletID && element.hash == coin.hash && element.vout == coin.vout); - - if (coinInfoList.isEmpty) { - this.addCoinInfo(coin); - } else { - final coinInfo = coinInfoList.first; - - coin.isFrozen = coinInfo.isFrozen; - coin.isSending = coinInfo.isSending; - coin.note = coinInfo.note; - } - }); - } - - final List keys = []; - this.unspentCoinsInfo.values.forEach((element) { - final existUnspentCoins = unspentCoins.where((coin) => element.hash.contains(coin.hash)); - - if (existUnspentCoins.isEmpty) { - keys.add(element.key); - } - }); - - if (keys.isNotEmpty) { - unspentCoinsInfo.deleteAll(keys); - } - } - - void addCoinInfo(Unspent coin) { - final newInfo = UnspentCoinsInfo( - walletId: idPrefix + walletInfo.name, - hash: coin.hash, - isFrozen: false, - isSending: coin.isSending, - noteRaw: "", - address: coin.address, - value: coin.value, - vout: coin.vout, - isChange: coin.isChange, - keyImage: coin.keyImage, - ); + @override + List get unspents => _unspents; - unspentCoinsInfo.add(newInfo); - } + @override + Future refreshUnspents() => fetchUnspents(); // walletBirthdayBlockHeight checks if the wallet birthday is set and returns // it. Returns -1 if not. diff --git a/cw_decred/lib/wallet_service.dart b/cw_decred/lib/wallet_service.dart index a0385b00e5..ed17e14daf 100644 --- a/cw_decred/lib/wallet_service.dart +++ b/cw_decred/lib/wallet_service.dart @@ -13,17 +13,14 @@ import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:path/path.dart'; -import 'package:hive/hive.dart'; -import 'package:cw_core/unspent_coins_info.dart'; class DecredWalletService extends WalletService< DecredNewWalletCredentials, DecredRestoreWalletFromSeedCredentials, DecredRestoreWalletFromPubkeyCredentials, DecredRestoreWalletFromHardwareCredentials> { - DecredWalletService(this.unspentCoinsInfoSource, this.isDirect); + DecredWalletService(this.isDirect); - final Box unspentCoinsInfoSource; final bool isDirect; final seedRestorePath = "m/44'/42'"; static final seedRestorePathTestnet = "m/44'/1'"; @@ -265,7 +262,6 @@ class DecredWalletService extends WalletService< walletInfo, di, password, - unspentCoinsInfoSource, libwallet!, closeLibwallet, passphrase: passphrase, diff --git a/cw_dogecoin/lib/src/dogecoin_wallet.dart b/cw_dogecoin/lib/src/dogecoin_wallet.dart index 41dd4047ae..1b405cf32b 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet.dart @@ -27,7 +27,6 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required Uint8List seedBytes, required EncryptionFileUtils encryptionFileUtils, String? passphrase, @@ -41,7 +40,6 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, network: DogecoinNetwork.mainnet, initialAddresses: initialAddresses, initialBalance: initialBalance, @@ -89,7 +87,6 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { required String password, required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required EncryptionFileUtils encryptionFileUtils, String? passphrase, String? addressPageType, @@ -102,7 +99,6 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, initialBalance: initialBalance, seedBytes: MnemonicBip39.toSeed(mnemonic, passphrase: passphrase), @@ -117,7 +113,6 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { static Future open({ required String name, required WalletInfo walletInfo, - required Box unspentCoinsInfo, required String password, required EncryptionFileUtils encryptionFileUtils, }) async { @@ -156,7 +151,6 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: snp?.addresses, initialBalance: snp?.balance, seedBytes: await MnemonicBip39.toSeed(keysData.mnemonic!, passphrase: keysData.passphrase), @@ -183,4 +177,7 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { ); return priv.signMessage(StringUtils.encode(message)); } + + @override + Uri coinControlUrl(String txId) => Uri.https("dogechain.info", "/tx/${txId}"); } diff --git a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart index 1a78e14ff5..c549758cfb 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart @@ -2,23 +2,22 @@ import 'dart:io'; import 'package:bip39/bip39.dart'; import 'package:cw_bitcoin/bitcoin_mnemonics_bip39.dart'; +import "package:cw_core/coin_control/coin_notes_store.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; import 'package:cw_core/encryption_file_utils.dart'; import 'package:cw_core/pathForWallet.dart'; -import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_dogecoin/cw_dogecoin.dart'; -import 'package:hive/hive.dart'; class DogeCoinWalletService extends WalletService< DogeCoinNewWalletCredentials, DogeCoinRestoreWalletFromSeedCredentials, DogeCoinRestoreWalletFromWIFCredentials, DogeCoinNewWalletCredentials> { - DogeCoinWalletService(this.unspentCoinsInfoSource, this.isDirect); + DogeCoinWalletService(this.isDirect); - final Box unspentCoinsInfoSource; final bool isDirect; @override @@ -37,7 +36,6 @@ class DogeCoinWalletService extends WalletService< password: credentials.password!, walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), passphrase: credentials.passphrase, ); @@ -58,7 +56,6 @@ class DogeCoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -70,7 +67,6 @@ class DogeCoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -87,15 +83,8 @@ class DogeCoinWalletService extends WalletService< } await WalletInfo.delete(walletInfo); - final unspentCoinsToDelete = unspentCoinsInfoSource.values - .where((unspentCoin) => unspentCoin.walletId == walletInfo.id) - .toList(); - - final keysToDelete = unspentCoinsToDelete.map((unspentCoin) => unspentCoin.key).toList(); - - if (keysToDelete.isNotEmpty) { - await unspentCoinsInfoSource.deleteAll(keysToDelete); - } + await FrozenCoinsStore.instance.deleteWallet(walletInfo.internalId); + await CoinNotesStore.instance.deleteWallet(walletInfo.internalId); } @override @@ -122,7 +111,6 @@ class DogeCoinWalletService extends WalletService< mnemonic: credentials.mnemonic, walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), passphrase: credentials.passphrase); await wallet.save(); diff --git a/cw_evm/lib/evm_chain_wallet.dart b/cw_evm/lib/evm_chain_wallet.dart index ae65a8878f..30c05510aa 100644 --- a/cw_evm/lib/evm_chain_wallet.dart +++ b/cw_evm/lib/evm_chain_wallet.dart @@ -17,6 +17,7 @@ import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/utils/homoglyph_normalizer.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_addresses.dart'; +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; @@ -612,7 +613,9 @@ abstract class EVMChainWalletBase } @override - int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0; + Future calculateEstimatedFee(TransactionPriority priority, int? amount, + {CoinSelection selection = const AllCoinSelection()}) async => + 0; @override Future updateEstimatedFeesParams(TransactionPriority? priority) async => diff --git a/cw_evm/test/evm_transaction_amount_currency_test.dart b/cw_evm/test/evm_transaction_amount_currency_test.dart new file mode 100644 index 0000000000..bd30e4b0d1 --- /dev/null +++ b/cw_evm/test/evm_transaction_amount_currency_test.dart @@ -0,0 +1,92 @@ +import "package:cw_core/erc20_token.dart"; +import "package:cw_evm/evm_chain_transaction_info.dart"; +import "package:flutter_test/flutter_test.dart"; + +/// A transaction as it is persisted: the contract address is stored, but the +/// currency the amount is denominated in is not. +Map persisted({required String contract, String symbol = "USDC"}) => { + "id": "0xabc", + "height": 100, + "amount": "1000000", + "exponent": 6, + "fee": "21000", + "direction": 0, + "date": DateTime(2026, 1, 1).millisecondsSinceEpoch, + "isPending": false, + "confirmations": 12, + "tokenSymbol": symbol, + "to": "0xto", + "from": "0xfrom", + "contractAddress": contract, + }; + +Erc20Token usdc({String? iconPath}) => Erc20Token( + name: "USD Coin", + symbol: "USDC", + contractAddress: "0xUSDC", + decimal: 6, + iconPath: iconPath, + ); + +void main() { + group("EVMChainTransactionInfo.fromJson", () { + test("with no tokens known the asset has no identity", () { + // The regression: a history restored from file was denominated in a + // placeholder with an empty contract address, so it carried no icon and + // nothing the price lookup could key on. + final tx = EVMChainTransactionInfo.fromJson(persisted(contract: "0xUSDC"), 1); + + final asset = tx.assetOfTransaction; + expect(asset, isA()); + expect((asset! as Erc20Token).contractAddress, "0xUSDC"); + expect(asset.iconPath, isNull); + }); + + test("a token matched by contract address becomes the asset", () { + final registered = usdc(iconPath: "assets/images/usdc.png"); + + final tx = EVMChainTransactionInfo.fromJson( + persisted(contract: "0xUSDC"), + 1, + tokens: [registered], + ); + + expect(tx.assetOfTransaction, registered); + expect(tx.assetOfTransaction?.iconPath, "assets/images/usdc.png"); + expect(tx.amount.currency, registered, reason: "the price lookup keys on this"); + }); + + test("matching is case insensitive on the contract address", () { + final registered = usdc(); + + final tx = EVMChainTransactionInfo.fromJson( + persisted(contract: "0xusdc"), + 1, + tokens: [registered], + ); + + expect(tx.assetOfTransaction, registered); + }); + + test("a contract address that matches nothing falls back", () { + final other = + Erc20Token(name: "Tether", symbol: "USDT", contractAddress: "0xUSDT", decimal: 6); + + final tx = EVMChainTransactionInfo.fromJson( + persisted(contract: "0xUSDC"), + 1, + tokens: [other], + ); + + expect(tx.assetOfTransaction, isNot(other)); + expect(tx.amount.currency.decimals, 6, reason: "decimals must survive the fallback"); + }); + + test("the chain's native symbol resolves to the native currency", () { + final tx = EVMChainTransactionInfo.fromJson(persisted(contract: "", symbol: "ETH"), 1); + + expect(tx.assetOfTransaction?.title, "ETH"); + expect(tx.assetOfTransaction, isNot(isA())); + }); + }); +} diff --git a/cw_monero/lib/api/coins_info.dart b/cw_monero/lib/api/coins_info.dart index 2f09f6e59b..34761b6769 100644 --- a/cw_monero/lib/api/coins_info.dart +++ b/cw_monero/lib/api/coins_info.dart @@ -9,52 +9,24 @@ import 'package:mutex/mutex.dart'; Wallet2Coins? coins = null; final coinsMutex = Mutex(); -Future refreshCoins(int accountIndex) async { - if (coinsMutex.isLocked) { - return; - } - coins = currentWallet!.coins(); - final coinsPtr = coins!.ffiAddress(); - await coinsMutex.acquire(); - await Isolate.run(() => monero.Coins_refresh(Pointer.fromAddress(coinsPtr))); - coinsMutex.release(); -} - -Future countOfCoins() async { - await coinsMutex.acquire(); - final count = coins!.count(); - coinsMutex.release(); - return count; -} - -Future getCoin(int index) async { - await coinsMutex.acquire(); - final coin = coins!.coin(index); - coinsMutex.release(); - return coin; -} - -Future getCoinByKeyImage(String keyImage) async { - final count = await countOfCoins(); - for (int i = 0; i < count; i++) { - final coin = await getCoin(i); - if (keyImage == coin.keyImage()) { - return i; - } - } - return null; -} - -Future freezeCoin(int index) async { - await coinsMutex.acquire(); - final coinsPtr = coins!.ffiAddress(); - await Isolate.run(() => monero.Coins_setFrozen(Pointer.fromAddress(coinsPtr), index: index)); - coinsMutex.release(); -} - -Future thawCoin(int index) async { - await coinsMutex.acquire(); - final coinsPtr = coins!.ffiAddress(); - await Isolate.run(() => monero.Coins_thaw(Pointer.fromAddress(coinsPtr), index: index)); - coinsMutex.release(); -} +Future refreshCoins(int accountIndex) => coinsMutex.protect(() async { + final refreshed = currentWallet!.coins(); + final coinsPtr = refreshed.ffiAddress(); + await Isolate.run(() => monero.Coins_refresh(Pointer.fromAddress(coinsPtr))); + coins = refreshed; + }); + +Future> readAllCoins() => coinsMutex.protect(() async { + final all = coins!; + return List.generate(all.count(), all.coin); + }); + +Future freezeCoin(int index) => coinsMutex.protect(() async { + final coinsPtr = coins!.ffiAddress(); + await Isolate.run(() => monero.Coins_setFrozen(Pointer.fromAddress(coinsPtr), index: index)); + }); + +Future thawCoin(int index) => coinsMutex.protect(() async { + final coinsPtr = coins!.ffiAddress(); + await Isolate.run(() => monero.Coins_thaw(Pointer.fromAddress(coinsPtr), index: index)); + }); diff --git a/cw_monero/lib/api/get_all_unspent.dart b/cw_monero/lib/api/get_all_unspent.dart index a18b57c98a..3722cf1733 100644 --- a/cw_monero/lib/api/get_all_unspent.dart +++ b/cw_monero/lib/api/get_all_unspent.dart @@ -15,12 +15,11 @@ Map> getAllUnspent() { ret[subaddr.toString()] = {}; } - final unspent = MoneroUnspent.fromUnspent( + final unspent = MoneroUnspent( address: coin.address(), hash: coin.hash(), keyImage: coin.keyImage(), value: coin.amount(), - isFrozen: coin.frozen(), isUnlocked: coin.unlocked(), isSpent: coin.spent(), ); diff --git a/cw_monero/lib/monero_frozen_coins_store.dart b/cw_monero/lib/monero_frozen_coins_store.dart new file mode 100644 index 0000000000..b684295f6a --- /dev/null +++ b/cw_monero/lib/monero_frozen_coins_store.dart @@ -0,0 +1,49 @@ +import "package:cw_core/coin_control/frozen_coins_store.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_monero/api/coins_info.dart"; + +class MoneroFrozenCoinsStore extends FrozenCoinsStore { + MoneroFrozenCoinsStore({ + Future Function(int index) freeze = freezeCoin, + Future Function(int index) thaw = thawCoin, + }) : _freeze = freeze, + _thaw = thaw; + + // these are function pointers so they can be mocked in unit tests + final Future Function(int index) _freeze; + final Future Function(int index) _thaw; + + final Map _frozen = {}; + final Map _indexes = {}; + + void beginRefresh() { + _frozen.clear(); + _indexes.clear(); + } + + void record({required String keyImage, required int index, required bool frozen}) { + _frozen[keyImage] = frozen; + _indexes[keyImage] = index; + } + + @override + Future> frozenIds(int walletInfoId) async => + _frozen.entries.where((entry) => entry.value).map((entry) => entry.key).toSet(); + + @override + Future setFrozen(int walletInfoId, String id, bool frozen) async { + final index = _indexes[id]; + if (index == null) { + printV("MoneroFrozenCoinsStore: no coin index for $id, frozen flag cached only"); + _frozen[id] = frozen; + return; + } + + await (frozen ? _freeze(index) : _thaw(index)); + + _frozen[id] = frozen; + } + + @override + Future deleteWallet(int walletInfoId) async => beginRefresh(); +} diff --git a/cw_monero/lib/monero_transaction_creation_credentials.dart b/cw_monero/lib/monero_transaction_creation_credentials.dart index 96f2b16379..62281ace8e 100644 --- a/cw_monero/lib/monero_transaction_creation_credentials.dart +++ b/cw_monero/lib/monero_transaction_creation_credentials.dart @@ -1,9 +1,15 @@ -import 'package:cw_core/monero_transaction_priority.dart'; -import 'package:cw_core/output_info.dart'; +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/monero_transaction_priority.dart"; +import "package:cw_core/output_info.dart"; class MoneroTransactionCreationCredentials { - MoneroTransactionCreationCredentials({required this.outputs, required this.priority}); + MoneroTransactionCreationCredentials({ + required this.outputs, + required this.priority, + this.coinSelection = const AllCoinSelection(), + }); final List outputs; final MoneroTransactionPriority priority; + final CoinSelection coinSelection; } diff --git a/cw_monero/lib/monero_unspent.dart b/cw_monero/lib/monero_unspent.dart index f81292e1bd..885da11d7a 100644 --- a/cw_monero/lib/monero_unspent.dart +++ b/cw_monero/lib/monero_unspent.dart @@ -1,73 +1,25 @@ import 'package:cw_core/unspent_transaction_output.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_monero/api/coins_info.dart'; class MoneroUnspent extends Unspent { - static MoneroUnspent fromUnspent({ + MoneroUnspent({ required String address, required String hash, required String keyImage, required int value, - required bool isFrozen, - required bool isUnlocked, - required bool isSpent, - }) { - return MoneroUnspent( - address: address, - hash: hash, - keyImage: keyImage, - value: value, - isFrozen: isFrozen, - isUnlocked: isUnlocked, - isSpent: isSpent); - } - - MoneroUnspent( - {required String address, - required String hash, - required String keyImage, - required int value, - required bool isFrozen, - required this.isUnlocked, - required this.isSpent}) - : super(address, hash, value, 0, keyImage) { - _frozen = isFrozen; - } - - bool _frozen = false; - - @override - set isFrozen(bool freeze) { - _frozen = freeze; - printV("set isFrozen: $freeze ($keyImage): $freeze"); - getCoinByKeyImage(keyImage!).then((coinId) async { - if (coinId == null) return; - if (freeze) { - await freezeCoin(coinId); - _frozen = true; - } else { - await thawCoin(coinId); - _frozen = false; - } - }); - } - - @override - bool get isFrozen => _frozen; + required this.isUnlocked, + required this.isSpent, + }) : super(address, hash, value, 0, keyImage); final bool isUnlocked; final bool isSpent; - Map toJson() { - return { - 'address': address, - 'hash': hash, - 'keyImage': keyImage, - 'value': value, - 'isFrozen': isFrozen, - 'isUnlocked': isUnlocked, - 'isChange': isChange, - 'isSpent': isSpent, - }; - } + Map toJson() => { + 'address': address, + 'hash': hash, + 'keyImage': keyImage, + 'value': value, + 'isUnlocked': isUnlocked, + 'isChange': isChange, + 'isSpent': isSpent, + }; } diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index a76e7d628d..61ff227700 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -15,9 +15,11 @@ import 'package:cw_core/node.dart'; import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; -import 'package:cw_core/unspent_coins_info.dart'; +import "package:cw_core/coin_control/coin_control_wallet.dart"; +import "package:cw_core/unspent_transaction_output.dart"; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import "package:cw_core/coin_control/coin_selection.dart"; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_monero/api/account_list.dart'; @@ -34,12 +36,12 @@ import 'package:cw_monero/monero_transaction_creation_credentials.dart'; import 'package:cw_monero/monero_transaction_history.dart'; import 'package:cw_monero/monero_transaction_info.dart'; import 'package:cw_monero/monero_unspent.dart'; +import "package:cw_monero/monero_frozen_coins_store.dart"; import 'package:cw_monero/monero_wallet_addresses.dart'; import 'package:cw_monero/monero_wallet_service.dart'; import 'package:cw_monero/pending_monero_transaction.dart'; import 'package:cw_monero/trezor.dart'; import 'package:flutter/foundation.dart'; -import 'package:hive/hive.dart'; import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; import 'package:mobx/mobx.dart'; import 'package:monero/monero.dart' as monero; @@ -53,11 +55,11 @@ const MIN_RESTORE_HEIGHT = 1000; class MoneroWallet = MoneroWalletBase with _$MoneroWallet; abstract class MoneroWalletBase - extends WalletBase with Store { + extends WalletBase + with Store, CoinControlWallet { MoneroWalletBase( {required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required String password}) : balance = ObservableMap.of({ CryptoCurrency.xmr: MoneroBalance( @@ -71,7 +73,6 @@ abstract class MoneroWalletBase _password = password, syncStatus = NotConnectedSyncStatus(), unspentCoins = [], - this.unspentCoinsInfo = unspentCoinsInfo, super(walletInfo, derivationInfo) { transactionHistory = MoneroTransactionHistory(); walletAddresses = MoneroWalletAddresses(walletInfo, transactionHistory); @@ -98,8 +99,6 @@ abstract class MoneroWalletBase static const int _autoSaveInterval = 30; - Box unspentCoinsInfo; - void Function(FlutterErrorDetails)? onError; @override @@ -159,6 +158,16 @@ abstract class MoneroWalletBase bool _hasSyncAfterStartup; Timer? _autoSaveTimer; List unspentCoins; + + @override + List get unspents => unspentCoins; + + @override + Future refreshUnspents() => updateUnspent(); + + @override + final MoneroFrozenCoinsStore frozenCoinsStore = MoneroFrozenCoinsStore(); + String _password; Future init() async { @@ -190,6 +199,14 @@ abstract class MoneroWalletBase @override Future? updateBalance() => null; + // updateBalance is a no-op here: this wallet publishes its balance from sync + // callbacks instead. _askForUpdateBalance reads wallet2 in memory, so it is + // cheap enough to run on a toggle, and re-reading the whole balance rather + // than patching the frozen total is what keeps available correct -- wallet2 + // excludes frozen outputs from the unlocked balance itself. + @override + Future refreshBalanceAfterFreeze() => _askForUpdateBalance(); + @override Future checkNodeHealth() async { try { @@ -441,10 +458,9 @@ abstract class MoneroWalletBase await updateUnspent(); - for (final utx in unspentCoins) { - if (utx.isSending) { - inputs.add(utx.keyImage!); - } + final candidates = await spendableCoins(selection: _credentials.coinSelection); + for (final utx in candidates) { + inputs.add(utx.keyImage!); } if (hasMultiDestination) { @@ -509,7 +525,11 @@ abstract class MoneroWalletBase } @override - int calculateEstimatedFee(TransactionPriority priority, int? amount) { + Future calculateEstimatedFee( + TransactionPriority priority, + int? amount, { + CoinSelection selection = const AllCoinSelection(), + }) async { // FIXME: hardcoded value; if (priority is MoneroTransactionPriority) { @@ -653,7 +673,7 @@ abstract class MoneroWalletBase setupBackgroundSync(password, currentWallet!); monero_wallet.rescanBlockchainAsync(); await startSync(); - _askForUpdateBalance(); + await _askForUpdateBalance(); walletAddresses.accountList.update(); await updateTransactions(); await save(); @@ -669,22 +689,26 @@ abstract class MoneroWalletBase Future _updateUnspent() async { try { - refreshCoins(walletAddresses.account!.id); + await refreshCoins(walletAddresses.account!.id); - // Collected separately, so nobody ever sees a half filled list - final unspentCoins = []; + unspentCoins.clear(); + frozenCoinsStore.beginRefresh(); - final coinCount = await countOfCoins(); - for (var i = 0; i < coinCount; i++) { - final coin = await getCoin(i); + final allCoins = await readAllCoins(); + for (var i = 0; i < allCoins.length; i++) { + final coin = allCoins[i]; final coinSpent = coin.spent(); if (coinSpent == false && coin.subaddrAccount() == walletAddresses.account!.id) { - final unspent = await MoneroUnspent.fromUnspent( + frozenCoinsStore.record( + keyImage: coin.keyImage(), + index: i, + frozen: coin.frozen(), + ); + final unspent = MoneroUnspent( address: coin.address(), hash: coin.hash(), keyImage: coin.keyImage(), value: coin.amount(), - isFrozen: coin.frozen(), isUnlocked: coin.unlocked(), isSpent: coinSpent, ); @@ -702,52 +726,8 @@ abstract class MoneroWalletBase unspentCoins.add(unspent); } } - this.unspentCoins - ..clear() - ..addAll(unspentCoins); - - if (unspentCoinsInfo.isEmpty) { - unspentCoins.forEach((coin) => _addCoinInfo(coin)); - return; - } - - if (unspentCoins.isNotEmpty) { - final usedInfoKeys = {}; - for (final coin in unspentCoins) { - final coinInfoList = unspentCoinsInfo.values - .where((element) => - element.walletId.contains(id) && - element.accountIndex == walletAddresses.account!.id && - !usedInfoKeys.contains(element.key) && - _isSameCoin(element, coin)) - .toList(); - - if (coinInfoList.isNotEmpty) { - // The key image only breaks a tie between identical outputs of one transaction - final coinInfo = coinInfoList.firstWhere( - (element) => element.keyImage == coin.keyImage, - orElse: () => coinInfoList.first, - ); - usedInfoKeys.add(coinInfo.key); - - coin.isFrozen = coinInfo.isFrozen; - coin.isSending = coinInfo.isSending; - coin.note = coinInfo.note; - - // The key image of a hardware wallet's output is only known after syncing with the - // device; keep the existing info (frozen state, note) instead of orphaning it. - if (coinInfo.keyImage != coin.keyImage) { - coinInfo.keyImage = coin.keyImage; - await coinInfo.save(); - } - } else { - await _addCoinInfo(coin); - } - } - } - await _refreshUnspentCoinsInfo(); - _askForUpdateBalance(); + await _askForUpdateBalance(); } catch (e, s) { printV(e.toString()); onError?.call(FlutterErrorDetails( @@ -758,53 +738,6 @@ abstract class MoneroWalletBase } } - /// The key image can't identify an output: a hardware wallet doesn't know the key images of its - /// outputs until they are synced with the device, until then they all carry the same - /// placeholder, which made every output match the info of the first one. - bool _isSameCoin(UnspentCoinsInfo info, MoneroUnspent coin) => - info.hash == coin.hash && info.address == coin.address && info.value == coin.value; - - Future _addCoinInfo(MoneroUnspent coin) async { - final newInfo = UnspentCoinsInfo( - walletId: id, - hash: coin.hash, - isFrozen: coin.isFrozen, - isSending: coin.isSending, - noteRaw: coin.note, - address: coin.address, - value: coin.value, - vout: 0, - keyImage: coin.keyImage, - isChange: coin.isChange, - accountIndex: walletAddresses.account!.id); - - await unspentCoinsInfo.add(newInfo); - } - - Future _refreshUnspentCoinsInfo() async { - try { - final List keys = []; - final currentWalletUnspentCoins = unspentCoinsInfo.values.where((element) => - element.walletId.contains(id) && element.accountIndex == walletAddresses.account!.id); - - if (currentWalletUnspentCoins.isNotEmpty) { - currentWalletUnspentCoins.forEach((element) { - final existUnspentCoins = unspentCoins.where((coin) => _isSameCoin(element, coin)); - - if (existUnspentCoins.isEmpty) { - keys.add(element.key); - } - }); - } - - if (keys.isNotEmpty) { - await unspentCoinsInfo.deleteAll(keys); - } - } catch (e) { - printV(e.toString()); - } - } - String getTransactionAddress(int accountIndex, int addressIndex) => monero_wallet.getAddress(accountIndex: accountIndex, addressIndex: addressIndex); @@ -936,44 +869,33 @@ abstract class MoneroWalletBase return nodeHeight - heightDistance; } - void _askForUpdateBalance() { + Future _askForUpdateBalance() async { final unlockedBalance = _getUnlockedBalance(); final fullBalance = monero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id); - final frozenBalance = _getFrozenBalance(); + final frozen = Money.fromInt(await frozenBalance(), CryptoCurrency.xmr); if (balance[currency]!.fullBalance != fullBalance || balance[currency]!.available != unlockedBalance || - balance[currency]!.frozen != frozenBalance) { - balance[currency] = MoneroBalance( - fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozen: frozenBalance); + balance[currency]!.frozen != frozen) { + balance[currency] = + MoneroBalance(fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozen: frozen); } } Money _getUnlockedBalance() => monero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id); - Money _getFrozenBalance() { - var frozenBalance = 0; - - for (final coin in unspentCoinsInfo.values.where((element) => - element.walletId == id && element.accountIndex == walletAddresses.account!.id)) { - if (coin.isFrozen && !coin.isSending) frozenBalance += coin.value; - } - - return Money.fromInt(frozenBalance, CryptoCurrency.xmr); - } - void _onNewBlock(int height, int blocksLeft, double ptc) async { printV("onNewBlock: $height, $blocksLeft, $ptc"); try { if (walletInfo.isRecovery) { await updateTransactions(); - _askForUpdateBalance(); + await _askForUpdateBalance(); walletAddresses.accountList.update(); } if (blocksLeft < 100) { await updateTransactions(); - _askForUpdateBalance(); + await _askForUpdateBalance(); walletAddresses.accountList.update(); syncStatus = SyncedSyncStatus(); @@ -996,7 +918,7 @@ abstract class MoneroWalletBase void _onNewTransaction() async { try { await updateTransactions(); - _askForUpdateBalance(); + await _askForUpdateBalance(); await Future.delayed(Duration(seconds: 1)); } catch (e) { printV(e.toString()); diff --git a/cw_monero/lib/monero_wallet_service.dart b/cw_monero/lib/monero_wallet_service.dart index 8066a1a536..9cc3571180 100644 --- a/cw_monero/lib/monero_wallet_service.dart +++ b/cw_monero/lib/monero_wallet_service.dart @@ -102,9 +102,8 @@ class MoneroWalletService extends WalletService< MoneroRestoreWalletFromSeedCredentials, MoneroRestoreWalletFromKeysCredentials, MoneroRestoreWalletFromHardwareCredentials> { - MoneroWalletService(this.unspentCoinsInfoSource); + MoneroWalletService(); - final Box unspentCoinsInfoSource; static bool walletFilesExist(String path) => !File(path).existsSync() && !File('$path.keys').existsSync(); @@ -149,7 +148,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -189,7 +187,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password); if (wallet.hardwareWalletType == HardwareWalletType.ledger) { @@ -253,7 +250,6 @@ class MoneroWalletService extends WalletService< final currentWallet = MoneroWallet( walletInfo: currentWalletInfo, derivationInfo: await currentWalletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); @@ -282,7 +278,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -333,7 +328,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -388,7 +382,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -435,7 +428,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); await wallet.init(); @@ -479,7 +471,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); await wallet.init(); @@ -511,7 +502,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); await wallet.init(); @@ -561,7 +551,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); return wallet.seed; diff --git a/cw_monero/test/monero_frozen_coins_store_test.dart b/cw_monero/test/monero_frozen_coins_store_test.dart new file mode 100644 index 0000000000..323c29a67a --- /dev/null +++ b/cw_monero/test/monero_frozen_coins_store_test.dart @@ -0,0 +1,241 @@ +import "dart:io"; + +import "package:cw_core/coin_control/coin_notes_store.dart"; +import "package:cw_core/coin_control/frozen_coins_store.dart"; +import "package:cw_core/db/sqlite.dart"; +import "package:cw_monero/monero_frozen_coins_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:path_provider_platform_interface/path_provider_platform_interface.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +// Faking the documents dir keeps getAppDir() off the platform channel, so the +// test runs with a plain `flutter test` on any host and in CI. +class _FakePathProviderPlatform extends PathProviderPlatform { + _FakePathProviderPlatform(this.root); + + final String root; + + @override + Future getApplicationDocumentsPath() async => root; + + @override + Future getApplicationSupportPath() async => root; +} + +/// Stands in for the wallet2 freeze api, which no unit test can call: it needs +/// a loaded wallet behind the FFI. Records what actually reached it, in the +/// order it completed. +class _FakeWallet2 { + final List calls = []; + + /// Holds each call open, so a caller that does not await can be caught. + Duration delay = Duration.zero; + + bool fail = false; + + Future freeze(int index) => _apply("freeze:$index"); + + Future thaw(int index) => _apply("thaw:$index"); + + Future _apply(String call) async { + if (delay > Duration.zero) { + await Future.delayed(delay); + } + if (fail) { + throw Exception("wallet2 is busy"); + } + calls.add(call); + } +} + +void main() { + // The tables key on WalletInfo.internalId; this store never writes to them, + // which is what the database assertions below prove. + const wallet = 1; + final dataRoot = Directory("./test/data/monero_frozen_coins_store"); + + late MoneroFrozenCoinsStore store; + late _FakeWallet2 wallet2; + + /// Reads the database directly, to prove what did and did not reach it. + /// Named to avoid shadowing the `db` global from sqlite.dart. + final sqlite = FrozenCoinsStore(); + final notes = CoinNotesStore(); + + /// Mimics the walk MoneroWallet.updateUnspent does over wallet2's coin list. + void walk(Map frozenByKeyImage) { + store.beginRefresh(); + var index = 0; + frozenByKeyImage.forEach((keyImage, frozen) { + store.record(keyImage: keyImage, index: index++, frozen: frozen); + }); + } + + setUpAll(() async { + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + dataRoot.createSync(recursive: true); + + PathProviderPlatform.instance = _FakePathProviderPlatform(dataRoot.absolute.path); + Directory("${dataRoot.path}/cake_wallet").createSync(recursive: true); + + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await initDb(); + }); + + tearDownAll(() async { + await db?.close(); + db = null; + if (dataRoot.existsSync()) { + dataRoot.deleteSync(recursive: true); + } + }); + + setUp(() async { + wallet2 = _FakeWallet2(); + store = MoneroFrozenCoinsStore(freeze: wallet2.freeze, thaw: wallet2.thaw); + await sqlite.deleteWallet(wallet); + await notes.deleteWallet(wallet); + }); + + group("frozen state comes from wallet2, not the database", () { + test("nothing is frozen before the coin list has been walked", () async { + expect(await store.frozenIds(wallet), isEmpty); + }); + + test("adopts whatever wallet2 reported during the walk", () async { + walk({"ki-a": true, "ki-b": false, "ki-c": true}); + + expect(await store.frozenIds(wallet), {"ki-a", "ki-c"}); + }); + + test("a refresh replaces the previous flags rather than merging", () async { + walk({"ki-a": true, "ki-b": false}); + walk({"ki-a": false, "ki-b": true}); + + expect(await store.frozenIds(wallet), {"ki-b"}); + }); + + test("a coin that disappears from the walk stops being reported", () async { + walk({"ki-a": true, "ki-b": true}); + walk({"ki-b": true}); + + expect(await store.frozenIds(wallet), {"ki-b"}); + }); + + test("freezing is never written to the database", () async { + walk({"ki-a": false}); + await store.setFrozen(wallet, "ki-a", true); + + // wallet2 owns this, so a row here would be a second source of truth for + // something the wallet already persists. + expect(await sqlite.frozenIds(wallet), isEmpty); + + final rows = await db!.query( + FrozenCoinsStore.tableName, + where: "walletInfoId = ?", + whereArgs: [wallet], + ); + expect(rows, isEmpty); + }); + }); + + group("changes reach wallet2", () { + test("freezing calls freeze with the index from the walk", () async { + walk({"ki-a": false, "ki-b": false}); + await store.setFrozen(wallet, "ki-b", true); + + expect(wallet2.calls, ["freeze:1"]); + expect(await store.frozenIds(wallet), {"ki-b"}); + }); + + test("thawing calls thaw and clears the cached flag", () async { + walk({"ki-a": true}); + await store.setFrozen(wallet, "ki-a", false); + + expect(wallet2.calls, ["thaw:0"]); + expect(await store.frozenIds(wallet), isEmpty); + }); + + test("the cache is not updated until wallet2 has taken the change", () async { + walk({"ki-a": false}); + wallet2.delay = const Duration(milliseconds: 30); + + final pending = store.setFrozen(wallet, "ki-a", true); + await Future.delayed(Duration.zero); + + // The call is awaited, so nothing observes the new flag while it is still + // in flight -- the cache cannot get ahead of the wallet. + expect(await store.frozenIds(wallet), isEmpty); + + await pending; + expect(await store.frozenIds(wallet), {"ki-a"}); + }); + + test("successive changes reach wallet2 in the order they were made", () async { + walk({"ki-a": false, "ki-b": false}); + wallet2.delay = const Duration(milliseconds: 10); + + await store.setFrozen(wallet, "ki-a", true); + await store.setFrozen(wallet, "ki-b", true); + await store.setFrozen(wallet, "ki-a", false); + + // Each caller waits, so an index cannot be acted on after the walk that + // produced it has been replaced by a later refresh. + expect(wallet2.calls, ["freeze:0", "freeze:1", "thaw:0"]); + expect(await store.frozenIds(wallet), {"ki-b"}); + }); + + test("a failure reaches the caller and leaves the cache alone", () async { + walk({"ki-a": false}); + wallet2.fail = true; + + // The Bloc turns this into a visible failure. Swallowing it would leave + // the switch showing a freeze the wallet never took. + await expectLater(store.setFrozen(wallet, "ki-a", true), throwsException); + expect(await store.frozenIds(wallet), isEmpty); + }); + + test("a failed thaw leaves the coin frozen, as wallet2 still has it", () async { + walk({"ki-a": true}); + wallet2.fail = true; + + await expectLater(store.setFrozen(wallet, "ki-a", false), throwsException); + expect(await store.frozenIds(wallet), {"ki-a"}); + }); + + test("a coin absent from the last walk caches its flag and calls nothing", () async { + // No index to act on, so wallet2 cannot be told -- but reads must still + // reflect what the user asked for until the next refresh. + await store.setFrozen(wallet, "ki-unknown", true); + + expect(wallet2.calls, isEmpty); + expect(await store.frozenIds(wallet), {"ki-unknown"}); + }); + }); + + group("notes are not this store's business", () { + test("the shared notes store handles them, untouched by a walk", () async { + await notes.save(wallet, "ki-a", "rent"); + + walk({"ki-a": true}); + await store.setFrozen(wallet, "ki-a", false); + + // Nothing in the Monero override reads or writes a note, so there is no + // Monero-specific note path that could diverge from the other chains'. + expect((await notes.forWallet(wallet))["ki-a"], "rent"); + }); + }); + + group("deleteWallet", () { + test("clears the cache", () async { + walk({"ki-a": true}); + + await store.deleteWallet(wallet); + + expect(await store.frozenIds(wallet), isEmpty); + }); + }); +} diff --git a/cw_nano/lib/nano_wallet.dart b/cw_nano/lib/nano_wallet.dart index b8472f2de3..c40186db95 100644 --- a/cw_nano/lib/nano_wallet.dart +++ b/cw_nano/lib/nano_wallet.dart @@ -17,6 +17,7 @@ import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; @@ -149,7 +150,9 @@ abstract class NanoWalletBase } @override - int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0; // always 0 :) + Future calculateEstimatedFee(TransactionPriority priority, int? amount, + {CoinSelection selection = const AllCoinSelection()}) async => + 0; // always 0 :) @override Future changePassword(String password) => throw UnimplementedError("changePassword"); diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart index 89eb4b75e3..4c31436edd 100644 --- a/cw_solana/lib/solana_wallet.dart +++ b/cw_solana/lib/solana_wallet.dart @@ -13,6 +13,7 @@ import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/utils/homoglyph_normalizer.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_addresses.dart'; +import "package:cw_core/coin_control/coin_selection.dart"; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; @@ -202,7 +203,9 @@ abstract class SolanaWalletBase } @override - int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0; + Future calculateEstimatedFee(TransactionPriority priority, int? amount, + {CoinSelection selection = const AllCoinSelection()}) async => + 0; @override Future changePassword(String password) => throw UnimplementedError("changePassword"); diff --git a/cw_tron/lib/tron_wallet.dart b/cw_tron/lib/tron_wallet.dart index 488717fd89..ca7aeee113 100644 --- a/cw_tron/lib/tron_wallet.dart +++ b/cw_tron/lib/tron_wallet.dart @@ -15,6 +15,7 @@ import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; import "package:cw_core/utils/print_verbose.dart"; import 'package:cw_core/wallet_addresses.dart'; +import "package:cw_core/coin_control/coin_selection.dart"; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_keys_file.dart'; @@ -237,7 +238,9 @@ abstract class TronWalletBase } @override - int calculateEstimatedFee(TransactionPriority priority, int? amount) => 0; + Future calculateEstimatedFee(TransactionPriority priority, int? amount, + {CoinSelection selection = const AllCoinSelection()}) async => + 0; @override Future changePassword(String password) => throw UnimplementedError("changePassword"); diff --git a/cw_wownero/lib/wownero_unspent.dart b/cw_wownero/lib/wownero_unspent.dart index fdfdfc7a4e..5c930d7cb9 100644 --- a/cw_wownero/lib/wownero_unspent.dart +++ b/cw_wownero/lib/wownero_unspent.dart @@ -2,10 +2,9 @@ import 'package:cw_core/unspent_transaction_output.dart'; class WowneroUnspent extends Unspent { WowneroUnspent( - String address, String hash, String keyImage, int value, bool isFrozen, this.isUnlocked) - : super(address, hash, value, 0, keyImage) { - this.isFrozen = isFrozen; - } + String address, String hash, String keyImage, int value, this.isFrozen, this.isUnlocked) + : super(address, hash, value, 0, keyImage); + final bool isFrozen; final bool isUnlocked; } diff --git a/cw_wownero/lib/wownero_wallet.dart b/cw_wownero/lib/wownero_wallet.dart index 5eb89b5eef..04a70decc3 100644 --- a/cw_wownero/lib/wownero_wallet.dart +++ b/cw_wownero/lib/wownero_wallet.dart @@ -15,9 +15,9 @@ import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; -import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; +import "package:cw_core/coin_control/coin_selection.dart"; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wownero_amount_format.dart'; @@ -38,7 +38,6 @@ import 'package:cw_wownero/wownero_transaction_info.dart'; import 'package:cw_wownero/wownero_unspent.dart'; import 'package:cw_wownero/wownero_wallet_addresses.dart'; import 'package:flutter/foundation.dart'; -import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:monero/wownero.dart' as wownero; @@ -56,7 +55,6 @@ abstract class WowneroWalletBase WowneroWalletBase( {required WalletInfo walletInfo, required DerivationInfo derivationInfo, - required Box unspentCoinsInfo, required String password}) : balance = ObservableMap.of({ CryptoCurrency.wow: WowneroBalance( @@ -72,7 +70,6 @@ abstract class WowneroWalletBase isEnabledAutoGenerateSubaddress = true, syncStatus = NotConnectedSyncStatus(), unspentCoins = [], - this.unspentCoinsInfo = unspentCoinsInfo, super(walletInfo, derivationInfo) { transactionHistory = WowneroTransactionHistory(); walletAddresses = WowneroWalletAddresses(walletInfo, transactionHistory); @@ -103,7 +100,6 @@ abstract class WowneroWalletBase static const int _autoSaveInterval = 30; - Box unspentCoinsInfo; void Function(FlutterErrorDetails)? onError; @@ -301,12 +297,6 @@ abstract class WowneroWalletBase await updateUnspent(); } - for (final utx in unspentCoins) { - if (utx.isSending) { - allInputsAmount += utx.value; - inputs.add(utx.keyImage!); - } - } final spendAllCoins = inputs.length == unspentCoins.length; if (hasMultiDestination) { @@ -323,10 +313,6 @@ abstract class WowneroWalletBase 'You do not have enough WOW to send this amount.'); } - if (!spendAllCoins && (allInputsAmount < totalAmount + estimatedFee)) { - throw WowneroTransactionNoInputsException(inputs.length); - } - final wowneroOutputs = outputs.map((output) { final outputAddress = output.isParsedAddress ? output.extractedAddress : output.address; @@ -352,12 +338,6 @@ abstract class WowneroWalletBase 'You do not have enough unlocked balance. Unlocked: $formattedBalance. Transaction amount: ${output.cryptoAmount}.'); } - final estimatedFee = calculateEstimatedFee(_credentials.priority, formattedAmount); - if (!spendAllCoins && - ((formattedAmount != null && allInputsAmount < (formattedAmount + estimatedFee)) || - formattedAmount == null)) { - throw WowneroTransactionNoInputsException(inputs.length); - } pendingTransactionDescription = await transaction_history.createTransaction( address: address!, @@ -371,7 +351,8 @@ abstract class WowneroWalletBase } @override - int calculateEstimatedFee(TransactionPriority priority, int? amount) { + Future calculateEstimatedFee(TransactionPriority priority, int? amount, + {CoinSelection selection = const AllCoinSelection()}) async { // FIXME: hardcoded value; if (priority is MoneroTransactionPriority) { @@ -529,33 +510,6 @@ abstract class WowneroWalletBase unspentCoins.add(unspent); } } - - if (unspentCoinsInfo.isEmpty) { - unspentCoins.forEach((coin) => _addCoinInfo(coin)); - return; - } - - if (unspentCoins.isNotEmpty) { - unspentCoins.forEach((coin) { - final coinInfoList = unspentCoinsInfo.values.where((element) => - element.walletId.contains(id) && - element.accountIndex == walletAddresses.account!.id && - element.keyImage!.contains(coin.keyImage!)); - - if (coinInfoList.isNotEmpty) { - final coinInfo = coinInfoList.first; - - coin.isFrozen = coinInfo.isFrozen; - coin.isSending = coinInfo.isSending; - coin.note = coinInfo.note; - } else { - _addCoinInfo(coin); - } - }); - } - - await _refreshUnspentCoinsInfo(); - _askForUpdateBalance(); } catch (e, s) { printV(e.toString()); onError?.call(FlutterErrorDetails( @@ -566,48 +520,6 @@ abstract class WowneroWalletBase } } - Future _addCoinInfo(WowneroUnspent coin) async { - final newInfo = UnspentCoinsInfo( - walletId: id, - hash: coin.hash, - isFrozen: coin.isFrozen, - isSending: coin.isSending, - noteRaw: coin.note, - address: coin.address, - value: coin.value, - vout: 0, - keyImage: coin.keyImage, - isChange: coin.isChange, - accountIndex: walletAddresses.account!.id); - - await unspentCoinsInfo.add(newInfo); - } - - Future _refreshUnspentCoinsInfo() async { - try { - final List keys = []; - final currentWalletUnspentCoins = unspentCoinsInfo.values.where((element) => - element.walletId.contains(id) && element.accountIndex == walletAddresses.account!.id); - - if (currentWalletUnspentCoins.isNotEmpty) { - currentWalletUnspentCoins.forEach((element) { - final existUnspentCoins = - unspentCoins.where((coin) => element.keyImage!.contains(coin.keyImage!)); - - if (existUnspentCoins.isEmpty) { - keys.add(element.key); - } - }); - } - - if (keys.isNotEmpty) { - await unspentCoinsInfo.deleteAll(keys); - } - } catch (e) { - printV(e.toString()); - } - } - String getTransactionAddress(int accountIndex, int addressIndex) => wownero_wallet.getAddress(accountIndex: accountIndex, addressIndex: addressIndex); @@ -742,11 +654,6 @@ abstract class WowneroWalletBase Money _getFrozenBalance() { var frozenBalance = 0; - for (final coin in unspentCoinsInfo.values.where((element) => - element.walletId == id && element.accountIndex == walletAddresses.account!.id)) { - if (coin.isFrozen) frozenBalance += coin.value; - } - return Money.fromInt(frozenBalance, CryptoCurrency.wow); } diff --git a/cw_wownero/lib/wownero_wallet_service.dart b/cw_wownero/lib/wownero_wallet_service.dart index 4fb8ffa794..0556e8ed2e 100644 --- a/cw_wownero/lib/wownero_wallet_service.dart +++ b/cw_wownero/lib/wownero_wallet_service.dart @@ -75,9 +75,8 @@ class WowneroWalletService extends WalletService< WowneroRestoreWalletFromSeedCredentials, WowneroRestoreWalletFromKeysCredentials, WowneroNewWalletCredentials> { - WowneroWalletService(this.unspentCoinsInfoSource); + WowneroWalletService(); - final Box unspentCoinsInfoSource; static bool walletFilesExist(String path) => !File(path).existsSync() && !File('$path.keys').existsSync(); @@ -112,7 +111,6 @@ class WowneroWalletService extends WalletService< final wallet = WowneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -155,7 +153,6 @@ class WowneroWalletService extends WalletService< wallet = WowneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password); throw WalletDeprecationException(seed: wallet.seed, curr: wallet.currency); @@ -245,7 +242,6 @@ class WowneroWalletService extends WalletService< final currentWallet = WowneroWallet( walletInfo: currentWalletInfo, derivationInfo: await currentWalletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password); await currentWallet.renameWalletFiles(newName); @@ -273,7 +269,6 @@ class WowneroWalletService extends WalletService< final wallet = WowneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -310,7 +305,6 @@ class WowneroWalletService extends WalletService< final wallet = WowneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -358,7 +352,6 @@ class WowneroWalletService extends WalletService< final wallet = WowneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); await wallet.init(); @@ -390,7 +383,6 @@ class WowneroWalletService extends WalletService< final wallet = WowneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password); await wallet.init(); diff --git a/cw_zano/lib/zano_wallet.dart b/cw_zano/lib/zano_wallet.dart index 20ad63f5f3..7b180d51d8 100644 --- a/cw_zano/lib/zano_wallet.dart +++ b/cw_zano/lib/zano_wallet.dart @@ -14,6 +14,7 @@ import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import "package:cw_core/coin_control/coin_selection.dart"; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_credentials.dart'; import 'package:cw_core/wallet_info.dart'; @@ -143,7 +144,8 @@ abstract class ZanoWalletBase } @override - int calculateEstimatedFee(TransactionPriority priority, [int? amount = null]) => + Future calculateEstimatedFee(TransactionPriority priority, int? amount, + {CoinSelection selection = const AllCoinSelection()}) async => getCurrentTxFee(priority); @override @@ -446,8 +448,8 @@ abstract class ZanoWalletBase balance[CryptoCurrency.zano]?.unlocked ?? Money.zero(CryptoCurrency.zano); final unlockedBalanceCurrency = balance[credentials.currency]?.unlocked ?? Money.zero(credentials.currency); - final fee = - Money(BigInt.from(calculateEstimatedFee(credentials.priority)), CryptoCurrency.zano); + final fee = Money( + BigInt.from(await calculateEstimatedFee(credentials.priority, null)), CryptoCurrency.zano); var totalAmount = Money.zero(credentials.currency); void checkForEnoughBalances() { diff --git a/cw_zcash/lib/src/zcash_wallet.dart b/cw_zcash/lib/src/zcash_wallet.dart index 4948baf26a..2ec5e11774 100644 --- a/cw_zcash/lib/src/zcash_wallet.dart +++ b/cw_zcash/lib/src/zcash_wallet.dart @@ -13,6 +13,7 @@ import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import "package:cw_core/coin_control/coin_selection.dart"; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_credentials.dart'; import 'package:cw_core/wallet_info.dart'; @@ -128,7 +129,8 @@ abstract class ZcashWalletBase } @override - int calculateEstimatedFee(final TransactionPriority priority, final int? amount) { + Future calculateEstimatedFee(final TransactionPriority priority, final int? amount, + {CoinSelection selection = const AllCoinSelection()}) async { return internalCalculateEstimatedFee(priority, amount); } diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 0936a065ae..731bc2efb8 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -1,6 +1,13 @@ part of 'bitcoin.dart'; class CWBitcoin extends Bitcoin { + Future> _allSpendable( + ElectrumWallet wallet, { + UnspentCoinType coinType = UnspentCoinType.any, + }) async => + (await wallet.spendableCoins(coinType: coinType)) + .cast(); + WalletCredentials createBitcoinRestoreWalletFromSeedCredentials({ required String name, required String mnemonic, @@ -148,6 +155,7 @@ class CWBitcoin extends Bitcoin { required TransactionPriority priority, int? feeRate, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, + CoinSelection coinSelection = const AllCoinSelection(), String? payjoinUri, }) { final bitcoinFeeRate = @@ -169,6 +177,7 @@ class CWBitcoin extends Bitcoin { priority: priority as BitcoinTransactionPriority, feeRate: bitcoinFeeRate, coinTypeToSpendFrom: coinTypeToSpendFrom, + coinSelection: coinSelection, payjoinUri: payjoinUri); } @@ -201,6 +210,7 @@ class CWBitcoin extends Bitcoin { final estimatedTx = await electrumWallet.estimateSendAllTx( [BitcoinOutput(address: p2pkhAddr, value: BigInt.zero)], getFeeRate(wallet, priority as BitcoinCashTransactionPriority), + candidates: await _allSpendable(electrumWallet), ); return estimatedTx.amount; @@ -211,7 +221,7 @@ class CWBitcoin extends Bitcoin { final estimatedTx = await electrumWallet.estimateSendAllTx( [BitcoinOutput(address: dogeAddr, value: BigInt.zero)], getFeeRate(wallet, priority as BitcoinTransactionPriority), - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: await _allSpendable(electrumWallet, coinType: coinTypeToSpendFrom), ); return estimatedTx.amount; } @@ -225,7 +235,7 @@ class CWBitcoin extends Bitcoin { ? priority as LitecoinTransactionPriority : priority as BitcoinTransactionPriority, ), - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: await _allSpendable(electrumWallet, coinType: coinTypeToSpendFrom), ); return estimatedTx.amount; @@ -252,40 +262,17 @@ class CWBitcoin extends Bitcoin { {int? customRate}) => (priority as BitcoinTransactionPriority).labelWithRate(rate, customRate); - @override - List getUnspents(Object wallet, - {UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any}) { - final bitcoinWallet = wallet as ElectrumWallet; - - final coins = bitcoinWallet.unspentCoinsForCurrentAccount; - - return coins.where((element) { - switch (coinTypeToSpendFrom) { - case UnspentCoinType.mweb: - return element.bitcoinAddressRecord.type == SegwitAddresType.mweb; - case UnspentCoinType.nonMweb: - return element.bitcoinAddressRecord.type != SegwitAddresType.mweb; - case UnspentCoinType.lightning: - case UnspentCoinType.any: - return true; - } - }).toList(); - } Future updateUnspents(Object wallet) async { final bitcoinWallet = wallet as ElectrumWallet; await bitcoinWallet.updateAllUnspents(); } - WalletService createBitcoinWalletService(Box unspentCoinSource, - Box payjoinSessionSource, bool isDirect) { - return BitcoinWalletService(unspentCoinSource, payjoinSessionSource, isDirect); - } + WalletService createBitcoinWalletService( + Box payjoinSessionSource, bool isDirect) => + BitcoinWalletService(payjoinSessionSource, isDirect); - WalletService createLitecoinWalletService( - Box unspentCoinSource, bool isDirect) { - return LitecoinWalletService(unspentCoinSource, isDirect); - } + WalletService createLitecoinWalletService(bool isDirect) => LitecoinWalletService(isDirect); @override TransactionPriority getBitcoinTransactionPriorityMedium() => BitcoinTransactionPriority.medium; @@ -516,10 +503,11 @@ class CWBitcoin extends Bitcoin { } @override - int getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount, - {int? outputsCount, int? size}) { + Future getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount, + {int? outputsCount, int? size}) async { final bitcoinWallet = wallet as ElectrumWallet; return bitcoinWallet.calculateEstimatedFeeWithFeeRate( + candidates: await _allSpendable(bitcoinWallet), feeRate, amount, outputsCount: outputsCount, @@ -604,8 +592,8 @@ class CWBitcoin extends Bitcoin { } @override - bool isPayjoinAvailable(Object wallet) => - (wallet is BitcoinWallet) && (wallet as BitcoinWallet).isPayjoinAvailable; + Future isPayjoinAvailable(Object wallet) async => + wallet is BitcoinWallet && await wallet.isPayjoinAvailable; @override BitcoinAddressType getOptionToType(ReceivePageOption option) { @@ -786,8 +774,7 @@ class CWBitcoin extends Bitcoin { @override String getPayjoinEndpoint(Object wallet) { final _wallet = wallet as ElectrumWallet; - if (!isPayjoinAvailable(wallet)) return ''; - return (_wallet.walletAddresses as BitcoinWalletAddresses).payjoinEndpoint ?? ''; + return (_wallet.walletAddresses as BitcoinWalletAddresses).payjoinEndpoint ?? ""; } @override diff --git a/lib/bitcoin_cash/cw_bitcoin_cash.dart b/lib/bitcoin_cash/cw_bitcoin_cash.dart index eb7a38a8bd..33570cc2ee 100644 --- a/lib/bitcoin_cash/cw_bitcoin_cash.dart +++ b/lib/bitcoin_cash/cw_bitcoin_cash.dart @@ -5,9 +5,8 @@ class CWBitcoinCash extends BitcoinCash { String getCashAddrFormat(String address) => AddressUtils.getCashAddrFormat(address); @override - WalletService createBitcoinCashWalletService( - Box unspentCoinSource, bool isDirect) { - return BitcoinCashWalletService(unspentCoinSource, isDirect); + WalletService createBitcoinCashWalletService(bool isDirect) { + return BitcoinCashWalletService(isDirect); } @override diff --git a/lib/decred/cw_decred.dart b/lib/decred/cw_decred.dart index 958a544f07..f99bad2c02 100644 --- a/lib/decred/cw_decred.dart +++ b/lib/decred/cw_decred.dart @@ -32,8 +32,8 @@ class CWDecred extends Decred { DecredRestoreWalletFromPubkeyCredentials(name: name, pubkey: pubkey, password: password); @override - WalletService createDecredWalletService(Box unspentCoinSource, bool isDirect) => - DecredWalletService(unspentCoinSource, isDirect); + WalletService createDecredWalletService(bool isDirect) => + DecredWalletService(isDirect); @override List getTransactionPriorities() => DecredTransactionPriority.all; @@ -49,7 +49,8 @@ class CWDecred extends Decred { DecredTransactionPriority.deserialize(raw: raw); @override - Object createDecredTransactionCredentials(List outputs, TransactionPriority priority) => + Object createDecredTransactionCredentials(List outputs, TransactionPriority priority, + {CoinSelection coinSelection = const AllCoinSelection()}) => DecredTransactionCredentials( outputs .map((out) => OutputInfo( @@ -63,6 +64,7 @@ class CWDecred extends Decred { )) .toList(), priority: priority as DecredTransactionPriority, + coinSelection: coinSelection, ); List getAddressInfos(Object wallet) { @@ -82,17 +84,7 @@ class CWDecred extends Decred { await decredWallet.walletAddresses.generateNewAddress(label); } - @override - List getUnspents(Object wallet) { - final decredWallet = wallet as DecredWallet; - return decredWallet.unspents(); - } - @override - void updateUnspents(Object wallet) { - final decredWallet = wallet as DecredWallet; - decredWallet.unspents(); - } @override int heightByDate(DateTime date) { diff --git a/lib/di.dart b/lib/di.dart index 8fbe5bf433..f6560f4f4c 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -56,6 +56,7 @@ import 'package:cake_wallet/new-ui/model/charts/price_store.dart'; import 'package:cake_wallet/new-ui/new_dashboard.dart'; import 'package:cake_wallet/new-ui/pages/about_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart'; +import 'package:cake_wallet/entities/fiat_api_mode.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart'; import 'package:cake_wallet/new-ui/pages/buy_sell/buy_sell_amount_page.dart'; @@ -63,6 +64,7 @@ import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart'; import 'package:cake_wallet/new-ui/pages/charts_page.dart'; import 'package:cake_wallet/new-ui/pages/coin_control_page.dart'; +import 'package:cake_wallet/new-ui/viewmodels/coin_control/coin_control_bloc.dart'; import 'package:cake_wallet/new-ui/pages/addresses_page.dart'; import 'package:cake_wallet/new-ui/pages/home_page.dart'; import 'package:cake_wallet/new-ui/pages/send_page.dart'; @@ -247,9 +249,6 @@ import 'package:cake_wallet/view_model/start_tor_view_model.dart'; import 'package:cake_wallet/view_model/support_view_model.dart'; import 'package:cake_wallet/view_model/trade_details_view_model.dart'; import 'package:cake_wallet/view_model/transaction_details_view_model.dart'; -import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_details_view_model.dart'; -import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart'; -import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart'; @@ -276,6 +275,8 @@ import 'package:cw_core/node.dart'; import 'package:cw_core/payjoin_session.dart'; import 'package:cw_core/receive_page_option.dart'; import 'package:cw_core/transaction_info.dart'; +import 'package:cw_core/coin_control/coin_control_wallet.dart'; +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_info.dart'; @@ -307,7 +308,6 @@ late Box