From 02abb4c56e66417851f4aa6cc0bdc84a4e2d4642 Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Wed, 9 Sep 2026 21:15:56 +0200 Subject: [PATCH 1/5] [wip] coin control refactor --- .../lib/bitcoin_transaction_credentials.dart | 3 + cw_bitcoin/lib/bitcoin_unspent.dart | 7 + cw_bitcoin/lib/bitcoin_wallet.dart | 29 +- cw_bitcoin/lib/bitcoin_wallet_service.dart | 19 +- cw_bitcoin/lib/electrum_wallet.dart | 271 +++----- cw_bitcoin/lib/litecoin_wallet.dart | 56 +- cw_bitcoin/lib/litecoin_wallet_addresses.dart | 3 - cw_bitcoin/lib/litecoin_wallet_service.dart | 19 +- cw_bitcoin/lib/payjoin/manager.dart | 6 +- cw_bitcoin/test/bitcoin_unspent_id_test.dart | 73 ++ .../lib/src/bitcoin_cash_wallet.dart | 29 +- .../lib/src/bitcoin_cash_wallet_service.dart | 17 +- .../lib/coin_control/coin_control_wallet.dart | 57 ++ .../lib/coin_control/coin_notes_store.dart | 24 + cw_core/lib/coin_control/coin_selection.dart | 23 + .../lib/coin_control/frozen_coins_store.dart | 27 + cw_core/lib/db/sqlite.dart | 29 +- cw_core/lib/unspent_coins_info.dart | 100 +++ cw_core/lib/unspent_transaction_output.dart | 22 +- cw_core/lib/wallet_base.dart | 13 +- .../coin_control_wallet_test.dart | 297 ++++++++ .../coin_control/coin_selection_test.dart | 120 ++++ .../fake_coin_control_stores.dart | 45 ++ .../coin_control/sqlite_test_harness.dart | 49 ++ cw_core/test/transaction_history_test.dart | 171 +++++ cw_core/test/transaction_status_test.dart | 113 ++++ cw_core/test/transaction_title_test.dart | 89 +++ cw_decred/lib/transaction_credentials.dart | 11 +- cw_decred/lib/wallet.dart | 119 +--- cw_decred/lib/wallet_service.dart | 1 - cw_dogecoin/lib/src/dogecoin_wallet.dart | 9 +- .../lib/src/dogecoin_wallet_service.dart | 18 +- cw_evm/lib/evm_chain_wallet.dart | 5 +- .../evm_transaction_amount_currency_test.dart | 92 +++ cw_monero/lib/api/coins_info.dart | 74 +- cw_monero/lib/api/get_all_unspent.dart | 3 +- cw_monero/lib/monero_frozen_coins_store.dart | 60 ++ ...nero_transaction_creation_credentials.dart | 8 +- cw_monero/lib/monero_unspent.dart | 74 +- cw_monero/lib/monero_wallet.dart | 149 ++-- cw_monero/lib/monero_wallet_service.dart | 10 - cw_nano/lib/nano_wallet.dart | 5 +- cw_solana/lib/solana_wallet.dart | 5 +- cw_tron/lib/tron_wallet.dart | 5 +- cw_wownero/lib/wownero_unspent.dart | 7 +- cw_wownero/lib/wownero_wallet.dart | 118 +--- cw_wownero/lib/wownero_wallet_service.dart | 7 - cw_zano/lib/zano_wallet.dart | 8 +- cw_zcash/lib/src/zcash_wallet.dart | 4 +- lib/bitcoin/cw_bitcoin.dart | 43 +- lib/decred/cw_decred.dart | 14 +- lib/di.dart | 58 +- lib/main.dart | 1 + lib/monero/cw_monero.dart | 17 +- lib/new-ui/pages/coin_control_page.dart | 457 ++++++------- lib/new-ui/pages/send_page.dart | 27 +- .../coin_control/coin_control_bloc.dart | 149 ++++ .../coin_control/coin_control_event.dart | 41 ++ .../coin_control/coin_control_state.dart | 100 +++ .../widgets/swap_page/swap_options_page.dart | 37 +- lib/router.dart | 4 +- lib/src/screens/send/widgets/send_card.dart | 10 - .../unspent_coins_details_page.dart | 123 ++-- .../unspent_coins_list_page.dart | 217 ------ .../widgets/unspent_coins_list_item.dart | 213 ------ lib/src/widgets/list_row.dart | 72 +- .../exchange/exchange_view_model.dart | 24 +- lib/view_model/send/output.dart | 4 +- lib/view_model/send/send_view_model.dart | 85 +-- .../unspent_coins_details_view_model.dart | 106 --- .../unspent_coins/unspent_coins_item.dart | 58 -- .../unspent_coins_list_view_model.dart | 286 -------- .../wallet_address_list_view_model.dart | 15 +- lib/wownero/cw_wownero.dart | 10 - .../viewmodels/coin_control_bloc_test.dart | 640 ++++++++++++++++++ tool/configure.dart | 25 +- 76 files changed, 3172 insertions(+), 2167 deletions(-) create mode 100644 cw_bitcoin/test/bitcoin_unspent_id_test.dart create mode 100644 cw_core/lib/coin_control/coin_control_wallet.dart create mode 100644 cw_core/lib/coin_control/coin_notes_store.dart create mode 100644 cw_core/lib/coin_control/coin_selection.dart create mode 100644 cw_core/lib/coin_control/frozen_coins_store.dart create mode 100644 cw_core/test/coin_control/coin_control_wallet_test.dart create mode 100644 cw_core/test/coin_control/coin_selection_test.dart create mode 100644 cw_core/test/coin_control/fake_coin_control_stores.dart create mode 100644 cw_core/test/coin_control/sqlite_test_harness.dart create mode 100644 cw_core/test/transaction_history_test.dart create mode 100644 cw_core/test/transaction_status_test.dart create mode 100644 cw_core/test/transaction_title_test.dart create mode 100644 cw_evm/test/evm_transaction_amount_currency_test.dart create mode 100644 cw_monero/lib/monero_frozen_coins_store.dart create mode 100644 lib/new-ui/viewmodels/coin_control/coin_control_bloc.dart create mode 100644 lib/new-ui/viewmodels/coin_control/coin_control_event.dart create mode 100644 lib/new-ui/viewmodels/coin_control/coin_control_state.dart delete mode 100644 lib/src/screens/unspent_coins/unspent_coins_list_page.dart delete mode 100644 lib/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart delete mode 100644 lib/view_model/unspent_coins/unspent_coins_details_view_model.dart delete mode 100644 lib/view_model/unspent_coins/unspent_coins_item.dart delete mode 100644 lib/view_model/unspent_coins/unspent_coins_list_view_model.dart create mode 100644 test/new-ui/viewmodels/coin_control_bloc_test.dart diff --git a/cw_bitcoin/lib/bitcoin_transaction_credentials.dart b/cw_bitcoin/lib/bitcoin_transaction_credentials.dart index 7d6894e149..4add222c88 100644 --- a/cw_bitcoin/lib/bitcoin_transaction_credentials.dart +++ b/cw_bitcoin/lib/bitcoin_transaction_credentials.dart @@ -1,4 +1,5 @@ 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'; @@ -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..e66fc58eee 100644 --- a/cw_bitcoin/lib/bitcoin_unspent.dart +++ b/cw_bitcoin/lib/bitcoin_unspent.dart @@ -1,3 +1,4 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_core/unspent_transaction_output.dart'; @@ -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 a583dcc010..fcc75459e8 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -36,7 +36,8 @@ 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/coin_control/coin_selection.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/zpub.dart'; import 'package:cw_core/wallet_info.dart'; @@ -58,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, @@ -84,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 @@ -178,7 +177,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, @@ -214,7 +212,6 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, initialSilentAddresses: initialSilentAddresses, initialSilentAddressIndex: initialSilentAddressIndex, @@ -233,7 +230,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, @@ -317,7 +313,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, @@ -425,9 +420,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { @override bool get hasLightningSupport => lightningWallet?.sdk != null; - 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, @@ -601,7 +595,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 = @@ -612,10 +606,12 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return tx; } - List getUtxoWithPrivateKeys({bool confirmedOnly = false}) => unspentCoins - .where((e) => e.isSending && !e.isFrozen && (!confirmedOnly || (e.confirmations ?? 0) > 0)) - .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) - .toList(); + Future> getUtxoWithPrivateKeys({bool confirmedOnly = false}) async => + (await spendableCoins()) + .cast() + .where((e) => !confirmedOnly || (e.confirmations ?? 0) > 0) + .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) + .toList(); Future commitPsbt(String finalizedPsbt) { final psbt = PsbtV2()..deserializeV0(base64.decode(finalizedPsbt)); @@ -726,4 +722,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..141a10769f 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -4,6 +4,8 @@ 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'; @@ -63,7 +65,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 +91,6 @@ class BitcoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, payjoinBox: payjoinSessionSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -103,7 +103,6 @@ class BitcoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, payjoinBox: payjoinSessionSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -121,15 +120,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.id); + await CoinNotesStore.instance.deleteWallet(walletInfo.id); } @override @@ -148,7 +140,6 @@ class BitcoinWalletService extends WalletService< xpub: xpub, walletInfo: credentials.walletInfo!, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfoSource, networkParam: network, encryptionFileUtils: encryptionFileUtilsFor(isDirect), payjoinBox: payjoinSessionSource, @@ -172,7 +163,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 +189,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_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 8b1cc99fbb..295b4cc3b0 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -41,6 +41,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/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'; @@ -62,12 +65,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, @@ -102,7 +104,6 @@ abstract class ElectrumWalletBase ) } : {}), - this.unspentCoinsInfo = unspentCoinsInfo, this.isTestnet = !network.isMainnet, this._mnemonic = mnemonic, _useLightning = useLightning, @@ -359,7 +360,6 @@ abstract class ElectrumWalletBase bool isEnabledAutoGenerateSubaddress; late electrum.ElectrumClient electrumClient; - Box unspentCoinsInfo; @override late ElectrumWalletAddresses walletAddresses; @@ -513,6 +513,13 @@ abstract class ElectrumWalletBase String _password; List unspentCoins; + + @override + List get unspents => unspentCoins; + + @override + Future refreshUnspents() => updateAllUnspents(); + List _feeRates; // ignore: prefer_final_fields @@ -532,7 +539,6 @@ abstract class ElectrumWalletBase Future init() async { await walletAddresses.init(); await transactionHistory.init(); - await cleanUpDuplicateUnspentCoins(); await save(); _autoSaveTimer = @@ -917,14 +923,21 @@ abstract class ElectrumWalletBase int _coinSelectionPriority(BitcoinUnspent utx) => _coinSelectionOrder.putIfAbsent( '${utx.hash}:${utx.vout}', () => _coinSelectionRng.nextInt(1 << 32)); + /// Builds the input set for one transaction from [candidates]. + /// + /// The candidates are passed in rather than derived here: they come from + /// [spendableCoins], which is the single place the user's selection, frozen + /// state and the requested coin type are combined. This method must never + /// consult [unspentCoins] itself -- reading that directly is what let a coin + /// the user had unselected reach a transaction. 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 = []; @@ -935,21 +948,7 @@ abstract class ElectrumWalletBase bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; - var availableInputs = unspentCoins.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 @@ -1101,12 +1100,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( @@ -1166,6 +1165,10 @@ abstract class ElectrumWalletBase String? memo, bool? useUnconfirmed, bool hasSilentPayment = false, + required List candidates, + // Only for change-address selection; the inputs are already decided by + // [candidates]. This is the half of the coin type that cannot fold into a + // selection, because Litecoin picks a change address by it. UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { // Attempting to send less than the dust limit @@ -1173,14 +1176,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 @@ -1190,7 +1185,7 @@ abstract class ElectrumWalletBase feeRate: feeRate, memo: memo, hasSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); if (credentialsAmount > maxSpendable) { throw BitcoinTransactionWrongBalanceException(); @@ -1208,7 +1203,7 @@ abstract class ElectrumWalletBase feeRate, memo: memo, hasSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); } } @@ -1233,7 +1228,7 @@ abstract class ElectrumWalletBase feeRate: feeRate, outputsVBytes: outputsVBytes, paysToSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; @@ -1254,6 +1249,7 @@ abstract class ElectrumWalletBase inputsCount: utxoDetails.utxos.length + 1, memo: memo, hasSilentPayment: hasSilentPayment, + candidates: candidates, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } @@ -1375,6 +1371,7 @@ abstract class ElectrumWalletBase memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, hasSilentPayment: hasSilentPayment, + candidates: candidates, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } else { @@ -1433,12 +1430,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 = [ @@ -1511,6 +1508,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; @@ -1573,7 +1580,7 @@ abstract class ElectrumWalletBase feeRateInt, memo: memo, hasSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, + candidates: candidates, ); } else { estimatedTx = await estimateTxForAmount( @@ -1583,6 +1590,7 @@ abstract class ElectrumWalletBase feeRateInt, memo: memo, hasSilentPayment: hasSilentPayment, + candidates: candidates, coinTypeToSpendFrom: coinTypeToSpendFrom, ); } @@ -1795,17 +1803,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); } @@ -1815,24 +1834,18 @@ abstract class ElectrumWalletBase if (amount != null) { int totalValue = 0; - for (final input in unspentCoins) { + 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 unspentCoins) { - if (input.isSending) { - inputsCount += 1; - } - } + inputsCount = candidates.length; } // If send all, then we have no change value @@ -1933,15 +1946,6 @@ abstract class ElectrumWalletBase } } - final currentWalletUnspentCoins = - unspentCoinsInfo.values.where((element) => element.walletId == id); - - if (currentWalletUnspentCoins.length != updatedUnspentCoins.length) { - unspentCoins.forEach((coin) => addCoinInfo(coin)); - } - - await updateCoins(unspentCoins); - await _refreshUnspentCoinsInfo(); } Future?>> _fetchUnspentsRegular( @@ -2042,38 +2046,22 @@ abstract class ElectrumWalletBase return updatedUnspentCoins; } - Future updateCoins(List newUnspentCoins) async { - if (newUnspentCoins.isEmpty) { + /// Re-fetches one address' outputs and merges them into the coin list. + /// + /// Nothing is hydrated onto the coins: they are chain data, and the user's + /// frozen state lives in the store keyed by output id. + @action + Future updateUnspentsForAddress(BitcoinAddressRecord address) async { + final fetched = await fetchUnspent(address); + if (fetched == null || fetched.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) - coin.bitcoinAddressRecord.balance += coinInfo.value; - } else { - addCoinInfo(coin); - } - }); - } - - @action - Future updateUnspentsForAddress(BitcoinAddressRecord address) async { - final newUnspentCoins = await fetchUnspent(address); - await updateCoins(newUnspentCoins ?? []); + final byId = {for (final coin in unspentCoins) coin.id: coin}; + for (final coin in fetched) { + byId[coin.id] = coin; + } + unspentCoins = byId.values.toList(); } @action @@ -2099,78 +2087,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); - } - int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize(); Future canReplaceByFee(ElectrumTransactionInfo tx) async { @@ -2199,8 +2115,8 @@ abstract class ElectrumWalletBase throw Exception("Receiver output not found."); } - final availableInputs = unspentCoins.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; @@ -2327,8 +2243,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 = unspentCoins - .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()); @@ -3533,7 +3450,11 @@ abstract class ElectrumWalletBase printV( 'Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching'); - var totalFrozen = 0; + // One frozen total, derived by matching stored records against the live + // output list. The previous version walked the whole store with no wallet + // filter and re-derived the sum in a nested loop, so a second wallet's + // records were counted here too. + var totalFrozen = await frozenBalance(); var totalConfirmed = 0; var totalUnconfirmed = 0; @@ -3544,7 +3465,6 @@ abstract class ElectrumWalletBase if (tx.unspents != null) { tx.unspents!.forEach((unspent) { if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { - if (unspent.isFrozen) totalFrozen += unspent.value; totalConfirmed += unspent.value; } }); @@ -3552,21 +3472,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; - } - } - }); - }); - if (balances.isNotEmpty && balances.first['confirmed'] == null) { // if we got null balance responses from the server, set our connection status to lost and return our last known balance: printV("got null balance responses from the server, setting connection status to lost"); diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index fbd9462339..7edfa7c7ae 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'; @@ -66,7 +67,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 +89,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { xpub: xpub, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, network: LitecoinNetwork.mainnet, initialAddresses: initialAddresses, initialBalance: initialBalance, @@ -190,7 +189,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, @@ -218,7 +216,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { password: password, walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, initialMwebAddresses: initialMwebAddresses, initialBalance: initialBalance, @@ -242,7 +239,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 { @@ -318,7 +314,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, @@ -856,6 +851,29 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { await updateAllUnspents(); } + /// MWEB outputs are only spendable when MWEB is switched on, so the rule + /// lives here rather than in a coin type the caller has to remember to + /// downgrade before building a transaction. + @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 { @@ -899,8 +917,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: @@ -945,27 +961,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) { @@ -1613,4 +1608,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { return BtcTransaction.fromRaw(rawHex); } + + @override + Uri coinControlUrl(String txId) => Uri.https("litecoin.earlyordies.com", "/tx/${txId}"); } diff --git a/cw_bitcoin/lib/litecoin_wallet_addresses.dart b/cw_bitcoin/lib/litecoin_wallet_addresses.dart index fe85cd7823..a683e6ab12 100644 --- a/cw_bitcoin/lib/litecoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/litecoin_wallet_addresses.dart @@ -194,9 +194,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..9f33bc176e 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -2,6 +2,8 @@ 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'; @@ -50,7 +52,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 +76,6 @@ class LitecoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -87,7 +87,6 @@ class LitecoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -125,15 +124,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.id); + await CoinNotesStore.instance.deleteWallet(walletInfo.id); } @override @@ -160,7 +152,6 @@ class LitecoinWalletService extends WalletService< xpub: credentials.hwAccountData.xpub, walletInfo: credentials.walletInfo!, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.save(); @@ -181,7 +172,6 @@ class LitecoinWalletService extends WalletService< spendPubkeyOverride: credentials.spendPubkey, walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); @@ -204,7 +194,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_cash/lib/src/bitcoin_cash_wallet.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart index 18b9ea1c93..8001c7bdb9 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, @@ -73,7 +70,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, @@ -86,7 +82,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), @@ -101,7 +96,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 { @@ -140,7 +134,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); @@ -178,15 +171,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; } @@ -230,4 +228,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..9349ff5bbd 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart @@ -3,6 +3,8 @@ 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'; @@ -36,7 +38,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 +59,6 @@ class BitcoinCashWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -70,7 +70,6 @@ class BitcoinCashWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -87,15 +86,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.id); + await CoinNotesStore.instance.deleteWallet(walletInfo.id); } @override @@ -121,7 +113,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/coin_control/coin_control_wallet.dart b/cw_core/lib/coin_control/coin_control_wallet.dart new file mode 100644 index 0000000000..0ceb0c8890 --- /dev/null +++ b/cw_core/lib/coin_control/coin_control_wallet.dart @@ -0,0 +1,57 @@ +import "package:cw_core/balance.dart"; +import "package:cw_core/coin_control/coin_notes_store.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; + + CoinNotesStore get coinNotesStore => CoinNotesStore.instance; + + Future> frozenIds() => frozenCoinsStore.frozenIds(id); + + Future setFrozen(String coinId, bool frozen) => + frozenCoinsStore.setFrozen(id, coinId, frozen); + + Future> notes() => coinNotesStore.forWallet(id); + + Future saveNote(String coinId, String note) => coinNotesStore.save(id, coinId, note); + + 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..13e0b93928 --- /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(String walletId) async { + final rows = await db!.query(tableName, where: "walletId = ?", whereArgs: [walletId]); + return { + for (final row in rows) row["id"]! as String: row["note"] as String? ?? "", + }; + } + + Future save(String walletId, String id, String note) => db!.insert( + tableName, + {"walletId": walletId, "id": id, "note": note}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + Future deleteWallet(String walletId) => + db!.delete(tableName, where: "walletId = ?", whereArgs: [walletId]); +} 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..05a7a34842 --- /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(String walletId) async { + final rows = await db!.query( + tableName, + columns: ["id"], + where: "walletId = ? AND frozen = 1", + whereArgs: [walletId], + ); + return rows.map((row) => row["id"]! as String).toSet(); + } + + Future setFrozen(String walletId, String id, bool frozen) => db!.insert( + tableName, + {"walletId": walletId, "id": id, "frozen": frozen ? 1 : 0}, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + Future deleteWallet(String walletId) => + db!.delete(tableName, where: "walletId = ?", whereArgs: [walletId]); +} diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart index 1c8ade0371..dbc084102d 100644 --- a/cw_core/lib/db/sqlite.dart +++ b/cw_core/lib/db/sqlite.dart @@ -63,7 +63,7 @@ Future _initDb({String? pathOverride}) async { } } await db?.close(); - db = await openDatabase(dbFile.path, version: 11, + db = await openDatabase(dbFile.path, version: 12, onUpgrade: (Database db, int oldVersion, int newVersion) async { printV("migrating: $oldVersion, $newVersion"); if (oldVersion <= 1) { @@ -156,6 +156,11 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings ( if (oldVersion <= 10) { await _createImportedNFTTable(db); } + + if (oldVersion <= 11) { + await _createCoinControlTables(db); + } + }, onCreate: (Database db, int version) async { await db.execute(''' CREATE TABLE WalletInfo ( @@ -255,7 +260,27 @@ CREATE TABLE BalanceCardStyleSettings ( await _createSplTokenTable(db); await _createTronTokenTable(db); await _createImportedNFTTable(db); - }); + await _createCoinControlTables(db); + },); +} + +Future _createCoinControlTables(Database db) async { + await db.execute(''' +CREATE TABLE IF NOT EXISTS FrozenCoin ( + walletId TEXT NOT NULL, + id TEXT NOT NULL, + frozen INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (walletId, id) +); +'''); + await db.execute(''' +CREATE TABLE IF NOT EXISTS CoinNote ( + walletId TEXT NOT NULL, + id TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + PRIMARY KEY (walletId, id) +); +'''); } Future _createTradeTable(Database db) async { diff --git a/cw_core/lib/unspent_coins_info.dart b/cw_core/lib/unspent_coins_info.dart index 8e0ec7883a..18621ee472 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,81 @@ class UnspentCoinsInfo extends HiveObject with UnspentComparable { String get note => noteRaw ?? ''; set note(String value) => noteRaw = value; + + /// Wallet types whose core module owns frozen state, so it is not migrated. + /// + /// Monero persists the flag inside the wallet file, which is where it is read + /// from now, so a row in the FrozenCoin table would be a second source of + /// truth for something the wallet already has. + static const _typesOwningFrozenState = [WalletType.monero, WalletType.wownero]; + + static Future migrateAllToSqlite(List wallets) async { + final box = await CakeHive.openBox(boxName); + final walletTypes = {for (final wallet in wallets) wallet.id: wallet.type}; + + for (final record in box.values.toList()) { + final type = walletTypes[record.walletId]; + if (type == null) { + // Left behind by a wallet that has since been deleted. + continue; + } + + try { + await record.migrateToSqlite(type); + } catch (e) { + printV("Error migrating unspent record ${record.walletId}: $e, continuing anyway"); + } + } + + await box.deleteFromDisk(); + } + + Future migrateToSqlite(WalletType walletType) async { + // This box held a record for every output the wallet had ever seen, so + // only the ones carrying something the user set are worth a row. The new + // tables read an absent row as not frozen with no note, which is what all + // the rest of these amount to. + final shouldMigrateFrozen = isFrozen && !_typesOwningFrozenState.contains(walletType); + if (note.isEmpty && !shouldMigrateFrozen) { + return; + } + + final id = _outputId(walletType); + + if (note.isNotEmpty) { + await CoinNotesStore.instance.save(walletId, id, note); + } + + if (shouldMigrateFrozen) { + await FrozenCoinsStore.instance.setFrozen(walletId, id, true); + } + + // isSending is deliberately dropped: the selection is not durable state, + // and persisting it is what let an unselected output be spent after the + // sending flow that unselected it had closed. + } + + /// The id the new tables key on, as the output itself now reports it. + /// + /// Reproduced from the record rather than asked of the chain module, because + /// by the time this runs the record is all that is left -- the outputs it + /// describes are not fetched during startup. + String _outputId(WalletType walletType) { + final image = keyImage; + if (image != null && image.isNotEmpty) { + return image; + } + + // An MWEB output is identified by its hash alone. The vout on these + // records is an index into the wallet's MWEB address list, which shifts as + // that list grows, so it was never part of the identity. + 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 024095b80a..90e5457f73 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,17 @@ abstract class WalletBase createTransaction(Object credentials); - int calculateEstimatedFee(TransactionPriority priority, int? amount); + /// Estimated fee for spending [amount]. + /// + /// Async because wallets with an output model have to read which of their + /// outputs are frozen, and [selection] because the fee depends on how many + /// inputs the transaction will take -- narrowing the selection changes it. + /// Wallets without an output model ignore both. + 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..105a105128 --- /dev/null +++ b/cw_core/test/coin_control/coin_control_wallet_test.dart @@ -0,0 +1,297 @@ +import "package:cw_core/coin_control/coin_control_wallet.dart"; +import "package:cw_core/coin_control/coin_notes_store.dart"; +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"; + +Unspent coin(String hash, int vout, {int value = 1000}) => + Unspent("addr-$hash", hash, value, vout, null); + +/// The mixin asks only for a wallet id, so a double needs only that. Anything +/// more would be testing WalletBase rather than coin control. +class _FakeWallet with CoinControlWallet { + _FakeWallet({ + required this.id, + required this.unspents, + required this.frozenCoinsStore, + CoinNotesStore? coinNotesStore, + this.coinTypeOf, + }) : coinNotesStore = coinNotesStore ?? FakeCoinNotesStore(); + + @override + final String id; + + @override + List unspents; + + @override + final FrozenCoinsStore frozenCoinsStore; + + @override + final CoinNotesStore coinNotesStore; + + /// Stands in for a chain that has more than one kind of output. + final UnspentCoinType Function(Unspent coin)? coinTypeOf; + + int refreshCount = 0; + + @override + Future refreshUnspents() async => refreshCount++; + + @override + bool allowsCoinType(Unspent coin, UnspentCoinType coinType) { + if (coinTypeOf == null || coinType == UnspentCoinType.any) { + return true; + } + return coinTypeOf!(coin) == coinType; + } +} + +void main() { + late FakeFrozenCoinsStore store; + late _FakeWallet 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 = _FakeWallet( + id: "wallet-a", + unspents: [a, b, c], + frozenCoinsStore: store, + ); + }); + + group("spendableCoins", () { + test("returns everything under an all-outputs selection", () async { + final spendable = await wallet.spendableCoins(const AllCoinSelection()); + expect(spendable.map((coin) => coin.id), [a.id, b.id, c.id]); + }); + + test("returns only the selected outputs", () async { + final spendable = await wallet.spendableCoins(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(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(const AllCoinSelection()); + 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(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(const AllCoinSelection()); + expect(spendable.map((coin) => coin.id), [a.id, b.id, c.id]); + }); + + test("a note alone does not make an output unspendable", () async { + // Notes live in their own table for exactly this reason: there is no + // path by which annotating an output can affect what is spendable. + await wallet.saveNote(b.id, "just a note"); + + final spendable = await wallet.spendableCoins(const AllCoinSelection()); + expect(spendable, hasLength(3)); + }); + + test("reads the frozen set once per call, not once per output", () async { + final counting = _CountingStore(store); + final counted = _FakeWallet( + id: "wallet-a", + unspents: List.generate(50, (i) => coin("tx$i", 0)), + frozenCoinsStore: counting, + ); + + await counted.spendableCoins(const AllCoinSelection()); + + // 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("wallet-b", b.id, true); + + final spendable = await wallet.spendableCoins(const AllCoinSelection()); + 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(SpecificCoinSelection({a.id, b.id})); + expect(spendable.map((coin) => coin.id), [a.id]); + }); + }); + + group("spendableCoins with a coin type", () { + late _FakeWallet mixedWallet; + final mweb = coin("mweb-out", 0); + final regular = coin("regular-out", 0); + + setUp(() { + mixedWallet = _FakeWallet( + 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(const AllCoinSelection(), coinType: UnspentCoinType.any); + expect(spendable, hasLength(2)); + }); + + test("narrows to the requested kind", () async { + final spendable = await mixedWallet.spendableCoins( + const AllCoinSelection(), + 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( + const AllCoinSelection(), + 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( + 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( + const AllCoinSelection(), + coinType: UnspentCoinType.nonMweb, + ); + expect(spendable, isEmpty); + }); + }); + + 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("wallet-b", a.id, true); + expect(await wallet.frozenBalance(), 0); + }); + }); + + group("notes", () { + test("round-trip through the wallet", () async { + await wallet.saveNote(a.id, "cold storage"); + expect((await wallet.notes())[a.id], "cold storage"); + }); + + test("are scoped to the wallet", () async { + final notesStore = FakeCoinNotesStore(); + final other = _FakeWallet( + id: "wallet-b", + unspents: [a], + frozenCoinsStore: store, + coinNotesStore: notesStore, + ); + final mine = _FakeWallet( + id: "wallet-a", + unspents: [a], + frozenCoinsStore: store, + coinNotesStore: notesStore, + ); + + await other.saveNote(a.id, "other wallet"); + + expect(await mine.notes(), isEmpty); + }); + + test("freezing an output does not touch its note", () async { + await wallet.saveNote(a.id, "cold storage"); + await wallet.setFrozen(a.id, true); + await wallet.setFrozen(a.id, false); + + expect((await wallet.notes())[a.id], "cold storage"); + }); + }); +} + +class _CountingStore implements FrozenCoinsStore { + _CountingStore(this._inner); + + final FrozenCoinsStore _inner; + int frozenIdsCalls = 0; + + @override + Future> frozenIds(String walletId) { + frozenIdsCalls++; + return _inner.frozenIds(walletId); + } + + @override + Future setFrozen(String walletId, String id, bool frozen) => + _inner.setFrozen(walletId, id, frozen); + + @override + Future deleteWallet(String walletId) => _inner.deleteWallet(walletId); +} 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..e316b88244 --- /dev/null +++ b/cw_core/test/coin_control/coin_selection_test.dart @@ -0,0 +1,120 @@ +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("compares equal to another instance", () { + expect(const AllCoinSelection(), 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("compares equal regardless of id order", () { + expect( + SpecificCoinSelection(["aa:0", "bb:1"]), + SpecificCoinSelection(["bb:1", "aa:0"]), + ); + }); + + test("is not equal to a selection with different ids", () { + expect( + SpecificCoinSelection(["aa:0"]), + isNot(SpecificCoinSelection(["aa:0", "bb:1"])), + ); + }); + + test("is never equal to an all-outputs selection", () { + expect(SpecificCoinSelection(["aa:0"]), isNot(const AllCoinSelection())); + }); + + 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..4b47b9d190 --- /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(String walletId) async { + final rows = _byWallet[walletId] ?? const {}; + return rows.entries.where((entry) => entry.value).map((entry) => entry.key).toSet(); + } + + @override + Future setFrozen(String walletId, String id, bool frozen) async => + _byWallet.putIfAbsent(walletId, () => {})[id] = frozen; + + @override + Future deleteWallet(String walletId) async => _byWallet.remove(walletId); +} + +/// 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(String walletId) async => + Map.of(_byWallet[walletId] ?? const {}); + + @override + Future save(String walletId, String id, String note) async => + _byWallet.putIfAbsent(walletId, () => {})[id] = note; + + @override + Future deleteWallet(String walletId) async => _byWallet.remove(walletId); +} 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/transaction_history_test.dart b/cw_core/test/transaction_history_test.dart new file mode 100644 index 0000000000..9f1d1e6103 --- /dev/null +++ b/cw_core/test/transaction_history_test.dart @@ -0,0 +1,171 @@ +import 'package:cw_core/amount/money.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:cw_core/transaction_history.dart'; +import 'package:cw_core/transaction_info.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Tx extends TransactionInfo { + _Tx(String id, {DateTime? at}) + : super( + id: id, + amount: Money.fromInt(1, CryptoCurrency.btc), + direction: TransactionDirection.incoming, + date: at ?? DateTime(2026, 1, 1), + ) { + isPending = false; + } +} + +class _History extends TransactionHistory<_Tx> {} + + +/// Collects everything the history announces, the way a consumer would after +/// buffering a burst. +Future> drain(_History history, void Function() body) async { + final collected = []; + final subscription = history.changes.listen(collected.add); + body(); + // Let the broadcast controller deliver. + await Future.delayed(Duration.zero); + await subscription.cancel(); + return collected; +} + +void main() { + group('history keys', () { + // Zcash used to key its map 'tx_' while the item's own id was the + // bare hash. The change journal carries the stored key, and the UI matches + // rows by item id, so every update announced a key no row could be found + // under and appended another copy instead of replacing it. + test('a stale map key cannot become the stored key', () async { + final history = _History(); + final changes = await drain( + history, + () => history.addMany({'tx_a': _Tx('a'), 'tx_b': _Tx('b')}), + ); + + expect(history.transactions.keys, ['a', 'b']); + expect(changes.map((change) => (change as ItemAdded).id), ['a', 'b']); + }); + + test('re-adding the same transaction replaces it and reports an update', + () async { + final history = _History()..addOne(_Tx('a')); + final changes = await drain(history, () => history.addOne(_Tx('a'))); + + expect(history.transactions, hasLength(1)); + expect(changes.single, isA()); + }); + }); + + group('TransactionHistoryBase', () { + test('exposes a read-only map', () { + final history = _History()..addOne(_Tx('a')); + + expect(history.transactions.length, 1); + expect(() => history.transactions.remove('a'), throwsUnsupportedError); + expect(() => history.transactions['b'] = _Tx('b'), throwsUnsupportedError); + }); + + test('distinguishes a first insert from a replacement', () async { + final history = _History(); + + expect(await drain(history, () => history.addOne(_Tx('a'))), [isA()]); + expect(await drain(history, () => history.addOne(_Tx('a'))), [isA()]); + }); + + test('announces removals, and stays quiet for absent ids', () async { + final history = _History()..addOne(_Tx('a')); + + expect(await drain(history, () => history.remove('a')), [isA()]); + expect(await drain(history, () => history.remove('a')), isEmpty); + }); + + test('emits one event for clear() rather than one per entry', () async { + final history = _History()..addMany({for (var i = 0; i < 500; i++) '$i': _Tx('$i')}); + + final changes = await drain(history, history.clear); + + expect(changes, [isA()]); + expect(history.transactions, isEmpty); + }); + + test('clear() on an empty history says nothing', () async { + expect(await drain(_History(), () {}), isEmpty); + expect(await drain(_History(), _History().clear), isEmpty); + }); + + test('markUpdated announces in-place mutation, which is otherwise invisible', () async { + final tx = _Tx('a'); + final history = _History()..addOne(tx); + + final changes = await drain(history, () { + tx.confirmations = 3; + history.markUpdated(['a']); + }); + + expect(changes, [isA()]); + expect((changes.single as ItemUpdated).id, 'a'); + }); + + test('markUpdated ignores ids it does not hold', () async { + final history = _History(); + expect(await drain(history, () => history.markUpdated(['nope'])), isEmpty); + }); + + test('markLoaded fires once and flips hasLoaded', () async { + final history = _History(); + expect(history.hasLoaded, isFalse); + + expect(await drain(history, history.markLoaded), [isA()]); + expect(history.hasLoaded, isTrue); + expect(await drain(history, history.markLoaded), isEmpty); + }); + + test('an empty load still reports as loaded', () async { + // The case that makes HistoryLoaded necessary: a wallet with no + // transactions produces no item events at all. + final history = _History(); + final changes = await drain(history, history.markLoaded); + + expect(changes, [isA()]); + expect(history.transactions, isEmpty); + }); + + test('removeWhere announces each removal and leaves the rest', () async { + final history = _History() + ..addMany({'a': _Tx('a'), 'b': _Tx('b'), 'c': _Tx('c')}); + + final changes = await drain( + history, + () => history.removeWhere((id, _) => id != 'b'), + ); + + expect(changes, everyElement(isA())); + expect(changes.length, 2); + expect(history.transactions.keys, ['b']); + }); + + test('the journal is ordered and not deduplicated', () async { + final history = _History(); + + final changes = await drain(history, () { + history.addOne(_Tx('a')); + history.addOne(_Tx('a')); + history.remove('a'); + }); + + expect(changes, [isA(), isA(), isA()]); + }); + + test('nothing is emitted after dispose', () async { + final history = _History(); + await history.dispose(); + + // Must not throw on a closed controller. + expect(() => history.addOne(_Tx('a')), returnsNormally); + expect(history.transactions.length, 1); + }); + }); +} diff --git a/cw_core/test/transaction_status_test.dart b/cw_core/test/transaction_status_test.dart new file mode 100644 index 0000000000..f3e816f6b4 --- /dev/null +++ b/cw_core/test/transaction_status_test.dart @@ -0,0 +1,113 @@ +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/transaction_direction.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:flutter_test/flutter_test.dart"; + +class _Tx extends TransactionInfo { + _Tx({int confirmations = 0, Map? extra}) + : super( + id: "a", + amount: Money.fromInt(1, CryptoCurrency.btc), + direction: TransactionDirection.incoming, + date: DateTime(2026, 1, 1), + ) { + isPending = true; + this.confirmations = confirmations; + if (extra != null) { + additionalInfo = extra; + } + } +} + +/// A chain that just needs N confirmations, like monero and zano. +/// A transaction denominated in something other than the wallet's own coin. +class _TokenTx extends TransactionInfo { + _TokenTx(CryptoCurrency token) + : super( + id: "t", + amount: Money.fromInt(1, token), + direction: TransactionDirection.incoming, + date: DateTime(2026, 1, 1), + ) { + isPending = false; + } +} + +class _CountingTx extends _Tx { + _CountingTx({required super.confirmations, required this.needed}); + + final int needed; + + @override + int get neededConfirmations => needed; +} + +/// Mirrors the litecoin peg-out threshold on ElectrumTransactionInfo. +class _PegTx extends _Tx { + _PegTx({required super.confirmations, super.extra}); + + bool get _isPegOut => additionalInfo["isPegOut"] as bool? ?? false; + bool get _fromPegOut => additionalInfo["fromPegOut"] as bool? ?? false; + + @override + int get neededConfirmations => (_isPegOut || _fromPegOut) ? 6 : 0; +} + +void main() { + group("TransactionInfo.status", () { + test("a chain needing no confirmations reports nothing", () { + expect(_Tx(confirmations: 0).neededConfirmations, 0); + expect(_Tx(confirmations: 0).status, isNull); + expect(_Tx(confirmations: 99).status, isNull); + }); + + test("progress is reported while below the threshold", () { + expect(_CountingTx(confirmations: 0, needed: 10).status, "(0/10)"); + expect(_CountingTx(confirmations: 3, needed: 10).status, "(3/10)"); + expect(_CountingTx(confirmations: 9, needed: 10).status, "(9/10)"); + }); + + test("progress stops once the threshold is reached", () { + expect(_CountingTx(confirmations: 10, needed: 10).status, isNull); + expect(_CountingTx(confirmations: 40, needed: 10).status, isNull); + }); + }); + + group("litecoin peg threshold", () { + test("a plain transaction has no threshold and no status", () { + final tx = _PegTx(confirmations: 0); + expect(tx.neededConfirmations, 0); + expect(tx.status, isNull); + }); + + test("a peg-out in flight reports progress toward six", () { + final tx = _PegTx(confirmations: 3, extra: {"isPegOut": true}); + expect(tx.neededConfirmations, 6); + expect(tx.status, "(3/6)"); + }); + + test("fromPegOut counts toward the threshold too", () { + final tx = _PegTx(confirmations: 2, extra: {"fromPegOut": true}); + expect(tx.neededConfirmations, 6); + expect(tx.status, "(2/6)"); + }); + + test("a settled peg-out reports nothing", () { + expect(_PegTx(confirmations: 10, extra: {"isPegOut": true}).status, isNull); + }); + }); + + group("TransactionInfo.assetOfTransaction", () { + test("reads the currency the amount is denominated in", () { + // No longer stamped by the wallet: whatever currency the amount was built + // with *is* the asset. + expect(_Tx().assetOfTransaction, CryptoCurrency.btc); + }); + + test("a token amount reports that token", () { + final tx = _TokenTx(CryptoCurrency.usdcpoly); + expect(tx.assetOfTransaction, CryptoCurrency.usdcpoly); + }); + }); +} diff --git a/cw_core/test/transaction_title_test.dart b/cw_core/test/transaction_title_test.dart new file mode 100644 index 0000000000..787ef86c19 --- /dev/null +++ b/cw_core/test/transaction_title_test.dart @@ -0,0 +1,89 @@ +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/transaction_direction.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:flutter_test/flutter_test.dart"; + +class _Tx extends TransactionInfo { + _Tx({required TransactionDirection direction, required bool isPending}) + : super( + id: "a", + amount: Money.fromInt(1, CryptoCurrency.btc), + direction: direction, + date: DateTime(2026, 1, 1), + ) { + this.isPending = isPending; + } +} + +class _ShieldingTx extends _Tx { + _ShieldingTx({ + required super.isPending, + this.shielding = false, + this.migration = false, + }) : super(direction: TransactionDirection.incoming); + + final bool shielding; + final bool migration; + + @override + String get title { + if (migration) { + return "transaction_migration"; + } + if (shielding) { + return "shielding"; + } + return super.title; + } + + @override + bool get hasStatus => migration ? false : super.hasStatus; +} + +void main() { + group("TransactionInfo.title", () { + test("settled transactions are received or sent, with no status", () { + final incoming = _Tx(direction: TransactionDirection.incoming, isPending: false); + expect(incoming.title, "received"); + expect(incoming.hasStatus, isFalse); + + final outgoing = _Tx(direction: TransactionDirection.outgoing, isPending: false); + expect(outgoing.title, "sent"); + expect(outgoing.hasStatus, isFalse); + }); + + test("in-flight transactions are receiving or sending, and carry status", () { + final incoming = _Tx(direction: TransactionDirection.incoming, isPending: true); + expect(incoming.title, "receiving"); + expect(incoming.hasStatus, isTrue); + + final outgoing = _Tx(direction: TransactionDirection.outgoing, isPending: true); + expect(outgoing.title, "sending"); + expect(outgoing.hasStatus, isTrue); + }); + + test("a subclass state wins over the shared one", () { + expect(_ShieldingTx(isPending: false, shielding: true).title, "shielding"); + expect(_ShieldingTx(isPending: false, migration: true).title, "transaction_migration"); + }); + + test("a subclass state still reports status only while in flight", () { + expect(_ShieldingTx(isPending: true, shielding: true).hasStatus, isTrue); + expect(_ShieldingTx(isPending: false, shielding: true).hasStatus, isFalse); + }); + + test("a state can suppress status even while in flight", () { + // A migration in flight still reads plainly, as it did before the move. + expect(_ShieldingTx(isPending: true, migration: true).hasStatus, isFalse); + }); + + test("a subclass falls back to the shared logic when its state doesn't apply", () { + expect(_ShieldingTx(isPending: false).title, "received"); + + final inFlight = _ShieldingTx(isPending: true); + expect(inFlight.title, "receiving"); + expect(inFlight.hasStatus, isTrue); + }); + }); +} diff --git a/cw_decred/lib/transaction_credentials.dart b/cw_decred/lib/transaction_credentials.dart index 5ace384f47..9477d6b71d 100644 --- a/cw_decred/lib/transaction_credentials.dart +++ b/cw_decred/lib/transaction_credentials.dart @@ -1,10 +1,19 @@ +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; + + /// Which outputs the user allowed this transaction to spend. + final CoinSelection coinSelection; } diff --git a/cw_decred/lib/wallet.dart b/cw_decred/lib/wallet.dart index 9bb76643e5..9f1ef0f95f 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,6 +25,7 @@ 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'; @@ -39,15 +41,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 +93,6 @@ abstract class DecredWalletBase FeeCache feeRateMedium = FeeCache(defaultFeeRate); FeeCache feeRateSlow = FeeCache(defaultFeeRate); Timer? syncTimer; - Box unspentCoinsInfo; @override @observable @@ -369,18 +369,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 +402,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 +455,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 @@ -586,18 +585,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), @@ -686,70 +674,17 @@ 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, - ); + /// The wallet's spendable outputs. + /// + /// dcrwallet already filters out what the protocol will not let us spend + /// (immature coinbase, ticket-locked outputs) via the `spendable` flag, so + /// those never enter this list. Nothing is written here: this is chain data, + /// and the user's frozen state lives in the store keyed by output id. + @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..cf26ef8c01 100644 --- a/cw_decred/lib/wallet_service.dart +++ b/cw_decred/lib/wallet_service.dart @@ -265,7 +265,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 5b952bd55f..da9fdc0fb8 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, @@ -87,7 +85,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, @@ -100,7 +97,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), @@ -115,7 +111,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 { @@ -154,7 +149,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), @@ -181,4 +175,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..b0ccac67c2 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart @@ -2,8 +2,11 @@ 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/coin_control/coin_control_wallet.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; @@ -37,7 +40,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 +60,6 @@ class DogeCoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -70,7 +71,6 @@ class DogeCoinWalletService extends WalletService< password: password, name: name, walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, encryptionFileUtils: encryptionFileUtilsFor(isDirect), ); await wallet.init(); @@ -87,15 +87,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.id); + await CoinNotesStore.instance.deleteWallet(walletInfo.id); } @override @@ -122,7 +115,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 c9e07c4a79..5c70481541 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'; @@ -595,7 +596,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..29d0aee1d7 100644 --- a/cw_monero/lib/api/coins_info.dart +++ b/cw_monero/lib/api/coins_info.dart @@ -9,52 +9,28 @@ 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 countOfCoins() => coinsMutex.protect(() async => coins!.count()); + +Future getCoin(int index) => coinsMutex.protect(() async => coins!.coin(index)); + +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..0502a75434 --- /dev/null +++ b/cw_monero/lib/monero_frozen_coins_store.dart @@ -0,0 +1,60 @@ +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(String walletId) async => + _frozen.entries.where((entry) => entry.value).map((entry) => entry.key).toSet(); + + @override + Future setFrozen(String walletId, String id, bool frozen) async { + final index = _indexes[id]; + if (index == null) { + // Nothing to act on in wallet2, so the flag is cached alone and the next + // refresh replaces it with whatever the wallet reports. + printV("MoneroFrozenCoinsStore: no coin index for $id, frozen flag cached only"); + _frozen[id] = frozen; + return; + } + + // Awaited rather than fired and forgotten. The index came from the last + // walk, so a change still in flight while the coin list is refreshed can + // be applied against a different ordering -- the mutex serialises the + // calls but cannot tell that an index has gone stale between them. + // + // Awaiting also puts the cache update after the wallet has taken the + // change, so a failure leaves the two agreeing rather than leaving the + // cache claiming something wallet2 rejected, and reaches the caller + // instead of only the log. + await (frozen ? _freeze(index) : _thaw(index)); + + _frozen[id] = frozen; + } + + @override + Future deleteWallet(String walletId) async => beginRefresh(); +} diff --git a/cw_monero/lib/monero_transaction_creation_credentials.dart b/cw_monero/lib/monero_transaction_creation_credentials.dart index 96f2b16379..9b1412b257 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/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 afd9284be8..08910c58a8 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -15,9 +15,12 @@ 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/coin_control/coin_control_wallet.dart'; import 'package:cw_core/unspent_coins_info.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,6 +37,7 @@ 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'; @@ -53,11 +57,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 +75,6 @@ abstract class MoneroWalletBase _password = password, syncStatus = NotConnectedSyncStatus(), unspentCoins = [], - this.unspentCoinsInfo = unspentCoinsInfo, super(walletInfo, derivationInfo) { transactionHistory = MoneroTransactionHistory(); walletAddresses = MoneroWalletAddresses(walletInfo, transactionHistory); @@ -98,8 +101,6 @@ abstract class MoneroWalletBase static const int _autoSaveInterval = 30; - Box unspentCoinsInfo; - void Function(FlutterErrorDetails)? onError; @override @@ -150,6 +151,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 { @@ -442,10 +453,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) { @@ -502,7 +512,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) { @@ -646,7 +660,7 @@ abstract class MoneroWalletBase setupBackgroundSync(password, currentWallet!); monero_wallet.rescanBlockchainAsync(); await startSync(); - _askForUpdateBalance(); + await _askForUpdateBalance(); walletAddresses.accountList.update(); await updateTransactions(); await save(); @@ -655,21 +669,26 @@ abstract class MoneroWalletBase Future updateUnspent() async { try { - refreshCoins(walletAddresses.account!.id); + await refreshCoins(walletAddresses.account!.id); 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, ); @@ -682,32 +701,7 @@ abstract class MoneroWalletBase } } - 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(); + await _askForUpdateBalance(); } catch (e, s) { printV(e.toString()); onError?.call(FlutterErrorDetails( @@ -718,48 +712,6 @@ abstract class MoneroWalletBase } } - 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) => 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) => monero_wallet.getAddress(accountIndex: accountIndex, addressIndex: addressIndex); @@ -891,44 +843,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(); @@ -951,7 +892,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 e774b47ba0..753dd051d5 100644 --- a/cw_monero/lib/monero_wallet_service.dart +++ b/cw_monero/lib/monero_wallet_service.dart @@ -149,7 +149,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 +188,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 +251,6 @@ class MoneroWalletService extends WalletService< final currentWallet = MoneroWallet( walletInfo: currentWalletInfo, derivationInfo: await currentWalletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); @@ -282,7 +279,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -337,7 +333,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -392,7 +387,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: credentials.walletInfo!, derivationInfo: await credentials.walletInfo!.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: credentials.password!); await wallet.init(); @@ -439,7 +433,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: walletInfo, derivationInfo: derivationInfo, - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); await wallet.init(); @@ -483,7 +476,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); await wallet.init(); @@ -515,7 +507,6 @@ class MoneroWalletService extends WalletService< final wallet = MoneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); await wallet.init(); @@ -565,7 +556,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_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 8253b58946..afc9babd16 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 32ff8c75c3..c56340b4ee 100644 --- a/cw_tron/lib/tron_wallet.dart +++ b/cw_tron/lib/tron_wallet.dart @@ -14,6 +14,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/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'; @@ -230,7 +231,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 8c5eab714e..7e1ab42434 100644 --- a/cw_wownero/lib/wownero_wallet.dart +++ b/cw_wownero/lib/wownero_wallet.dart @@ -15,9 +15,12 @@ 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/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'; @@ -56,7 +59,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 +74,6 @@ abstract class WowneroWalletBase isEnabledAutoGenerateSubaddress = true, syncStatus = NotConnectedSyncStatus(), unspentCoins = [], - this.unspentCoinsInfo = unspentCoinsInfo, super(walletInfo, derivationInfo) { transactionHistory = WowneroTransactionHistory(); walletAddresses = WowneroWalletAddresses(walletInfo, transactionHistory); @@ -103,7 +104,6 @@ abstract class WowneroWalletBase static const int _autoSaveInterval = 30; - Box unspentCoinsInfo; void Function(FlutterErrorDetails)? onError; @@ -158,6 +158,12 @@ abstract class WowneroWalletBase Timer? _autoSaveTimer; List unspentCoins; + @override + List get unspents => unspentCoins; + + @override + Future refreshUnspents() => updateUnspent(); + Future init() async { await walletAddresses.init(); balance = ObservableMap.of({ @@ -295,12 +301,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) { @@ -311,7 +311,7 @@ abstract class WowneroWalletBase final totalAmount = outputs.fold(0, (acc, value) => acc + value.cryptoAmount.amount.toInt()); - final estimatedFee = calculateEstimatedFee(_credentials.priority, totalAmount); + final estimatedFee = await calculateEstimatedFee(_credentials.priority, totalAmount); if (unlockedBalance < totalAmount) { throw WowneroTransactionCreationException( 'You do not have enough WOW to send this amount.'); @@ -346,7 +346,7 @@ abstract class WowneroWalletBase 'You do not have enough unlocked balance. Unlocked: $formattedBalance. Transaction amount: ${output.cryptoAmount}.'); } - final estimatedFee = calculateEstimatedFee(_credentials.priority, formattedAmount); + final estimatedFee = await calculateEstimatedFee(_credentials.priority, formattedAmount); if (!spendAllCoins && ((formattedAmount != null && allInputsAmount < (formattedAmount + estimatedFee)) || formattedAmount == null)) { @@ -365,7 +365,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) { @@ -491,7 +492,7 @@ abstract class WowneroWalletBase wownero_wallet.setRefreshFromBlockHeight(height: height); wownero_wallet.rescanBlockchainAsync(); await startSync(); - _askForUpdateBalance(); + await _askForUpdateBalance(); walletAddresses.accountList.update(); await _askForUpdateTransactionHistory(); await save(); @@ -524,32 +525,7 @@ abstract class WowneroWalletBase } } - 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(); + await _askForUpdateBalance(); } catch (e, s) { printV(e.toString()); onError?.call(FlutterErrorDetails( @@ -560,48 +536,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); @@ -711,10 +645,10 @@ abstract class WowneroWalletBase return nodeHeight - heightDistance; } - void _askForUpdateBalance() { + Future _askForUpdateBalance() async { final unlockedBalance = _getUnlockedBalance(); final fullBalance = _getFullBalance(); - final frozenBalance = _getFrozenBalance(); + final frozenBalance = await _getFrozenBalance(); if (balance[currency]!.fullBalance != fullBalance || balance[currency]!.available != unlockedBalance || @@ -733,28 +667,20 @@ abstract class WowneroWalletBase wownero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id), CryptoCurrency.wow); - 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); - } + Future _getFrozenBalance() async => + Money.fromInt(0, CryptoCurrency.wow); void _onNewBlock(int height, int blocksLeft, double ptc) async { try { if (walletInfo.isRecovery) { await _askForUpdateTransactionHistory(); - _askForUpdateBalance(); + await _askForUpdateBalance(); walletAddresses.accountList.update(); } if (blocksLeft < 100) { await _askForUpdateTransactionHistory(); - _askForUpdateBalance(); + await _askForUpdateBalance(); walletAddresses.accountList.update(); syncStatus = SyncedSyncStatus(); @@ -777,7 +703,7 @@ abstract class WowneroWalletBase void _onNewTransaction() async { try { await _askForUpdateTransactionHistory(); - _askForUpdateBalance(); + await _askForUpdateBalance(); await Future.delayed(Duration(seconds: 1)); } catch (e) { printV(e.toString()); diff --git a/cw_wownero/lib/wownero_wallet_service.dart b/cw_wownero/lib/wownero_wallet_service.dart index 4fb8ffa794..f34bba5467 100644 --- a/cw_wownero/lib/wownero_wallet_service.dart +++ b/cw_wownero/lib/wownero_wallet_service.dart @@ -112,7 +112,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 +154,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 +243,6 @@ class WowneroWalletService extends WalletService< final currentWallet = WowneroWallet( walletInfo: currentWalletInfo, derivationInfo: await currentWalletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password); await currentWallet.renameWalletFiles(newName); @@ -273,7 +270,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 +306,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 +353,6 @@ class WowneroWalletService extends WalletService< final wallet = WowneroWallet( walletInfo: walletInfo, derivationInfo: await walletInfo.getDerivationInfo(), - unspentCoinsInfo: unspentCoinsInfoSource, password: password, ); await wallet.init(); @@ -390,7 +384,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 df5ceff703..f9d8888da5 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'; @@ -142,7 +143,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 @@ -444,8 +446,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 0a6e192d80..5c379b2bab 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 4e4789e578..8fe86d0fcb 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,22 +262,6 @@ 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; - return bitcoinWallet.unspentCoins.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; @@ -513,10 +507,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, @@ -601,8 +596,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) { @@ -781,9 +776,9 @@ class CWBitcoin extends Bitcoin { } @override - String getPayjoinEndpoint(Object wallet) { + Future getPayjoinEndpoint(Object wallet) async { final _wallet = wallet as ElectrumWallet; - if (!isPayjoinAvailable(wallet)) return ''; + if (!await isPayjoinAvailable(wallet)) return ''; return (_wallet.walletAddresses as BitcoinWalletAddresses).payjoinEndpoint ?? ''; } diff --git a/lib/decred/cw_decred.dart b/lib/decred/cw_decred.dart index 958a544f07..fb0939fab0 100644 --- a/lib/decred/cw_decred.dart +++ b/lib/decred/cw_decred.dart @@ -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 a4f691703f..d440a89824 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -56,7 +56,9 @@ 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/account_customizer.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/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'; @@ -166,7 +168,6 @@ import 'package:cake_wallet/src/screens/trade_details/trade_details_page.dart'; import 'package:cake_wallet/src/screens/transaction_details/rbf_details_page.dart'; import 'package:cake_wallet/src/screens/transaction_details/transaction_details_page.dart'; import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_details_page.dart'; -import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_list_page.dart'; import 'package:cake_wallet/src/screens/ur/animated_ur_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart'; import 'package:cake_wallet/src/screens/wallet/wallet_edit_page.dart'; @@ -273,9 +274,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_address_list/wallet_address_edit_or_create_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; @@ -301,6 +299,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'; @@ -555,7 +555,6 @@ Future setup({ getIt.get(), getIt.get(), getIt.get(), - getIt.get(), getIt.get(), getIt.get(), ), @@ -904,7 +903,6 @@ Future setup({ param1: getIt.get().wallet!.hardwareWalletType!) : null, coinTypeToSpendFrom: coinTypeToSpendFrom ?? UnspentCoinType.nonMweb, - getIt.get(param1: coinTypeToSpendFrom), getIt.get()), ); @@ -1548,43 +1546,35 @@ Future setup({ getIt.registerFactory(() => SupportOtherLinksPage(getIt.get())); - getIt.registerFactoryParam( - (coinTypeToSpendFrom, _) { - final wallet = getIt.get().wallet; + // The coin control Bloc is a factory: one per opening of the page, and its + // selection lives no longer than that. + getIt.registerFactoryParam((args, _) { + final wallet = getIt.get().wallet!; - return UnspentCoinsListViewModel( - wallet: wallet!, - unspentCoinsInfo: _unspentCoinsInfoSource, - fiatConversationStore: getIt.get(), - appStore: getIt.get(), - coinTypeToSpendFrom: coinTypeToSpendFrom ?? UnspentCoinType.any, + return CoinControlBloc( + wallet: wallet as CoinControlWallet, + constraint: args?.coinTypeToSpendFrom ?? UnspentCoinType.any, + initialSelection: args?.initialSelection ?? const AllCoinSelection(), ); }); - getIt.registerFactoryParam( - (coinTypeToSpendFrom, _) => UnspentCoinsListPage( - unspentCoinsListViewModel: - getIt.get(param1: coinTypeToSpendFrom))); - - getIt.registerFactoryParam( - (coinTypeToSpendFrom, canEdit) => NewCoinControlPage( - unspentCoinsListViewModel: - getIt.get(param1: coinTypeToSpendFrom), - canEdit: canEdit ?? true, + getIt.registerFactoryParam( + (args, _) => NewCoinControlPage( + bloc: getIt.get(param1: args), + canEdit: args?.canEdit ?? true, + fiatConversionStore: getIt.get(), + fiatCurrency: getIt.get().settingsStore.fiatCurrency, + isFiatDisabled: + getIt.get().settingsStore.fiatApiMode == FiatApiMode.disabled, )); - getIt.registerFactoryParam( - (item, model) => - UnspentCoinsDetailsViewModel(unspentCoinsItem: item, unspentCoinsListViewModel: model)); - getIt.registerFactoryParam, void>((List args, _) { - final item = args.first as UnspentCoinsItem; - final unspentCoinsListViewModel = args[1] as UnspentCoinsListViewModel; + final rowId = args.first as String; + final bloc = args[1] as CoinControlBloc; return UnspentCoinsDetailsPage( - unspentCoinsDetailsViewModel: getIt.get( - param1: item, param2: unspentCoinsListViewModel)); + rowId: rowId, + bloc: bloc); }); getIt.registerFactory(() => YatService()); diff --git a/lib/main.dart b/lib/main.dart index 28fed70d60..c98ae7877e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -269,6 +269,7 @@ Future initializeAppConfigs({bool loadWallet = true}) async { await performErc20TokenHiveMigration(); await performSplTokenHiveMigration(); await performTronTokenHiveMigration(); + await performUnspentCoinsInfoHiveMigration(); final secureStorage = secureStorageShared; final transactionDescriptionsBoxKey = diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index 05450ca773..d003d8e301 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -319,7 +319,9 @@ class CWMonero extends Monero { @override Object createMoneroTransactionCreationCredentials( - {required List outputs, required TransactionPriority priority}) => + {required List outputs, + required TransactionPriority priority, + CoinSelection coinSelection = const AllCoinSelection()}) => MoneroTransactionCreationCredentials( outputs: outputs .map((out) => OutputInfo( @@ -332,7 +334,8 @@ class CWMonero extends Monero { isParsedAddress: out.isParsedAddress, )) .toList(), - priority: priority as MoneroTransactionPriority); + priority: priority as MoneroTransactionPriority, + coinSelection: coinSelection); @override Object createMoneroTransactionCreationCredentialsRaw( @@ -396,17 +399,7 @@ class CWMonero extends Monero { return {'id': ptx.id, 'hex': ptx.hex}; } - @override - List getUnspents(Object wallet) { - final moneroWallet = wallet as MoneroWallet; - return moneroWallet.unspentCoins; - } - @override - Future updateUnspents(Object wallet) async { - final moneroWallet = wallet as MoneroWallet; - await moneroWallet.updateUnspent(); - } @override Future getCurrentHeight() async { diff --git a/lib/new-ui/pages/coin_control_page.dart b/lib/new-ui/pages/coin_control_page.dart index 5d19ffc7e4..f6b4fab471 100644 --- a/lib/new-ui/pages/coin_control_page.dart +++ b/lib/new-ui/pages/coin_control_page.dart @@ -1,270 +1,275 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/new-ui/widgets/coin_control_page/coin_control_list_item.dart'; -import 'package:cake_wallet/new-ui/widgets/modal_header.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; -import 'package:cw_core/unspent_coin_type.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import "package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart"; +import "package:cake_wallet/entities/calculate_fiat_amount.dart"; +import "package:cake_wallet/entities/fiat_currency.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/viewmodels/coin_control/coin_control_bloc.dart"; +import "package:cake_wallet/new-ui/widgets/coin_control_page/coin_control_list_item.dart"; +import "package:cake_wallet/new-ui/widgets/modal_header.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/store/dashboard/fiat_conversion_store.dart"; +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/unspent_coin_type.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; class CoinControlPageArgs { + const CoinControlPageArgs({ + required this.canEdit, + this.coinTypeToSpendFrom, + this.initialSelection, + }); + final bool canEdit; final UnspentCoinType? coinTypeToSpendFrom; - - const CoinControlPageArgs({required this.canEdit, this.coinTypeToSpendFrom}); + final CoinSelection? initialSelection; } -class NewCoinControlPage extends StatefulWidget { - const NewCoinControlPage( - {super.key, required this.unspentCoinsListViewModel, required this.canEdit}); +class NewCoinControlPage extends StatelessWidget { + const NewCoinControlPage({ + required this.bloc, + required this.canEdit, + required this.fiatConversionStore, + required this.fiatCurrency, + required this.isFiatDisabled, + super.key, + }); - final UnspentCoinsListViewModel unspentCoinsListViewModel; + final CoinControlBloc bloc; final bool canEdit; + final FiatConversionStore fiatConversionStore; + final FiatCurrency fiatCurrency; + final bool isFiatDisabled; @override - State createState() => _NewCoinControlPageState(); -} - -class _NewCoinControlPageState extends State { - late Future _initialization; - - @override - void initState() { - super.initState(); - _initialization = widget.unspentCoinsListViewModel.initialSetup(); - } - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: !widget.unspentCoinsListViewModel.isSavingItems, - onPopInvokedWithResult: (didPop, result) async { - await widget.unspentCoinsListViewModel.dispose(); - if (!didPop && mounted) Navigator.of(context).pop(); - }, - child: Material( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - color: Colors.transparent, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(16))), - child: Column( - children: [ - ModalTopBar( - title: "", - trailingWidget: GestureDetector( - onTap: Navigator.of(context).pop, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(99999)), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), - child: Text( - S.of(context).done, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600), + Widget build(BuildContext context) => BlocProvider.value( + value: bloc, + child: BlocListener( + listenWhen: (_, state) => state is CoinControlSaved, + listener: (context, state) => + Navigator.of(context).pop((state as CoinControlSaved).selection), + child: Material( + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16))), + child: Column( + children: [ + BlocBuilder( + builder: (context, state) => ModalTopBar( + title: "", + trailingWidget: GestureDetector( + onTap: () { + if (!canEdit) { + Navigator.of(context).pop(); + return; + } + context.read().add(const SelectionSaved()); + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(99999)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Text( + S.of(context).done, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600,), + ), + ), + ), ), ), - ), ), - ), - FutureBuilder( - future: _initialization, - builder: (context, asyncSnapshot) { - if (asyncSnapshot.connectionState == ConnectionState.waiting) { - return Expanded( - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 12, - children: [ - CupertinoActivityIndicator(), - Text("${S.of(context).loading}...") - ], - )), - ); - } + BlocBuilder(builder: (context, state) { + if (state is CoinControlLoading) { + return Expanded( + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12, + children: [ + const CupertinoActivityIndicator(), + Text("${S.of(context).loading}...") + ], + )), + ); + } - if (asyncSnapshot.hasError) - return Center(child: Text(S.of(context).coin_control_load_failed)); + if (state is CoinControlFailure) { + return Center(child: Text(S.of(context).coin_control_load_failed)); + } - return Expanded( - child: SingleChildScrollView( - child: SafeArea( - child: Column( - children: [ + if (state is! CoinControlLoaded) { + return const SizedBox.shrink(); + } + + return Expanded( + child: SingleChildScrollView( + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: ModalHeader( + iconPath: "assets/new-ui/settings_row_icons/coin-control.svg", + title: "Coin Control", + message: canEdit + ? S.of(context).coin_control_desc + : S.of(context).coin_control_desc_no_edit), + ), + if (state.rows.isNotEmpty && canEdit) + Padding( + padding: const EdgeInsets.all(12.0), + child: Row( + spacing: 20, + children: [ + GestureDetector( + onTap: () => context + .read() + .add(SelectAllChanged(value: true)), + child: Text(S.of(context).select_all, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + fontWeight: FontWeight.w400)), + ), + GestureDetector( + onTap: () => context + .read() + .add(SelectAllChanged(value: false)), + child: Text(S.of(context).unselect_all, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 14, + fontWeight: FontWeight.w400)), + ) + ], + ), + ) + else + SizedBox( + height: 24, + ), + if (state.rows.isEmpty) ...[ + SizedBox(height: 12), + Center( + child: Text( + S.of(context).no_unspent_coins, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + )), + ], + if (state.selectable.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: ModalHeader( - iconPath: "assets/new-ui/settings_row_icons/coin-control.svg", - title: "Coin Control", - message: widget.canEdit - ? S.of(context).coin_control_desc - : S.of(context).coin_control_desc_no_edit), + child: _section(context, state.selectable), ), - if (widget.unspentCoinsListViewModel.items.isNotEmpty && - widget.canEdit) - Padding( - padding: const EdgeInsets.all(12.0), - child: Row( - spacing: 20, + if (state.frozen.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Column( + spacing: 10, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: () { - widget.unspentCoinsListViewModel.toggleSelectAll(true); - }, - child: Text(S.of(context).select_all, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 14, - fontWeight: FontWeight.w400)), - ), - GestureDetector( - onTap: () { - widget.unspentCoinsListViewModel.toggleSelectAll(false); - }, - child: Text(S.of(context).unselect_all, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 14, - fontWeight: FontWeight.w400)), - ) - ], - ), - ) - else - SizedBox( - height: 24, - ), - if (widget.unspentCoinsListViewModel.nonFrozenItems.isEmpty && - widget.unspentCoinsListViewModel.frozenItems.isEmpty) ...[ - SizedBox(height: 12), - Center( - child: Text( - S.of(context).no_unspent_coins, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - )), - ], - Observer( - builder: (_) => widget - .unspentCoinsListViewModel.nonFrozenItems.isEmpty - ? SizedBox.shrink() - : Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: CoinControlListSection( - canEdit: widget.canEdit, - items: widget.unspentCoinsListViewModel.nonFrozenItems, - unspentCoinsListViewModel: - widget.unspentCoinsListViewModel), + SizedBox(height: 12), + Text( + S.of(context).frozen, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).colorScheme.onSurfaceVariant), ), + _section(context, state.frozen), + ]), ), - Observer( - builder: (context) => - widget.unspentCoinsListViewModel.frozenItems.isEmpty - ? SizedBox.shrink() - : Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Column( - spacing: 10, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: 12), - Text( - S.of(context).frozen, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w400, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant), - ), - CoinControlListSection( - canEdit: widget.canEdit, - items: widget - .unspentCoinsListViewModel.frozenItems, - unspentCoinsListViewModel: - widget.unspentCoinsListViewModel), - ]), - ), - ), - SizedBox(height: 12) - ], - ), + SizedBox(height: 12) + ], ), ), - ); - }) - ], + ), + ); + }) + ], + ), ), ), ), ); + + Widget _section(BuildContext context, List rows) => CoinControlListSection( + rows: rows, + canEdit: canEdit, + fiatAmountFor: _fiatAmountFor, + ); + + /// Fiat is formatted here rather than in the Bloc: prices tick independently + /// of coin state, so folding them in would re-emit state for no reason. + String _fiatAmountFor(CoinRow row) { + if (isFiatDisabled) return ""; + + final price = fiatConversionStore.prices[row.amount.currency]; + if (price == null || price == 0.0) return ""; + + return "${fiatCurrency.title} " + "${calculateFiatAmount(price: price, cryptoAmount: row.amount.toString())}"; } } class CoinControlListSection extends StatelessWidget { - const CoinControlListSection( - {super.key, - required this.items, - required this.unspentCoinsListViewModel, - required this.canEdit}); + const CoinControlListSection({ + super.key, + required this.rows, + required this.canEdit, + required this.fiatAmountFor, + }); - final List items; + final List rows; final bool canEdit; - final UnspentCoinsListViewModel unspentCoinsListViewModel; + final String Function(CoinRow row) fiatAmountFor; @override - Widget build(BuildContext context) { - return ListView.separated( + Widget build(BuildContext context) => ListView.separated( shrinkWrap: true, padding: EdgeInsets.zero, - physics: NeverScrollableScrollPhysics(), - itemCount: items.length, + physics: const NeverScrollableScrollPhysics(), + itemCount: rows.length, separatorBuilder: (_, __) => Container( height: 1, color: Theme.of(context).colorScheme.surfaceContainerHigh, ), - itemBuilder: (_, int index) { - return Observer(builder: (_) { - final item = items[index]; - final fiatAmount = unspentCoinsListViewModel.fiatAmounts[item.amount] ?? ''; - return GestureDetector( - onTap: () => Navigator.of(context).pushNamed( - Routes.unspentCoinsDetails, - arguments: [item, unspentCoinsListViewModel], - ), - child: CoinControlListItem( - note: item.note, - amount: item.amount, - fiatAmount: fiatAmount, - address: item.address, - isSending: item.isSending, - isFrozen: item.isFrozen, - isChange: item.isChange, - isSilentPayment: item.isSilentPayment, - isLoading: item.isBeingSaved, - isFirst: index == 0, - isLast: index == items.length - 1, - hasCheckbox: canEdit, - onCheckBoxTap: item.isFrozen - ? null - : () async { - item.isSending = !item.isSending; - await unspentCoinsListViewModel.saveUnspentCoinInfo(item); - }, - ), - ); - }); + itemBuilder: (_, index) { + final row = rows[index]; + + return GestureDetector( + onTap: () => Navigator.of(context).pushNamed( + Routes.unspentCoinsDetails, + arguments: [row.id, context.read()], + ), + child: CoinControlListItem( + note: row.note, + amount: row.amount.toString(), + fiatAmount: fiatAmountFor(row), + address: row.address, + isSending: row.isSelected, + isFrozen: row.isFrozen, + isChange: row.isChange, + isSilentPayment: row.isSilentPayment, + isLoading: false, + isFirst: index == 0, + isLast: index == rows.length - 1, + hasCheckbox: canEdit, + onCheckBoxTap: () => context + .read() + .add(SelectionChanged(row.id, value: !row.isSelected)), + ), + ); }, ); - } } diff --git a/lib/new-ui/pages/send_page.dart b/lib/new-ui/pages/send_page.dart index 0a6ea20d6a..0de9984f4c 100644 --- a/lib/new-ui/pages/send_page.dart +++ b/lib/new-ui/pages/send_page.dart @@ -1,5 +1,7 @@ import "dart:async"; +import "package:cake_wallet/di.dart"; +import "package:cw_core/coin_control/coin_selection.dart"; import "package:cake_wallet/core/address_resolver/parsed_address.dart"; import "package:cake_wallet/core/address_validator.dart"; import "package:cake_wallet/core/anypay/anypay_models.dart"; @@ -620,19 +622,30 @@ class _NewSendPageState extends State { ListItemRegularRowWidget( keyValue: "", label: S.of(context).coin_control, - onTap: () { - showCupertinoModalBottomSheet( + onTap: () async { + final selection = + await showCupertinoModalBottomSheet< + CoinSelection?>( enableDrag: false, useRootNavigator: true, isDismissible: false, context: context, - builder: (context) => NewCoinControlPage( - unspentCoinsListViewModel: widget - .sendViewModel - .unspentCoinsListViewModel, - canEdit: true, + builder: (_) => + getIt.get( + param1: CoinControlPageArgs( + canEdit: true, + coinTypeToSpendFrom: widget + .sendViewModel.coinTypeToSpendFrom, + initialSelection: widget + .sendViewModel.coinSelection, + ), ), ); + + if (selection != null) { + widget.sendViewModel + .applyCoinSelection(selection); + } }, ), ], diff --git a/lib/new-ui/viewmodels/coin_control/coin_control_bloc.dart b/lib/new-ui/viewmodels/coin_control/coin_control_bloc.dart new file mode 100644 index 0000000000..875a4d6a6c --- /dev/null +++ b/lib/new-ui/viewmodels/coin_control/coin_control_bloc.dart @@ -0,0 +1,149 @@ +import "package:bloc/bloc.dart"; +import "package:bloc_concurrency/bloc_concurrency.dart"; +import "package:cake_wallet/core/utilities.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/coin_control/coin_control_wallet.dart"; +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/unspent_coin_type.dart"; +import "package:meta/meta.dart"; + +part "coin_control_event.dart"; + +part "coin_control_state.dart"; + +class CoinControlBloc extends Bloc { + CoinControlBloc({ + required this.wallet, + this.constraint = UnspentCoinType.any, + CoinSelection initialSelection = const AllCoinSelection(), + }) : _initialSelection = initialSelection, + super(const CoinControlLoading()) { + on<_Init>(_init); + on(_onSelectionChanged, transformer: sequential()); + on(_onSelectAllChanged, transformer: sequential()); + on(_onNoteChanged, transformer: sequential()); + on(_onFreezeToggled, transformer: sequential()); + on(_onSaved); + + add(const _Init()); + } + + final CoinControlWallet wallet; + final CoinSelection _initialSelection; + final UnspentCoinType constraint; + + Future _init(_Init event, Emitter emit) async { + emit(const CoinControlLoading()); + + try { + await wallet.refreshUnspents(); + + final frozen = await wallet.frozenIds(); + final notes = await wallet.notes(); + + final rows = []; + for (final coin in wallet.unspents) { + if (!wallet.allowsCoinType(coin, constraint)) { + continue; + } + + final isFrozen = frozen.contains(coin.id); + + rows.add( + CoinRow( + id: coin.id, + txHash: coin.hash, + address: coin.address, + amount: Money.fromInt(coin.value, wallet.currency), + note: notes[coin.id] ?? "", + isSelected: !isFrozen && _initialSelection.allows(coin), + isFrozen: isFrozen, + isChange: coin.isChange, + isSilentPayment: coin.isSilentPayment, + ), + ); + } + + rows.sort((a, b) => b.amount.amount.compareTo(a.amount.amount)); + + emit(CoinControlLoaded(rows: rows)); + } catch (e, st) { + emit(CoinControlFailure(e, st)); + } + } + + void _onSelectionChanged(SelectionChanged event, Emitter emit) { + final current = state; + if (current is! CoinControlLoaded) { + return; + } + + final row = current.rowFor(event.id); + if (row == null || row.isFrozen) { + return; + } + + emit(current.withRow(row.copyWith(isSelected: event.value))); + } + + void _onSelectAllChanged(SelectAllChanged event, Emitter emit) { + final current = state; + if (current is! CoinControlLoaded) { + return; + } + + emit( + current.copyWith( + rows: current.rows + .map((row) => row.isFrozen ? row : row.copyWith(isSelected: event.value)) + .toList(), + ), + ); + } + + Future _onNoteChanged(NoteChanged event, Emitter emit) async { + if (state case final CoinControlLoaded s) { + try { + await wallet.saveNote(event.id, event.note); + + emit( + s.withRow( + s.rowFor(event.id)!.copyWith( + note: event.note, + ), + ), + ); + } catch (e, st) { + emit(CoinControlFailure(e, st)); + return; + } + } + } + + Future _onFreezeToggled(FreezeToggled event, Emitter emit) async { + if (state case final CoinControlLoaded s) { + try { + await wallet.setFrozen(event.id, event.value); + + emit( + s.withRow( + s.rowFor(event.id)!.copyWith( + isFrozen: event.value, + isSelected: event.value ? false : null, + ), + ), + ); + } catch (e, st) { + emit(CoinControlFailure(e, st)); + return; + } + } + } + + void _onSaved(SelectionSaved event, Emitter emit) { + if (state case final CoinControlLoaded s) { + emit(CoinControlSaved(s.selection)); + } + } +} diff --git a/lib/new-ui/viewmodels/coin_control/coin_control_event.dart b/lib/new-ui/viewmodels/coin_control/coin_control_event.dart new file mode 100644 index 0000000000..d69890c16a --- /dev/null +++ b/lib/new-ui/viewmodels/coin_control/coin_control_event.dart @@ -0,0 +1,41 @@ +part of "coin_control_bloc.dart"; + +@immutable +sealed class CoinControlEvent { + const CoinControlEvent(); +} + +final class _Init extends CoinControlEvent { + const _Init(); +} + +final class SelectionChanged extends CoinControlEvent { + const SelectionChanged(this.id, {required this.value}); + + final String id; + final bool value; +} + +final class SelectAllChanged extends CoinControlEvent { + const SelectAllChanged({required this.value}); + + final bool value; +} + +final class NoteChanged extends CoinControlEvent { + const NoteChanged(this.id, {required this.note}); + + final String id; + final String note; +} + +final class FreezeToggled extends CoinControlEvent { + const FreezeToggled(this.id, {required this.value}); + + final String id; + final bool value; +} + +final class SelectionSaved extends CoinControlEvent { + const SelectionSaved(); +} diff --git a/lib/new-ui/viewmodels/coin_control/coin_control_state.dart b/lib/new-ui/viewmodels/coin_control/coin_control_state.dart new file mode 100644 index 0000000000..7da8f82b9f --- /dev/null +++ b/lib/new-ui/viewmodels/coin_control/coin_control_state.dart @@ -0,0 +1,100 @@ +part of "coin_control_bloc.dart"; + +@immutable +class CoinRow { + const CoinRow({ + required this.id, + required this.txHash, + required this.address, + required this.amount, + required this.note, + required this.isSelected, + required this.isFrozen, + required this.isChange, + required this.isSilentPayment, + }); + + final String id; + final String txHash; + final String address; + final Money amount; + final String note; + final bool isSelected; + final bool isFrozen; + final bool isChange; + final bool isSilentPayment; + + CoinRow copyWith({ + String? note, + bool? isSelected, + bool? isFrozen, + }) => + CoinRow( + id: id, + txHash: txHash, + address: address, + amount: amount, + note: note ?? this.note, + isSelected: isSelected ?? this.isSelected, + isFrozen: isFrozen ?? this.isFrozen, + isChange: isChange, + isSilentPayment: isSilentPayment, + ); + + @override + bool operator ==(Object other) => + other is CoinRow && + other.id == id && + other.note == note && + other.isSelected == isSelected && + other.isFrozen == isFrozen; + + @override + int get hashCode => Object.hash(id, note, isSelected, isFrozen); +} + +@immutable +sealed class CoinControlState { + const CoinControlState(); +} + +final class CoinControlLoading extends CoinControlState { + const CoinControlLoading(); +} + +final class CoinControlLoaded extends CoinControlState { + const CoinControlLoaded({required this.rows}); + + final List rows; + + List get selectable => rows.where((row) => !row.isFrozen).toList(); + + List get frozen => rows.where((row) => row.isFrozen).toList(); + + bool get isAllSelected => selectable.every((row) => row.isFrozen || row.isSelected); + + CoinSelection get selection => isAllSelected + ? const AllCoinSelection() + : SpecificCoinSelection(rows.where((row) => row.isSelected).map((row) => row.id)); + + CoinRow? rowFor(String id) => rows.firstWhereOrNull((item) => item.id == id); + + CoinControlLoaded copyWith({List? rows}) => CoinControlLoaded(rows: rows ?? this.rows); + + CoinControlLoaded withRow(CoinRow updated) => copyWith( + rows: rows.map((row) => row.id == updated.id ? updated : row).toList(), + ); +} + +final class CoinControlSaved extends CoinControlState { + const CoinControlSaved(this.selection); + + final CoinSelection selection; +} + +final class CoinControlFailure extends CoinControlState { + const CoinControlFailure(this.error, this.st); + + final Object error; + final StackTrace st; +} diff --git a/lib/new-ui/widgets/swap_page/swap_options_page.dart b/lib/new-ui/widgets/swap_page/swap_options_page.dart index 98c710e9d9..0bd2f8b3e5 100644 --- a/lib/new-ui/widgets/swap_page/swap_options_page.dart +++ b/lib/new-ui/widgets/swap_page/swap_options_page.dart @@ -1,6 +1,9 @@ import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart'; import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_selector.dart'; import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_toggle.dart'; +import 'package:cw_core/coin_control/coin_selection.dart'; +import 'package:cw_core/unspent_coin_type.dart'; +import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/pages/coin_control_page.dart'; import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; @@ -101,19 +104,27 @@ class SwapOptionsPage extends StatelessWidget { ListItemRegularRow( keyValue: "coin control", label: "Coin Control", - onTap: () { - showCupertinoModalBottomSheet( - enableDrag: false, - useRootNavigator: true, - isDismissible: false, - context: context, - builder: (context) { - return NewCoinControlPage( - unspentCoinsListViewModel: - exchangeViewModel.unspentCoinsListViewModel, - canEdit: true, - ); - }); + onTap: () async { + // A swap cannot spend MWEB outputs, so the page is + // opened with that constraint and with the + // selection this flow holds. + final selection = + await showCupertinoModalBottomSheet( + enableDrag: false, + useRootNavigator: true, + isDismissible: false, + context: context, + builder: (_) => getIt.get( + param1: CoinControlPageArgs( + canEdit: true, + coinTypeToSpendFrom: UnspentCoinType.nonMweb, + initialSelection: exchangeViewModel.coinSelection, + ), + )); + + if (selection != null) { + exchangeViewModel.applyCoinSelection(selection); + } }), ListItemSelector( keyValue: "curr", diff --git a/lib/router.dart b/lib/router.dart index a583e587c7..2e42107573 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -783,8 +783,8 @@ Route createRoute(RouteSettings settings) { case Routes.unspentCoinsList: final args = settings.arguments as CoinControlPageArgs?; - return handleRouteWithPlatformAwareness((context) => - getIt.get(param1: args?.coinTypeToSpendFrom, param2: args?.canEdit)); + return handleRouteWithPlatformAwareness( + (context) => getIt.get(param1: args)); case Routes.unspentCoinsDetails: final args = settings.arguments as List; diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index 3f1682b117..319a999301 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -119,7 +119,6 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin sendViewModel.isReadyForSend, (bool isReadyForSend) { if (isReadyForSend) { - sendViewModel.updateSendingBalance(); } }); diff --git a/lib/src/screens/unspent_coins/unspent_coins_details_page.dart b/lib/src/screens/unspent_coins/unspent_coins_details_page.dart index 34a76d5b97..d4d856fdc1 100644 --- a/lib/src/screens/unspent_coins/unspent_coins_details_page.dart +++ b/lib/src/screens/unspent_coins/unspent_coins_details_page.dart @@ -1,71 +1,84 @@ -import 'package:cake_wallet/src/screens/transaction_details/blockexplorer_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/textfield_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/widgets/textfield_list_row.dart'; -import 'package:cake_wallet/src/screens/unspent_coins/widgets/unspent_coins_switch_row.dart'; -import 'package:cake_wallet/src/widgets/standard_list.dart'; -import 'package:cake_wallet/utils/show_bar.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_switch_item.dart'; -import 'package:flutter/material.dart'; -import 'package:cake_wallet/src/widgets/list_row.dart'; -import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:cake_wallet/generated/i18n.dart'; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/viewmodels/coin_control/coin_control_bloc.dart"; +import "package:cake_wallet/src/screens/base_page.dart"; +import "package:cake_wallet/src/screens/transaction_details/widgets/textfield_list_row.dart"; +import "package:cake_wallet/src/screens/unspent_coins/widgets/unspent_coins_switch_row.dart"; +import "package:cake_wallet/src/widgets/list_row.dart"; +import "package:cake_wallet/utils/show_bar.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:url_launcher/url_launcher.dart"; class UnspentCoinsDetailsPage extends BasePage { - UnspentCoinsDetailsPage({required this.unspentCoinsDetailsViewModel}); + UnspentCoinsDetailsPage({required this.rowId, required this.bloc}); @override String get title => S.current.unspent_coins_details_title; - final UnspentCoinsDetailsViewModel unspentCoinsDetailsViewModel; + final String rowId; + final CoinControlBloc bloc; @override - Widget body(BuildContext context) { - return SectionStandardList( - sectionCount: 1, - itemCounter: (int _) => unspentCoinsDetailsViewModel.items.length, - itemBuilder: (__, index) { - final item = unspentCoinsDetailsViewModel.items[index]; - - if (item is StandartListItem) { - return GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: item.value)); - showBar(context, S.of(context).transaction_details_copied(item.title)); - }, - child: ListRow(title: '${item.title}:', value: item.value), - ); + Widget body(BuildContext context) => BlocBuilder( + bloc: bloc, + builder: (context, state) { + if(state is! CoinControlLoaded) { + return const Center(child: CupertinoActivityIndicator()); } - if (item is TextFieldListItem) { - return TextFieldListRow( - title: item.title, - value: item.value, - onSubmitted: item.onSubmitted, - ); - } + final row = state.rowFor(rowId)!; - if (item is UnspentCoinsSwitchItem) { - return Observer( - builder: (_) => UnspentCoinsSwitchRow( - title: item.title, - switchValue: item.switchValue(), - onSwitchValueChange: item.onSwitchValueChange, + return Column( + children: [ + _row(context, S.of(context).transaction_details_amount, row.amount.toString()), + _row(context, S.of(context).transaction_details_transaction_id, row.txHash), + _row(context, S.of(context).widgets_address, row.address), + TextFieldListRow( + title: S.of(context).note_tap_to_change, + value: row.note, + onSubmitted: (value) { + bloc.add(NoteChanged(row.id, note: value)); + }, + ), + UnspentCoinsSwitchRow( + title: S.of(context).freeze, + switchValue: row.isFrozen, + onSwitchValueChange: (value) { + bloc.add(FreezeToggled(row.id, value: value)); + }, + ), + if (bloc.wallet.coinControlUrl(row.txHash) != null) + GestureDetector( + child: ListRow( + onTap: () { + try { + launchUrl(bloc.wallet.coinControlUrl(row.txHash)!); + } catch (_) {} + }, + title: S.of(context).view_in_block_explorer, + value: + "${S.of(context).view_transaction_on}${bloc.wallet.coinControlUrl(row.txHash)!.authority}", + ), ), - ); - } + ], + ); + }, + ); - if (item is BlockExplorerListItem) { - return GestureDetector( - onTap: item.onTap, - child: ListRow(title: '${item.title}:', value: item.value), - ); - } + Widget _row(BuildContext context, String title, String value) => ListRow( + onTap: () => _copy( + context, + title, + value, + ), + title: title, + value: value, + ); - return Container(); - }); + void _copy(BuildContext context, String title, String text) { + Clipboard.setData(ClipboardData(text: text)); + showBar(context, S.of(context).transaction_details_copied(title)); } } diff --git a/lib/src/screens/unspent_coins/unspent_coins_list_page.dart b/lib/src/screens/unspent_coins/unspent_coins_list_page.dart deleted file mode 100644 index d162a593da..0000000000 --- a/lib/src/screens/unspent_coins/unspent_coins_list_page.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart'; -import 'package:cake_wallet/src/widgets/alert_with_no_action.dart.dart'; -import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; -import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:mobx/mobx.dart'; - -class UnspentCoinsListPage extends BasePage { - UnspentCoinsListPage({required this.unspentCoinsListViewModel}); - - @override - String get title => S.current.unspent_coins_title; - - @override - Widget leading(BuildContext context) { - return MergeSemantics( - child: SizedBox( - height: 37, - width: 37, - child: ButtonTheme( - minWidth: double.minPositive, - child: Semantics( - label: S.of(context).seed_alert_back, - child: TextButton( - style: ButtonStyle( - overlayColor: WidgetStateProperty.all(Colors.transparent), - ), - onPressed: () async => await handleOnPopInvoked(context), - child: backButton(context), - ), - ), - ), - ), - ); - } - - final UnspentCoinsListViewModel unspentCoinsListViewModel; - - Future handleOnPopInvoked(BuildContext context) async { - final navigator = Navigator.of(context); - final hasChanged = unspentCoinsListViewModel.hasAdjustableFieldChanged; - if (unspentCoinsListViewModel.items.isEmpty || !hasChanged) { - if (navigator.canPop()) navigator.pop(); - return; - } - - unspentCoinsListViewModel.setIsDisposing(true); - await unspentCoinsListViewModel.dispose(); - - if (navigator.canPop()) navigator.pop(); - if (navigator.canPop()) navigator.pop(); - } - - @override - Widget body(BuildContext context) => - UnspentCoinsListForm(unspentCoinsListViewModel, handleOnPopInvoked); -} - -class UnspentCoinsListForm extends StatefulWidget { - UnspentCoinsListForm(this.unspentCoinsListViewModel, this.handleOnPopInvoked); - - final UnspentCoinsListViewModel unspentCoinsListViewModel; - final Future Function(BuildContext context) handleOnPopInvoked; - - @override - UnspentCoinsListFormState createState() => UnspentCoinsListFormState(unspentCoinsListViewModel); -} - -class UnspentCoinsListFormState extends State { - UnspentCoinsListFormState(this.unspentCoinsListViewModel); - - final UnspentCoinsListViewModel unspentCoinsListViewModel; - - late Future _initialization; - ReactionDisposer? _disposer; - - @override - void initState() { - super.initState(); - _initialization = unspentCoinsListViewModel.initialSetup(); - _setupReactions(); - } - - void _setupReactions() { - _disposer = reaction( - (_) => unspentCoinsListViewModel.isDisposing, - (isDisposing) { - if (isDisposing) { - _showSavingDataAlert(); - } - }, - ); - } - - void _showSavingDataAlert() { - showDialog( - context: context, - useRootNavigator: false, - builder: (BuildContext context) { - return AlertWithNoAction( - alertContent: 'Updating, please wait…', - alertBarrierDismissible: false, - ); - }, - ); - } - - @override - void dispose() { - _disposer?.call(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, Object? result) async { - if (didPop) return; - if (mounted) await widget.handleOnPopInvoked(context); - }, - child: FutureBuilder( - future: _initialization, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return Center( - child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.primary, - )); - } - - if (snapshot.hasError) return Center(child: Text('Failed to load unspent coins')); - - return Container( - padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Observer( - builder: (_) => Column( - children: [ - if (unspentCoinsListViewModel.items.isNotEmpty) - Row( - children: [ - SizedBox(width: 12), - StandardCheckbox( - iconColor: Theme.of(context).colorScheme.onSurfaceVariant, - value: unspentCoinsListViewModel.isAllSelected, - onChanged: (value) => unspentCoinsListViewModel.toggleSelectAll(value), - ), - SizedBox(width: 12), - Text( - S.current.all_coins, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ], - ), - SizedBox(height: 15), - Expanded( - child: unspentCoinsListViewModel.items.isEmpty - ? Center( - child: Text( - 'No unspent coins available', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - )) - : ListView.separated( - itemCount: unspentCoinsListViewModel.items.length, - separatorBuilder: (_, __) => SizedBox(height: 15), - itemBuilder: (_, int index) { - final item = unspentCoinsListViewModel.items[index]; - return Observer(builder: (_) { - final fiatAmount = - unspentCoinsListViewModel.fiatAmounts[item.amount] ?? ''; - return GestureDetector( - onTap: () => Navigator.of(context).pushNamed( - Routes.unspentCoinsDetails, - arguments: [item, unspentCoinsListViewModel], - ), - child: UnspentCoinsListItem( - note: item.note, - amount: item.amount, - fiatAmount: fiatAmount, - address: item.address, - isSending: item.isSending, - isFrozen: item.isFrozen, - isChange: item.isChange, - isSilentPayment: item.isSilentPayment, - onCheckBoxTap: item.isFrozen - ? null - : () async { - item.isSending = !item.isSending; - await unspentCoinsListViewModel - .saveUnspentCoinInfo(item); - }, - ), - ); - }); - }, - ), - ), - ], - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart b/lib/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart deleted file mode 100644 index 72d3ad4324..0000000000 --- a/lib/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart +++ /dev/null @@ -1,213 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/widgets/standard_checkbox.dart'; -import 'package:flutter/material.dart'; - -class UnspentCoinsListItem extends StatelessWidget { - UnspentCoinsListItem({ - required this.note, - required this.amount, - required this.fiatAmount, - required this.address, - required this.isSending, - required this.isFrozen, - required this.isChange, - required this.isSilentPayment, - this.onCheckBoxTap, - }); - - final String note; - final String amount; - final String fiatAmount; - final String address; - final bool isSending; - final bool isFrozen; - final bool isChange; - final bool isSilentPayment; - final Function()? onCheckBoxTap; - - @override - Widget build(BuildContext context) { - final unselectedItemColor = Theme.of(context).colorScheme.surfaceContainer; - final selectedItemColor = Theme.of(context).colorScheme.primary; - final itemColor = isSending ? selectedItemColor : unselectedItemColor; - final amountColor = isSending - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface; - final addressColor = isSending - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface; - - return Container( - height: 70, - padding: EdgeInsets.symmetric(vertical: 6, horizontal: 12), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(12)), - color: itemColor, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.only(right: 12), - child: StandardCheckbox( - iconColor: amountColor, - borderColor: addressColor, - value: isSending, - onChanged: (value) => onCheckBoxTap?.call(), - ), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (note.isNotEmpty) - AutoSizeText( - note, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: amountColor, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - ), - AutoSizeText( - amount, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: amountColor, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - ) - ], - ), - if (isFrozen) - Container( - height: 17, - padding: EdgeInsets.only(left: 6, right: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8.5)), - color: Theme.of(context).colorScheme.primary, - ), - alignment: Alignment.center, - child: Text( - S.of(context).frozen, - style: Theme.of(context).textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w600, - color: itemColor, - fontSize: 8, - ), - ), - ), - ], - ), - if (fiatAmount.isNotEmpty) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AutoSizeText( - fiatAmount, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: amountColor, - fontSize: 1, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - ), - ], - ), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AutoSizeText( - '${address.substring(0, 5)}...${address.substring(address.length - 5)}', // ToDo: Maybe use address label - style: Theme.of(context).textTheme.bodySmall!.copyWith( - color: addressColor, - ), - maxLines: 1, - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - if (isChange) - Container( - height: 17, - padding: EdgeInsets.only(left: 6, right: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8.5)), - color: Theme.of(context).colorScheme.primaryContainer, - ), - alignment: Alignment.center, - child: Text( - S.of(context).unspent_change, - style: Theme.of(context).textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w600, - fontSize: 8, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - if (address.toLowerCase().contains("mweb")) - Container( - height: 17, - padding: EdgeInsets.only(left: 6, right: 6), - margin: EdgeInsets.only(left: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.all( - Radius.circular(8.5), - ), - color: Theme.of(context).colorScheme.primaryContainer, - ), - alignment: Alignment.center, - child: Text( - "MWEB", - style: Theme.of(context).textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w600, - fontSize: 8, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - if (isSilentPayment) - Container( - height: 17, - padding: EdgeInsets.only(left: 6, right: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(8.5)), - color: Theme.of(context).colorScheme.primaryContainer, - ), - alignment: Alignment.center, - child: Text( - S.of(context).silent_payments, - style: Theme.of(context).textTheme.bodySmall!.copyWith( - fontWeight: FontWeight.w600, - fontSize: 8, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/src/widgets/list_row.dart b/lib/src/widgets/list_row.dart index 0b0209c06d..abf749c519 100644 --- a/lib/src/widgets/list_row.dart +++ b/lib/src/widgets/list_row.dart @@ -11,7 +11,9 @@ class ListRow extends StatelessWidget { this.color, this.hintTextColor, this.mainTextColor, - this.textWidget}); + this.textWidget, + this.onTap, + }); final String title; final String value; @@ -23,6 +25,7 @@ class ListRow extends StatelessWidget { final Color? hintTextColor; final Color? mainTextColor; final Widget? textWidget; + final VoidCallback? onTap; Widget _getTextWidget(BuildContext context) => textWidget ?? @@ -37,39 +40,42 @@ class ListRow extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - color: color ?? Theme.of(context).colorScheme.surface, - child: Padding( - padding: padding ?? const EdgeInsets.only(left: 24, top: 16, bottom: 16, right: 24), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontSize: titleFontSize, - fontWeight: FontWeight.w500, - color: hintTextColor, - ), - textAlign: TextAlign.left, - ), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: _getTextWidget(context)), - image != null - ? Padding( - padding: EdgeInsets.only(left: 24), - child: image, - ) - : Offstage() - ], + return GestureDetector( + onTap: onTap, + child: Container( + width: double.infinity, + color: color ?? Theme.of(context).colorScheme.surface, + child: Padding( + padding: padding ?? const EdgeInsets.only(left: 24, top: 16, bottom: 16, right: 24), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: titleFontSize, + fontWeight: FontWeight.w500, + color: hintTextColor, + ), + textAlign: TextAlign.left, ), - ) - ]), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _getTextWidget(context)), + image != null + ? Padding( + padding: EdgeInsets.only(left: 24), + child: image, + ) + : Offstage() + ], + ), + ) + ]), + ), ), ); } diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index 5b6ee9e102..f14713a6b7 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -55,7 +55,6 @@ import 'package:cake_wallet/utils/feature_flag.dart'; import 'package:cake_wallet/utils/token_utilities.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; import 'package:cake_wallet/view_model/send/fees_view_model.dart'; -import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currencies_with_memo.dart'; @@ -64,6 +63,8 @@ import 'package:cw_core/spl_token.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/tron_token.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/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; @@ -100,7 +101,6 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with this.tradesStore, this.sharedPreferences, this.contactListViewModel, - this.unspentCoinsListViewModel, this.feesViewModel, this.fiatConversionStore, ) : isSendAllEnabled = false, @@ -129,10 +129,6 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with const excludeReceiveCurrencies = [CryptoCurrency.btt]; _initialPairBasedOnWallet(); - unspentCoinsListViewModel.initialSetup().then((_) { - unspentCoinsListViewModel.resetUnspentCoinsInfoSelections(); - }); - final Map exchangeProvidersSelection = json.decode(sharedPreferences.getString(PreferencesKey.exchangeProvidersSelection) ?? "{}") as Map; @@ -259,8 +255,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with } } else { final currency = depositCurrency; - final sendingBalance = Money.fromInt( - await unspentCoinsListViewModel.getSendingBalance(UnspentCoinType.any), currency); + final sendingBalance = Money.fromInt(await _spendableTotal(), currency); final amount = _appStore.amountParsingProxy.asDisplayStringWithSymbol(sendingBalance); if (depositCurrency == currency) { depositAvailableAmount = amount; @@ -578,7 +573,16 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with final ContactListViewModel contactListViewModel; - final UnspentCoinsListViewModel unspentCoinsListViewModel; + @observable + CoinSelection coinSelection = const AllCoinSelection(); + + @action + void applyCoinSelection(CoinSelection selection) => coinSelection = selection; + + Future _spendableTotal() async => wallet is CoinControlWallet + ? (await (wallet as CoinControlWallet).spendableCoins(selection: coinSelection)) + .fold(0, (sum, coin) => sum + coin.value) + : 0; final FeesViewModel feesViewModel; @@ -1399,7 +1403,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with changeDepositAmount(amount: amount.toString(), isCanonical: true); } else if (wallet.type == WalletType.monero) { - final amount = await unspentCoinsListViewModel.getSendingBalance(UnspentCoinType.any); + final amount = await _spendableTotal(); changeDepositAmount( amount: wallet.currency.formatAmount(BigInt.from(amount)), isCanonical: true); diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 106dc7a898..6b6c4e961b 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -137,7 +137,7 @@ abstract class OutputBase with Store { int fee = 0; if (_settingsStore.getPriority(_wallet.type, chainId: _wallet.chainId) != null) { - fee = _wallet.calculateEstimatedFee( + fee = await _wallet.calculateEstimatedFee( _settingsStore.getPriority(_wallet.type, chainId: _wallet.chainId)!, cryptoAmountMoney.amount.toInt(), ); @@ -160,7 +160,7 @@ abstract class OutputBase with Store { } if (_settingsStore.getPriority(_wallet.type) == bitcoin!.getBitcoinTransactionPriorityCustom()) { - fee = bitcoin!.getEstimatedFeeWithFeeRate( + fee = await bitcoin!.getEstimatedFeeWithFeeRate( _wallet, _settingsStore.customBitcoinFeeRate, cryptoAmountMoney.amount.toInt()); } diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 9e3fa7f552..1bc0d92ed3 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -50,7 +50,6 @@ import 'package:cake_wallet/view_model/send/fees_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cake_wallet/view_model/send/send_template_view_model.dart'; import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; -import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; import 'package:cake_wallet/wownero/wownero.dart'; import 'package:cake_wallet/zano/zano.dart'; import 'package:cake_wallet/zcash/zcash.dart'; @@ -65,6 +64,8 @@ import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/transaction_priority.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/utils/print_verbose.dart'; import 'package:cw_core/wallet_type.dart'; @@ -91,14 +92,11 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor output.updateWallet(wallet); } - // Update unspent coins list view model with the new wallet reference - unspentCoinsListViewModel.updateWallet(wallet); - - // Update sending balance to reflect the new wallet's balance - updateSendingBalance(); + coinSelection = const AllCoinSelection(); } - UnspentCoinsListViewModel unspentCoinsListViewModel; + @observable + CoinSelection coinSelection = const AllCoinSelection(); SendViewModelBase( this._appStore, @@ -109,7 +107,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor this.contactListViewModel, this.transactionDescriptionBox, this.hardwareWalletViewModel, - this.unspentCoinsListViewModel, this.feesViewModel, { this.coinTypeToSpendFrom = UnspentCoinType.nonMweb, }) : state = InitialExecutionState(), @@ -126,8 +123,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor super(appStore: _appStore) { outputs.add(Output(wallet, _appStore, _fiatConversationStore, _outputCryptoCurrencyHandler)); - unspentCoinsListViewModel.initialSetup(); - // .then((_) => unspentCoinsListViewModel.resetUnspentCoinsInfoSelections()); reaction((_) { if (isEVMCompatibleChain(wallet.type)) { @@ -143,7 +138,6 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor if (selectedCryptoCurrency == selectionAtChainChange) { selectedCryptoCurrency = wallet.currency; } - updateSendingBalance(); }); } @@ -367,45 +361,24 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor } @action - Future updateSendingBalance() async { - // force the sendingBalance to recompute since unspent coins aren't observable - // or at least mobx can't detect the changes - - final currentType = coinTypeToSpendFrom; - - if (currentType == UnspentCoinType.any) { - coinTypeToSpendFrom = UnspentCoinType.nonMweb; - } else if (currentType == UnspentCoinType.nonMweb) { - coinTypeToSpendFrom = UnspentCoinType.any; - } else if (currentType == UnspentCoinType.mweb) { - coinTypeToSpendFrom = UnspentCoinType.nonMweb; - } - - // set it back to the original value: - coinTypeToSpendFrom = currentType; - } + void applyCoinSelection(CoinSelection selection) => coinSelection = selection; @computed Future get sendingBalance async { - // only for electrum, monero, wownero, decred wallets atm: - switch (wallet.type) { - case WalletType.bitcoin: - if (selectedCryptoCurrency == CryptoCurrency.btcln) return balance; - return _appStore.amountParsingProxy.getDisplayCryptoString( - await unspentCoinsListViewModel.getSendingBalance(coinTypeToSpendFrom), - walletTypeToCryptoCurrency(walletType)); - case WalletType.litecoin: - case WalletType.bitcoinCash: - case WalletType.dogecoin: - case WalletType.monero: - case WalletType.wownero: - case WalletType.decred: - final sendingBalance = - await unspentCoinsListViewModel.getSendingBalance(coinTypeToSpendFrom); - return walletTypeToCryptoCurrency(walletType).formatAmount(BigInt.from(sendingBalance)); - default: - return balance; + final CoinControlWallet? w = + wallet is CoinControlWallet ? wallet as CoinControlWallet : null; + if (w == null) return balance; + if (selectedCryptoCurrency == CryptoCurrency.btcln) return balance; + + final spendable = await w.spendableCoins(selection: coinSelection, coinType: coinTypeToSpendFrom); + final total = spendable.fold(0, (sum, coin) => sum + coin.value); + + if (wallet.type == WalletType.bitcoin) { + return _appStore.amountParsingProxy + .getDisplayCryptoString(total, walletTypeToCryptoCurrency(walletType)); } + + return walletTypeToCryptoCurrency(walletType).formatAmount(BigInt.from(total)); } @computed @@ -442,16 +415,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor @computed bool get hasCoinControl => - [ - WalletType.bitcoin, - WalletType.litecoin, - WalletType.monero, - WalletType.wownero, - WalletType.decred, - WalletType.bitcoinCash, - WalletType.dogecoin - ].contains(wallet.type) && - coinTypeToSpendFrom != UnspentCoinType.lightning; + wallet is CoinControlWallet && coinTypeToSpendFrom != UnspentCoinType.lightning; @computed bool get hasFees => feesViewModel.hasFees && coinTypeToSpendFrom != UnspentCoinType.lightning; @@ -1278,6 +1242,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor priority: priority!, feeRate: feesViewModel.customBitcoinFeeRate, coinTypeToSpendFrom: coinTypeToSpendFrom, + coinSelection: coinSelection, payjoinUri: _settingsStore.usePayjoin ? payjoinUri : null, ); case WalletType.litecoin: @@ -1287,11 +1252,12 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor feeRate: feesViewModel.customBitcoinFeeRate, // if it's an exchange flow then disable sending from mweb coins coinTypeToSpendFrom: provider != null ? UnspentCoinType.nonMweb : coinTypeToSpendFrom, + coinSelection: coinSelection, ); case WalletType.monero: - return monero! - .createMoneroTransactionCreationCredentials(outputs: outputs, priority: priority!); + return monero!.createMoneroTransactionCreationCredentials( + outputs: outputs, priority: priority!, coinSelection: coinSelection); case WalletType.wownero: return wownero! @@ -1322,7 +1288,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor outputs: outputs, priority: priority!, currency: selectedCryptoCurrency); case WalletType.decred: this.coinTypeToSpendFrom = UnspentCoinType.any; - return decred!.createDecredTransactionCredentials(outputs, priority!); + return decred! + .createDecredTransactionCredentials(outputs, priority!, coinSelection: coinSelection); case WalletType.zcash: return zcash!.createZcashTransactionCredentials( outputs, diff --git a/lib/view_model/unspent_coins/unspent_coins_details_view_model.dart b/lib/view_model/unspent_coins/unspent_coins_details_view_model.dart deleted file mode 100644 index cbf1994017..0000000000 --- a/lib/view_model/unspent_coins/unspent_coins_details_view_model.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/transaction_details/blockexplorer_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/textfield_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.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/unspent_coins/unspent_coins_switch_item.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:mobx/mobx.dart'; -import 'package:url_launcher/url_launcher.dart'; - -part 'unspent_coins_details_view_model.g.dart'; - -class UnspentCoinsDetailsViewModel = UnspentCoinsDetailsViewModelBase - with _$UnspentCoinsDetailsViewModel; - -abstract class UnspentCoinsDetailsViewModelBase with Store { - UnspentCoinsDetailsViewModelBase( - {required this.unspentCoinsItem, required this.unspentCoinsListViewModel}) - : items = [], - _type = unspentCoinsListViewModel.wallet.type, - isFrozen = unspentCoinsItem.isFrozen, - note = unspentCoinsItem.note { - items = [ - StandartListItem(title: S.current.transaction_details_amount, value: unspentCoinsItem.amount), - StandartListItem( - title: S.current.transaction_details_transaction_id, value: unspentCoinsItem.hash), - StandartListItem(title: S.current.widgets_address, value: formattedAddress), - TextFieldListItem( - title: S.current.note_tap_to_change, - value: note, - onSubmitted: (value) { - unspentCoinsItem.note = value; - unspentCoinsListViewModel.saveUnspentCoinInfo(unspentCoinsItem); - }), - UnspentCoinsSwitchItem( - title: S.current.freeze, - value: '', - switchValue: () => isFrozen, - onSwitchValueChange: (value) async { - isFrozen = value; - unspentCoinsItem.isFrozen = value; - if (value) unspentCoinsItem.isSending = !value; - await unspentCoinsListViewModel.saveUnspentCoinInfo(unspentCoinsItem); - }) - ]; - - if ([WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin] - .contains(_type)) { - items.add(BlockExplorerListItem( - title: S.current.view_in_block_explorer, - value: _explorerDescription(_type), - onTap: () { - try { - final url = Uri.parse(_explorerUrl(_type, unspentCoinsItem.hash)); - return launchUrl(url, mode: LaunchMode.externalApplication); - } catch (e) {} - }, - )); - } - } - - String _explorerUrl(WalletType type, String txId) { - switch (type) { - case WalletType.bitcoin: - return 'https://ordinals.com/tx/${txId}'; - case WalletType.litecoin: - return 'https://litecoin.earlyordies.com/tx/${txId}'; - case WalletType.bitcoinCash: - return 'https://blockchair.com/bitcoin-cash/transaction/${txId}'; - case WalletType.dogecoin: - return 'https://dogechain.info/tx/${txId}'; - default: - return ''; - } - } - - String _explorerDescription(WalletType type) { - switch (type) { - case WalletType.bitcoin: - return '${S.current.view_transaction_on}Ordinals.com'; - case WalletType.litecoin: - return '${S.current.view_transaction_on}Earlyordies.com'; - case WalletType.bitcoinCash: - return '${S.current.view_transaction_on}Blockchair.com'; - case WalletType.dogecoin: - return '${S.current.view_transaction_on}Dogechain.info'; - default: - return ''; - } - } - - @observable - bool isFrozen; - - @observable - String note; - - final UnspentCoinsItem unspentCoinsItem; - final UnspentCoinsListViewModel unspentCoinsListViewModel; - final WalletType _type; - List items; - - String get formattedAddress => unspentCoinsItem.address; -} diff --git a/lib/view_model/unspent_coins/unspent_coins_item.dart b/lib/view_model/unspent_coins/unspent_coins_item.dart deleted file mode 100644 index ece1872b11..0000000000 --- a/lib/view_model/unspent_coins/unspent_coins_item.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:cw_core/unspent_comparable_mixin.dart'; -import 'package:mobx/mobx.dart'; - -part 'unspent_coins_item.g.dart'; - -class UnspentCoinsItem = UnspentCoinsItemBase with _$UnspentCoinsItem; - -abstract class UnspentCoinsItemBase with Store, UnspentComparable { - UnspentCoinsItemBase( - {required this.address, - required this.amount, - required this.hash, - required this.isFrozen, - required this.note, - required this.isSending, - required this.isChange, - required this.value, - required this.vout, - required this.keyImage, - required this.isSilentPayment, - this.isBeingSaved = false}); - - @observable - String address; - - @observable - String amount; - - @observable - String hash; - - @observable - bool isFrozen; - - @observable - String note; - - @observable - bool isSending; - - @observable - bool isChange; - - @observable - int value; - - @observable - int vout; - - @observable - String? keyImage; - - @observable - bool isSilentPayment; - - @observable - bool isBeingSaved; -} diff --git a/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart b/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart deleted file mode 100644 index f8e351e57c..0000000000 --- a/lib/view_model/unspent_coins/unspent_coins_list_view_model.dart +++ /dev/null @@ -1,286 +0,0 @@ -import 'package:cake_wallet/bitcoin/bitcoin.dart'; -import 'package:cake_wallet/entities/fiat_api_mode.dart'; -import 'package:cake_wallet/entities/fiat_currency.dart'; -import 'package:cake_wallet/monero/monero.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; -import 'package:cake_wallet/utils/exception_handler.dart'; -import 'package:cake_wallet/decred/decred.dart'; -import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart'; -import 'package:cake_wallet/wownero/wownero.dart'; -import 'package:cw_core/balance.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_coins_info.dart'; -import 'package:cw_core/unspent_transaction_output.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:hive/hive.dart'; -import 'package:collection/collection.dart'; -import 'package:mobx/mobx.dart'; - -part 'unspent_coins_list_view_model.g.dart'; - -class UnspentCoinsListViewModel = UnspentCoinsListViewModelBase with _$UnspentCoinsListViewModel; - -abstract class UnspentCoinsListViewModelBase with Store { - UnspentCoinsListViewModelBase({ - required this.wallet, - required Box unspentCoinsInfo, - this.coinTypeToSpendFrom = UnspentCoinType.any, - required FiatConversionStore fiatConversationStore, - required AppStore appStore, - }) : _unspentCoinsInfo = unspentCoinsInfo, - _fiatConversationStore = fiatConversationStore, - _appStore = appStore, - items = ObservableList(), - _originalState = {}; - - @observable - WalletBase, TransactionInfo> wallet; - final Box _unspentCoinsInfo; - final FiatConversionStore _fiatConversationStore; - final UnspentCoinType coinTypeToSpendFrom; - final AppStore _appStore; - - @observable - ObservableList items; - - @computed - List get nonFrozenItems => items.where((e) => !e.isFrozen).toList(); - - @computed - List get frozenItems => items.where((e) => e.isFrozen).toList(); - - final Map> _originalState; - - @observable - bool isDisposing = false; - - @observable - bool isSavingItems = false; - - @computed - bool get isAllSelected => items.every((element) => element.isFrozen || element.isSending); - - @computed - FiatCurrency get fiatCurrency => _appStore.settingsStore.fiatCurrency; - - @computed - bool get isFiatDisabled => _appStore.settingsStore.fiatApiMode == FiatApiMode.disabled; - - @computed - Map get fiatAmounts { - final currency = wallet.currency; - final price = _fiatConversationStore.prices[currency]; - if (price == null || price == 0.0 || isFiatDisabled) return {}; - - final result = {}; - for (final item in items) { - final formatted = formatAmountToString(item.value); - final cryptoAmount = double.tryParse(formatted.replaceAll(',', '')) ?? 0.0; - final fiatValue = price * cryptoAmount; - result[item.amount] = '${fiatCurrency.title} ${fiatValue.toStringAsFixed(2)}'; - } - - return result; - } - - Future initialSetup() async { - await _updateUnspents(); - _storeOriginalState(); - } - - void _storeOriginalState() { - _originalState.clear(); - for (final item in items) { - _originalState[item.hash] = { - 'isFrozen': item.isFrozen, - 'note': item.note, - 'isSending': item.isSending, - }; - } - } - - bool _hasAdjustableFieldChanged(UnspentCoinsItem item) { - final original = _originalState[item.hash]; - if (original == null) return false; - return original['isFrozen'] != item.isFrozen || - original['note'] != item.note || - original['isSending'] != item.isSending; - } - - bool get hasAdjustableFieldChanged => items.any(_hasAdjustableFieldChanged); - - @action - Future saveUnspentCoinInfo(UnspentCoinsItem item) async { - try { - item.isBeingSaved = true; - isSavingItems = true; - final existingInfo = _unspentCoinsInfo.values - .firstWhereOrNull((element) => element.walletId == wallet.id && element == item); - if (existingInfo == null) return; - - existingInfo.isFrozen = item.isFrozen; - existingInfo.isSending = item.isSending; - existingInfo.note = item.note; - - await existingInfo.save(); - item.isBeingSaved = false; - isSavingItems = false; - } catch (e) { - printV('Error saving coin info: $e'); - item.isBeingSaved = false; - isSavingItems = false; - } - } - - String formatAmountToString(int fullBalance) => - wallet.currency.formatAmount(BigInt.from(fullBalance)); - - Future _updateUnspents() async { - if (wallet.type == WalletType.monero) { - await monero!.updateUnspents(wallet); - } - if (wallet.type == WalletType.wownero) { - await wownero!.updateUnspents(wallet); - } - if ([WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin] - .contains(wallet.type)) { - await bitcoin!.updateUnspents(wallet); - } - if (wallet.type == WalletType.decred) { - decred!.updateUnspents(wallet); - } - _updateUnspentCoinsInfo(); - } - - List _getUnspents() { - switch (wallet.type) { - case WalletType.monero: - return monero!.getUnspents(wallet); - case WalletType.wownero: - return wownero!.getUnspents(wallet); - case WalletType.bitcoin: - case WalletType.litecoin: - case WalletType.bitcoinCash: - case WalletType.dogecoin: - return bitcoin!.getUnspents(wallet, coinTypeToSpendFrom: coinTypeToSpendFrom); - case WalletType.decred: - return decred!.getUnspents(wallet); - default: - return List.empty(); - } - } - - List _getSpecificUnspents(UnspentCoinType overrideCoinTypeToSpendFrom) { - switch (wallet.type) { - case WalletType.monero: - return monero!.getUnspents(wallet); - case WalletType.wownero: - return wownero!.getUnspents(wallet); - case WalletType.bitcoin: - case WalletType.litecoin: - case WalletType.bitcoinCash: - case WalletType.dogecoin: - return bitcoin!.getUnspents(wallet, coinTypeToSpendFrom: overrideCoinTypeToSpendFrom); - case WalletType.decred: - return decred!.getUnspents(wallet); - default: - return List.empty(); - } - } - - @action - Future getSendingBalance(UnspentCoinType overrideCoinTypeToSpendFrom) async { - // return items.where((element) => element.isSending).fold(0, (previousValue, element) => previousValue + element.value); - // go through all unspent coins and add up the value minus frozen and non sending: - int total = 0; - await _updateUnspents(); - Set seen = {}; - for (final item in _getSpecificUnspents(overrideCoinTypeToSpendFrom)) { - if (seen.contains(item.toString())) continue; - seen.add(item.toString()); - if (item.isFrozen || !item.isSending) continue; - total += item.value; - } - return total; - } - - @action - void _updateUnspentCoinsInfo() { - final unspents = _getUnspents() - .map((elem) { - try { - final existingItem = _unspentCoinsInfo.values - .firstWhereOrNull((item) => item.walletId == wallet.id && item == elem); - - if (existingItem == null) return null; - - final symbol = _appStore.amountParsingProxy.getCryptoSymbol(wallet.currency); - - return UnspentCoinsItem( - address: elem.address, - amount: - '${_appStore.amountParsingProxy.getDisplayCryptoString(elem.value, wallet.currency)} $symbol', - hash: elem.hash, - isFrozen: existingItem.isFrozen, - note: existingItem.note, - isSending: existingItem.isSending, - value: elem.value, - vout: elem.vout, - keyImage: elem.keyImage, - isChange: elem.isChange, - isSilentPayment: existingItem.isSilentPayment ?? false, - ); - } catch (e, s) { - printV('Error: $e\nStack: $s'); - ExceptionHandler.onError( - FlutterErrorDetails(exception: e, stack: s), - ); - return null; - } - }) - .whereType() - .toList(); - - unspents.sort((a, b) => b.value.compareTo(a.value)); - items.clear(); - items.addAll(unspents); - } - - @action - void resetUnspentCoinsInfoSelections() { - // reset all unspent coins selections to true except frozen ones - for (final item in items) { - if (!item.isFrozen) { - item.isSending = true; - saveUnspentCoinInfo(item); - } - } - } - - @action - void toggleSelectAll(bool value) { - for (final item in items) { - if (item.isFrozen || item.isSending == value) continue; - item.isSending = value; - saveUnspentCoinInfo(item); - } - } - - @action - void setIsDisposing(bool value) => isDisposing = value; - - @action - void updateWallet(WalletBase newWallet) => wallet = newWallet; - - @action - Future dispose() async { - await _updateUnspents(); - await wallet.updateBalance(); - } -} diff --git a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart index dd8b819689..b3d2ef695b 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart @@ -205,9 +205,14 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo WalletAddressListItem get address => WalletAddressListItem(address: wallet.walletAddresses.address, isPrimary: false); - @computed - String get payjoinEndpoint => - wallet.type == WalletType.bitcoin ? bitcoin!.getPayjoinEndpoint(wallet) : ""; + @observable + String payjoinEndpoint = ""; + + @action + Future updatePayjoinEndpoint() async { + payjoinEndpoint = + wallet.type == WalletType.bitcoin ? await bitcoin!.getPayjoinEndpoint(wallet) : ""; + } @computed bool get isPayjoinUnavailable => payjoinEndpoint.isEmpty; @@ -684,6 +689,10 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo } void _init() { + // Resolved here and on every wallet change, because it is no longer a + // synchronous getter: it depends on the wallet having a spendable output. + updatePayjoinEndpoint(); + _baseItems = []; if (wallet.walletAddresses.hiddenAddresses.isNotEmpty) { diff --git a/lib/wownero/cw_wownero.dart b/lib/wownero/cw_wownero.dart index cacce940cd..7bfdf9c363 100644 --- a/lib/wownero/cw_wownero.dart +++ b/lib/wownero/cw_wownero.dart @@ -348,17 +348,7 @@ class CWWownero extends Wownero { return {'id': ptx.id, 'hex': ptx.hex, 'key': ptx.txKey}; } - @override - List getUnspents(Object wallet) { - final wowneroWallet = wallet as WowneroWallet; - return wowneroWallet.unspentCoins; - } - @override - Future updateUnspents(Object wallet) async { - final wowneroWallet = wallet as WowneroWallet; - await wowneroWallet.updateUnspent(); - } @override Future getCurrentHeight() async { diff --git a/test/new-ui/viewmodels/coin_control_bloc_test.dart b/test/new-ui/viewmodels/coin_control_bloc_test.dart new file mode 100644 index 0000000000..9fcaff8d11 --- /dev/null +++ b/test/new-ui/viewmodels/coin_control_bloc_test.dart @@ -0,0 +1,640 @@ +import "package:cake_wallet/new-ui/viewmodels/coin_control/coin_control_bloc.dart"; +import "package:cw_core/coin_control/coin_control_wallet.dart"; +import "package:cw_core/coin_control/coin_selection.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/unspent_coin_type.dart"; +import "package:cw_core/unspent_transaction_output.dart"; +import "package:flutter_test/flutter_test.dart"; + +Unspent coin(String hash, {int value = 1000, bool isChange = false}) => + Unspent("addr-$hash", hash, value, 0, null)..isChange = isChange; + +/// The Bloc asks for a wallet and a currency, so a double needs no more. +class _FakeWallet with CoinControlWallet { + _FakeWallet({ + required this.unspents, + this.id = "wallet-a", + this.mwebIds = const {}, + this.failRefresh = false, + this.failWrites = false, + this.writeDelay = Duration.zero, + }); + + @override + final String id; + + @override + List unspents; + + /// Stored state, keyed by output id, in the two tables the app keeps it in. + /// The Bloc reaches the stores only through the wallet, so overriding these + /// four members is the whole surface it can touch -- no store double needed. + final Map frozenRecords = {}; + final Map noteRecords = {}; + + /// What each store was told, in the order it landed. + final List frozenWrites = []; + final List noteWrites = []; + + /// Delays writes, so ordering between rapid changes can be observed. + Duration writeDelay; + + /// Ids to treat as MWEB outputs, so the coin type constraint can be exercised. + final Set mwebIds; + + bool failRefresh; + bool failWrites; + int refreshCount = 0; + + @override + Future refreshUnspents() async { + refreshCount++; + if (failRefresh) throw Exception("electrum unreachable"); + } + + @override + bool allowsCoinType(Unspent coin, UnspentCoinType coinType) { + final isMweb = mwebIds.contains(coin.id); + switch (coinType) { + case UnspentCoinType.mweb: + return isMweb; + case UnspentCoinType.nonMweb: + return !isMweb; + case UnspentCoinType.any: + case UnspentCoinType.lightning: + return true; + } + } + + @override + Future> frozenIds() async => + frozenRecords.entries.where((entry) => entry.value).map((entry) => entry.key).toSet(); + + @override + Future> notes() async => Map.of(noteRecords); + + @override + Future setFrozen(String coinId, bool frozen) async { + await _write(); + frozenRecords[coinId] = frozen; + frozenWrites.add(frozen); + } + + @override + Future saveNote(String coinId, String note) async { + await _write(); + noteRecords[coinId] = note; + noteWrites.add(note); + } + + Future _write() async { + if (failWrites) throw Exception("database is locked"); + if (writeDelay > Duration.zero) await Future.delayed(writeDelay); + } +} + +void main() { + late _FakeWallet wallet; + + final a = coin("aa", value: 300); + final b = coin("bb", value: 200); + final c = coin("cc", value: 100); + + CoinControlBloc build({ + CoinSelection initialSelection = const AllCoinSelection(), + UnspentCoinType constraint = UnspentCoinType.any, + }) => + CoinControlBloc( + wallet: wallet, + currency: CryptoCurrency.btc, + constraint: constraint, + initialSelection: initialSelection, + ); + + /// The Bloc adds Init from its constructor, so every test waits for the load + /// to settle before acting. + Future loaded(CoinControlBloc bloc) async { + final state = await bloc.stream.firstWhere((state) => state is! CoinControlLoading); + return state as CoinControlLoaded; + } + + setUp(() { + wallet = _FakeWallet(unspents: [a, b, c]); + }); + + group("initialization", () { + test("starts loading, then loads a row per output", () async { + final bloc = build(); + expect(bloc.state, isA()); + + final state = await loaded(bloc); + expect(state.rows.map((row) => row.id), [a.id, b.id, c.id]); + await bloc.close(); + }); + + test("refreshes the output list first", () async { + final bloc = build(); + await loaded(bloc); + expect(wallet.refreshCount, 1); + await bloc.close(); + }); + + test("orders rows by descending value", () async { + wallet.unspents = [c, a, b]; + final bloc = build(); + + final state = await loaded(bloc); + expect(state.rows.map((row) => row.amount.amount.toInt()), [300, 200, 100]); + await bloc.close(); + }); + + test("selects everything when the caller has no selection yet", () async { + final state = await loaded(build()); + expect(state.rows.every((row) => row.isSelected), isTrue); + expect(state.isAllSelected, isTrue); + }); + + test("restores a previous selection", () async { + final state = await loaded(build(initialSelection: SpecificCoinSelection({a.id}))); + + expect(state.rowFor(a.id)!.isSelected, isTrue); + expect(state.rowFor(b.id)!.isSelected, isFalse); + expect(state.isAllSelected, isFalse); + }); + + test("never selects a frozen output, even under an all-outputs selection", () async { + wallet.frozenRecords[b.id] = true; + + final state = await loaded(build()); + expect(state.rowFor(b.id)!.isFrozen, isTrue); + expect(state.rowFor(b.id)!.isSelected, isFalse); + }); + + test("carries notes onto the rows", () async { + wallet.noteRecords[a.id] = "rent"; + + final state = await loaded(build()); + expect(state.rowFor(a.id)!.note, "rent"); + expect(state.rowFor(b.id)!.note, isEmpty); + }); + + test("hides outputs the coin type constraint excludes", () async { + wallet = _FakeWallet( + unspents: [a, b, c], + mwebIds: {b.id}, + ); + + final state = await loaded(build(constraint: UnspentCoinType.nonMweb)); + expect(state.rows.map((row) => row.id), [a.id, c.id]); + }); + + test("fails visibly when the output list cannot be fetched", () async { + wallet.failRefresh = true; + final bloc = build(); + + final state = await bloc.stream.firstWhere((state) => state is! CoinControlLoading); + expect(state, isA()); + await bloc.close(); + }); + + test("loads with no rows when the wallet has no outputs", () async { + wallet.unspents = []; + + final state = await loaded(build()); + expect(state.rows, isEmpty); + // Nothing to save, so Done stays disabled regardless of isAllSelected. + expect(state.canSave, isFalse); + // An empty wallet reports an all-outputs selection rather than an empty + // explicit one, so a coin arriving later is still spendable. + expect(state.selection, const AllCoinSelection()); + }); + }); + + group("selecting", () { + test("unselecting one output leaves the rest alone", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectionChanged(b.id, value: false)); + final state = await loaded(bloc); + + expect(state.rowFor(a.id)!.isSelected, isTrue); + expect(state.rowFor(b.id)!.isSelected, isFalse); + expect(state.rowFor(c.id)!.isSelected, isTrue); + await bloc.close(); + }); + + test("writes nothing to storage", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectionChanged(b.id, value: false)); + await loaded(bloc); + + // The whole difference between unselecting and freezing. + expect(wallet.frozenRecords, isEmpty); + expect(wallet.noteRecords, isEmpty); + await bloc.close(); + }); + + test("is ignored for a frozen output", () async { + wallet.frozenRecords[b.id] = true; + final bloc = build(); + final before = await loaded(bloc); + + bloc.add(SelectionChanged(b.id, value: true)); + await Future.delayed(Duration.zero); + + expect(bloc.state, before); + await bloc.close(); + }); + + test("is ignored for an unknown id", () async { + final bloc = build(); + final before = await loaded(bloc); + + bloc.add(SelectionChanged("not-a-real-id", value: false)); + await Future.delayed(Duration.zero); + + expect(bloc.state, before); + await bloc.close(); + }); + }); + + group("select all", () { + test("selects every selectable output", () async { + final bloc = build(initialSelection: SpecificCoinSelection(const {})); + await loaded(bloc); + + bloc.add(SelectAllChanged(value: true)); + final state = await loaded(bloc); + + expect(state.isAllSelected, isTrue); + await bloc.close(); + }); + + test("unselects every output", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectAllChanged(value: false)); + final state = await loaded(bloc); + + expect(state.rows.any((row) => row.isSelected), isFalse); + expect(state.canSave, isFalse); + await bloc.close(); + }); + + test("leaves frozen outputs alone", () async { + wallet.frozenRecords[b.id] = true; + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectAllChanged(value: true)); + final state = await loaded(bloc); + + expect(state.rowFor(b.id)!.isSelected, isFalse); + expect(state.isAllSelected, isTrue, reason: "frozen rows are not selectable"); + await bloc.close(); + }); + }); + + group("freezing", () { + test("writes immediately and marks the row", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + final state = await loaded(bloc); + + expect(state.rowFor(b.id)!.isFrozen, isTrue); + expect(await wallet.frozenIds(), {b.id}); + await bloc.close(); + }); + + test("also unselects the output", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + final state = await loaded(bloc); + + expect(state.rowFor(b.id)!.isSelected, isFalse); + await bloc.close(); + }); + + test("unfreezing clears the flag and leaves the row unselected", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + await loaded(bloc); + bloc.add(FreezeToggled(b.id, value: false)); + final state = await loaded(bloc); + + expect(state.rowFor(b.id)!.isFrozen, isFalse); + expect(state.rowFor(b.id)!.isSelected, isFalse); + expect(await wallet.frozenIds(), isEmpty); + await bloc.close(); + }); + + test("a freeze and a note in flight together both survive", () async { + // The two events have separate queues, so they can overlap. Each handler + // applies its own field to the state as it stands once its write lands, + // which is what stops the later one from publishing a row built before + // the earlier one's change. + wallet = _FakeWallet(unspents: [a, b, c], writeDelay: const Duration(milliseconds: 20)); + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + bloc.add(NoteChanged(b.id, note: "cold")); + + // One emission per write, in whichever order the two queues finish. + final state = await bloc.stream.take(2).last as CoinControlLoaded; + + expect(state.rowFor(b.id)!.isFrozen, isTrue); + expect(state.rowFor(b.id)!.note, "cold"); + expect(state.rowFor(b.id)!.isSelected, isFalse); + await bloc.close(); + }); + + test("an event for an unknown output writes nothing", () async { + final bloc = build(); + final before = await loaded(bloc); + + bloc.add(FreezeToggled("no-such-output", value: true)); + bloc.add(NoteChanged("no-such-output", note: "n")); + await Future.delayed(Duration.zero); + + expect(bloc.state, before); + expect(wallet.frozenRecords, isEmpty); + expect(wallet.noteRecords, isEmpty); + await bloc.close(); + }); + + test("a note-only change leaves the frozen flag alone", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + await loaded(bloc); + bloc.add(NoteChanged(b.id, note: "still frozen")); + final state = await loaded(bloc); + + expect(state.rowFor(b.id)!.isFrozen, isTrue); + expect(state.rowFor(b.id)!.note, "still frozen"); + await bloc.close(); + }); + + test("a freeze-only change leaves the note alone", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(NoteChanged(b.id, note: "keep me")); + await loaded(bloc); + bloc.add(FreezeToggled(b.id, value: true)); + final state = await loaded(bloc); + + expect(state.rowFor(b.id)!.note, "keep me"); + expect(state.rowFor(b.id)!.isFrozen, isTrue); + await bloc.close(); + }); + + test("rapid freezes on one output apply in order, not concurrently", () async { + // The reason the handler is sequential(): on Monero each write reaches + // wallet2 by an index taken from the last refresh, so two of them in + // flight at once could be applied against different orderings. + wallet = _FakeWallet(unspents: [a, b, c], writeDelay: const Duration(milliseconds: 20)); + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + bloc.add(FreezeToggled(b.id, value: false)); + bloc.add(FreezeToggled(b.id, value: true)); + + await bloc.stream.take(3).last; + + expect(wallet.frozenWrites, [true, false, true], reason: "no write was skipped or reordered"); + expect(wallet.frozenRecords[b.id], isTrue, reason: "the last change wins"); + await bloc.close(); + }); + + test("rapid notes on one output apply in order", () async { + wallet = _FakeWallet(unspents: [a, b, c], writeDelay: const Duration(milliseconds: 20)); + final bloc = build(); + await loaded(bloc); + + bloc.add(NoteChanged(b.id, note: "first")); + bloc.add(NoteChanged(b.id, note: "second")); + + final state = await bloc.stream.take(2).last as CoinControlLoaded; + + expect(wallet.noteWrites, ["first", "second"]); + expect(state.rowFor(b.id)!.note, "second"); + await bloc.close(); + }); + + test("a failed write surfaces and does not change the row", () async { + final bloc = build(); + await loaded(bloc); + wallet.failWrites = true; + + bloc.add(FreezeToggled(b.id, value: true)); + final state = await bloc.stream.first; + + expect(state, isA()); + expect(await wallet.frozenIds(), isEmpty); + await bloc.close(); + }); + }); + + group("notes", () { + test("are written and shown on the row", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(NoteChanged(a.id, note: "cold storage")); + final state = await loaded(bloc); + + expect(state.rowFor(a.id)!.note, "cold storage"); + expect(wallet.noteRecords[a.id], "cold storage"); + await bloc.close(); + }); + + test("do not make an output unspendable", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(NoteChanged(a.id, note: "note")); + final state = await loaded(bloc); + + expect(state.rowFor(a.id)!.isSelected, isTrue); + expect(state.rowFor(a.id)!.isFrozen, isFalse); + await bloc.close(); + }); + + test("clearing a note keeps the record and the row", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(NoteChanged(a.id, note: "temporary")); + await loaded(bloc); + bloc.add(NoteChanged(a.id, note: "")); + final state = await loaded(bloc); + + expect(state.rowFor(a.id)!.note, isEmpty); + expect(wallet.noteRecords.length, 1); + await bloc.close(); + }); + }); + + group("saving", () { + test("everything selected yields an all-outputs selection", () async { + // Not an enumerated list of every current id: that would freeze the set + // of outputs and silently exclude one received afterwards. + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectionSaved()); + final state = await bloc.stream.firstWhere((state) => state is CoinControlSaved); + + expect((state as CoinControlSaved).selection, const AllCoinSelection()); + await bloc.close(); + }); + + test("a partial selection yields the chosen ids", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectionChanged(b.id, value: false)); + await loaded(bloc); + bloc.add(SelectionSaved()); + final state = await bloc.stream.firstWhere((state) => state is CoinControlSaved); + + expect((state as CoinControlSaved).selection, SpecificCoinSelection({a.id, c.id})); + await bloc.close(); + }); + + test("a frozen output is excluded by the wallet, not by the selection", () async { + // Freezing every-other-row-selected still yields an all-outputs + // selection, because frozen is applied by spendableCoins independently. + // That is the point of keeping the two concepts apart, and it keeps a + // coin received later spendable. + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + await loaded(bloc); + bloc.add(SelectionSaved()); + final state = await bloc.stream.firstWhere((state) => state is CoinControlSaved); + + final selection = (state as CoinControlSaved).selection; + expect(selection, const AllCoinSelection()); + + final spendable = await wallet.spendableCoins(selection); + expect(spendable.map((coin) => coin.id), [a.id, c.id]); + await bloc.close(); + }); + + test("an explicitly narrowed selection excludes both", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + await loaded(bloc); + bloc.add(SelectionChanged(c.id, value: false)); + await loaded(bloc); + bloc.add(SelectionSaved()); + final state = await bloc.stream.firstWhere((state) => state is CoinControlSaved); + + final selection = (state as CoinControlSaved).selection; + expect(selection, SpecificCoinSelection({a.id})); + expect((await wallet.spendableCoins(selection)).map((coin) => coin.id), [a.id]); + await bloc.close(); + }); + + test("all selectable selected while one is frozen still yields all-outputs", () async { + // spendableCoins excludes frozen outputs itself, so an all-outputs + // selection is the honest answer and stays correct for coins arriving + // later. + wallet.frozenRecords[b.id] = true; + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectionSaved()); + final state = await bloc.stream.firstWhere((state) => state is CoinControlSaved); + + expect((state as CoinControlSaved).selection, const AllCoinSelection()); + await bloc.close(); + }); + + test("refuses to save an empty selection", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectAllChanged(value: false)); + final before = await loaded(bloc); + expect(before.canSave, isFalse); + + bloc.add(SelectionSaved()); + await Future.delayed(Duration.zero); + + expect(bloc.state, isA()); + await bloc.close(); + }); + + test("does not wait on an in-flight write, because it cannot affect the result", () async { + // The selection is a set of output ids and carries neither frozen state + // nor notes, so a write still landing cannot change what is handed back. + wallet = _FakeWallet(unspents: [a, b, c], writeDelay: const Duration(milliseconds: 30)); + final bloc = build(); + await loaded(bloc); + + bloc.add(FreezeToggled(b.id, value: true)); + bloc.add(SelectionSaved()); + + final state = await bloc.stream.firstWhere((state) => state is CoinControlSaved); + expect((state as CoinControlSaved).selection, const AllCoinSelection()); + await bloc.close(); + }); + }); + + group("derived state", () { + test("canSave is false only when nothing is selected", () async { + final bloc = build(); + var state = await loaded(bloc); + expect(state.canSave, isTrue); + + bloc.add(SelectAllChanged(value: false)); + state = await loaded(bloc); + expect(state.canSave, isFalse); + + bloc.add(SelectionChanged(a.id, value: true)); + state = await loaded(bloc); + expect(state.canSave, isTrue); + await bloc.close(); + }); + + test("selectable and frozen partition the rows", () async { + wallet.frozenRecords[b.id] = true; + + final state = await loaded(build()); + expect(state.selectable.map((row) => row.id), [a.id, c.id]); + expect(state.frozen.map((row) => row.id), [b.id]); + }); + + test("isAllSelected is false when a selectable row is unticked", () async { + final bloc = build(); + await loaded(bloc); + + bloc.add(SelectionChanged(c.id, value: false)); + final state = await loaded(bloc); + + expect(state.isAllSelected, isFalse); + await bloc.close(); + }); + }); +} + +/// Delays writes so a save racing an in-flight freeze can be observed. diff --git a/tool/configure.dart b/tool/configure.dart index c11d020e10..cdbbe5b958 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -113,6 +113,7 @@ import 'package:cw_core/pending_transaction.dart'; import 'package:cw_core/receive_page_option.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/transaction_priority.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/unspent_transaction_output.dart'; @@ -211,7 +212,7 @@ abstract class Bitcoin { int getFeeRate(Object wallet, TransactionPriority priority); Future generateNewAddress(Object wallet, String label); Future updateAddress(Object wallet,String address, String label); - Object createBitcoinTransactionCredentials(List outputs, {required TransactionPriority priority, int? feeRate, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, String? payjoinUri}); + Object createBitcoinTransactionCredentials(List outputs, {required TransactionPriority priority, int? feeRate, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, CoinSelection coinSelection = const AllCoinSelection(), String? payjoinUri}); String getAddress(Object wallet); List getSilentPaymentAddresses(Object wallet); @@ -225,8 +226,6 @@ abstract class Bitcoin { int formatterStringDoubleToBitcoinAmount(String amount); String bitcoinTransactionPriorityWithLabel(TransactionPriority priority, int rate, {int? customRate}); - List getUnspents(Object wallet, {UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any}); - Future updateUnspents(Object wallet); WalletService createBitcoinWalletService( Box unspentCoinSource, Box payjoinSessionSource, bool isDirect); WalletService createLitecoinWalletService(Box unspentCoinSource, bool isDirect); @@ -246,7 +245,7 @@ abstract class Bitcoin { ReceivePageOption getBitcoinLightningReceivePageOption(); ReceivePageOption getBitcoinSegwitPageOption(); ReceivePageOption getLitecoinMwebReceivePageOption(); - bool isPayjoinAvailable(Object wallet); + Future isPayjoinAvailable(Object wallet); bool hasSelectedSilentPayments(Object wallet); bool hasSelectedLightning(Object wallet); bool isBitcoinReceivePageOption(ReceivePageOption option); @@ -263,7 +262,7 @@ abstract class Bitcoin { int getTransactionVSize(Object wallet, String txHex); Future isChangeSufficientForFee(Object wallet, String txId, String newFee); int getFeeAmountForPriority(Object wallet, TransactionPriority priority, int inputsCount, int outputsCount, {int? size}); - int getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount, + Future getEstimatedFeeWithFeeRate(Object wallet, int feeRate, int? amount, {int? outputsCount, int? size}); int feeAmountWithFeeRate(Object wallet, int feeRate, int inputsCount, int outputsCount, {int? size}); Future checkIfMempoolAPIIsEnabled(Object wallet); @@ -289,7 +288,7 @@ abstract class Bitcoin { Future commitPsbtUR(Object wallet, List urCodes); void updatePayjoinState(Object wallet, bool state); - String getPayjoinEndpoint(Object wallet); + Future getPayjoinEndpoint(Object wallet); void resumePayjoinSessions(Object wallet); void stopPayjoinSessions(Object wallet); Map getSilentPaymentKeys(Object wallet); @@ -326,6 +325,7 @@ Future generateMonero(bool hasImplementation) async { const moneroCommonHeaders = """ import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/unspent_transaction_output.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:mobx/mobx.dart'; @@ -436,8 +436,6 @@ abstract class Monero { List getTransactionPriorities(); List getMoneroWordList(String language); - List getUnspents(Object wallet); - Future updateUnspents(Object wallet); Future getCurrentHeight(); @@ -465,7 +463,7 @@ abstract class Monero { WalletCredentials createMoneroNewWalletCredentials({required String name, required String language, required int seedType, required String? passphrase, String? password, String? mnemonic}); Map getKeys(Object wallet); int? getRestoreHeight(Object wallet); - Object createMoneroTransactionCreationCredentials({required List outputs, required TransactionPriority priority}); + Object createMoneroTransactionCreationCredentials({required List outputs, required TransactionPriority priority, CoinSelection coinSelection = const AllCoinSelection()}); Object createMoneroTransactionCreationCredentialsRaw({required List outputs, required TransactionPriority priority}); String formatterMoneroAmountToString({required int amount}); double formatterMoneroAmountToDouble({required int amount}); @@ -538,6 +536,7 @@ Future generateWownero(bool hasImplementation) async { const wowneroCommonHeaders = """ import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/unspent_transaction_output.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:mobx/mobx.dart'; @@ -645,8 +644,6 @@ abstract class Wownero { List getTransactionPriorities(); List getWowneroWordList(String language); - List getUnspents(Object wallet); - Future updateUnspents(Object wallet); Future getCurrentHeight(); void wownerocCheck(); @@ -721,6 +718,7 @@ Future generateBitcoinCash(bool hasImplementation) async { const bitcoinCashCommonHeaders = """ import 'dart:typed_data'; +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/unspent_transaction_output.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/unspent_coins_info.dart'; @@ -1276,6 +1274,7 @@ import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/output_info.dart'; import 'package:cw_core/wallet_service.dart'; +import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/unspent_transaction_output.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cake_wallet/view_model/send/output.dart'; @@ -1306,14 +1305,12 @@ abstract class Decred { TransactionPriority getDecredTransactionPrioritySlow(); TransactionPriority deserializeDecredTransactionPriority(int raw); - Object createDecredTransactionCredentials(List outputs, TransactionPriority priority); + Object createDecredTransactionCredentials(List outputs, TransactionPriority priority, {CoinSelection coinSelection = const AllCoinSelection()}); List getAddressInfos(Object wallet); Future updateAddress(Object wallet, String address, String label); Future generateNewAddress(Object wallet, String label); - List getUnspents(Object wallet); - void updateUnspents(Object wallet); int heightByDate(DateTime date); From 39a6fae306dba7ffc87ee06066da445835d73e1c Mon Sep 17 00:00:00 2001 From: Robert Malikowski Date: Thu, 10 Sep 2026 16:22:36 +0200 Subject: [PATCH 2/5] [wip] coin control refactor --- .../lib/bitcoin_transaction_credentials.dart | 8 +- cw_bitcoin/lib/bitcoin_unspent.dart | 6 +- cw_bitcoin/lib/bitcoin_wallet.dart | 1 - cw_bitcoin/lib/bitcoin_wallet_service.dart | 4 +- cw_bitcoin/lib/electrum_wallet.dart | 38 +--- cw_bitcoin/lib/litecoin_wallet.dart | 3 - cw_bitcoin/lib/litecoin_wallet_service.dart | 5 +- .../lib/src/bitcoin_cash_wallet_service.dart | 5 +- cw_core/lib/amount/money.dart | 9 + .../lib/coin_control/coin_control_wallet.dart | 7 - cw_core/lib/db/sqlite.dart | 8 +- cw_core/lib/unspent_coins_info.dart | 50 ++--- cw_core/lib/wallet_base.dart | 6 - cw_decred/lib/transaction_credentials.dart | 2 - cw_decred/lib/wallet.dart | 11 +- cw_decred/lib/wallet_service.dart | 5 +- .../lib/src/dogecoin_wallet_service.dart | 6 +- cw_monero/lib/api/coins_info.dart | 4 - cw_monero/lib/monero_frozen_coins_store.dart | 11 -- ...nero_transaction_creation_credentials.dart | 6 +- cw_monero/lib/monero_wallet.dart | 10 +- cw_monero/lib/monero_wallet_service.dart | 3 +- cw_solana/lib/solana_wallet.dart | 2 +- cw_tron/lib/tron_wallet.dart | 2 +- cw_wownero/lib/wownero_wallet.dart | 45 ++--- cw_wownero/lib/wownero_wallet_service.dart | 3 +- cw_zano/lib/zano_wallet.dart | 2 +- cw_zcash/lib/src/zcash_wallet.dart | 2 +- lib/bitcoin/cw_bitcoin.dart | 17 +- lib/bitcoin_cash/cw_bitcoin_cash.dart | 5 +- lib/decred/cw_decred.dart | 4 +- lib/di.dart | 36 ++-- lib/dogecoin/cw_dogecoin.dart | 5 +- lib/main.dart | 3 - lib/monero/cw_monero.dart | 3 +- lib/new-ui/pages/coin_control_page.dart | 174 +++++++++--------- .../coin_control/coin_control_bloc.dart | 10 +- .../widgets/swap_page/swap_options_page.dart | 3 - .../dashboard/fiat_conversion_store.dart | 31 ++++ .../exchange/exchange_view_model.dart | 4 +- .../wallet_address_list_view_model.dart | 15 +- lib/wownero/cw_wownero.dart | 5 +- tool/configure.dart | 18 +- 43 files changed, 232 insertions(+), 365 deletions(-) diff --git a/cw_bitcoin/lib/bitcoin_transaction_credentials.dart b/cw_bitcoin/lib/bitcoin_transaction_credentials.dart index 4add222c88..ebbcea4a7b 100644 --- a/cw_bitcoin/lib/bitcoin_transaction_credentials.dart +++ b/cw_bitcoin/lib/bitcoin_transaction_credentials.dart @@ -1,7 +1,7 @@ -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'; +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( diff --git a/cw_bitcoin/lib/bitcoin_unspent.dart b/cw_bitcoin/lib/bitcoin_unspent.dart index e66fc58eee..564fb48a2a 100644 --- a/cw_bitcoin/lib/bitcoin_unspent.dart +++ b/cw_bitcoin/lib/bitcoin_unspent.dart @@ -1,6 +1,6 @@ -import 'package:bitcoin_base/bitcoin_base.dart'; -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) diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index fcc75459e8..8867dfe51b 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -37,7 +37,6 @@ 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_bitcoin/bitcoin_unspent.dart'; -import 'package:cw_core/coin_control/coin_selection.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/zpub.dart'; import 'package:cw_core/wallet_info.dart'; diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index 141a10769f..aeddbe8547 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -8,7 +8,6 @@ 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'; @@ -23,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; diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 295b4cc3b0..56144c4dc1 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -41,9 +41,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/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/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'; @@ -923,13 +923,6 @@ abstract class ElectrumWalletBase int _coinSelectionPriority(BitcoinUnspent utx) => _coinSelectionOrder.putIfAbsent( '${utx.hash}:${utx.vout}', () => _coinSelectionRng.nextInt(1 << 32)); - /// Builds the input set for one transaction from [candidates]. - /// - /// The candidates are passed in rather than derived here: they come from - /// [spendableCoins], which is the single place the user's selection, frozen - /// state and the requested coin type are combined. This method must never - /// consult [unspentCoins] itself -- reading that directly is what let a coin - /// the user had unselected reach a transaction. UtxoDetails _createUTXOS({ required bool sendAll, required bool paysToSilentPayment, @@ -1166,9 +1159,6 @@ abstract class ElectrumWalletBase bool? useUnconfirmed, bool hasSilentPayment = false, required List candidates, - // Only for change-address selection; the inputs are already decided by - // [candidates]. This is the half of the coin type that cannot fold into a - // selection, because Litecoin picks a change address by it. UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { // Attempting to send less than the dust limit @@ -2046,22 +2036,16 @@ abstract class ElectrumWalletBase return updatedUnspentCoins; } - /// Re-fetches one address' outputs and merges them into the coin list. - /// - /// Nothing is hydrated onto the coins: they are chain data, and the user's - /// frozen state lives in the store keyed by output id. @action Future updateUnspentsForAddress(BitcoinAddressRecord address) async { final fetched = await fetchUnspent(address); - if (fetched == null || fetched.isEmpty) { - return; - } + if (fetched == null || fetched.isEmpty) return; - final byId = {for (final coin in unspentCoins) coin.id: coin}; - for (final coin in fetched) { - byId[coin.id] = coin; - } - unspentCoins = byId.values.toList(); + unspentCoins = { + // this doubles as deduplication + for (final coin in unspentCoins) coin.id: coin, + for (final coin in fetched) coin.id: coin, + }.values.toList(); } @action @@ -3450,10 +3434,6 @@ abstract class ElectrumWalletBase printV( 'Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching'); - // One frozen total, derived by matching stored records against the live - // output list. The previous version walked the whole store with no wallet - // filter and re-derived the sum in a nested loop, so a second wallet's - // records were counted here too. var totalFrozen = await frozenBalance(); var totalConfirmed = 0; var totalUnconfirmed = 0; diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index 7edfa7c7ae..9131624476 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -851,9 +851,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { await updateAllUnspents(); } - /// MWEB outputs are only spendable when MWEB is switched on, so the rule - /// lives here rather than in a coin type the caller has to remember to - /// downgrade before building a transaction. @override bool allowsCoinType(Unspent coin, UnspentCoinType coinType) { final isMweb = coin is BitcoinUnspent && diff --git a/cw_bitcoin/lib/litecoin_wallet_service.dart b/cw_bitcoin/lib/litecoin_wallet_service.dart index 9f33bc176e..258238fc3c 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -5,8 +5,6 @@ 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'; @@ -22,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 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 9349ff5bbd..90e25ff66e 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_service.dart @@ -7,20 +7,17 @@ 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 diff --git a/cw_core/lib/amount/money.dart b/cw_core/lib/amount/money.dart index 3997da5012..c0b4a4946b 100644 --- a/cw_core/lib/amount/money.dart +++ b/cw_core/lib/amount/money.dart @@ -1,6 +1,7 @@ import "dart:math"; import "package:cw_core/amount/utils.dart"; +import "package:cw_core/crypto_amount_format.dart"; import "package:cw_core/crypto_currency.dart"; import "package:cw_core/currency.dart"; import "package:cw_core/format_fixed.dart"; @@ -35,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 index 0ceb0c8890..0f3c1331a6 100644 --- a/cw_core/lib/coin_control/coin_control_wallet.dart +++ b/cw_core/lib/coin_control/coin_control_wallet.dart @@ -1,5 +1,4 @@ import "package:cw_core/balance.dart"; -import "package:cw_core/coin_control/coin_notes_store.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"; @@ -17,17 +16,11 @@ mixin CoinControlWallet FrozenCoinsStore.instance; - CoinNotesStore get coinNotesStore => CoinNotesStore.instance; - Future> frozenIds() => frozenCoinsStore.frozenIds(id); Future setFrozen(String coinId, bool frozen) => frozenCoinsStore.setFrozen(id, coinId, frozen); - Future> notes() => coinNotesStore.forWallet(id); - - Future saveNote(String coinId, String note) => coinNotesStore.save(id, coinId, note); - bool allowsCoinType(Unspent coin, UnspentCoinType coinType) => true; Future> spendableCoins( diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart index dbc084102d..7c34d1ed98 100644 --- a/cw_core/lib/db/sqlite.dart +++ b/cw_core/lib/db/sqlite.dart @@ -265,22 +265,22 @@ CREATE TABLE BalanceCardStyleSettings ( } Future _createCoinControlTables(Database db) async { - await db.execute(''' + await db.execute(""" CREATE TABLE IF NOT EXISTS FrozenCoin ( walletId TEXT NOT NULL, id TEXT NOT NULL, frozen INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (walletId, id) ); -'''); - await db.execute(''' +"""); + await db.execute(""" CREATE TABLE IF NOT EXISTS CoinNote ( walletId TEXT NOT NULL, id TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', PRIMARY KEY (walletId, id) ); -'''); +"""); } Future _createTradeTable(Database db) async { diff --git a/cw_core/lib/unspent_coins_info.dart b/cw_core/lib/unspent_coins_info.dart index 18621ee472..5539a69180 100644 --- a/cw_core/lib/unspent_coins_info.dart +++ b/cw_core/lib/unspent_coins_info.dart @@ -88,22 +88,17 @@ class UnspentCoinsInfo extends HiveObject with UnspentComparable { set note(String value) => noteRaw = value; - /// Wallet types whose core module owns frozen state, so it is not migrated. - /// - /// Monero persists the flag inside the wallet file, which is where it is read - /// from now, so a row in the FrozenCoin table would be a second source of - /// truth for something the wallet already has. - static const _typesOwningFrozenState = [WalletType.monero, WalletType.wownero]; - static Future migrateAllToSqlite(List wallets) async { final box = await CakeHive.openBox(boxName); - final walletTypes = {for (final wallet in wallets) wallet.id: wallet.type}; for (final record in box.values.toList()) { - final type = walletTypes[record.walletId]; + final type = wallets + .cast() + .firstWhere((item) => item!.id == record.walletId, orElse: () => null) + ?.type; + if (type == null) { - // Left behind by a wallet that has since been deleted. - continue; + return; } try { @@ -112,49 +107,26 @@ class UnspentCoinsInfo extends HiveObject with UnspentComparable { printV("Error migrating unspent record ${record.walletId}: $e, continuing anyway"); } } - - await box.deleteFromDisk(); } Future migrateToSqlite(WalletType walletType) async { - // This box held a record for every output the wallet had ever seen, so - // only the ones carrying something the user set are worth a row. The new - // tables read an absent row as not frozen with no note, which is what all - // the rest of these amount to. - final shouldMigrateFrozen = isFrozen && !_typesOwningFrozenState.contains(walletType); - if (note.isEmpty && !shouldMigrateFrozen) { - return; - } - final id = _outputId(walletType); if (note.isNotEmpty) { await CoinNotesStore.instance.save(walletId, id, note); } - - if (shouldMigrateFrozen) { - await FrozenCoinsStore.instance.setFrozen(walletId, id, true); + if (walletType != WalletType.monero) { + await FrozenCoinsStore.instance.setFrozen(walletId, id, isFrozen); } - // isSending is deliberately dropped: the selection is not durable state, - // and persisting it is what let an unselected output be spent after the - // sending flow that unselected it had closed. } - /// The id the new tables key on, as the output itself now reports it. - /// - /// Reproduced from the record rather than asked of the chain module, because - /// by the time this runs the record is all that is left -- the outputs it - /// describes are not fetched during startup. + String _outputId(WalletType walletType) { - final image = keyImage; - if (image != null && image.isNotEmpty) { - return image; + if (keyImage != null && keyImage!.isNotEmpty) { + return keyImage!; } - // An MWEB output is identified by its hash alone. The vout on these - // records is an index into the wallet's MWEB address list, which shifts as - // that list grows, so it was never part of the identity. if (walletType == WalletType.litecoin && _isMwebAddress(address)) { return hash; } diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 90e5457f73..95711945ab 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -93,12 +93,6 @@ abstract class WalletBase createTransaction(Object credentials); - /// Estimated fee for spending [amount]. - /// - /// Async because wallets with an output model have to read which of their - /// outputs are frozen, and [selection] because the fee depends on how many - /// inputs the transaction will take -- narrowing the selection changes it. - /// Wallets without an output model ignore both. Future calculateEstimatedFee( TransactionPriority priority, int? amount, { diff --git a/cw_decred/lib/transaction_credentials.dart b/cw_decred/lib/transaction_credentials.dart index 9477d6b71d..7db995d989 100644 --- a/cw_decred/lib/transaction_credentials.dart +++ b/cw_decred/lib/transaction_credentials.dart @@ -13,7 +13,5 @@ class DecredTransactionCredentials { final List outputs; final DecredTransactionPriority? priority; final int? feeRate; - - /// Which outputs the user allowed this transaction to spend. final CoinSelection coinSelection; } diff --git a/cw_decred/lib/wallet.dart b/cw_decred/lib/wallet.dart index 9f1ef0f95f..1cf5cb4ad0 100644 --- a/cw_decred/lib/wallet.dart +++ b/cw_decred/lib/wallet.dart @@ -3,7 +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/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'; @@ -25,14 +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/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'; @@ -674,12 +673,6 @@ abstract class DecredWalletBase } } - /// The wallet's spendable outputs. - /// - /// dcrwallet already filters out what the protocol will not let us spend - /// (immature coinbase, ticket-locked outputs) via the `spendable` flag, so - /// those never enter this list. Nothing is written here: this is chain data, - /// and the user's frozen state lives in the store keyed by output id. @override List get unspents => _unspents; diff --git a/cw_decred/lib/wallet_service.dart b/cw_decred/lib/wallet_service.dart index cf26ef8c01..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'"; diff --git a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart index b0ccac67c2..23b7ae3c4c 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet_service.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet_service.dart @@ -6,22 +6,18 @@ 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/coin_control/coin_control_wallet.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 diff --git a/cw_monero/lib/api/coins_info.dart b/cw_monero/lib/api/coins_info.dart index 29d0aee1d7..34761b6769 100644 --- a/cw_monero/lib/api/coins_info.dart +++ b/cw_monero/lib/api/coins_info.dart @@ -16,10 +16,6 @@ Future refreshCoins(int accountIndex) => coinsMutex.protect(() async { coins = refreshed; }); -Future countOfCoins() => coinsMutex.protect(() async => coins!.count()); - -Future getCoin(int index) => coinsMutex.protect(() async => coins!.coin(index)); - Future> readAllCoins() => coinsMutex.protect(() async { final all = coins!; return List.generate(all.count(), all.coin); diff --git a/cw_monero/lib/monero_frozen_coins_store.dart b/cw_monero/lib/monero_frozen_coins_store.dart index 0502a75434..46bb75f0ff 100644 --- a/cw_monero/lib/monero_frozen_coins_store.dart +++ b/cw_monero/lib/monero_frozen_coins_store.dart @@ -34,22 +34,11 @@ class MoneroFrozenCoinsStore extends FrozenCoinsStore { Future setFrozen(String walletId, String id, bool frozen) async { final index = _indexes[id]; if (index == null) { - // Nothing to act on in wallet2, so the flag is cached alone and the next - // refresh replaces it with whatever the wallet reports. printV("MoneroFrozenCoinsStore: no coin index for $id, frozen flag cached only"); _frozen[id] = frozen; return; } - // Awaited rather than fired and forgotten. The index came from the last - // walk, so a change still in flight while the coin list is refreshed can - // be applied against a different ordering -- the mutex serialises the - // calls but cannot tell that an index has gone stale between them. - // - // Awaiting also puts the cache update after the wallet has taken the - // change, so a failure leaves the two agreeing rather than leaving the - // cache claiming something wallet2 rejected, and reaches the caller - // instead of only the log. await (frozen ? _freeze(index) : _thaw(index)); _frozen[id] = frozen; diff --git a/cw_monero/lib/monero_transaction_creation_credentials.dart b/cw_monero/lib/monero_transaction_creation_credentials.dart index 9b1412b257..62281ace8e 100644 --- a/cw_monero/lib/monero_transaction_creation_credentials.dart +++ b/cw_monero/lib/monero_transaction_creation_credentials.dart @@ -1,6 +1,6 @@ -import 'package:cw_core/coin_control/coin_selection.dart'; -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({ diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index 08910c58a8..bbef9fb8c3 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -15,12 +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/coin_control/coin_control_wallet.dart'; -import 'package:cw_core/unspent_coins_info.dart'; -import 'package:cw_core/unspent_transaction_output.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/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'; @@ -37,13 +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_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; diff --git a/cw_monero/lib/monero_wallet_service.dart b/cw_monero/lib/monero_wallet_service.dart index 753dd051d5..66765e6548 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(); diff --git a/cw_solana/lib/solana_wallet.dart b/cw_solana/lib/solana_wallet.dart index afc9babd16..380441dc70 100644 --- a/cw_solana/lib/solana_wallet.dart +++ b/cw_solana/lib/solana_wallet.dart @@ -13,7 +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/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'; diff --git a/cw_tron/lib/tron_wallet.dart b/cw_tron/lib/tron_wallet.dart index c56340b4ee..e7a3b0c8fc 100644 --- a/cw_tron/lib/tron_wallet.dart +++ b/cw_tron/lib/tron_wallet.dart @@ -14,7 +14,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/wallet_addresses.dart'; -import 'package:cw_core/coin_control/coin_selection.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'; diff --git a/cw_wownero/lib/wownero_wallet.dart b/cw_wownero/lib/wownero_wallet.dart index 7e1ab42434..ecfdb070e1 100644 --- a/cw_wownero/lib/wownero_wallet.dart +++ b/cw_wownero/lib/wownero_wallet.dart @@ -15,12 +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/coin_control/coin_control_wallet.dart'; -import 'package:cw_core/unspent_transaction_output.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/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'; @@ -41,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; @@ -158,12 +154,6 @@ abstract class WowneroWalletBase Timer? _autoSaveTimer; List unspentCoins; - @override - List get unspents => unspentCoins; - - @override - Future refreshUnspents() => updateUnspent(); - Future init() async { await walletAddresses.init(); balance = ObservableMap.of({ @@ -311,16 +301,12 @@ abstract class WowneroWalletBase final totalAmount = outputs.fold(0, (acc, value) => acc + value.cryptoAmount.amount.toInt()); - final estimatedFee = await calculateEstimatedFee(_credentials.priority, totalAmount); + final estimatedFee = calculateEstimatedFee(_credentials.priority, totalAmount); if (unlockedBalance < totalAmount) { throw WowneroTransactionCreationException( '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; @@ -346,12 +332,6 @@ abstract class WowneroWalletBase 'You do not have enough unlocked balance. Unlocked: $formattedBalance. Transaction amount: ${output.cryptoAmount}.'); } - final estimatedFee = await calculateEstimatedFee(_credentials.priority, formattedAmount); - if (!spendAllCoins && - ((formattedAmount != null && allInputsAmount < (formattedAmount + estimatedFee)) || - formattedAmount == null)) { - throw WowneroTransactionNoInputsException(inputs.length); - } pendingTransactionDescription = await transaction_history.createTransaction( address: address!, @@ -492,7 +472,7 @@ abstract class WowneroWalletBase wownero_wallet.setRefreshFromBlockHeight(height: height); wownero_wallet.rescanBlockchainAsync(); await startSync(); - await _askForUpdateBalance(); + _askForUpdateBalance(); walletAddresses.accountList.update(); await _askForUpdateTransactionHistory(); await save(); @@ -524,8 +504,6 @@ abstract class WowneroWalletBase unspentCoins.add(unspent); } } - - await _askForUpdateBalance(); } catch (e, s) { printV(e.toString()); onError?.call(FlutterErrorDetails( @@ -645,10 +623,10 @@ abstract class WowneroWalletBase return nodeHeight - heightDistance; } - Future _askForUpdateBalance() async { + void _askForUpdateBalance() { final unlockedBalance = _getUnlockedBalance(); final fullBalance = _getFullBalance(); - final frozenBalance = await _getFrozenBalance(); + final frozenBalance = _getFrozenBalance(); if (balance[currency]!.fullBalance != fullBalance || balance[currency]!.available != unlockedBalance || @@ -667,20 +645,23 @@ abstract class WowneroWalletBase wownero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id), CryptoCurrency.wow); - Future _getFrozenBalance() async => - Money.fromInt(0, CryptoCurrency.wow); + Money _getFrozenBalance() { + var frozenBalance = 0; + + return Money.fromInt(frozenBalance, CryptoCurrency.wow); + } void _onNewBlock(int height, int blocksLeft, double ptc) async { try { if (walletInfo.isRecovery) { await _askForUpdateTransactionHistory(); - await _askForUpdateBalance(); + _askForUpdateBalance(); walletAddresses.accountList.update(); } if (blocksLeft < 100) { await _askForUpdateTransactionHistory(); - await _askForUpdateBalance(); + _askForUpdateBalance(); walletAddresses.accountList.update(); syncStatus = SyncedSyncStatus(); @@ -703,7 +684,7 @@ abstract class WowneroWalletBase void _onNewTransaction() async { try { await _askForUpdateTransactionHistory(); - await _askForUpdateBalance(); + _askForUpdateBalance(); await Future.delayed(Duration(seconds: 1)); } catch (e) { printV(e.toString()); diff --git a/cw_wownero/lib/wownero_wallet_service.dart b/cw_wownero/lib/wownero_wallet_service.dart index f34bba5467..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(); diff --git a/cw_zano/lib/zano_wallet.dart b/cw_zano/lib/zano_wallet.dart index f9d8888da5..d7fbfd1767 100644 --- a/cw_zano/lib/zano_wallet.dart +++ b/cw_zano/lib/zano_wallet.dart @@ -14,7 +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/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'; diff --git a/cw_zcash/lib/src/zcash_wallet.dart b/cw_zcash/lib/src/zcash_wallet.dart index 5c379b2bab..05f9652234 100644 --- a/cw_zcash/lib/src/zcash_wallet.dart +++ b/cw_zcash/lib/src/zcash_wallet.dart @@ -13,7 +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/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'; diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 8fe86d0fcb..272850b7bb 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -268,15 +268,11 @@ class CWBitcoin extends Bitcoin { 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; @@ -776,10 +772,9 @@ class CWBitcoin extends Bitcoin { } @override - Future getPayjoinEndpoint(Object wallet) async { + String getPayjoinEndpoint(Object wallet) { final _wallet = wallet as ElectrumWallet; - if (!await 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 fb0939fab0..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; diff --git a/lib/di.dart b/lib/di.dart index d440a89824..79968d7ec2 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -331,7 +331,6 @@ late Box