From e4703a9ace5b16af6152a788869e529b02372a6f Mon Sep 17 00:00:00 2001 From: Rafael Saes Date: Tue, 20 Feb 2024 19:19:25 -0300 Subject: [PATCH] feat: rebase btc-addr-types, migrate to bitcoin_base --- cw_bitcoin/lib/bitcoin_address_record.dart | 38 +- .../lib/bitcoin_receive_page_option.dart | 46 ++ cw_bitcoin/lib/bitcoin_unspent.dart | 33 +- cw_bitcoin/lib/bitcoin_wallet.dart | 105 ++- cw_bitcoin/lib/bitcoin_wallet_addresses.dart | 28 +- cw_bitcoin/lib/electrum.dart | 21 + cw_bitcoin/lib/electrum_transaction_info.dart | 81 +- cw_bitcoin/lib/electrum_wallet.dart | 710 +++++++++++++++++- cw_bitcoin/lib/electrum_wallet_addresses.dart | 135 +++- cw_bitcoin/lib/electrum_wallet_snapshot.dart | 59 +- cw_bitcoin/pubspec.lock | 18 + cw_bitcoin/pubspec.yaml | 4 + cw_bitcoin_cash/pubspec.yaml | 5 +- cw_core/lib/sync_status.dart | 14 +- cw_core/lib/unspent_transaction_output.dart | 3 +- howto-build-android.md | 20 +- lib/bitcoin/cw_bitcoin.dart | 173 ++++- lib/core/address_validator.dart | 16 +- lib/core/sync_status_title.dart | 6 +- lib/di.dart | 5 + .../present_receive_option_picker.dart | 128 ++-- lib/src/screens/receive/receive_page.dart | 32 +- lib/src/screens/send/widgets/send_card.dart | 3 +- lib/src/widgets/address_text_field.dart | 6 +- .../dashboard/dashboard_view_model.dart | 5 + lib/view_model/rescan_view_model.dart | 11 +- .../wallet_address_list_view_model.dart | 23 +- model_generator.sh | 18 +- tool/configure.dart | 1 + 29 files changed, 1438 insertions(+), 309 deletions(-) create mode 100644 cw_bitcoin/lib/bitcoin_receive_page_option.dart diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart index 676edb4a5..eb5fd2cbe 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -10,21 +10,36 @@ class BitcoinAddressRecord { int balance = 0, String name = '', bool isUsed = false, + required this.type, + String? scriptHash, + required this.network, + this.silentPaymentTweak, }) : _txCount = txCount, _balance = balance, _name = name, _isUsed = isUsed; - factory BitcoinAddressRecord.fromJSON(String jsonSource) { + factory BitcoinAddressRecord.fromJSON(String jsonSource, {BasedUtxoNetwork? network}) { final decoded = json.decode(jsonSource) as Map; - return BitcoinAddressRecord(decoded['address'] as String, - index: decoded['index'] as int, - isHidden: decoded['isHidden'] as bool? ?? false, - isUsed: decoded['isUsed'] as bool? ?? false, - txCount: decoded['txCount'] as int? ?? 0, - name: decoded['name'] as String? ?? '', - balance: decoded['balance'] as int? ?? 0); + return BitcoinAddressRecord( + decoded['address'] as String, + index: decoded['index'] as int, + isHidden: decoded['isHidden'] as bool? ?? false, + isUsed: decoded['isUsed'] as bool? ?? false, + txCount: decoded['txCount'] as int? ?? 0, + name: decoded['name'] as String? ?? '', + balance: decoded['balance'] as int? ?? 0, + type: decoded['type'] != null && decoded['type'] != '' + ? BitcoinAddressType.values + .firstWhere((type) => type.toString() == decoded['type'] as String) + : SegwitAddresType.p2wpkh, + scriptHash: decoded['scriptHash'] as String?, + network: (decoded['network'] as String?) == null + ? network + : BasedUtxoNetwork.fromName(decoded['network'] as String), + silentPaymentTweak: decoded['silentPaymentTweak'] as String?, + ); } final String address; @@ -34,6 +49,9 @@ class BitcoinAddressRecord { int _balance; String _name; bool _isUsed; + String? scriptHash; + BasedUtxoNetwork? network; + final String? silentPaymentTweak; int get txCount => _txCount; @@ -66,5 +84,9 @@ class BitcoinAddressRecord { 'name': name, 'isUsed': isUsed, 'balance': balance, + 'type': type.toString(), + 'scriptHash': scriptHash, + 'network': network?.value, + 'silentPaymentTweak': silentPaymentTweak, }); } diff --git a/cw_bitcoin/lib/bitcoin_receive_page_option.dart b/cw_bitcoin/lib/bitcoin_receive_page_option.dart new file mode 100644 index 000000000..2b025965b --- /dev/null +++ b/cw_bitcoin/lib/bitcoin_receive_page_option.dart @@ -0,0 +1,46 @@ +import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_core/receive_page_option.dart'; + +class BitcoinReceivePageOption implements ReceivePageOption { + static const p2wpkh = BitcoinReceivePageOption._('Segwit (P2WPKH)'); + static const p2sh = BitcoinReceivePageOption._('Segwit-Compatible (P2SH)'); + static const p2tr = BitcoinReceivePageOption._('Taproot (P2TR)'); + static const p2wsh = BitcoinReceivePageOption._('Segwit (P2WSH)'); + static const p2pkh = BitcoinReceivePageOption._('Legacy (P2PKH)'); + + static const silent_payments = BitcoinReceivePageOption._('Silent Payments'); + + const BitcoinReceivePageOption._(this.value); + + final String value; + + String toString() { + return value; + } + + static const all = [ + BitcoinReceivePageOption.p2wpkh, + BitcoinReceivePageOption.p2sh, + BitcoinReceivePageOption.p2tr, + BitcoinReceivePageOption.p2wsh, + BitcoinReceivePageOption.p2pkh + ]; + + factory BitcoinReceivePageOption.fromType(BitcoinAddressType type) { + switch (type) { + case SegwitAddresType.p2tr: + return BitcoinReceivePageOption.p2tr; + case SegwitAddresType.p2wsh: + return BitcoinReceivePageOption.p2wsh; + case P2pkhAddressType.p2pkh: + return BitcoinReceivePageOption.p2pkh; + case P2shAddressType.p2wpkhInP2sh: + return BitcoinReceivePageOption.p2sh; + case SilentPaymentsAddresType.p2sp: + return BitcoinReceivePageOption.silent_payments; + case SegwitAddresType.p2wpkh: + default: + return BitcoinReceivePageOption.p2wpkh; + } + } +} diff --git a/cw_bitcoin/lib/bitcoin_unspent.dart b/cw_bitcoin/lib/bitcoin_unspent.dart index 9c198c27c..131f47ab6 100644 --- a/cw_bitcoin/lib/bitcoin_unspent.dart +++ b/cw_bitcoin/lib/bitcoin_unspent.dart @@ -1,15 +1,38 @@ +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(BitcoinAddressRecord addressRecord, String hash, int value, int vout) + BitcoinUnspent(BitcoinAddressRecord addressRecord, String hash, int value, int vout, + {this.silentPaymentTweak, this.type}) : bitcoinAddressRecord = addressRecord, super(addressRecord.address, hash, value, vout, null); - factory BitcoinUnspent.fromJSON( - BitcoinAddressRecord address, Map json) => - BitcoinUnspent(address, json['tx_hash'] as String, json['value'] as int, - json['tx_pos'] as int); + factory BitcoinUnspent.fromJSON(BitcoinAddressRecord address, Map json) => + BitcoinUnspent( + address, + json['tx_hash'] as String, + json['value'] as int, + json['tx_pos'] as int, + silentPaymentTweak: json['silent_payment_tweak'] as String?, + type: json['type'] == null + ? null + : BitcoinAddressType.values.firstWhere((e) => e.toString() == json['type']), + ); + + Map toJson() { + final json = { + 'address_record': bitcoinAddressRecord.toJSON(), + 'tx_hash': hash, + 'value': value, + 'tx_pos': vout, + 'silent_payment_tweak': silentPaymentTweak, + 'type': type.toString(), + }; + return json; + } final BitcoinAddressRecord bitcoinAddressRecord; + String? silentPaymentTweak; + BitcoinAddressType? type; } diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index 9cdb78f2d..f79abb70d 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -17,17 +17,22 @@ part 'bitcoin_wallet.g.dart'; class BitcoinWallet = BitcoinWalletBase with _$BitcoinWallet; abstract class BitcoinWalletBase extends ElectrumWallet with Store { - BitcoinWalletBase( - {required String mnemonic, - required String password, - required WalletInfo walletInfo, - required Box unspentCoinsInfo, - required Uint8List seedBytes, - List? initialAddresses, - ElectrumBalance? initialBalance, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) - : super( + BitcoinWalletBase({ + required String mnemonic, + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required Uint8List seedBytes, + String? addressPageType, + BasedUtxoNetwork? networkParam, + List? initialAddresses, + ElectrumBalance? initialBalance, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + List? initialSilentAddresses, + int initialSilentAddressIndex = 0, + SilentPaymentOwner? silentAddress, + }) : super( mnemonic: mnemonic, password: password, walletInfo: walletInfo, @@ -38,15 +43,17 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { seedBytes: seedBytes, currency: CryptoCurrency.btc) { walletAddresses = BitcoinWalletAddresses( - walletInfo, - electrumClient: electrumClient, - initialAddresses: initialAddresses, - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex, - mainHd: hd, - sideHd: bitcoin.HDWallet.fromSeed(seedBytes, network: networkType) - .derivePath("m/0'/1"), - networkType: networkType); + walletInfo, + initialAddresses: initialAddresses, + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + initialSilentAddresses: initialSilentAddresses, + initialSilentAddressIndex: initialSilentAddressIndex, + silentAddress: silentAddress, + mainHd: hd, + sideHd: bitcoin.HDWallet.fromSeed(seedBytes, network: networkType).derivePath("m/0'/1"), + network: networkParam ?? network, + ); autorun((_) { this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; }); @@ -58,20 +65,29 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required WalletInfo walletInfo, required Box unspentCoinsInfo, List? initialAddresses, + List? initialSilentAddresses, ElectrumBalance? initialBalance, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0 + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + int initialSilentAddressIndex = 0, }) async { return BitcoinWallet( - mnemonic: mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: initialAddresses, - initialBalance: initialBalance, - seedBytes: await mnemonicToSeedBytes(mnemonic), - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex); + mnemonic: mnemonic, + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: initialAddresses, + initialSilentAddresses: initialSilentAddresses, + initialSilentAddressIndex: initialSilentAddressIndex, + silentAddress: await SilentPaymentOwner.fromMnemonic(mnemonic, + hrp: network == BitcoinNetwork.testnet ? 'tsp' : 'sp'), + initialBalance: initialBalance, + seedBytes: await mnemonicToSeedBytes(mnemonic), + initialRegularAddressIndex: initialRegularAddressIndex, + initialChangeAddressIndex: initialChangeAddressIndex, + addressPageType: addressPageType, + networkParam: network, + ); } static Future open({ @@ -82,14 +98,21 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { }) async { final snp = await ElectrumWallletSnapshot.load(name, walletInfo.type, password); return BitcoinWallet( - mnemonic: snp.mnemonic, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - initialAddresses: snp.addresses, - initialBalance: snp.balance, - seedBytes: await mnemonicToSeedBytes(snp.mnemonic), - initialRegularAddressIndex: snp.regularAddressIndex, - initialChangeAddressIndex: snp.changeAddressIndex); + mnemonic: snp.mnemonic, + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + initialAddresses: snp.addresses, + initialSilentAddresses: snp.silentAddresses, + initialSilentAddressIndex: snp.silentAddressIndex, + silentAddress: await SilentPaymentOwner.fromMnemonic(snp.mnemonic, + hrp: snp.network == BitcoinNetwork.testnet ? 'tsp' : 'sp'), + initialBalance: snp.balance, + seedBytes: await mnemonicToSeedBytes(snp.mnemonic), + initialRegularAddressIndex: snp.regularAddressIndex, + initialChangeAddressIndex: snp.changeAddressIndex, + addressPageType: snp.addressPageType, + networkParam: snp.network, + ); } -} \ No newline at end of file +} diff --git a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart index 36d37127d..a8b4511f0 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart @@ -11,22 +11,18 @@ part 'bitcoin_wallet_addresses.g.dart'; class BitcoinWalletAddresses = BitcoinWalletAddressesBase with _$BitcoinWalletAddresses; abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with Store { - BitcoinWalletAddressesBase(WalletInfo walletInfo, - {required bitcoin.HDWallet mainHd, - required bitcoin.HDWallet sideHd, - required bitcoin.NetworkType networkType, - required ElectrumClient electrumClient, - List? initialAddresses, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) - : super(walletInfo, - initialAddresses: initialAddresses, - initialRegularAddressIndex: initialRegularAddressIndex, - initialChangeAddressIndex: initialChangeAddressIndex, - mainHd: mainHd, - sideHd: sideHd, - electrumClient: electrumClient, - networkType: networkType); + BitcoinWalletAddressesBase( + WalletInfo walletInfo, { + required super.mainHd, + required super.sideHd, + required super.network, + super.initialAddresses, + super.initialRegularAddressIndex, + super.initialChangeAddressIndex, + super.initialSilentAddresses, + super.initialSilentAddressIndex = 0, + super.silentAddress, + }) : super(walletInfo); @override String getAddress({required int index, required bitcoin.HDWallet hd}) => diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index a05c251fe..fe7d1e507 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -335,6 +335,27 @@ class ElectrumClient { } } + // https://electrumx.readthedocs.io/en/latest/protocol-methods.html#blockchain-headers-subscribe + // example response: + // { + // "height": 520481, + // "hex": "00000020890208a0ae3a3892aa047c5468725846577cfcd9b512b50000000000000000005dc2b02f2d297a9064ee103036c14d678f9afc7e3d9409cf53fd58b82e938e8ecbeca05a2d2103188ce804c4" + // } + Future getCurrentBlockChainTip() => + call(method: 'blockchain.headers.subscribe').then((result) { + if (result is Map) { + return result["height"] as int; + } + + return null; + }); + + BehaviorSubject? chainTipUpdate() { + _id += 1; + return subscribe( + id: 'blockchain.headers.subscribe', method: 'blockchain.headers.subscribe'); + } + BehaviorSubject? scripthashUpdate(String scripthash) { _id += 1; return subscribe( diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index bf5ec2c4f..a0f11071f 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -1,8 +1,8 @@ -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:bitcoin_flutter/src/payments/index.dart' show PaymentData; +import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/bitcoin_amount_format.dart'; +import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/format_amount.dart'; @@ -20,6 +20,8 @@ class ElectrumTransactionBundle { } class ElectrumTransactionInfo extends TransactionInfo { + BitcoinUnspent? unspent; + ElectrumTransactionInfo(this.type, {required String id, required int height, @@ -28,7 +30,9 @@ class ElectrumTransactionInfo extends TransactionInfo { required TransactionDirection direction, required bool isPending, required DateTime date, - required int confirmations}) { + required int confirmations, + String? to, + this.unspent}) { this.id = id; this.height = height; this.amount = amount; @@ -37,6 +41,7 @@ class ElectrumTransactionInfo extends TransactionInfo { this.date = date; this.isPending = isPending; this.confirmations = confirmations; + this.to = to; } factory ElectrumTransactionInfo.fromElectrumVerbose( @@ -143,52 +148,24 @@ class ElectrumTransactionInfo extends TransactionInfo { confirmations: bundle.confirmations); } - factory ElectrumTransactionInfo.fromHexAndHeader(WalletType type, String hex, - {List? addresses, required int height, int? timestamp, required int confirmations}) { - final tx = bitcoin.Transaction.fromHex(hex); - var exist = false; - var amount = 0; - - if (addresses != null) { - tx.outs.forEach((out) { - try { - final p2pkh = bitcoin.P2PKH( - data: PaymentData(output: out.script), network: bitcoin.bitcoin); - exist = addresses.contains(p2pkh.data.address); - - if (exist) { - amount += out.value!; - } - } catch (_) {} - }); - } - - final date = timestamp != null - ? DateTime.fromMillisecondsSinceEpoch(timestamp * 1000) - : DateTime.now(); - - return ElectrumTransactionInfo(type, - id: tx.getId(), - height: height, - isPending: false, - fee: null, - direction: TransactionDirection.incoming, - amount: amount, - date: date, - confirmations: confirmations); - } - - factory ElectrumTransactionInfo.fromJson( - Map data, WalletType type) { - return ElectrumTransactionInfo(type, - id: data['id'] as String, - height: data['height'] as int, - amount: data['amount'] as int, - fee: data['fee'] as int, - direction: parseTransactionDirectionFromInt(data['direction'] as int), - date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), - isPending: data['isPending'] as bool, - confirmations: data['confirmations'] as int); + factory ElectrumTransactionInfo.fromJson(Map data, WalletType type) { + return ElectrumTransactionInfo( + type, + id: data['id'] as String, + height: data['height'] as int, + amount: data['amount'] as int, + fee: data['fee'] as int, + direction: parseTransactionDirectionFromInt(data['direction'] as int), + date: DateTime.fromMillisecondsSinceEpoch(data['date'] as int), + isPending: data['isPending'] as bool, + confirmations: data['confirmations'] as int, + to: data['to'] as String?, + unspent: data['unspent'] != null + ? BitcoinUnspent.fromJSON( + BitcoinAddressRecord.fromJSON(data['unspent']['address_record'] as String), + data['unspent'] as Map) + : null, + ); } final WalletType type; @@ -232,6 +209,12 @@ class ElectrumTransactionInfo extends TransactionInfo { m['isPending'] = isPending; m['confirmations'] = confirmations; m['fee'] = fee; + m['to'] = to; + m['unspent'] = unspent?.toJson() ?? {}; return m; } + + String toString() { + return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, confirmations: $confirmations, to: $to, unspent: $unspent)'; + } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 8a41c1733..c99551e78 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:math'; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; @@ -130,22 +131,93 @@ abstract class ElectrumWalletBase List unspentCoins; List _feeRates; Map?> _scripthashesUpdateSubject; + BehaviorSubject? _chainTipUpdateSubject; bool _isTransactionUpdating; + // Future? _isolate; void Function(FlutterErrorDetails)? _onError; + Timer? _autoSaveTimer; + static const int _autoSaveInterval = 30; Future init() async { await walletAddresses.init(); await transactionHistory.init(); - await save(); + + _autoSaveTimer = + Timer.periodic(Duration(seconds: _autoSaveInterval), (_) async => await save()); } + // @action + // Future _setListeners(int height, {int? chainTip}) async { + // final currentChainTip = chainTip ?? await electrumClient.getCurrentBlockChainTip() ?? 0; + // syncStatus = AttemptingSyncStatus(); + + // if (_isolate != null) { + // final runningIsolate = await _isolate!; + // runningIsolate.kill(priority: Isolate.immediate); + // } + + // final receivePort = ReceivePort(); + // _isolate = Isolate.spawn( + // startRefresh, + // ScanData( + // sendPort: receivePort.sendPort, + // primarySilentAddress: walletAddresses.primarySilentAddress!, + // networkType: networkType, + // height: height, + // chainTip: currentChainTip, + // electrumClient: ElectrumClient(), + // transactionHistoryIds: transactionHistory.transactions.keys.toList(), + // node: electrumClient.uri.toString(), + // labels: walletAddresses.labels, + // )); + + // await for (var message in receivePort) { + // if (message is BitcoinUnspent) { + // if (!unspentCoins.any((utx) => + // utx.hash.contains(message.hash) && + // utx.vout == message.vout && + // utx.address.contains(message.address))) { + // unspentCoins.add(message); + + // if (unspentCoinsInfo.values.any((element) => + // element.walletId.contains(id) && + // element.hash.contains(message.hash) && + // element.address.contains(message.address))) { + // _addCoinInfo(message); + + // await walletInfo.save(); + // await save(); + // } + + // balance[currency] = await _fetchBalances(); + // } + // } + + // if (message is Map) { + // transactionHistory.addMany(message); + // await transactionHistory.save(); + // } + + // // check if is a SyncStatus type since "is SyncStatus" doesn't work here + // if (message is SyncResponse) { + // syncStatus = message.syncStatus; + // walletInfo.restoreHeight = message.height; + // await walletInfo.save(); + // } + // } + // } + @action @override Future startSync() async { try { - syncStatus = AttemptingSyncStatus(); - await walletAddresses.discoverAddresses(); + await _setInitialHeight(); + } catch (_) {} + + try { + rescan(height: walletInfo.restoreHeight); + await updateTransactions(); _subscribeForUpdates(); await updateUnspent(); @@ -175,6 +247,12 @@ abstract class ElectrumWalletBase } }; syncStatus = ConnectedSyncStatus(); + + // final currentChainTip = await electrumClient.getCurrentBlockChainTip(); + + // if ((currentChainTip ?? 0) > walletInfo.restoreHeight) { + // _setListeners(walletInfo.restoreHeight, chainTip: currentChainTip); + // } } catch (e) { print(e.toString()); syncStatus = FailedSyncStatus(); @@ -197,7 +275,47 @@ abstract class ElectrumWalletBase for (final utx in unspentCoins) { if (utx.isSending) { allInputsAmount += utx.value; - inputs.add(utx); + leftAmount = leftAmount - utx.value; + + if (utx.bitcoinAddressRecord.silentPaymentTweak != null) { + // final d = ECPrivate.fromHex(walletAddresses.primarySilentAddress!.spendPrivkey.toHex()) + // .tweakAdd(utx.bitcoinAddressRecord.silentPaymentTweak!)!; + + // inputPrivKeys.add(bitcoin.PrivateKeyInfo(d, true)); + // address = bitcoin.P2trAddress(address: utx.address, networkType: networkType); + // keyPairs.add(bitcoin.ECPair.fromPrivateKey(d.toCompressedHex().fromHex, + // compressed: true, network: networkType)); + // scriptType = bitcoin.AddressType.p2tr; + // script = bitcoin.P2trAddress(pubkey: d.publicKey.toHex(), networkType: networkType) + // .scriptPubkey + // .toBytes(); + } + + final address = _addressTypeFromStr(utx.address, network); + final privkey = generateECPrivate( + hd: utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, + index: utx.bitcoinAddressRecord.index, + network: network); + + privateKeys.add(privkey); + + utxos.add( + UtxoWithAddress( + utxo: BitcoinUtxo( + txHash: utx.hash, + value: BigInt.from(utx.value), + vout: utx.vout, + scriptType: _getScriptType(address), + ), + ownerDetails: + UtxoAddressDetails(publicKey: privkey.getPublic().toHex(), address: address), + ), + ); + + bool amountIsAcquired = !sendAll && leftAmount <= 0; + if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) { + break; + } } } @@ -344,20 +462,83 @@ abstract class ElectrumWalletBase txb.sign(vin: i, keyPair: keyPair, witnessValue: witnessValue); } - return PendingBitcoinTransaction(txb.build(), type, - electrumClient: electrumClient, amount: amount, fee: fee) - ..addListener((transaction) async { - transactionHistory.addOne(transaction); - await updateBalance(); + if (SilentPaymentAddress.regex.hasMatch(outputAddress)) { + // final outpointsHash = SilentPayment.hashOutpoints(outpoints); + // final generatedOutputs = SilentPayment.generateMultipleRecipientPubkeys(inputPrivKeys, + // outpointsHash, SilentPaymentDestination.fromAddress(outputAddress, outputAmount!)); + + // generatedOutputs.forEach((recipientSilentAddress, generatedOutput) { + // generatedOutput.forEach((output) { + // outputs.add(BitcoinOutputDetails( + // address: P2trAddress( + // program: ECPublic.fromHex(output.$1.toHex()).toTapPoint(), + // networkType: networkType), + // value: BigInt.from(output.$2), + // )); + // }); + // }); + } + + outputAddresses.add(address); + + if (hasMultiDestination) { + if (out.sendAll || out.formattedCryptoAmount! <= 0) { + throw BitcoinTransactionWrongBalanceException(currency); + } + + final outputAmount = out.formattedCryptoAmount!; + credentialsAmount += outputAmount; + + outputs.add(BitcoinOutput(address: address, value: BigInt.from(outputAmount))); + } else { + if (!sendAll) { + final outputAmount = out.formattedCryptoAmount!; + credentialsAmount += outputAmount; + outputs.add(BitcoinOutput(address: address, value: BigInt.from(outputAmount))); + } else { + // The value will be changed after estimating the Tx size and deducting the fee from the total + outputs.add(BitcoinOutput(address: address, value: BigInt.from(0))); + } + } + } + + final estimatedTx = await _estimateTxFeeAndInputsToUse( + credentialsAmount, sendAll, outputAddresses, outputs, transactionCredentials); + + final txb = BitcoinTransactionBuilder( + utxos: estimatedTx.utxos, + outputs: outputs, + fee: BigInt.from(estimatedTx.fee), + network: network); + + final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { + final key = estimatedTx.privateKeys + .firstWhereOrNull((element) => element.getPublic().toHex() == publicKey); + + if (key == null) { + throw Exception("Cannot find private key"); + } + + if (utxo.utxo.isP2tr()) { + return key.signTapRoot(txDigest, sighash: sighash); + } else { + return key.signInput(txDigest, sigHash: sighash); + } }); } String toJSON() => json.encode({ 'mnemonic': mnemonic, - 'account_index': walletAddresses.currentReceiveAddressIndex.toString(), - 'change_address_index': walletAddresses.currentChangeAddressIndex.toString(), - 'addresses': walletAddresses.addresses.map((addr) => addr.toJSON()).toList(), - 'balance': balance[currency]?.toJSON() + 'account_index': walletAddresses.currentReceiveAddressIndexByType, + 'change_address_index': walletAddresses.currentChangeAddressIndexByType, + 'addresses': walletAddresses.allAddresses.map((addr) => addr.toJSON()).toList(), + 'address_page_type': walletInfo.addressPageType == null + ? SegwitAddresType.p2wpkh.toString() + : walletInfo.addressPageType.toString(), + 'balance': balance[currency]?.toJSON(), + 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(), + 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(), + 'network_type': network == BitcoinNetwork.testnet ? 'testnet' : 'mainnet', }); int feeRate(TransactionPriority priority) { @@ -461,21 +642,38 @@ abstract class ElectrumWalletBase generateKeyPair(hd: hd, index: index, network: networkType); @override - Future rescan({required int height}) async => throw UnimplementedError(); + Future rescan({required int height, int? chainTip, ScanData? scanData}) async { + // _setListeners(height); + } @override Future close() async { try { await electrumClient.close(); } catch (_) {} + _autoSaveTimer?.cancel(); } Future makePath() async => pathForWallet(name: walletInfo.name, type: walletInfo.type); Future updateUnspent() async { - final unspent = await Future.wait(walletAddresses.addresses.map((address) => electrumClient - .getListUnspentWithAddress(address.address, networkType) - .then((unspent) => unspent.map((unspent) { + // Update unspents stored from scanned silent payment transactions + transactionHistory.transactions.values.forEach((tx) { + if (tx.unspent != null) { + if (!unspentCoins + .any((utx) => utx.hash.contains(tx.unspent!.hash) && utx.vout == tx.unspent!.vout)) { + unspentCoins.add(tx.unspent!); + } + } + }); + + List updatedUnspentCoins = []; + + final addressesSet = walletAddresses.allAddresses.map((addr) => addr.address).toSet(); + + await Future.wait(walletAddresses.allAddresses.map((address) => electrumClient + .getListUnspentWithAddress(address.address, network) + .then((unspent) => Future.forEach>(unspent, (unspent) async { try { return BitcoinUnspent.fromJSON(address, unspent); } catch (_) { @@ -495,8 +693,10 @@ abstract class ElectrumWalletBase if (unspentCoins.isNotEmpty) { unspentCoins.forEach((coin) { - final coinInfoList = unspentCoinsInfo.values - .where((element) => element.walletId.contains(id) && element.hash.contains(coin.hash)); + final coinInfoList = unspentCoinsInfo.values.where((element) => + element.walletId.contains(id) && + element.hash.contains(coin.hash) && + element.address.contains(coin.address)); if (coinInfoList.isNotEmpty) { final coinInfo = coinInfoList.first; @@ -513,6 +713,7 @@ abstract class ElectrumWalletBase await _refreshUnspentCoinsInfo(); } + @action Future _addCoinInfo(BitcoinUnspent coin) async { final newInfo = UnspentCoinsInfo( walletId: id, @@ -569,7 +770,22 @@ abstract class ElectrumWalletBase ins.add(tx); } - return ElectrumTransactionBundle(original, ins: ins, time: time, confirmations: confirmations); + final original = BtcTransaction.fromRaw(transactionHex); + final ins = []; + + for (final vin in original.inputs) { + try { + final id = HEX.encode(HEX.decode(vin.txId).reversed.toList()); + final txHex = await electrumClient.getTransactionHex(hash: id); + final tx = BtcTransaction.fromRaw(txHex); + ins.add(tx); + } catch (_) { + ins.add(BtcTransaction.fromRaw(await electrumClient.getTransactionHex(hash: vin.txId))); + } + } + + return ElectrumTransactionBundle(original, + ins: ins, time: time, confirmations: confirmations, height: height); } Future fetchTransactionInfo( @@ -666,7 +882,7 @@ abstract class ElectrumWalletBase } } - void _subscribeForUpdates() { + void _subscribeForUpdates() async { scriptHashes.forEach((sh) async { await _scripthashesUpdateSubject[sh]?.close(); _scripthashesUpdateSubject[sh] = electrumClient.scripthashUpdate(sh); @@ -685,6 +901,23 @@ abstract class ElectrumWalletBase } }); }); + + await _chainTipUpdateSubject?.close(); + _chainTipUpdateSubject = electrumClient.chainTipUpdate(); + _chainTipUpdateSubject?.listen((_) async { + try { + final currentHeight = await electrumClient.getCurrentBlockChainTip(); + if (currentHeight != null) walletInfo.restoreHeight = currentHeight; + // _setListeners(walletInfo.restoreHeight, chainTip: currentHeight); + } catch (e, s) { + print(e.toString()); + _onError?.call(FlutterErrorDetails( + exception: e, + stack: s, + library: this.runtimeType.toString(), + )); + } + }); } Future _fetchBalances() async { @@ -698,20 +931,25 @@ abstract class ElectrumWalletBase } var totalFrozen = 0; + var totalConfirmed = 0; + var totalUnconfirmed = 0; + + // Add values from unspent coins that are not fetched by the address list + // i.e. scanned silent payments unspentCoinsInfo.values.forEach((info) { unspentCoins.forEach((element) { if (element.hash == info.hash && - info.isFrozen && element.bitcoinAddressRecord.address == info.address && element.value == info.value) { - totalFrozen += element.value; + if (info.isFrozen) totalFrozen += element.value; + if (element.bitcoinAddressRecord.silentPaymentTweak != null) { + totalConfirmed += element.value; + } } }); }); final balances = await Future.wait(balanceFutures); - var totalConfirmed = 0; - var totalUnconfirmed = 0; for (var i = 0; i < balances.length; i++) { final addressRecord = addresses[i]; @@ -758,4 +996,426 @@ abstract class ElectrumWalletBase final HD = index == null ? hd : hd.derive(index); return base64Encode(HD.signMessage(message)); } + + Future _setInitialHeight() async { + if (walletInfo.isRecovery) { + return; + } + + if (walletInfo.restoreHeight == 0) { + final currentHeight = await electrumClient.getCurrentBlockChainTip(); + if (currentHeight != null) walletInfo.restoreHeight = currentHeight; + } + } +} + +class ScanData { + final SendPort sendPort; + final SilentPaymentReceiver primarySilentAddress; + final int height; + final String node; + final bitcoin.NetworkType networkType; + final int chainTip; + final ElectrumClient electrumClient; + final List transactionHistoryIds; + final Map labels; + + ScanData({ + required this.sendPort, + required this.primarySilentAddress, + required this.height, + required this.node, + required this.networkType, + required this.chainTip, + required this.electrumClient, + required this.transactionHistoryIds, + required this.labels, + }); + + factory ScanData.fromHeight(ScanData scanData, int newHeight) { + return ScanData( + sendPort: scanData.sendPort, + primarySilentAddress: scanData.primarySilentAddress, + height: newHeight, + node: scanData.node, + networkType: scanData.networkType, + chainTip: scanData.chainTip, + transactionHistoryIds: scanData.transactionHistoryIds, + electrumClient: scanData.electrumClient, + labels: scanData.labels, + ); + } +} + +class SyncResponse { + final int height; + final SyncStatus syncStatus; + + SyncResponse(this.height, this.syncStatus); +} + +// Future startRefresh(ScanData scanData) async { +// var cachedBlockchainHeight = scanData.chainTip; + +// Future getNodeHeightOrUpdate(int baseHeight) async { +// if (cachedBlockchainHeight < baseHeight || cachedBlockchainHeight == 0) { +// final electrumClient = scanData.electrumClient; +// if (!electrumClient.isConnected) { +// final node = scanData.node; +// await electrumClient.connectToUri(Uri.parse(node)); +// } + +// cachedBlockchainHeight = +// await electrumClient.getCurrentBlockChainTip() ?? cachedBlockchainHeight; +// } + +// return cachedBlockchainHeight; +// } + +// var lastKnownBlockHeight = 0; +// var initialSyncHeight = 0; + +// var syncHeight = scanData.height; +// var currentChainTip = scanData.chainTip; + +// if (syncHeight <= 0) { +// syncHeight = currentChainTip; +// } + +// if (initialSyncHeight <= 0) { +// initialSyncHeight = syncHeight; +// } + +// if (lastKnownBlockHeight == syncHeight) { +// scanData.sendPort.send(SyncResponse(currentChainTip, SyncedSyncStatus())); +// return; +// } + +// // Run this until no more blocks left to scan txs. At first this was recursive +// // i.e. re-calling the startRefresh function but this was easier for the above values to retain +// // their initial values +// while (true) { +// lastKnownBlockHeight = syncHeight; + +// final syncingStatus = +// SyncingSyncStatus.fromHeightValues(currentChainTip, initialSyncHeight, syncHeight); +// scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); + +// if (syncingStatus.blocksLeft <= 0) { +// scanData.sendPort.send(SyncResponse(currentChainTip, SyncedSyncStatus())); +// return; +// } + +// // print(["Scanning from height:", syncHeight]); + +// try { +// final networkPath = +// scanData.networkType.network == bitcoin.BtcNetwork.mainnet ? "" : "/testnet"; + +// // This endpoint gets up to 10 latest blocks from the given height +// final tenNewestBlocks = +// (await http.get(Uri.parse("https://blockstream.info$networkPath/api/blocks/$syncHeight"))) +// .body; +// var decodedBlocks = json.decode(tenNewestBlocks) as List; + +// decodedBlocks.sort((a, b) => (a["height"] as int).compareTo(b["height"] as int)); +// decodedBlocks = +// decodedBlocks.where((element) => (element["height"] as int) >= syncHeight).toList(); + +// // for each block, get up to 25 txs +// for (var i = 0; i < decodedBlocks.length; i++) { +// final blockJson = decodedBlocks[i]; +// final blockHash = blockJson["id"]; +// final txCount = blockJson["tx_count"] as int; + +// // print(["Scanning block index:", i, "with tx count:", txCount]); + +// int startIndex = 0; +// // go through each tx in block until no more txs are left +// while (startIndex < txCount) { +// // This endpoint gets up to 25 txs from the given block hash and start index +// final twentyFiveTxs = json.decode((await http.get(Uri.parse( +// "https://blockstream.info$networkPath/api/block/$blockHash/txs/$startIndex"))) +// .body) as List; + +// // print(["Scanning txs index:", startIndex]); + +// // For each tx, apply silent payment filtering and do shared secret calculation when applied +// for (var i = 0; i < twentyFiveTxs.length; i++) { +// try { +// final tx = twentyFiveTxs[i]; +// final txid = tx["txid"] as String; + +// // print(["Scanning tx:", txid]); + +// // TODO: if tx already scanned & stored skip +// // if (scanData.transactionHistoryIds.contains(txid)) { +// // // already scanned tx, continue to next tx +// // pos++; +// // continue; +// // } + +// List pubkeys = []; +// List outpoints = []; + +// bool skip = false; + +// for (var i = 0; i < (tx["vin"] as List).length; i++) { +// final input = tx["vin"][i]; +// final prevout = input["prevout"]; +// final scriptPubkeyType = prevout["scriptpubkey_type"]; +// String? pubkey; + +// if (scriptPubkeyType == "v0_p2wpkh" || scriptPubkeyType == "v1_p2tr") { +// final witness = input["witness"]; +// if (witness == null) { +// skip = true; +// // print("Skipping, no witness"); +// break; +// } + +// if (witness.length == 2) { +// pubkey = witness[1] as String; +// } else if (witness.length == 1) { +// pubkey = "02" + (prevout["scriptpubkey"] as String).fromHex.sublist(2).hex; +// } +// } + +// if (scriptPubkeyType == "p2pkh") { +// pubkey = bitcoin.P2pkhAddress( +// scriptSig: bitcoin.Script.fromRaw(hexData: input["scriptsig"] as String)) +// .pubkey; +// } + +// if (pubkey == null) { +// skip = true; +// // print("Skipping, invalid witness"); +// break; +// } + +// pubkeys.add(pubkey); +// outpoints.add( +// bitcoin.Outpoint(txid: input["txid"] as String, index: input["vout"] as int)); +// } + +// if (skip) { +// // skipped tx, continue to next tx +// continue; +// } + +// Map outpointsByP2TRpubkey = {}; +// for (var i = 0; i < (tx["vout"] as List).length; i++) { +// final output = tx["vout"][i]; +// if (output["scriptpubkey_type"] != "v1_p2tr") { +// // print("Skipping, not a v1_p2tr output"); +// continue; +// } + +// final script = (output["scriptpubkey"] as String).fromHex; + +// // final alreadySpentOutput = (await electrumClient.getHistory( +// // scriptHashFromScript(script, networkType: scanData.networkType))) +// // .length > +// // 1; + +// // if (alreadySpentOutput) { +// // print("Skipping, invalid witness"); +// // break; +// // } + +// final p2tr = bitcoin.P2trAddress( +// program: script.sublist(2).hex, networkType: scanData.networkType); +// final address = p2tr.address; + +// print(["Verifying taproot address:", address]); + +// outpointsByP2TRpubkey[script.sublist(2).hex] = +// bitcoin.Outpoint(txid: txid, index: i, value: output["value"] as int); +// } + +// if (pubkeys.isEmpty || outpoints.isEmpty || outpointsByP2TRpubkey.isEmpty) { +// // skipped tx, continue to next tx +// continue; +// } + +// final outpointHash = bitcoin.SilentPayment.hashOutpoints(outpoints); + +// final result = bitcoin.scanOutputs( +// scanData.primarySilentAddress.scanPrivkey, +// scanData.primarySilentAddress.spendPubkey, +// bitcoin.getSumInputPubKeys(pubkeys), +// outpointHash, +// outpointsByP2TRpubkey.keys.map((e) => e.fromHex).toList(), +// labels: scanData.labels, +// ); + +// if (result.isEmpty) { +// // no results tx, continue to next tx +// continue; +// } + +// if (result.length > 1) { +// print("MULTIPLE UNSPENT COINS FOUND!"); +// } else { +// print("UNSPENT COIN FOUND!"); +// } + +// result.forEach((key, value) async { +// final outpoint = outpointsByP2TRpubkey[key]; + +// if (outpoint == null) { +// return; +// } + +// final tweak = value[0]; +// String? label; +// if (value.length > 1) label = value[1]; + +// final txInfo = ElectrumTransactionInfo( +// WalletType.bitcoin, +// id: txid, +// height: syncHeight, +// amount: outpoint.value!, +// fee: 0, +// direction: TransactionDirection.incoming, +// isPending: false, +// date: DateTime.fromMillisecondsSinceEpoch((blockJson["timestamp"] as int) * 1000), +// confirmations: currentChainTip - syncHeight, +// to: bitcoin.SilentPaymentAddress.createLabeledSilentPaymentAddress( +// scanData.primarySilentAddress.scanPubkey, +// scanData.primarySilentAddress.spendPubkey, +// label != null ? label.fromHex : "0".fromHex, +// hrp: scanData.primarySilentAddress.hrp, +// version: scanData.primarySilentAddress.version) +// .toString(), +// unspent: null, +// ); + +// final status = json.decode((await http +// .get(Uri.parse("https://blockstream.info/testnet/api/tx/$txid/outspends"))) +// .body) as List; + +// bool spent = false; +// for (final s in status) { +// if ((s["spent"] as bool) == true) { +// spent = true; + +// scanData.sendPort.send({txid: txInfo}); + +// final sentTxId = s["txid"] as String; +// final sentTx = json.decode((await http +// .get(Uri.parse("https://blockstream.info/testnet/api/tx/$sentTxId"))) +// .body); + +// int amount = 0; +// for (final out in (sentTx["vout"] as List)) { +// amount += out["value"] as int; +// } + +// final height = s["status"]["block_height"] as int; + +// scanData.sendPort.send({ +// sentTxId: ElectrumTransactionInfo( +// WalletType.bitcoin, +// id: sentTxId, +// height: height, +// amount: amount, +// fee: 0, +// direction: TransactionDirection.outgoing, +// isPending: false, +// date: DateTime.fromMillisecondsSinceEpoch( +// (s["status"]["block_time"] as int) * 1000), +// confirmations: currentChainTip - height, +// ) +// }); +// } +// } + +// if (spent) { +// return; +// } + +// final unspent = BitcoinUnspent( +// BitcoinAddressRecord( +// bitcoin.P2trAddress(program: key, networkType: scanData.networkType).address, +// index: 0, +// isHidden: true, +// isUsed: true, +// silentAddressLabel: null, +// silentPaymentTweak: tweak, +// type: bitcoin.AddressType.p2tr, +// ), +// txid, +// outpoint.value!, +// outpoint.index, +// silentPaymentTweak: tweak, +// type: bitcoin.AddressType.p2tr, +// ); + +// // found utxo for tx, send unspent coin to main isolate +// scanData.sendPort.send(unspent); + +// // also send tx data for tx history +// txInfo.unspent = unspent; +// scanData.sendPort.send({txid: txInfo}); +// }); +// } catch (_) {} +// } + +// // Finished scanning batch of txs in block, add 25 to start index and continue to next block in loop +// startIndex += 25; +// } + +// // Finished scanning block, add 1 to height and continue to next block in loop +// syncHeight += 1; +// currentChainTip = await getNodeHeightOrUpdate(syncHeight); +// scanData.sendPort.send(SyncResponse(syncHeight, +// SyncingSyncStatus.fromHeightValues(currentChainTip, initialSyncHeight, syncHeight))); +// } +// } catch (e, stacktrace) { +// print(stacktrace); +// print(e.toString()); + +// scanData.sendPort.send(SyncResponse(syncHeight, NotConnectedSyncStatus())); +// break; +// } +// } +// } + +class EstimatedTxResult { + EstimatedTxResult( + {required this.utxos, required this.privateKeys, required this.fee, required this.amount}); + + final List utxos; + final List privateKeys; + final int fee; + final int amount; +} + +BitcoinBaseAddress _addressTypeFromStr(String address, BasedUtxoNetwork network) { + if (P2pkhAddress.regex.hasMatch(address)) { + return P2pkhAddress.fromAddress(address: address, network: network); + } else if (P2shAddress.regex.hasMatch(address)) { + return P2shAddress.fromAddress(address: address, network: network); + } else if (P2wshAddress.regex.hasMatch(address)) { + return P2wshAddress.fromAddress(address: address, network: network); + } else if (P2trAddress.regex.hasMatch(address)) { + return P2trAddress.fromAddress(address: address, network: network); + } else { + return P2wpkhAddress.fromAddress(address: address, network: network); + } +} + +BitcoinAddressType _getScriptType(BitcoinBaseAddress type) { + if (type is P2pkhAddress) { + return P2pkhAddressType.p2pkh; + } else if (type is P2shAddress) { + return P2shAddressType.p2wpkhInP2sh; + } else if (type is P2wshAddress) { + return SegwitAddresType.p2wsh; + } else if (type is P2trAddress) { + return SegwitAddresType.p2tr; + } else { + return SegwitAddresType.p2wpkh; + } } diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index 850d58f40..934f72b09 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -1,8 +1,6 @@ import 'package:bitbox/bitbox.dart' as bitbox; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; import 'package:cw_bitcoin/bitcoin_address_record.dart'; -import 'package:cw_bitcoin/electrum.dart'; -import 'package:cw_bitcoin/script_hash.dart'; import 'package:cw_core/wallet_addresses.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; @@ -13,24 +11,39 @@ part 'electrum_wallet_addresses.g.dart'; class ElectrumWalletAddresses = ElectrumWalletAddressesBase with _$ElectrumWalletAddresses; abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { - ElectrumWalletAddressesBase(WalletInfo walletInfo, - {required this.mainHd, - required this.sideHd, - required this.electrumClient, - required this.networkType, - List? initialAddresses, - int initialRegularAddressIndex = 0, - int initialChangeAddressIndex = 0}) - : addresses = ObservableList.of((initialAddresses ?? []).toSet()), + ElectrumWalletAddressesBase( + WalletInfo walletInfo, { + required this.mainHd, + required this.sideHd, + required this.network, + List? initialAddresses, + Map? initialRegularAddressIndex, + Map? initialChangeAddressIndex, + List? initialSilentAddresses, + int initialSilentAddressIndex = 0, + SilentPaymentOwner? silentAddress, + }) : _addresses = ObservableList.of((initialAddresses ?? []).toSet()), + primarySilentAddress = silentAddress, + addressesByReceiveType = + ObservableList.of(([]).toSet()), receiveAddresses = ObservableList.of((initialAddresses ?? []) .where((addressRecord) => !addressRecord.isHidden && !addressRecord.isUsed) .toSet()), changeAddresses = ObservableList.of((initialAddresses ?? []) .where((addressRecord) => addressRecord.isHidden && !addressRecord.isUsed) .toSet()), - currentReceiveAddressIndex = initialRegularAddressIndex, - currentChangeAddressIndex = initialChangeAddressIndex, - super(walletInfo); + currentReceiveAddressIndexByType = initialRegularAddressIndex ?? {}, + currentChangeAddressIndexByType = initialChangeAddressIndex ?? {}, + _addressPageType = walletInfo.addressPageType != null + ? BitcoinAddressType.fromValue(walletInfo.addressPageType!) + : SegwitAddresType.p2wpkh, + silentAddresses = ObservableList.of((initialSilentAddresses ?? []) + .where((addressRecord) => addressRecord.silentPaymentTweak != null) + .toSet()), + currentSilentAddressIndex = initialSilentAddressIndex, + super(walletInfo) { + updateAddressesByMatch(); + } static const defaultReceiveAddressesCount = 22; static const defaultChangeAddressesCount = 17; @@ -43,18 +56,52 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { final ObservableList addresses; final ObservableList receiveAddresses; final ObservableList changeAddresses; - final ElectrumClient electrumClient; - final bitcoin.NetworkType networkType; + final ObservableList silentAddresses; + final BasedUtxoNetwork network; final bitcoin.HDWallet mainHd; final bitcoin.HDWallet sideHd; + final SilentPaymentOwner? primarySilentAddress; + + @observable + BitcoinAddressType _addressPageType = SegwitAddresType.p2wpkh; + + @computed + BitcoinAddressType get addressPageType => _addressPageType; + + @observable + String? activeSilentAddress; + + @computed + List get allAddresses => _addresses; + @override @computed String get address { - if (isEnabledAutoGenerateSubaddress) { - if (receiveAddresses.isEmpty) { - final newAddress = generateNewAddress(hd: mainHd).address; - return walletInfo.type == WalletType.bitcoinCash ? toCashAddr(newAddress) : newAddress; + if (addressPageType == SilentPaymentsAddresType.p2sp) { + if (activeSilentAddress != null) { + return activeSilentAddress!; + } + + return primarySilentAddress!.toString(); + } + + String receiveAddress; + + final typeMatchingReceiveAddresses = receiveAddresses.where(_isAddressPageTypeMatch); + + if ((isEnabledAutoGenerateSubaddress && receiveAddresses.isEmpty) || + typeMatchingReceiveAddresses.isEmpty) { + receiveAddress = generateNewAddress().address; + } else { + final previousAddressMatchesType = + previousAddressRecord != null && previousAddressRecord!.type == addressPageType; + + if (previousAddressMatchesType && + typeMatchingReceiveAddresses.first.address != addressesByReceiveType.first.address) { + receiveAddress = previousAddressRecord!.address; + } else { + receiveAddress = typeMatchingReceiveAddresses.first.address; } final receiveAddress = receiveAddresses.first.address; @@ -78,6 +125,11 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { @override set address(String addr) { + if (addressPageType == SilentPaymentsAddresType.p2sp) { + activeSilentAddress = addr; + return; + } + if (addr.startsWith('bitcoincash:')) { addr = toLegacy(addr); } @@ -94,6 +146,8 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { int currentReceiveAddressIndex; int currentChangeAddressIndex; + int currentSilentAddressIndex; + @observable BitcoinAddressRecord? previousAddressRecord; @@ -157,8 +211,45 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return address; } - BitcoinAddressRecord generateNewAddress({bitcoin.HDWallet? hd, String? label}) { - final isHidden = hd == sideHd; + Map get labels { + final labels = {}; + for (int i = 0; i < silentAddresses.length; i++) { + final silentAddressRecord = silentAddresses[i]; + final silentAddress = + SilentPaymentDestination.fromAddress(silentAddressRecord.address, 0).spendPubkey.toHex(); + + if (silentAddressRecord.silentPaymentTweak != null) + labels[silentAddress] = silentAddressRecord.silentPaymentTweak!; + } + return labels; + } + + BitcoinAddressRecord generateNewAddress({String label = ''}) { + if (addressPageType == SilentPaymentsAddresType.p2sp) { + currentSilentAddressIndex += 1; + + final tweak = BigInt.from(currentSilentAddressIndex); + + final address = BitcoinAddressRecord( + SilentPaymentAddress.createLabeledSilentPaymentAddress( + primarySilentAddress!.scanPubkey, primarySilentAddress!.spendPubkey, tweak, + hrp: primarySilentAddress!.hrp, version: primarySilentAddress!.version) + .toString(), + index: currentSilentAddressIndex, + isHidden: false, + name: label, + silentPaymentTweak: tweak.toString(), + network: network, + type: SilentPaymentsAddresType.p2sp, + ); + + silentAddresses.add(address); + + return address; + } + + final newAddressIndex = addressesByReceiveType.fold( + 0, (int acc, addressRecord) => addressRecord.isHidden == false ? acc + 1 : acc); final newAddressIndex = addresses.fold( 0, (int acc, addressRecord) => isHidden == addressRecord.isHidden ? acc + 1 : acc); diff --git a/cw_bitcoin/lib/electrum_wallet_snapshot.dart b/cw_bitcoin/lib/electrum_wallet_snapshot.dart index 86d3e2fed..4b9ccee0b 100644 --- a/cw_bitcoin/lib/electrum_wallet_snapshot.dart +++ b/cw_bitcoin/lib/electrum_wallet_snapshot.dart @@ -12,9 +12,14 @@ class ElectrumWallletSnapshot { required this.password, required this.mnemonic, required this.addresses, + required this.silentAddresses, required this.balance, required this.regularAddressIndex, - required this.changeAddressIndex}); + required this.changeAddressIndex, + required this.addressPageType, + required this.network, + required this.silentAddressIndex, + }); final String name; final String password; @@ -22,29 +27,52 @@ class ElectrumWallletSnapshot { String mnemonic; List addresses; + List silentAddresses; ElectrumBalance balance; - int regularAddressIndex; - int changeAddressIndex; + Map regularAddressIndex; + Map changeAddressIndex; + int silentAddressIndex; - static Future load(String name, WalletType type, String password) async { + static Future load( + String name, WalletType type, String password, BasedUtxoNetwork? network) async { final path = await pathForWallet(name: name, type: type); final jsonSource = await read(path: path, password: password); final data = json.decode(jsonSource) as Map; - final addressesTmp = data['addresses'] as List? ?? []; final mnemonic = data['mnemonic'] as String; + + final addressesTmp = data['addresses'] as List? ?? []; final addresses = addressesTmp .whereType() - .map((addr) => BitcoinAddressRecord.fromJSON(addr)) + .map((addr) => BitcoinAddressRecord.fromJSON(addr, network: network)) .toList(); + + final silentAddressesTmp = data['silent_addresses'] as List? ?? []; + final silentAddresses = silentAddressesTmp + .whereType() + .map((addr) => BitcoinAddressRecord.fromJSON(addr, network: network)) + .toList(); + final balance = ElectrumBalance.fromJSON(data['balance'] as String) ?? ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); - var regularAddressIndex = 0; - var changeAddressIndex = 0; + var regularAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; + var changeAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; + var silentAddressIndex = 0; try { - regularAddressIndex = int.parse(data['account_index'] as String? ?? '0'); - changeAddressIndex = int.parse(data['change_address_index'] as String? ?? '0'); - } catch (_) {} + regularAddressIndexByType = { + SegwitAddresType.p2wpkh.toString(): int.parse(data['account_index'] as String? ?? '0') + }; + changeAddressIndexByType = { + SegwitAddresType.p2wpkh.toString(): + int.parse(data['change_address_index'] as String? ?? '0') + }; + silentAddressIndex = int.parse(data['silent_address_index'] as String? ?? '0'); + } catch (_) { + try { + regularAddressIndexByType = data["account_index"] as Map? ?? {}; + changeAddressIndexByType = data["change_address_index"] as Map? ?? {}; + } catch (_) {} + } return ElectrumWallletSnapshot( name: name, @@ -52,8 +80,13 @@ class ElectrumWallletSnapshot { password: password, mnemonic: mnemonic, addresses: addresses, + silentAddresses: silentAddresses, balance: balance, - regularAddressIndex: regularAddressIndex, - changeAddressIndex: changeAddressIndex); + regularAddressIndex: regularAddressIndexByType, + changeAddressIndex: changeAddressIndexByType, + addressPageType: data['address_page_type'] as String? ?? SegwitAddresType.p2wpkh.toString(), + network: data['network_type'] == 'testnet' ? BitcoinNetwork.testnet : BitcoinNetwork.mainnet, + silentAddressIndex: silentAddressIndex, + ); } } diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index 3344cb807..ba4c06968 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -75,6 +75,15 @@ packages: url: "https://github.com/cake-tech/bitbox-flutter.git" source: git version: "1.0.1" + bitcoin_base: + dependency: "direct main" + description: + path: "." + ref: cake-update-v2 + resolved-ref: e4686da77cace5400697de69f7885020297cb900 + url: "https://github.com/cake-tech/bitcoin_base.git" + source: git + version: "4.0.0" bitcoin_flutter: dependency: "direct main" description: @@ -84,6 +93,15 @@ packages: url: "https://github.com/cake-tech/bitcoin_flutter.git" source: git version: "2.1.0" + blockchain_utils: + dependency: transitive + description: + path: "." + ref: cake-update-v1 + resolved-ref: b4db705d5409aba30730818ed0d3c09aac5b9992 + url: "https://github.com/rafael-xmr/blockchain_utils" + source: git + version: "1.6.0" boolean_selector: dependency: transitive description: diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index a50ff68ad..b6acab7f4 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -30,6 +30,10 @@ dependencies: rxdart: ^0.27.5 unorm_dart: ^0.2.0 cryptography: ^2.0.5 + bitcoin_base: + git: + url: https://github.com/cake-tech/bitcoin_base.git + ref: cake-update-v2 dev_dependencies: flutter_test: diff --git a/cw_bitcoin_cash/pubspec.yaml b/cw_bitcoin_cash/pubspec.yaml index 49a5efb15..7130b3c58 100644 --- a/cw_bitcoin_cash/pubspec.yaml +++ b/cw_bitcoin_cash/pubspec.yaml @@ -29,7 +29,10 @@ dependencies: git: url: https://github.com/cake-tech/bitbox-flutter.git ref: master - bitcoin_base: ^3.0.1 + bitcoin_base: + git: + url: https://github.com/cake-tech/bitcoin_base.git + ref: cake-update-v2 diff --git a/cw_core/lib/sync_status.dart b/cw_core/lib/sync_status.dart index 4983967d0..afddc7c7a 100644 --- a/cw_core/lib/sync_status.dart +++ b/cw_core/lib/sync_status.dart @@ -14,6 +14,16 @@ class SyncingSyncStatus extends SyncStatus { @override String toString() => '$blocksLeft'; + + factory SyncingSyncStatus.fromHeightValues(int chainTip, int initialSyncHeight, int syncHeight) { + final track = chainTip - initialSyncHeight; + final diff = track - (chainTip - syncHeight); + final ptc = diff <= 0 ? 0.0 : diff / track; + final left = chainTip - syncHeight; + + // sum 1 because if at the chain tip, will say "0 blocks left" + return SyncingSyncStatus(left + 1, ptc); + } } class SyncedSyncStatus extends SyncStatus { @@ -51,4 +61,6 @@ class ConnectedSyncStatus extends SyncStatus { class LostConnectionSyncStatus extends SyncStatus { @override double progress() => 1.0; -} \ No newline at end of file + @override + String toString() => 'Reconnecting'; +} diff --git a/cw_core/lib/unspent_transaction_output.dart b/cw_core/lib/unspent_transaction_output.dart index b52daf43c..01b26cdcc 100644 --- a/cw_core/lib/unspent_transaction_output.dart +++ b/cw_core/lib/unspent_transaction_output.dart @@ -16,5 +16,6 @@ class Unspent { bool isFrozen; String note; - bool get isP2wpkh => address.startsWith('bc') || address.startsWith('ltc'); + bool get isP2wpkh => + address.startsWith('bc') || address.startsWith('tb') || address.startsWith('ltc'); } diff --git a/howto-build-android.md b/howto-build-android.md index a2a4e4d9f..c3fe415ee 100644 --- a/howto-build-android.md +++ b/howto-build-android.md @@ -142,27 +142,9 @@ Then we need to generate localization files. `$ flutter packages pub run tool/generate_localization.dart` -Lastly, we will generate mobx models for the project. - -Generate mobx models for `cw_core`: - -`cd cw_core && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd ..` - -Generate mobx models for `cw_monero`: - -`cd cw_monero && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd ..` - -Generate mobx models for `cw_bitcoin`: - -`cd cw_bitcoin && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd ..` - -Generate mobx models for `cw_haven`: - -`cd cw_haven && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd ..` - Finally build mobx models for the app: -`$ flutter packages pub run build_runner build --delete-conflicting-outputs` +`$ ./model_generator.sh` ### 9. Build! diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 688825013..73c6ee9b0 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -176,6 +176,173 @@ class CWBitcoin extends Bitcoin { => BitcoinTransactionPriority.slow; @override - TransactionPriority getLitecoinTransactionPrioritySlow() - => LitecoinTransactionPriority.slow; -} \ No newline at end of file + List getWordList() => wordlist; + + @override + Map getWalletKeys(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + final keys = bitcoinWallet.keys; + + return { + 'wif': keys.wif, + 'privateKey': keys.privateKey, + 'publicKey': keys.publicKey + }; + } + + @override + List getTransactionPriorities() => BitcoinTransactionPriority.all; + + @override + List getLitecoinTransactionPriorities() => LitecoinTransactionPriority.all; + + @override + TransactionPriority deserializeBitcoinTransactionPriority(int raw) => + BitcoinTransactionPriority.deserialize(raw: raw); + + @override + TransactionPriority deserializeLitecoinTransactionPriority(int raw) => + LitecoinTransactionPriority.deserialize(raw: raw); + + @override + int getFeeRate(Object wallet, TransactionPriority priority) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.feeRate(priority); + } + + @override + Future generateNewAddress(Object wallet, String label) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.walletAddresses.generateNewAddress(label: label); + await wallet.save(); + } + + @override + Future updateAddress(Object wallet, String address, String label) async { + final bitcoinWallet = wallet as ElectrumWallet; + bitcoinWallet.walletAddresses.updateAddress(address, label); + await wallet.save(); + } + + @override + Object createBitcoinTransactionCredentials(List outputs, + {required TransactionPriority priority, int? feeRate}) => + BitcoinTransactionCredentials( + outputs + .map((out) => OutputInfo( + fiatAmount: out.fiatAmount, + cryptoAmount: out.cryptoAmount, + address: out.address, + note: out.note, + sendAll: out.sendAll, + extractedAddress: out.extractedAddress, + isParsedAddress: out.isParsedAddress, + formattedCryptoAmount: out.formattedCryptoAmount)) + .toList(), + priority: priority as BitcoinTransactionPriority, + feeRate: feeRate); + + @override + Object createBitcoinTransactionCredentialsRaw(List outputs, + {TransactionPriority? priority, required int feeRate}) => + BitcoinTransactionCredentials(outputs, + priority: priority != null ? priority as BitcoinTransactionPriority : null, + feeRate: feeRate); + + @override + List getAddresses(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.addressesByReceiveType + .map((BitcoinAddressRecord addr) => addr.address) + .toList(); + } + + @override + @computed + List getSubAddresses(Object wallet) { + final electrumWallet = wallet as ElectrumWallet; + return electrumWallet.walletAddresses.addressesByReceiveType + .map((BitcoinAddressRecord addr) => ElectrumSubAddress( + id: addr.index, + name: addr.name, + address: electrumWallet.type == WalletType.bitcoinCash ? addr.cashAddr : addr.address, + txCount: addr.txCount, + balance: addr.balance, + isChange: addr.isHidden)) + .toList(); + } + + @override + String getAddress(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.address; + } + + @override + String formatterBitcoinAmountToString({required int amount}) => + bitcoinAmountToString(amount: amount); + + @override + double formatterBitcoinAmountToDouble({required int amount}) => + bitcoinAmountToDouble(amount: amount); + + @override + int formatterStringDoubleToBitcoinAmount(String amount) => stringDoubleToBitcoinAmount(amount); + + @override + String bitcoinTransactionPriorityWithLabel(TransactionPriority priority, int rate) => + (priority as BitcoinTransactionPriority).labelWithRate(rate); + + @override + List getUnspents(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.unspentCoins; + } + + Future updateUnspents(Object wallet) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.updateUnspent(); + } + + WalletService createBitcoinWalletService( + Box walletInfoSource, Box unspentCoinSource) { + return BitcoinWalletService(walletInfoSource, unspentCoinSource); + } + + WalletService createLitecoinWalletService( + Box walletInfoSource, Box unspentCoinSource) { + return LitecoinWalletService(walletInfoSource, unspentCoinSource); + } + + @override + TransactionPriority getBitcoinTransactionPriorityMedium() => BitcoinTransactionPriority.medium; + + @override + TransactionPriority getLitecoinTransactionPriorityMedium() => LitecoinTransactionPriority.medium; + + @override + TransactionPriority getBitcoinTransactionPrioritySlow() => BitcoinTransactionPriority.slow; + + @override + TransactionPriority getLitecoinTransactionPrioritySlow() => LitecoinTransactionPriority.slow; + + @override + Future setAddressType(Object wallet, dynamic option) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.walletAddresses.setAddressType(option as BitcoinAddressType); + } + + @override + BitcoinReceivePageOption getSelectedAddressType(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return BitcoinReceivePageOption.fromType(bitcoinWallet.walletAddresses.addressPageType); + } + + @override + List getBitcoinReceivePageOptions() => BitcoinReceivePageOption.all; + + List getSilentAddresses(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.silentAddresses; + } +} diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 2ae9e3297..0fbdcc940 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -25,7 +25,7 @@ class AddressValidator extends TextValidator { return '^[0-9a-zA-Z]{59}\$|^[0-9a-zA-Z]{92}\$|^[0-9a-zA-Z]{104}\$' '|^[0-9a-zA-Z]{105}\$|^addr1[0-9a-zA-Z]{98}\$'; case CryptoCurrency.btc: - return '^3[0-9a-zA-Z]{32}\$|^3[0-9a-zA-Z]{33}\$|^bc1[0-9a-zA-Z]{59}\$'; + return '^${P2pkhAddress.regex.pattern}\$|^${P2shAddress.regex.pattern}\$|^${P2wpkhAddress.regex.pattern}\$|${P2trAddress.regex.pattern}\$|^${P2wshAddress.regex.pattern}\$|^${bitcoin.SilentPaymentAddress.REGEX.pattern}\$'; case CryptoCurrency.nano: return '[0-9a-zA-Z_]'; case CryptoCurrency.banano: @@ -263,12 +263,12 @@ class AddressValidator extends TextValidator { '|([^0-9a-zA-Z]|^)8[0-9a-zA-Z]{94}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[0-9a-zA-Z]{106}([^0-9a-zA-Z]|\$)'; case CryptoCurrency.btc: - return '([^0-9a-zA-Z]|^)1[0-9a-zA-Z]{32}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)1[0-9a-zA-Z]{33}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)3[0-9a-zA-Z]{32}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)3[0-9a-zA-Z]{33}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)bc1[0-9a-zA-Z]{39}([^0-9a-zA-Z]|\$)' - '|([^0-9a-zA-Z]|^)bc1[0-9a-zA-Z]{59}([^0-9a-zA-Z]|\$)'; + return '([^0-9a-zA-Z]|^)${P2pkhAddress.regex.pattern}|\$)' + '([^0-9a-zA-Z]|^)${P2shAddress.regex.pattern}|\$)' + '([^0-9a-zA-Z]|^)${P2wpkhAddress.regex.pattern}|\$)' + '([^0-9a-zA-Z]|^)${P2wshAddress.regex.pattern}|\$)' + '([^0-9a-zA-Z]|^)${P2trAddress.regex.pattern}|\$)' + '|${bitcoin.SilentPaymentAddress.REGEX.pattern}\$'; case CryptoCurrency.ltc: return '([^0-9a-zA-Z]|^)^L[a-zA-Z0-9]{26,33}([^0-9a-zA-Z]|\$)' '|([^0-9a-zA-Z]|^)[LM][a-km-zA-HJ-NP-Z1-9]{26,33}([^0-9a-zA-Z]|\$)' @@ -290,4 +290,4 @@ class AddressValidator extends TextValidator { return null; } } -} \ No newline at end of file +} diff --git a/lib/core/sync_status_title.dart b/lib/core/sync_status_title.dart index 66094de2b..fbb86fa9f 100644 --- a/lib/core/sync_status_title.dart +++ b/lib/core/sync_status_title.dart @@ -3,7 +3,9 @@ import 'package:cw_core/sync_status.dart'; String syncStatusTitle(SyncStatus syncStatus) { if (syncStatus is SyncingSyncStatus) { - return S.current.Blocks_remaining('${syncStatus.blocksLeft}'); + return syncStatus.blocksLeft == 1 + ? S.current.Block_remaining('${syncStatus.blocksLeft}') + : S.current.Blocks_remaining('${syncStatus.blocksLeft}'); } if (syncStatus is SyncedSyncStatus) { @@ -35,4 +37,4 @@ String syncStatusTitle(SyncStatus syncStatus) { } return ''; -} \ No newline at end of file +} diff --git a/lib/di.dart b/lib/di.dart index 05019a562..d55d413dd 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -228,6 +228,7 @@ import 'package:cake_wallet/src/screens/receive/fullscreen_qr_page.dart'; import 'package:cake_wallet/core/wallet_loading_service.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cake_wallet/entities/qr_view_data.dart'; +import 'package:bitcoin_flutter/bitcoin_flutter.dart' as btc; import 'buy/dfx/dfx_buy_provider.dart'; import 'core/totp_request_details.dart'; @@ -656,6 +657,10 @@ Future setup({ getIt.registerFactory(() { final wallet = getIt.get().wallet!; + // if ((wallet.type == WalletType.bitcoin && + // wallet.walletAddresses.addressPageType == btc.AddressType.p2sp) || + // wallet.type == WalletType.monero || + // wallet.type == WalletType.haven) { if (wallet.type == WalletType.monero || wallet.type == WalletType.haven) { return MoneroAccountListViewModel(wallet); } diff --git a/lib/src/screens/dashboard/widgets/present_receive_option_picker.dart b/lib/src/screens/dashboard/widgets/present_receive_option_picker.dart index 33bceeb5c..64125b145 100644 --- a/lib/src/screens/dashboard/widgets/present_receive_option_picker.dart +++ b/lib/src/screens/dashboard/widgets/present_receive_option_picker.dart @@ -10,8 +10,7 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:cake_wallet/generated/i18n.dart'; class PresentReceiveOptionPicker extends StatelessWidget { - PresentReceiveOptionPicker( - {required this.receiveOptionViewModel, required this.color}); + PresentReceiveOptionPicker({required this.receiveOptionViewModel, required this.color}); final ReceiveOptionViewModel receiveOptionViewModel; final Color color; @@ -43,17 +42,11 @@ class PresentReceiveOptionPicker extends StatelessWidget { Text( S.current.receive, style: TextStyle( - fontSize: 18.0, - fontWeight: FontWeight.bold, - fontFamily: 'Lato', - color: color), + fontSize: 18.0, fontWeight: FontWeight.bold, fontFamily: 'Lato', color: color), ), Observer( - builder: (_) => Text(receiveOptionViewModel.selectedReceiveOption.toString(), - style: TextStyle( - fontSize: 10.0, - fontWeight: FontWeight.w500, - color: color))) + builder: (_) => Text(describeOption(receiveOptionViewModel.selectedReceiveOption), + style: TextStyle(fontSize: 10.0, fontWeight: FontWeight.w500, color: color))) ], ), SizedBox(width: 5), @@ -73,65 +66,68 @@ class PresentReceiveOptionPicker extends StatelessWidget { backgroundColor: Colors.transparent, body: Stack( alignment: AlignmentDirectional.center, - children:[ AlertBackground( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Spacer(), - Container( - margin: EdgeInsets.symmetric(horizontal: 24), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(30), - color: Theme.of(context).colorScheme.background, - ), - child: Padding( - padding: const EdgeInsets.only(top: 24, bottom: 24), - child: (ListView.separated( - padding: EdgeInsets.zero, - shrinkWrap: true, - itemCount: receiveOptionViewModel.options.length, - itemBuilder: (_, index) { - final option = receiveOptionViewModel.options[index]; - return InkWell( - onTap: () { - Navigator.pop(popUpContext); + children: [ + AlertBackground( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Spacer(), + Container( + margin: EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(30), + color: Theme.of(context).colorScheme.background, + ), + child: Padding( + padding: const EdgeInsets.only(top: 24, bottom: 24), + child: (ListView.separated( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: receiveOptionViewModel.options.length, + itemBuilder: (_, index) { + final option = receiveOptionViewModel.options[index]; + return InkWell( + onTap: () { + Navigator.pop(popUpContext); - receiveOptionViewModel.selectReceiveOption(option); - }, - child: Padding( - padding: const EdgeInsets.only(left: 24, right: 24), - child: Observer(builder: (_) { - final value = receiveOptionViewModel.selectedReceiveOption; + receiveOptionViewModel.selectReceiveOption(option); + }, + child: Padding( + padding: const EdgeInsets.only(left: 24, right: 24), + child: Observer(builder: (_) { + final value = receiveOptionViewModel.selectedReceiveOption; - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(option.toString(), - textAlign: TextAlign.left, - style: textSmall( - color: Theme.of(context).extension()!.titleColor, - ).copyWith( - fontWeight: - value == option ? FontWeight.w800 : FontWeight.w500, - )), - RoundedCheckbox( - value: value == option, - ) - ], - ); - }), - ), - ); - }, - separatorBuilder: (_, index) => SizedBox(height: 30), - )), + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(describeOption(option), + textAlign: TextAlign.left, + style: textSmall( + color: Theme.of(context) + .extension()! + .titleColor, + ).copyWith( + fontWeight: + value == option ? FontWeight.w800 : FontWeight.w500, + )), + RoundedCheckbox( + value: value == option, + ) + ], + ); + }), + ), + ); + }, + separatorBuilder: (_, index) => SizedBox(height: 30), + )), + ), ), - ), - Spacer() - ], + Spacer() + ], + ), ), - ), AlertCloseButton(onTap: () => Navigator.of(popUpContext).pop(), bottom: 40) ], ), diff --git a/lib/src/screens/receive/receive_page.dart b/lib/src/screens/receive/receive_page.dart index 75719d123..3f3e546b3 100644 --- a/lib/src/screens/receive/receive_page.dart +++ b/lib/src/screens/receive/receive_page.dart @@ -67,8 +67,7 @@ class ReceivePage extends BasePage { @override Widget Function(BuildContext, Widget) get rootWrapper => - (BuildContext context, Widget scaffold) => - GradientBackground(scaffold: scaffold); + (BuildContext context, Widget scaffold) => GradientBackground(scaffold: scaffold); @override Widget trailing(BuildContext context) { @@ -159,7 +158,8 @@ class ReceivePage extends BasePage { trailingIcon: Icon( Icons.arrow_forward_ios, size: 14, - color: Theme.of(context).extension()!.iconsColor, + color: + Theme.of(context).extension()!.iconsColor, )); } @@ -185,11 +185,19 @@ class ReceivePage extends BasePage { final isCurrent = item.address == addressListViewModel.address.address; final backgroundColor = isCurrent - ? Theme.of(context).extension()!.currentTileBackgroundColor - : Theme.of(context).extension()!.tilesBackgroundColor; + ? Theme.of(context) + .extension()! + .currentTileBackgroundColor + : Theme.of(context) + .extension()! + .tilesBackgroundColor; final textColor = isCurrent - ? Theme.of(context).extension()!.currentTileTextColor - : Theme.of(context).extension()!.tilesTextColor; + ? Theme.of(context) + .extension()! + .currentTileTextColor + : Theme.of(context) + .extension()! + .tilesTextColor; return AddressCell.fromItem(item, isCurrent: isCurrent, @@ -211,6 +219,16 @@ class ReceivePage extends BasePage { child: cell, ); })), + if (!addressListViewModel.hasSilentAddresses) + Padding( + padding: EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Text(S.of(context).electrum_address_disclaimer, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15, + color: + Theme.of(context).extension()!.labelTextColor)), + ), ], ), )) diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index 5f15c9c4d..1c02922f9 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -2,6 +2,7 @@ import 'package:cake_wallet/themes/extensions/keyboard_theme.dart'; import 'package:cake_wallet/entities/priority_for_wallet_type.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cw_core/crypto_currency.dart'; @@ -164,7 +165,7 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin _presentQRScanner(BuildContext context) async { bool isCameraPermissionGranted = - await PermissionHandler.checkPermission(Permission.camera, context); + await PermissionHandler.checkPermission(Permission.camera, context); if (!isCameraPermissionGranted) return; final code = await presentQRScanner(); if (code.isEmpty) { diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index a794c2262..cd8206b32 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -274,6 +274,11 @@ abstract class DashboardViewModelBase with Store { WalletBase, TransactionInfo> wallet; bool get hasRescan => wallet.type == WalletType.monero || wallet.type == WalletType.haven; + // bool get hasRescan => + // (wallet.type == WalletType.bitcoin && + // wallet.walletAddresses.addressPageType == bitcoin.AddressType.p2sp) || + // wallet.type == WalletType.monero || + // wallet.type == WalletType.haven; final KeyService keyService; diff --git a/lib/view_model/rescan_view_model.dart b/lib/view_model/rescan_view_model.dart index c973b7b3f..e263f4a12 100644 --- a/lib/view_model/rescan_view_model.dart +++ b/lib/view_model/rescan_view_model.dart @@ -1,4 +1,5 @@ import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:mobx/mobx.dart'; part 'rescan_view_model.g.dart'; @@ -9,8 +10,8 @@ enum RescanWalletState { rescaning, none } abstract class RescanViewModelBase with Store { RescanViewModelBase(this._wallet) - : state = RescanWalletState.none, - isButtonEnabled = false; + : state = RescanWalletState.none, + isButtonEnabled = false; final WalletBase _wallet; @@ -23,8 +24,8 @@ abstract class RescanViewModelBase with Store { @action Future rescanCurrentWallet({required int restoreHeight}) async { state = RescanWalletState.rescaning; - await _wallet.rescan(height: restoreHeight); - _wallet.transactionHistory.clear(); + _wallet.rescan(height: restoreHeight); + if (_wallet.type != WalletType.bitcoin) _wallet.transactionHistory.clear(); state = RescanWalletState.none; } -} \ No newline at end of file +} 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 9270d1d44..330633e81 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 @@ -167,8 +167,6 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo }) : _baseItems = [], selectedCurrency = walletTypeToCryptoCurrency(appStore.wallet!.type), _cryptoNumberFormat = NumberFormat(_cryptoNumberPattern), - hasAccounts = - appStore.wallet!.type == WalletType.monero || appStore.wallet!.type == WalletType.haven, amount = '', _settingsStore = appStore.settingsStore, super(appStore: appStore) { @@ -180,7 +178,8 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo _init(); selectedCurrency = walletTypeToCryptoCurrency(wallet.type); - hasAccounts = wallet.type == WalletType.monero || wallet.type == WalletType.haven; + _hasAccounts = + hasSilentAddresses || wallet.type == WalletType.monero || wallet.type == WalletType.haven; } static const String _cryptoNumberPattern = '0.00000000'; @@ -339,7 +338,10 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo } @observable - bool hasAccounts; + bool _hasAccounts = false; + + @computed + bool get hasAccounts => _hasAccounts; @computed String get accountLabel { @@ -354,8 +356,21 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo return ''; } + @observable + // ignore: prefer_final_fields + bool? _hasSilentAddresses = null; + + @computed + bool get hasSilentAddresses => _hasSilentAddresses ?? wallet.type == WalletType.bitcoin; + // @computed + // bool get hasSilentAddresses => + // _hasSilentAddresses ?? + // wallet.type == WalletType.bitcoin && + // wallet.walletAddresses.addressPageType == btc.AddressType.p2sp; + @computed bool get hasAddressList => + hasSilentAddresses || wallet.type == WalletType.monero || wallet.type == WalletType.haven || wallet.type == WalletType.bitcoinCash || diff --git a/model_generator.sh b/model_generator.sh index a2b016bb0..fa1ea6fac 100755 --- a/model_generator.sh +++ b/model_generator.sh @@ -1,10 +1,10 @@ -cd cw_core && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_evm && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_monero && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_bitcoin && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_haven && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_nano && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_bitcoin_cash && flutter pub get && flutter packages pub run build_runner build --delete-conflicting-outputs && cd .. -cd cw_ethereum && flutter pub get && cd .. -cd cw_polygon && flutter pub get && cd .. +cd cw_core; flutter pub get; flutter packages pub run build_runner build --delete-conflicting-outputs; cd .. +cd cw_evm; flutter pub get; flutter packages pub run build_runner build --delete-conflicting-outputs; cd .. +cd cw_monero; flutter pub get; flutter packages pub run build_runner build --delete-conflicting-outputs; cd .. +cd cw_bitcoin; flutter pub get; flutter packages pub run build_runner build --delete-conflicting-outputs; cd .. +cd cw_haven; flutter pub get; flutter packages pub run build_runner build --delete-conflicting-outputs; cd .. +cd cw_nano; flutter pub get; flutter packages pub run build_runner build --delete-conflicting-outputs; cd .. +cd cw_bitcoin_cash; flutter pub get; flutter packages pub run build_runner build --delete-conflicting-outputs; cd .. +cd cw_polygon; flutter pub get; cd .. +cd cw_ethereum; flutter pub get; cd .. flutter packages pub run build_runner build --delete-conflicting-outputs diff --git a/tool/configure.dart b/tool/configure.dart index 408a6f6b1..61d8b1b89 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -118,6 +118,7 @@ abstract class Bitcoin { List getAddresses(Object wallet); String getAddress(Object wallet); + List getSilentAddresses(Object wallet); List getSubAddresses(Object wallet);