Cw 1551 quick bitcoin wallet sync (#3446)

* add Wallet Accounts page and integrate UI

* account model refactor

* add walletInfoAccount table and API

* add WalletAccountList API

* add multi-account support for Electrum wallets

* add Bitcoin lightning card and card style handling

* refine account filtering and card selection

* update current account balance on wallet changes

* update configure.dar

* Use named parameters for card design

* Add Bitcoin account handling and refactor lookup

* refactor dashboard

* refactor cards UI and account balance handling

* Improve Bitcoin account selection and balances

* restrict unspent coin usage to current account

* filter transactions by current Bitcoin account

* refactor electrum wallet

* refactor address generation

* add accountIndex to address fetch logic

* Update configure.dart

* reload transactions on Bitcoin account change

* restore multiple BTC accounts and improve fetch

* show account balance with in btc

* track account index in Bitcoin transactions

* discover btc BIP39 accounts during restore

* fix account switching and card ordering bugs

* fix card customizer

* limit account name length in modal

* update BTC account list balance display

* auto-reformat

* formating fix

* skip HD map validation

* fix 0 balance issue

* minor fix

* quick bitcoin wallet sync

* restore shuffle change output

* restore address lookup fix

* Update electrum.dart

* remove account customizer

* Merge branch 'CW-1142-Add-accounts-feature-to-BTC' into CW-1551-Quick-Bitcoin-Wallet-Sync

* probe only segwit in account discovery

* add account discovery limit and tracking

* Improve Electrum sync account handling [skip ci]

* remove legacy monero account and address list UI

* rework BTC account balance refres

* avoid unnecessary BTC account reloads

* refactor account header in addresses page

* fix blank popup when editing an account

* fix: dedupe UTXO balance

* minor fix

* add Bitcoin current account API

* Apply batched suggestions from code review [skip ci]

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

* address PR review comments [skip ci]

* remove account customizer and fix accounts init

* restrict extra BTC accounts to SegWit

* add BTC multi-account toggle

* Fix BTC sync gating and account balance update

* minor fix

* Update electrum_wallet.dart

* store selected account on WalletInfo

* Refine wallet account reset visibility

* refactor BTC address prep and move accounts page

* refactor account stack and customizer routing

* migrate legacy Lightning card styles

* unify dashboard account change state

* fix account routing and Electrum balances

* fix account setup and wallet cards UI

* disable Bitcoin Accounts for all hardware

* merge conflicts fix

* minor fixes

* update cards_view [skip ci]

* reverted cards view update [skip ci]

* fix asset name

* moved the transaction filtering into the electrum wallet

* localization

* disable accounts for watch-only wallets

* fix: stop card render from switching the wallet account

* fix: restore Lightning transactions in tx history

* minor fix

* fix: skip duplicate legacy address generation [skip ci]

* fix: correct account balance/design mismatch bugs

* addressing review comments

---------

Co-authored-by: Robert Malikowski <malikowskirobert@gmail.com>
Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
Serhii
2026-10-03 00:44:57 +02:00
committed by GitHub
co-authored by Omar Hatem Robert Malikowski
parent 0503d54290
commit d7ebf42814
84 changed files with 3991 additions and 2186 deletions
@@ -159,7 +159,7 @@ abstract class BalanceViewModelBase with Store {
bool get isEVMCompatible => isEVMCompatibleChain(wallet.type);
@computed
bool get hasAccounts => [WalletType.monero, WalletType.wownero].contains(wallet.type);
bool get hasAccounts => wallet.hasAccountsSupport;
@computed
SortBalanceBy get sortBalanceBy => settingsStore.sortBalanceBy;
@@ -5,6 +5,9 @@ import 'dart:io' show Platform;
import 'package:cake_wallet/.secrets.g.dart' as secrets;
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/core/key_service.dart';
import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart';
import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart';
import 'package:cw_core/account.dart';
import 'package:cake_wallet/view_model/dashboard/date_section_item.dart';
import "package:cw_core/balance_card_style_settings.dart";
import 'package:cake_wallet/core/trade_monitor.dart';
@@ -77,6 +80,7 @@ class DashboardViewModel = DashboardViewModelBase with _$DashboardViewModel;
abstract class DashboardViewModelBase with Store {
DashboardViewModelBase(
{required this.balanceViewModel,
required this.accountListViewModelFactory,
required this.tradeMonitor,
required this.appStore,
required this.tradesStore,
@@ -106,7 +110,7 @@ abstract class DashboardViewModelBase with Store {
showDecredInfoCard = wallet.type == WalletType.decred &&
(sharedPreferences.getBool(PreferencesKey.showDecredInfoCard) ?? true);
showSeedBackupReminder = wallet.walletInfo.showSeedBackupReminder;
isMultiAccountsEnabled = wallet.walletInfo.isMultiAccountsEnabled == true;
name = wallet.name;
type = wallet.type;
isShowFirstYatIntroduction = false;
@@ -115,6 +119,7 @@ abstract class DashboardViewModelBase with Store {
unawaited(isBackgroundSyncEnabled());
unawaited(isBatteryOptimizationEnabled());
unawaited(_loadConstraints());
accountListViewModel = accountListViewModelFactory();
final _wallet = wallet;
loadFilterItems();
@@ -122,8 +127,7 @@ abstract class DashboardViewModelBase with Store {
if (_wallet.type == WalletType.monero) {
subname = monero!.getCurrentAccount(_wallet).label;
_onMoneroAccountChangeReaction = reaction(
(_) => monero!.getMoneroWalletDetails(wallet).account,
_onAccountChangeReaction = reaction((_) => monero!.getMoneroWalletDetails(wallet).account,
(Account account) => _onMoneroAccountChange(_wallet));
_onMoneroBalanceChangeReaction = reaction(
@@ -151,9 +155,9 @@ abstract class DashboardViewModelBase with Store {
} else if (_wallet.type == WalletType.wownero) {
subname = wow.wownero!.getCurrentAccount(_wallet).label;
_onMoneroAccountChangeReaction = reaction(
_onAccountChangeReaction = reaction(
(_) => wow.wownero!.getWowneroWalletDetails(wallet).account,
(wow.Account account) => _onMoneroAccountChange(_wallet));
(Account account) => _onMoneroAccountChange(_wallet));
_onMoneroBalanceChangeReaction = reaction(
(_) => wow.wownero!.getWowneroWalletDetails(wallet).balance,
@@ -179,21 +183,12 @@ abstract class DashboardViewModelBase with Store {
),
);
} else {
final sortedTransactions = [...wallet.transactionHistory.transactions.values];
sortedTransactions.sort((a, b) => a.date.compareTo(b.date));
transactions = ObservableList.of(
sortedTransactions.map(
(transaction) => TransactionListItem(
transaction: transaction,
balanceViewModel: balanceViewModel,
appStore: appStore,
key: ValueKey('${_wallet.type.name}_transaction_history_item_${transaction.id}_key'),
),
),
);
subname = "";
_reloadTransactions();
}
_setupBitcoinAccountChangeReaction(_wallet);
// TODO: nano sub-account generation is disabled:
// if (_wallet.type == WalletType.nano || _wallet.type == WalletType.banano) {
// subname = nano!.getCurrentAccount(_wallet).label;
@@ -201,7 +196,9 @@ abstract class DashboardViewModelBase with Store {
_walletChangeDisposer?.reaction.dispose();
_walletChangeDisposer = reaction((_) => appStore.wallet, (wallet) {
accountListViewModel = accountListViewModelFactory();
_onWalletChange(wallet);
resetLightningMode();
_checkMweb();
loadCardDesigns();
showDecredInfoCard = wallet?.type == WalletType.decred &&
@@ -362,8 +359,14 @@ abstract class DashboardViewModelBase with Store {
transactions.clear();
final filteredTransactions = wallet.type == WalletType.bitcoin
? bitcoin!.getCurrentAccountBitcoinTransactions(wallet)
: wallet.transactionHistory.transactions.values.toList();
filteredTransactions.sort((a, b) => a.date.compareTo(b.date));
transactions.addAll(
wallet.transactionHistory.transactions.values.map(
filteredTransactions.map(
(transaction) => TransactionListItem(
transaction: transaction,
balanceViewModel: balanceViewModel,
@@ -425,60 +428,67 @@ abstract class DashboardViewModelBase with Store {
@action
Future<void> loadCardDesigns() async {
final accountStyleSettings =
final walletCardStyleSettings =
await BalanceCardStyleSettings.getAll(wallet.walletInfo.internalId);
final btcAccounts =
wallet.type == WalletType.bitcoin ? await wallet.walletInfo.getAccounts() : null;
final lightningCardIndex = btcAccounts?.length;
late final int numAccounts;
int numAccounts = 1;
if (wallet.type == WalletType.monero) {
numAccounts = monero!.getAccountList(wallet).accounts.length;
} else if (wallet.type == WalletType.wownero) {
numAccounts = wow.wownero!.getAccountList(wallet).accounts.length;
} else if (wallet.type == WalletType.bitcoin) {
// bitcoin and lightning
numAccounts = 2;
} else {
numAccounts = 1;
numAccounts = btcAccounts!.length + 1; // adding 1 for the lightning account
}
final usesAccountIndices = wallet.type == WalletType.bitcoin || balanceViewModel.hasAccounts;
cardDesigns.clear();
Map<int, int> newOrder = {};
final newOrder = <int, int>{};
final orderableCount = (wallet.type == WalletType.bitcoin) ? numAccounts - 1 : numAccounts;
for (int i = 0; i < numAccounts; i++) {
late final int index;
if (balanceViewModel.hasAccounts) {
final isLightning = wallet.type == WalletType.bitcoin && i == lightningCardIndex;
if (isLightning) {
index = -2;
} else if (usesAccountIndices) {
index = i;
} else if (wallet.type == WalletType.bitcoin && i == 1) {
index = 0;
} else {
index = -1;
}
final setting = accountStyleSettings.where((e) => e.accountIndex == index).firstOrNull;
final setting = walletCardStyleSettings.where((e) => e.accountIndex == index).firstOrNull ??
(index == 0 && wallet.type != WalletType.bitcoin
? walletCardStyleSettings.where((e) => e.accountIndex == -1).firstOrNull
: null);
late final CryptoCurrency curr;
if (wallet.type == WalletType.bitcoin && i == 1) {
curr = CryptoCurrency.btcln;
} else {
curr = wallet.currency;
}
final curr = isLightning ? CryptoCurrency.btcln : wallet.currency;
cardDesigns.add(CardDesign.fromStyleSettings(setting, curr));
if (setting?.cardOrder != null) {
newOrder[setting!.cardOrder] = i;
if (isLightning) {
continue;
}
if (setting?.cardOrder != null &&
setting!.cardOrder >= 0 &&
setting.cardOrder < orderableCount) {
newOrder[setting.cardOrder] = i;
}
}
// making sure ALL accounts have numbers, even the ones that existed before this feature was a thing
for (int i = 0; i < numAccounts; i++) {
if (!newOrder.containsKey(i) && !(wallet.type != WalletType.bitcoin && i == 1)) {
for (int i = 0; i < orderableCount; i++) {
if (!newOrder.containsValue(i)) {
int free = 0;
while (newOrder.containsValue(free)) {
while (newOrder.containsKey(free)) {
free++;
}
if (wallet.type == WalletType.bitcoin) {
newOrder[free] = 0;
} else {
newOrder[free] = i;
}
newOrder[free] = i;
}
}
cardOrder = newOrder.asObservable();
@@ -496,19 +506,22 @@ abstract class DashboardViewModelBase with Store {
: wallet.type == WalletType.wownero
? wow.wownero!.getCurrentAccount(wallet).id
: null;
final List<TransactionInfo> relevantTxs = [];
for (final tx in appStore.wallet!.transactionHistory.transactions.values) {
bool isRelevant = true;
if (wallet.type == WalletType.monero) {
isRelevant = monero!.getTransactionInfoAccountId(tx) == currentAccountId;
} else if (wallet.type == WalletType.wownero) {
isRelevant = wow.wownero!.getTransactionInfoAccountId(tx) == currentAccountId;
}
final allTxs = appStore.wallet!.transactionHistory.transactions.values;
final List<TransactionInfo> relevantTxs;
if (isRelevant) {
relevantTxs.add(tx);
}
if (wallet.type == WalletType.monero) {
relevantTxs = allTxs
.where((tx) => monero!.getTransactionInfoAccountId(tx) == currentAccountId)
.toList();
} else if (wallet.type == WalletType.wownero) {
relevantTxs = allTxs
.where((tx) => wow.wownero!.getTransactionInfoAccountId(tx) == currentAccountId)
.toList();
} else if (wallet.type == WalletType.bitcoin) {
relevantTxs = bitcoin!.getCurrentAccountBitcoinTransactions(wallet);
} else {
relevantTxs = allTxs.toList();
}
// printV("Transaction disposer callback (relevantTxs: ${relevantTxs.length} current: ${transactions.length})");
@@ -600,6 +613,12 @@ abstract class DashboardViewModelBase with Store {
@observable
ObservableMap<int, int> cardOrder;
@observable
bool lightningMode = false;
@observable
WalletAccountListViewModel? accountListViewModel;
@computed
bool get isDarkTheme => appStore.themeStore.currentTheme.isDark;
@@ -896,6 +915,42 @@ abstract class DashboardViewModelBase with Store {
@observable
late bool showSeedBackupReminder;
@computed
bool get canToggleMultiAccounts => wallet.canToggleMultiAccounts;
@observable
late bool isMultiAccountsEnabled;
@computed
List<AccountListItem> get visibleAccounts {
final all = accountListViewModel?.accounts;
if (all == null || all.isEmpty) return const <AccountListItem>[];
if (isMultiAccountsEnabled) return all.toList(growable: false);
final primary = all.where((a) => a.id == 0).firstOrNull ?? all.first;
return <AccountListItem>[primary];
}
@action
Future<void> setMultiAccountsEnabled(bool value) async {
if (!canToggleMultiAccounts) {
return;
}
if (!value) {
await _selectPrimaryAccount();
}
wallet.walletInfo.isMultiAccountsEnabled = value;
await wallet.walletInfo.save();
isMultiAccountsEnabled = value;
await accountListViewModel?.reload();
await loadCardDesigns();
if (value) {
unawaited(wallet.startSync());
}
}
@computed
bool get hasBalance => wallet.balance.values.any(
(balance) =>
@@ -1161,6 +1216,8 @@ abstract class DashboardViewModelBase with Store {
bitcoin!.updatePayjoinState(wallet, true);
}
final WalletAccountListViewModel? Function() accountListViewModelFactory;
BalanceViewModel balanceViewModel;
TradeMonitor tradeMonitor;
@@ -1215,7 +1272,7 @@ abstract class DashboardViewModelBase with Store {
@computed
bool get isEnabledBulletinAction => !settingsStore.disableBulletin;
ReactionDisposer? _onMoneroAccountChangeReaction;
ReactionDisposer? _onAccountChangeReaction;
ReactionDisposer? _onMoneroBalanceChangeReaction;
@@ -1289,6 +1346,31 @@ abstract class DashboardViewModelBase with Store {
}
}
@action
void toggleLightningMode() {
lightningMode = !lightningMode;
// Lightning belongs to the primary account only
if (lightningMode) {
unawaited(_selectPrimaryAccount());
}
}
Future<void> _selectPrimaryAccount() async {
if (wallet.type != WalletType.bitcoin) return;
final vm = accountListViewModel;
if (vm == null || vm.selectedAccount?.id == 0) return;
final primary = vm.accounts.where((a) => a.id == 0).firstOrNull;
if (primary != null) {
await vm.select(primary);
}
}
@action
void resetLightningMode() => lightningMode = false;
@action
void _onWalletChange(
WalletBase<Balance, TransactionHistoryBase<TransactionInfo>, TransactionInfo>? wallet) {
@@ -1299,16 +1381,19 @@ abstract class DashboardViewModelBase with Store {
this.wallet = wallet;
type = wallet.type;
name = wallet.name;
isMultiAccountsEnabled = wallet.walletInfo.isMultiAccountsEnabled == true;
_onAccountChangeReaction?.reaction.dispose();
_onAccountChangeReaction = null;
loadFilterItems();
if (wallet.type == WalletType.monero) {
subname = monero!.getCurrentAccount(wallet).label;
_onMoneroAccountChangeReaction?.reaction.dispose();
_onAccountChangeReaction?.reaction.dispose();
_onMoneroBalanceChangeReaction?.reaction.dispose();
_onMoneroAccountChangeReaction = reaction(
(_) => monero!.getMoneroWalletDetails(wallet).account,
_onAccountChangeReaction = reaction((_) => monero!.getMoneroWalletDetails(wallet).account,
(Account account) => _onMoneroAccountChange(wallet));
_onMoneroBalanceChangeReaction = reaction(
@@ -1319,12 +1404,12 @@ abstract class DashboardViewModelBase with Store {
} else if (wallet.type == WalletType.wownero) {
subname = wow.wownero!.getCurrentAccount(wallet).label;
_onMoneroAccountChangeReaction?.reaction.dispose();
_onAccountChangeReaction?.reaction.dispose();
_onMoneroBalanceChangeReaction?.reaction.dispose();
_onMoneroAccountChangeReaction = reaction(
_onAccountChangeReaction = reaction(
(_) => wow.wownero!.getWowneroWalletDetails(wallet).account,
(wow.Account account) => _onMoneroAccountChange(wallet));
(Account account) => _onMoneroAccountChange(wallet));
_onMoneroBalanceChangeReaction = reaction(
(_) => wow.wownero!.getWowneroWalletDetails(wallet).balance,
@@ -1335,7 +1420,7 @@ abstract class DashboardViewModelBase with Store {
// FIX-ME: Check for side effects
// subname = null;
subname = '';
_setupBitcoinAccountChangeReaction(wallet);
_reloadTransactions();
}
@@ -1378,6 +1463,21 @@ abstract class DashboardViewModelBase with Store {
}, _transactionDisposerCallback, delay: 300);
}
void _setupBitcoinAccountChangeReaction(WalletBase wallet) {
if (wallet.type != WalletType.bitcoin) {
return;
}
_onAccountChangeReaction?.reaction.dispose();
_onAccountChangeReaction = reaction(
(_) => accountListViewModel?.selectedAccount?.id,
(_) {
_reloadTransactions();
},
fireImmediately: true,
);
}
@action
void _onMoneroAccountChange(WalletBase wallet) {
if (wallet.type == WalletType.monero) {
@@ -55,10 +55,6 @@ class TransactionListItem extends ActionListItem with Keyable {
}
String get formattedTitle {
if (balanceViewModel.wallet.type == WalletType.bitcoin &&
transaction.additionalInfo['hasMissingInputTx'] == true) {
return 'Transaction has missing data';
}
if (transaction.additionalInfo['isIronwoodMigration'] == true) {
return 'Migration';