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
+26 -6
View File
@@ -1,12 +1,32 @@
class Account {
Account({required this.id, required this.label, this.balance});
Account({
required this.id,
required this.label,
String? balance,
}) : balance = balance ?? "0.00";
Account.fromMap(Map<String, Object> map)
: this.id = map['id'] == null ? 0 : int.parse(map['id'] as String),
this.label = (map['label'] ?? '') as String,
this.balance = (map['balance'] ?? '0.00') as String;
Account.fromMap(Map<String, Object?> map)
: id = map["id"] is int ? map["id"] as int : int.tryParse(map["id"]?.toString() ?? "") ?? 0,
label = (map["label"] ?? "") as String,
balance = (map["balance"] ?? "0.00") as String;
final int id;
final String label;
final String? balance;
final String balance;
Account copyWith({
int? id,
String? label,
String? balance,
}) => Account(
id: id ?? this.id,
label: label ?? this.label,
balance: balance ?? this.balance,
);
Map<String, Object> toMap() => {
"id": id.toString(),
"label": label,
"balance": balance,
};
}
-15
View File
@@ -1,15 +0,0 @@
import 'package:mobx/mobx.dart';
abstract class AccountList<T> {
ObservableList<T> get accounts;
void update();
List<T> getAll();
Future<void> addAccount({required String label});
Future<void> setLabelAccount({required int accountIndex, required String label});
void refresh();
}
+1 -1
View File
@@ -98,6 +98,6 @@ class BalanceCardStyleSettings {
}
Future<void> insert() async {
db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace);
await db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace);
}
}
+141 -76
View File
@@ -3,6 +3,7 @@ import "dart:io";
import "package:cw_core/db/sqlite_debug.dart";
import "package:cw_core/root_dir.dart";
import "package:cw_core/utils/print_verbose.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/foundation.dart";
import "package:path/path.dart" as p;
import "package:sqflite_common_ffi/sqflite_ffi.dart";
@@ -10,11 +11,11 @@ import "package:sqflite_common_ffi/sqflite_ffi.dart";
Database? db;
Future<void> _addColumnIfNotExists(
Database db, {
required String table,
required String column,
required String definition,
}) async {
Database db, {
required String table,
required String column,
required String definition,
}) async {
final result = await db.rawQuery("PRAGMA table_info($table)");
final columnExists = result.any((row) => row["name"] == column);
@@ -65,7 +66,7 @@ Future<void> _initDb({String? pathOverride}) async {
await db?.close();
db = await openDatabase(
dbFile.path,
version: 13,
version: 14,
onUpgrade: (db, oldVersion, newVersion) async {
printV("migrating: $oldVersion, $newVersion");
if (oldVersion <= 1) {
@@ -100,77 +101,101 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings (
definition: "BOOLEAN DEFAULT FALSE",
);
await _addColumnIfNotExists(
db,
table: 'BalanceCardStyleSettings',
column: 'cardOrder',
definition: 'INTEGER DEFAULT 0',
);
}
if (oldVersion <= 3) {
await _addColumnIfNotExists(db,
table: "WalletInfo", column: "showCombinedBalance", definition: "BOOLEAN DEFAULT TRUE");
// null - primary token (eth, sol etc)
// not null - address of fav token
// if address doesn't correspond to a valid token, fallback to primary token
await _addColumnIfNotExists(db,
table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL");
}
await _addColumnIfNotExists(
db,
table: 'BalanceCardStyleSettings',
column: 'cardOrder',
definition: 'INTEGER DEFAULT 0',
);
}
if (oldVersion <= 3) {
await _addColumnIfNotExists(db,
table: "WalletInfo", column: "showCombinedBalance", definition: "BOOLEAN DEFAULT TRUE");
// null - primary token (eth, sol etc)
// not null - address of fav token
// if address doesn't correspond to a valid token, fallback to primary token
await _addColumnIfNotExists(db,
table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL");
}
if (oldVersion <= 4) {
await _createBridgeTransferTable(db);
}
if (oldVersion <= 4) {
await _createBridgeTransferTable(db);
}
if (oldVersion <= 5) {
await _createTradeTable(db);
}
if (oldVersion <= 6) {
await _addColumnIfNotExists(
db,
table: 'Trade',
column: 'toAddressExtraId',
definition: 'TEXT',
);
}
if (oldVersion <= 7) {
await _createNodeTable(db);
}
if (oldVersion <= 8) {
await _addColumnIfNotExists(
db,
table: 'BalanceCardStyleSettings',
column: 'iconStyleIndex',
definition: 'INTEGER DEFAULT 0',
);
await _addColumnIfNotExists(
db,
table: 'BalanceCardStyleSettings',
column: 'isGradientOnly',
definition: 'BOOLEAN DEFAULT FALSE',
);
}
if (oldVersion <= 9) {
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
}
if (oldVersion <= 5) {
await _createTradeTable(db);
}
if (oldVersion <= 6) {
await _addColumnIfNotExists(
db,
table: 'Trade',
column: 'toAddressExtraId',
definition: 'TEXT',
);
}
if (oldVersion <= 7) {
await _createNodeTable(db);
}
if (oldVersion <= 8) {
await _addColumnIfNotExists(
db,
table: 'BalanceCardStyleSettings',
column: 'iconStyleIndex',
definition: 'INTEGER DEFAULT 0',
);
await _addColumnIfNotExists(
db,
table: 'BalanceCardStyleSettings',
column: 'isGradientOnly',
definition: 'BOOLEAN DEFAULT FALSE',
);
}
if (oldVersion <= 9) {
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
}
if (oldVersion <= 10) {
await _createImportedNFTTable(db);
}
if (oldVersion <= 11) {
await _addColumnIfNotExists(
db,
table: "WalletInfo",
column: "showSeedBackupReminder",
definition: "BOOLEAN DEFAULT FALSE",
);
}
if (oldVersion <= 12) {
await _createChartsTables(db);
}
if (oldVersion <= 10) {
await _createImportedNFTTable(db);
}
if (oldVersion <= 11) {
await _addColumnIfNotExists(
db,
table: "WalletInfo",
column: "showSeedBackupReminder",
definition: "BOOLEAN DEFAULT FALSE",
);
}
if(oldVersion <= 12) {
await _createChartsTables(db);
}
}, onCreate: (Database db, int version) async {
await db.execute('''
if (oldVersion <= 13) {
await _createWalletInfoAccountTable(db);
await _addColumnIfNotExists(
db,
table: "WalletInfo",
column: "accountDiscoveryLimit",
definition: "INTEGER DEFAULT NULL",
);
await _addColumnIfNotExists(
db,
table: "WalletInfo",
column: "isMultiAccountsEnabled",
definition: "INTEGER DEFAULT NULL",
);
await _addColumnIfNotExists(
db,
table: "WalletInfo",
column: "currentAccountIndex",
definition: "INTEGER NOT NULL DEFAULT 0",
);
await _migrateBitcoinCardStylesForAccounts(db);
}
},
onCreate: (Database db, int version) async {
await db.execute('''
CREATE TABLE WalletInfo (
walletInfoId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL,
@@ -193,9 +218,12 @@ CREATE TABLE WalletInfo (
hashedWalletIdentifier TEXT,
isNonSeedWallet INTEGER DEFAULT (0) NOT NULL,
sortOrder INTEGER DEFAULT (0) NOT NULL,
currentAccountIndex INTEGER NOT NULL DEFAULT 0,
receiveInfoboxDismissed BOOLEAN DEFAULT FALSE,
showCombinedBalance BOOLEAN DEFAULT TRUE,
favoriteTokenAddress TEXT DEFAULT NULL,
accountDiscoveryLimit INTEGER DEFAULT NULL,
isMultiAccountsEnabled INTEGER DEFAULT NULL,
showSeedBackupReminder BOOLEAN DEFAULT FALSE
);
''');
@@ -269,8 +297,8 @@ CREATE TABLE BalanceCardStyleSettings (
await _createErc20TokenTable(db);
await _createSplTokenTable(db);
await _createTronTokenTable(db);
await _createImportedNFTTable(db);
await _createImportedNFTTable(db);
await _createWalletInfoAccountTable(db);
},
);
}
@@ -356,6 +384,43 @@ ON Trade (id);
""");
}
Future<void> _createWalletInfoAccountTable(Database db) async {
await db.execute("""
CREATE TABLE IF NOT EXISTS WalletInfoAccount (
walletInfoId INTEGER NOT NULL,
accountIndex INTEGER NOT NULL,
label TEXT NOT NULL,
PRIMARY KEY (walletInfoId, accountIndex),
CONSTRAINT WalletInfoAccount_WalletInfo_FK FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId)
);
""");
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_walletinfoaccount_walletinfoid
ON WalletInfoAccount(walletInfoId);
""");
}
Future<void> _migrateBitcoinCardStylesForAccounts(Database db) async {
const bitcoinWallets = 'SELECT walletInfoId FROM WalletInfo WHERE "type" = ?';
final btc = WalletType.bitcoin.index;
// Lightning: 0 -> -2 (first, so accountIndex 0 is free for the Bitcoin card)
await db.rawUpdate(
"UPDATE OR REPLACE BalanceCardStyleSettings SET accountIndex = -2 "
"WHERE accountIndex = 0 AND walletInfoId IN ($bitcoinWallets)",
[btc],
);
// Bitcoin: -1 -> 0 (primary account, first in order)
await db.rawUpdate(
"UPDATE OR REPLACE BalanceCardStyleSettings SET accountIndex = 0, cardOrder = 0 "
"WHERE accountIndex = -1 AND walletInfoId IN ($bitcoinWallets)",
[btc],
);
}
Future<Map<String, dynamic>> dumpDb() async {
try {
return await _dumpDb();
+4
View File
@@ -142,6 +142,10 @@ abstract class WalletBase<BalanceType extends Balance, HistoryType extends Trans
bool get hasLightningSupport => false;
bool get hasSilentPaymentsScanning => false;
bool get hasAccountsSupport => false;
bool get hasNativeAccounts => false;
bool get canToggleMultiAccounts => hasAccountsSupport && !hasNativeAccounts;
// hardware wallet - bitbox, ledger, trezor.
// we also have airgap wallets but those can't sign messages
bool get canSignMessages => walletInfo.hardwareWalletType == null || walletInfo.isHardwareWallet;
+206 -47
View File
@@ -244,6 +244,68 @@ class WalletInfoAddress {
}
}
class WalletInfoAccount {
WalletInfoAccount({
required this.walletInfoId,
required this.accountIndex,
required this.label,
});
factory WalletInfoAccount.fromJson(Map<String, dynamic> json) => WalletInfoAccount(
walletInfoId: json["walletInfoId"] as int,
accountIndex: json["accountIndex"] as int,
label: json["label"] as String,
);
int walletInfoId;
int accountIndex;
String label;
static String get tableName => "walletInfoAccount";
static Future<List<WalletInfoAccount>> selectList(int walletInfoId) async {
final query = await db!.query(
tableName,
where: "walletInfoId = ?",
whereArgs: [walletInfoId],
orderBy: "accountIndex ASC",
);
return List.generate(query.length, (index) => WalletInfoAccount.fromJson(query[index]));
}
static Future<int> deleteByWalletInfoId(int walletInfoId) async =>
await db!.delete(tableName, where: "walletInfoId = ?", whereArgs: [walletInfoId]);
static Future<int> insertOrUpdate({
required int walletInfoId,
required int accountIndex,
required String label,
}) async =>
await db!.transaction((txn) async {
final updated = await txn.update(
tableName,
{"label": label},
where: "walletInfoId = ? AND accountIndex = ?",
whereArgs: [walletInfoId, accountIndex],
);
if (updated > 0) return updated;
return await txn.insert(tableName, {
"walletInfoId": walletInfoId,
"accountIndex": accountIndex,
"label": label,
});
});
Map<String, dynamic> toJson() => {
"walletInfoId": walletInfoId,
"accountIndex": accountIndex,
"label": label,
};
}
class DerivationInfo {
DerivationInfo({
this.id = 0,
@@ -346,6 +408,7 @@ class WalletInfo {
this.hashedWalletIdentifier,
this.isNonSeedWallet,
this.sortOrder,
this.currentAccountIndex,
this.addressPageType,
this.receiveInfoboxDismissed,
this.showCombinedBalance,
@@ -353,53 +416,65 @@ class WalletInfo {
this.showSeedBackupReminder)
: _yatLastUsedAddressController = StreamController<String>.broadcast();
factory WalletInfo.external(
{required String id,
required String name,
required WalletType type,
required bool isRecovery,
required int restoreHeight,
required DateTime date,
required String dirPath,
required String path,
required String address,
bool? showIntroCakePayCard,
String yatEid = '',
String yatLastUsedAddressRaw = '',
int? derivationInfoId,
HardwareWalletType? hardwareWalletType,
String? parentAddress,
String? hashedWalletIdentifier,
bool? isNonSeedWallet,
int? sortOrder,
bool? receiveInfoboxDismissed,
bool? showCombinedBalance,
String? favoriteTokenAddress}) {
return WalletInfo(
0,
id,
name,
type,
isRecovery,
restoreHeight,
date.millisecondsSinceEpoch,
dirPath,
path,
address,
yatEid,
yatLastUsedAddressRaw,
showIntroCakePayCard,
derivationInfoId ?? -1,
hardwareWalletType,
parentAddress,
hashedWalletIdentifier,
isNonSeedWallet ?? false,
sortOrder ?? 0,
null,
receiveInfoboxDismissed ?? false,
showCombinedBalance ?? true,
favoriteTokenAddress,
false);
factory WalletInfo.external({
required String id,
required String name,
required WalletType type,
required bool isRecovery,
required int restoreHeight,
required DateTime date,
required String dirPath,
required String path,
required String address,
bool? showIntroCakePayCard,
String yatEid = "",
String yatLastUsedAddressRaw = "",
int? derivationInfoId,
HardwareWalletType? hardwareWalletType,
String? parentAddress,
String? hashedWalletIdentifier,
bool? isNonSeedWallet,
int? sortOrder,
int currentAccountIndex = 0,
bool? receiveInfoboxDismissed,
bool? showCombinedBalance,
String? favoriteTokenAddress,
}) {
final wi = WalletInfo(
0,
id,
name,
type,
isRecovery,
restoreHeight,
date.millisecondsSinceEpoch,
dirPath,
path,
address,
yatEid.isNotEmpty ? yatEid : null,
yatLastUsedAddressRaw.isNotEmpty ? yatLastUsedAddressRaw : null,
showIntroCakePayCard ?? false,
derivationInfoId ?? -1,
hardwareWalletType,
parentAddress,
hashedWalletIdentifier,
isNonSeedWallet ?? false,
sortOrder ?? 0,
currentAccountIndex,
null,
receiveInfoboxDismissed ?? false,
showCombinedBalance ?? true,
favoriteTokenAddress?.isNotEmpty == true ? favoriteTokenAddress : null,
false,
);
if (type == WalletType.bitcoin) {
wi.isMultiAccountsEnabled = false;
} else if (type == WalletType.monero || type == WalletType.wownero) {
wi.isMultiAccountsEnabled = true;
}
return wi;
}
static String get tableName => 'walletInfo';
@@ -408,6 +483,8 @@ class WalletInfo {
int internalId;
int currentAccountIndex;
String id;
String name;
WalletType type;
@@ -506,8 +583,76 @@ class WalletInfo {
await WalletInfoAddress.insert(internalId, type, address);
}
Future<List<WalletInfoAccount>> getAccounts() async {
var accounts = await WalletInfoAccount.selectList(internalId);
if (accounts.isEmpty) {
await WalletInfoAccount.insertOrUpdate(
walletInfoId: internalId,
accountIndex: 0,
label: "Primary account",
);
accounts = await WalletInfoAccount.selectList(internalId);
}
if (!accounts.any((a) => a.accountIndex == currentAccountIndex)) {
currentAccountIndex = accounts.first.accountIndex;
await save();
}
return accounts;
}
Future<void> setSelectedAccount(int accountIndex) async {
currentAccountIndex = accountIndex;
await save();
}
Future<void> setAccounts(List<WalletInfoAccount> accounts) async {
await WalletInfoAccount.deleteByWalletInfoId(internalId);
for (final account in accounts) {
await WalletInfoAccount.insertOrUpdate(
walletInfoId: internalId,
accountIndex: account.accountIndex,
label: account.label,
);
}
if (accounts.isNotEmpty && !accounts.any((a) => a.accountIndex == currentAccountIndex)) {
currentAccountIndex = accounts.first.accountIndex;
await save();
}
}
Future<void> addAccount({
required int accountIndex,
required String label,
}) async {
await WalletInfoAccount.insertOrUpdate(
walletInfoId: internalId,
accountIndex: accountIndex,
label: label,
);
}
Future<void> renameAccount({
required int accountIndex,
required String label,
}) async {
await WalletInfoAccount.insertOrUpdate(
walletInfoId: internalId,
accountIndex: accountIndex,
label: label,
);
}
String? addressPageType;
String? network;
int? accountDiscoveryLimit;
bool? isMultiAccountsEnabled;
int derivationInfoId;
DerivationInfo? _derivationInfo;
@@ -586,12 +731,16 @@ class WalletInfo {
"hashedWalletIdentifier": hashedWalletIdentifier,
"isNonSeedWallet": isNonSeedWallet ? 1 : 0,
"sortOrder": sortOrder,
"currentAccountIndex": currentAccountIndex,
"addressPageType": addressPageType,
"receiveInfoboxDismissed": receiveInfoboxDismissed ? 1 : 0,
"showCombinedBalance": showCombinedBalance ? 1 : 0,
"favoriteTokenAddress": favoriteTokenAddress,
"showSeedBackupReminder": showSeedBackupReminder ? 1 : 0,
"network": network,
"accountDiscoveryLimit": accountDiscoveryLimit,
"isMultiAccountsEnabled":
isMultiAccountsEnabled == null ? null : (isMultiAccountsEnabled! ? 1 : 0),
};
factory WalletInfo.fromJson(Map<String, dynamic> json) {
@@ -617,12 +766,17 @@ class WalletInfo {
json['hashedWalletIdentifier'] as String?,
(json['isNonSeedWallet'] as int) == 1,
json['sortOrder'] as int? ?? 0,
json['currentAccountIndex'] as int? ?? 0,
json['addressPageType'] as String? ?? null,
json['receiveInfoboxDismissed'] != 0,
json["showCombinedBalance"] != 0,
json["favoriteTokenAddress"] as String? ?? null,
json["showSeedBackupReminder"] == 1);
info.network = json['network'] as String?;
info.accountDiscoveryLimit = json['accountDiscoveryLimit'] as int?;
final rawIsMultiAccountsEnabled = json['isMultiAccountsEnabled'];
info.isMultiAccountsEnabled =
rawIsMultiAccountsEnabled == null ? null : (rawIsMultiAccountsEnabled as int) == 1;
return info;
}
@@ -670,6 +824,11 @@ class WalletInfo {
await save();
}
Future<void> setAccountDiscoveryLimit(int limit) async {
accountDiscoveryLimit = limit;
await save();
}
Future<void> clearSeedBackupReminder() async {
final previous = showSeedBackupReminder;
showSeedBackupReminder = false;
+1
View File
@@ -271,6 +271,7 @@ class WalletInfo extends HiveObject {
hashedWalletIdentifier,
isNonSeedWallet,
0,
0,
addressPageType,
false,
true,