diff --git a/.github/workflows/pr_test_build.yml b/.github/workflows/pr_test_build.yml index acaa12fe00..b4ddcfefe4 100644 --- a/.github/workflows/pr_test_build.yml +++ b/.github/workflows/pr_test_build.yml @@ -42,7 +42,7 @@ jobs: - name: Flutter action uses: subosito/flutter-action@v1 with: - flutter-version: "3.19.5" + flutter-version: "3.19.6" channel: stable - name: Install package dependencies diff --git a/assets/images/cards.svg b/assets/images/cards.svg new file mode 100644 index 0000000000..699f9d3110 --- /dev/null +++ b/assets/images/cards.svg @@ -0,0 +1,65 @@ + + + + diff --git a/assets/images/tbtc.png b/assets/images/tbtc.png new file mode 100644 index 0000000000..bd4323edfc Binary files /dev/null and b/assets/images/tbtc.png differ diff --git a/assets/text/Release_Notes.txt b/assets/text/Release_Notes.txt index 483f249cf3..557dd8b265 100644 --- a/assets/text/Release_Notes.txt +++ b/assets/text/Release_Notes.txt @@ -1,3 +1,2 @@ -Add Tron wallet -Hardware wallets enhancements -Bug fixes \ No newline at end of file +Bitcoin Silent Payments +Bug fixes and generic enhancements diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart index d1c3b6a617..bf36e6fb99 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -3,8 +3,8 @@ import 'dart:convert'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/script_hash.dart' as sh; -class BitcoinAddressRecord { - BitcoinAddressRecord( +abstract class BaseBitcoinAddressRecord { + BaseBitcoinAddressRecord( this.address, { required this.index, this.isHidden = false, @@ -13,36 +13,14 @@ class BitcoinAddressRecord { String name = '', bool isUsed = false, required this.type, - String? scriptHash, required this.network, }) : _txCount = txCount, _balance = balance, _name = name, - _isUsed = isUsed, - scriptHash = scriptHash ?? sh.scriptHash(address, network: network); - - 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, - type: decoded['type'] != null && decoded['type'] != '' - ? BitcoinAddressType.values - .firstWhere((type) => type.toString() == decoded['type'] as String) - : SegwitAddresType.p2wpkh, - scriptHash: decoded['scriptHash'] as String?, - network: network, - ); - } + _isUsed = isUsed; @override - bool operator ==(Object o) => o is BitcoinAddressRecord && address == o.address; + bool operator ==(Object o) => o is BaseBitcoinAddressRecord && address == o.address; final String address; bool isHidden; @@ -51,8 +29,7 @@ class BitcoinAddressRecord { int _balance; String _name; bool _isUsed; - String? scriptHash; - BasedUtxoNetwork network; + BasedUtxoNetwork? network; int get txCount => _txCount; @@ -69,16 +46,57 @@ class BitcoinAddressRecord { void setAsUsed() => _isUsed = true; void setNewName(String label) => _name = label; - @override int get hashCode => address.hashCode; BitcoinAddressType type; - String updateScriptHash(BasedUtxoNetwork network) { + String toJSON(); +} + +class BitcoinAddressRecord extends BaseBitcoinAddressRecord { + BitcoinAddressRecord( + super.address, { + required super.index, + super.isHidden = false, + super.txCount = 0, + super.balance = 0, + super.name = '', + super.isUsed = false, + required super.type, + String? scriptHash, + required super.network, + }) : scriptHash = + scriptHash ?? (network != null ? sh.scriptHash(address, network: network) : null); + + 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, + type: decoded['type'] != null && decoded['type'] != '' + ? BitcoinAddressType.values + .firstWhere((type) => type.toString() == decoded['type'] as String) + : SegwitAddresType.p2wpkh, + scriptHash: decoded['scriptHash'] as String?, + network: network, + ); + } + + String? scriptHash; + + String getScriptHash(BasedUtxoNetwork network) { + if (scriptHash != null) return scriptHash!; scriptHash = sh.scriptHash(address, network: network); return scriptHash!; } + @override String toJSON() => json.encode({ 'address': address, 'index': index, @@ -91,3 +109,57 @@ class BitcoinAddressRecord { 'scriptHash': scriptHash, }); } + +class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { + BitcoinSilentPaymentAddressRecord( + super.address, { + required super.index, + super.isHidden = false, + super.txCount = 0, + super.balance = 0, + super.name = '', + super.isUsed = false, + required this.silentPaymentTweak, + required super.network, + required super.type, + }) : super(); + + factory BitcoinSilentPaymentAddressRecord.fromJSON(String jsonSource, + {BasedUtxoNetwork? network}) { + final decoded = json.decode(jsonSource) as Map; + + return BitcoinSilentPaymentAddressRecord( + 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, + network: (decoded['network'] as String?) == null + ? network + : BasedUtxoNetwork.fromName(decoded['network'] as String), + silentPaymentTweak: decoded['silent_payment_tweak'] as String?, + type: decoded['type'] != null && decoded['type'] != '' + ? BitcoinAddressType.values + .firstWhere((type) => type.toString() == decoded['type'] as String) + : SilentPaymentsAddresType.p2sp, + ); + } + + final String? silentPaymentTweak; + + @override + String toJSON() => json.encode({ + 'address': address, + 'index': index, + 'isHidden': isHidden, + 'isUsed': isUsed, + 'txCount': txCount, + 'name': name, + 'balance': balance, + 'type': type.toString(), + 'network': network?.value, + 'silent_payment_tweak': silentPaymentTweak, + }); +} diff --git a/cw_bitcoin/lib/bitcoin_receive_page_option.dart b/cw_bitcoin/lib/bitcoin_receive_page_option.dart index 2d2339a41d..aa3d4a4cd1 100644 --- a/cw_bitcoin/lib/bitcoin_receive_page_option.dart +++ b/cw_bitcoin/lib/bitcoin_receive_page_option.dart @@ -8,6 +8,8 @@ class BitcoinReceivePageOption implements ReceivePageOption { 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; @@ -17,6 +19,7 @@ class BitcoinReceivePageOption implements ReceivePageOption { } static const all = [ + BitcoinReceivePageOption.silent_payments, BitcoinReceivePageOption.p2wpkh, BitcoinReceivePageOption.p2tr, BitcoinReceivePageOption.p2wsh, @@ -24,6 +27,24 @@ class BitcoinReceivePageOption implements ReceivePageOption { BitcoinReceivePageOption.p2pkh ]; + BitcoinAddressType toType() { + switch (this) { + case BitcoinReceivePageOption.p2tr: + return SegwitAddresType.p2tr; + case BitcoinReceivePageOption.p2wsh: + return SegwitAddresType.p2wsh; + case BitcoinReceivePageOption.p2pkh: + return P2pkhAddressType.p2pkh; + case BitcoinReceivePageOption.p2sh: + return P2shAddressType.p2wpkhInP2sh; + case BitcoinReceivePageOption.silent_payments: + return SilentPaymentsAddresType.p2sp; + case BitcoinReceivePageOption.p2wpkh: + default: + return SegwitAddresType.p2wpkh; + } + } + factory BitcoinReceivePageOption.fromType(BitcoinAddressType type) { switch (type) { case SegwitAddresType.p2tr: @@ -34,6 +55,8 @@ class BitcoinReceivePageOption implements ReceivePageOption { 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 52edea0913..3691a7a22a 100644 --- a/cw_bitcoin/lib/bitcoin_unspent.dart +++ b/cw_bitcoin/lib/bitcoin_unspent.dart @@ -2,13 +2,66 @@ 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(BaseBitcoinAddressRecord addressRecord, String hash, int value, int vout) : bitcoinAddressRecord = addressRecord, super(addressRecord.address, hash, value, vout, null); - factory BitcoinUnspent.fromJSON(BitcoinAddressRecord address, Map json) => + factory BitcoinUnspent.fromJSON(BaseBitcoinAddressRecord? address, Map json) => BitcoinUnspent( - address, json['tx_hash'] as String, json['value'] as int, json['tx_pos'] as int); + address ?? BitcoinAddressRecord.fromJSON(json['address_record'].toString()), + json['tx_hash'] as String, + json['value'] as int, + json['tx_pos'] as int, + ); - final BitcoinAddressRecord bitcoinAddressRecord; + Map toJson() { + final json = { + 'address_record': bitcoinAddressRecord.toJSON(), + 'tx_hash': hash, + 'value': value, + 'tx_pos': vout, + }; + return json; + } + + final BaseBitcoinAddressRecord bitcoinAddressRecord; +} + +class BitcoinSilentPaymentsUnspent extends BitcoinUnspent { + BitcoinSilentPaymentsUnspent( + BitcoinSilentPaymentAddressRecord addressRecord, + String hash, + int value, + int vout, { + required this.silentPaymentTweak, + required this.silentPaymentLabel, + }) : super(addressRecord, hash, value, vout); + + @override + factory BitcoinSilentPaymentsUnspent.fromJSON( + BitcoinSilentPaymentAddressRecord? address, Map json) => + BitcoinSilentPaymentsUnspent( + address ?? BitcoinSilentPaymentAddressRecord.fromJSON(json['address_record'].toString()), + json['tx_hash'] as String, + json['value'] as int, + json['tx_pos'] as int, + silentPaymentTweak: json['silent_payment_tweak'] as String?, + silentPaymentLabel: json['silent_payment_label'] as String?, + ); + + @override + Map toJson() { + final json = { + 'address_record': bitcoinAddressRecord.toJSON(), + 'tx_hash': hash, + 'value': value, + 'tx_pos': vout, + 'silent_payment_tweak': silentPaymentTweak, + 'silent_payment_label': silentPaymentLabel, + }; + return json; + } + + String? silentPaymentTweak; + String? silentPaymentLabel; } diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index b02116541a..3954631e8c 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -39,22 +39,28 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { Map? initialRegularAddressIndex, Map? initialChangeAddressIndex, String? passphrase, + List? initialSilentAddresses, + int initialSilentAddressIndex = 0, + bool? alwaysScan, }) : super( - mnemonic: mnemonic, - passphrase: passphrase, - xpub: xpub, - password: password, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfo, - networkType: networkParam == null - ? bitcoin.bitcoin - : networkParam == BitcoinNetwork.mainnet + mnemonic: mnemonic, + passphrase: passphrase, + xpub: xpub, + password: password, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfo, + networkType: networkParam == null ? bitcoin.bitcoin - : bitcoin.testnet, - initialAddresses: initialAddresses, - initialBalance: initialBalance, - seedBytes: seedBytes, - currency: CryptoCurrency.btc) { + : networkParam == BitcoinNetwork.mainnet + ? bitcoin.bitcoin + : bitcoin.testnet, + initialAddresses: initialAddresses, + initialBalance: initialBalance, + seedBytes: seedBytes, + currency: + networkParam == BitcoinNetwork.testnet ? CryptoCurrency.tbtc : CryptoCurrency.btc, + alwaysScan: alwaysScan, + ) { // in a standard BIP44 wallet, mainHd derivation path = m/84'/0'/0'/0 (account 0, index unspecified here) // the sideHd derivation path = m/84'/0'/0'/1 (account 1, index unspecified here) // String derivationPath = walletInfo.derivationInfo!.derivationPath!; @@ -62,14 +68,18 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { // final hd = bitcoin.HDWallet.fromSeed(seedBytes, network: networkType); walletAddresses = BitcoinWalletAddresses( walletInfo, - electrumClient: electrumClient, initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, + initialSilentAddresses: initialSilentAddresses, + initialSilentAddressIndex: initialSilentAddressIndex, mainHd: hd, sideHd: accountHD.derive(1), network: networkParam ?? network, + masterHd: + seedBytes != null ? bitcoin.HDWallet.fromSeed(seedBytes, network: networkType) : null, ); + autorun((_) { this.walletAddresses.isEnabledAutoGenerateSubaddress = this.isEnabledAutoGenerateSubaddress; }); @@ -84,9 +94,11 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { String? addressPageType, BasedUtxoNetwork? network, List? initialAddresses, + List? initialSilentAddresses, ElectrumBalance? initialBalance, Map? initialRegularAddressIndex, Map? initialChangeAddressIndex, + int initialSilentAddressIndex = 0, }) async { late Uint8List seedBytes; @@ -109,6 +121,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, initialAddresses: initialAddresses, + initialSilentAddresses: initialSilentAddresses, + initialSilentAddressIndex: initialSilentAddressIndex, initialBalance: initialBalance, seedBytes: seedBytes, initialRegularAddressIndex: initialRegularAddressIndex, @@ -123,6 +137,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { required WalletInfo walletInfo, required Box unspentCoinsInfo, required String password, + required bool alwaysScan, }) async { final network = walletInfo.network != null ? BasedUtxoNetwork.fromName(walletInfo.network!) @@ -163,12 +178,15 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { walletInfo: walletInfo, unspentCoinsInfo: unspentCoinsInfo, initialAddresses: snp.addresses, + initialSilentAddresses: snp.silentAddresses, + initialSilentAddressIndex: snp.silentAddressIndex, initialBalance: snp.balance, seedBytes: seedBytes, initialRegularAddressIndex: snp.regularAddressIndex, initialChangeAddressIndex: snp.changeAddressIndex, addressPageType: snp.addressPageType, networkParam: network, + alwaysScan: alwaysScan, ); } @@ -179,7 +197,8 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { void setLedger(Ledger setLedger, LedgerDevice setLedgerDevice) { _ledger = setLedger; _ledgerDevice = setLedgerDevice; - _bitcoinLedgerApp = BitcoinLedgerApp(_ledger!, derivationPath: walletInfo.derivationInfo!.derivationPath!); + _bitcoinLedgerApp = + BitcoinLedgerApp(_ledger!, derivationPath: walletInfo.derivationInfo!.derivationPath!); } @override @@ -202,16 +221,17 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { final publicKeyAndDerivationPath = publicKeys[utxo.ownerDetails.address.pubKeyHash()]!; psbtReadyInputs.add(PSBTReadyUtxoWithAddress( - utxo: utxo.utxo, - rawTx: rawTx, - ownerDetails: utxo.ownerDetails, - ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, - ownerMasterFingerprint: masterFingerprint, - ownerPublicKey: publicKeyAndDerivationPath.publicKey, + utxo: utxo.utxo, + rawTx: rawTx, + ownerDetails: utxo.ownerDetails, + ownerDerivationPath: publicKeyAndDerivationPath.derivationPath, + ownerMasterFingerprint: masterFingerprint, + ownerPublicKey: publicKeyAndDerivationPath.publicKey, )); } - final psbt = PSBTTransactionBuild(inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF); + final psbt = + PSBTTransactionBuild(inputs: psbtReadyInputs, outputs: outputs, enableRBF: enableRBF); final rawHex = await _bitcoinLedgerApp!.signPsbt(_ledgerDevice!, psbt: psbt.psbt); return BtcTransaction.fromRaw(hex.encode(rawHex)); diff --git a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart index f125774929..486e69b111 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart @@ -15,10 +15,12 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S required super.mainHd, required super.sideHd, required super.network, - required super.electrumClient, super.initialAddresses, super.initialRegularAddressIndex, super.initialChangeAddressIndex, + super.initialSilentAddresses, + super.initialSilentAddressIndex = 0, + super.masterHd, }) : super(walletInfo); @override diff --git a/cw_bitcoin/lib/bitcoin_wallet_service.dart b/cw_bitcoin/lib/bitcoin_wallet_service.dart index cf99324da7..a9a6d96dbc 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_service.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_service.dart @@ -19,10 +19,11 @@ class BitcoinWalletService extends WalletService< BitcoinRestoreWalletFromSeedCredentials, BitcoinRestoreWalletFromWIFCredentials, BitcoinRestoreWalletFromHardware> { - BitcoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource); + BitcoinWalletService(this.walletInfoSource, this.unspentCoinsInfoSource, this.alwaysScan); final Box walletInfoSource; final Box unspentCoinsInfoSource; + final bool alwaysScan; @override WalletType getType() => WalletType.bitcoin; @@ -55,20 +56,24 @@ class BitcoinWalletService extends WalletService< .firstWhereOrNull((info) => info.id == WalletBase.idFor(name, getType()))!; try { final wallet = await BitcoinWalletBase.open( - password: password, - name: name, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + alwaysScan: alwaysScan, + ); await wallet.init(); saveBackup(name); return wallet; } catch (_) { await restoreWalletFilesFromBackup(name); final wallet = await BitcoinWalletBase.open( - password: password, - name: name, - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: name, + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + alwaysScan: alwaysScan, + ); await wallet.init(); return wallet; } @@ -87,10 +92,12 @@ class BitcoinWalletService extends WalletService< final currentWalletInfo = walletInfoSource.values .firstWhereOrNull((info) => info.id == WalletBase.idFor(currentName, getType()))!; final currentWallet = await BitcoinWalletBase.open( - password: password, - name: currentName, - walletInfo: currentWalletInfo, - unspentCoinsInfo: unspentCoinsInfoSource); + password: password, + name: currentName, + walletInfo: currentWalletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + alwaysScan: alwaysScan, + ); await currentWallet.renameWalletFiles(newName); await saveBackup(newName); @@ -105,12 +112,13 @@ class BitcoinWalletService extends WalletService< @override Future restoreFromHardwareWallet(BitcoinRestoreWalletFromHardware credentials, {bool? isTestnet}) async { - final network = isTestnet == true ? BitcoinNetwork.testnet : BitcoinNetwork.mainnet; credentials.walletInfo?.network = network.value; - credentials.walletInfo?.derivationInfo?.derivationPath = credentials.hwAccountData.derivationPath; + credentials.walletInfo?.derivationInfo?.derivationPath = + credentials.hwAccountData.derivationPath; - final wallet = await BitcoinWallet(password: credentials.password!, + final wallet = await BitcoinWallet( + password: credentials.password!, xpub: credentials.hwAccountData.xpub, walletInfo: credentials.walletInfo!, unspentCoinsInfo: unspentCoinsInfoSource, @@ -123,7 +131,7 @@ class BitcoinWalletService extends WalletService< @override Future restoreFromKeys(BitcoinRestoreWalletFromWIFCredentials credentials, - {bool? isTestnet}) async => + {bool? isTestnet}) async => throw UnimplementedError(); @override diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index 0553170ccf..afd5e2440b 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -41,23 +41,35 @@ class ElectrumClient { bool get isConnected => _isConnected; Socket? socket; - void Function(bool)? onConnectionStatusChange; + void Function(bool?)? onConnectionStatusChange; int _id; final Map _tasks; + Map get tasks => _tasks; final Map _errors; bool _isConnected; Timer? _aliveTimer; String unterminatedString; - Future connectToUri(Uri uri) async => await connect(host: uri.host, port: uri.port); + Uri? uri; + bool? useSSL; - Future connect({required String host, required int port}) async { + Future connectToUri(Uri uri, {bool? useSSL}) async { + this.uri = uri; + this.useSSL = useSSL; + await connect(host: uri.host, port: uri.port, useSSL: useSSL); + } + + Future connect({required String host, required int port, bool? useSSL}) async { try { await socket?.close(); } catch (_) {} - socket = await SecureSocket.connect(host, port, - timeout: connectionTimeout, onBadCertificate: (_) => true); + if (useSSL == false) { + socket = await Socket.connect(host, port, timeout: connectionTimeout); + } else { + socket = await SecureSocket.connect(host, port, + timeout: connectionTimeout, onBadCertificate: (_) => true); + } _setIsConnected(true); socket!.listen((Uint8List event) { @@ -79,7 +91,7 @@ class ElectrumClient { _setIsConnected(false); }, onDone: () { unterminatedString = ''; - _setIsConnected(false); + _setIsConnected(null); }); keepAlive(); } @@ -134,11 +146,12 @@ class ElectrumClient { await callWithTimeout(method: 'server.ping'); _setIsConnected(true); } on RequestFailedTimeoutException catch (_) { - _setIsConnected(false); + _setIsConnected(null); } } - Future> version() => call(method: 'server.version').then((dynamic result) { + Future> version() => + call(method: 'server.version', params: ["", "1.4"]).then((dynamic result) { if (result is List) { return result.map((dynamic val) => val.toString()).toList(); } @@ -266,6 +279,18 @@ class ElectrumClient { Future> getHeader({required int height}) async => await call(method: 'blockchain.block.get_header', params: [height]) as Map; + BehaviorSubject? tweaksSubscribe({required int height, required int count}) { + _id += 1; + return subscribe( + id: 'blockchain.tweaks.subscribe:${height + count}', + method: 'blockchain.tweaks.subscribe', + params: [height, count, false], + ); + } + + Future getTweaks({required int height}) async => + await callWithTimeout(method: 'blockchain.tweaks.subscribe', params: [height, 1, false]); + Future estimatefee({required int p}) => call(method: 'blockchain.estimatefee', params: [p]).then((dynamic result) { if (result is double) { @@ -308,9 +333,6 @@ class ElectrumClient { }); Future> feeRates({BasedUtxoNetwork? network}) async { - if (network == BitcoinNetwork.testnet) { - return [1, 1, 1]; - } try { final topDoubleString = await estimatefee(p: 1); final middleDoubleString = await estimatefee(p: 5); @@ -332,7 +354,7 @@ class ElectrumClient { // "hex": "00000020890208a0ae3a3892aa047c5468725846577cfcd9b512b50000000000000000005dc2b02f2d297a9064ee103036c14d678f9afc7e3d9409cf53fd58b82e938e8ecbeca05a2d2103188ce804c4" // } Future getCurrentBlockChainTip() => - call(method: 'blockchain.headers.subscribe').then((result) { + callWithTimeout(method: 'blockchain.headers.subscribe').then((result) { if (result is Map) { return result["height"] as int; } @@ -340,6 +362,12 @@ class ElectrumClient { return null; }); + BehaviorSubject? chainTipSubscribe() { + _id += 1; + return subscribe( + id: 'blockchain.headers.subscribe', method: 'blockchain.headers.subscribe'); + } + BehaviorSubject? scripthashUpdate(String scripthash) { _id += 1; return subscribe( @@ -396,7 +424,9 @@ class ElectrumClient { Future close() async { _aliveTimer?.cancel(); - await socket?.close(); + try { + await socket?.close(); + } catch (_) {} onConnectionStatusChange = null; } @@ -431,17 +461,25 @@ class ElectrumClient { _tasks[id]?.subject?.add(params.last); break; + case 'blockchain.headers.subscribe': + final params = request['params'] as List; + _tasks[method]?.subject?.add(params.last); + break; + case 'blockchain.tweaks.subscribe': + final params = request['params'] as List; + _tasks[_tasks.keys.first]?.subject?.add(params.last); + break; default: break; } } - void _setIsConnected(bool isConnected) { + void _setIsConnected(bool? isConnected) { if (_isConnected != isConnected) { onConnectionStatusChange?.call(isConnected); } - _isConnected = isConnected; + _isConnected = isConnected ?? false; } void _handleResponse(Map response) { diff --git a/cw_bitcoin/lib/electrum_balance.dart b/cw_bitcoin/lib/electrum_balance.dart index 165ea447e8..15d6843d87 100644 --- a/cw_bitcoin/lib/electrum_balance.dart +++ b/cw_bitcoin/lib/electrum_balance.dart @@ -3,8 +3,11 @@ import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_core/balance.dart'; class ElectrumBalance extends Balance { - const ElectrumBalance({required this.confirmed, required this.unconfirmed, required this.frozen}) - : super(confirmed, unconfirmed); + ElectrumBalance({ + required this.confirmed, + required this.unconfirmed, + required this.frozen, + }) : super(confirmed, unconfirmed); static ElectrumBalance? fromJSON(String? jsonSource) { if (jsonSource == null) { @@ -19,8 +22,8 @@ class ElectrumBalance extends Balance { frozen: decoded['frozen'] as int? ?? 0); } - final int confirmed; - final int unconfirmed; + int confirmed; + int unconfirmed; final int frozen; @override diff --git a/cw_bitcoin/lib/electrum_transaction_history.dart b/cw_bitcoin/lib/electrum_transaction_history.dart index d478c3b12d..a7de414e4d 100644 --- a/cw_bitcoin/lib/electrum_transaction_history.dart +++ b/cw_bitcoin/lib/electrum_transaction_history.dart @@ -11,13 +11,11 @@ part 'electrum_transaction_history.g.dart'; const transactionsHistoryFileName = 'transactions.json'; -class ElectrumTransactionHistory = ElectrumTransactionHistoryBase - with _$ElectrumTransactionHistory; +class ElectrumTransactionHistory = ElectrumTransactionHistoryBase with _$ElectrumTransactionHistory; abstract class ElectrumTransactionHistoryBase extends TransactionHistoryBase with Store { - ElectrumTransactionHistoryBase( - {required this.walletInfo, required String password}) + ElectrumTransactionHistoryBase({required this.walletInfo, required String password}) : _password = password, _height = 0 { transactions = ObservableMap(); @@ -30,8 +28,7 @@ abstract class ElectrumTransactionHistoryBase Future init() async => await _load(); @override - void addOne(ElectrumTransactionInfo transaction) => - transactions[transaction.id] = transaction; + void addOne(ElectrumTransactionInfo transaction) => transactions[transaction.id] = transaction; @override void addMany(Map transactions) => @@ -40,11 +37,13 @@ abstract class ElectrumTransactionHistoryBase @override Future save() async { try { - final dirPath = - await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); + final dirPath = await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); final path = '$dirPath/$transactionsHistoryFileName'; - final data = - json.encode({'height': _height, 'transactions': transactions}); + final txjson = {}; + for (final tx in transactions.entries) { + txjson[tx.key] = tx.value.toJson(); + } + final data = json.encode({'height': _height, 'transactions': txjson}); await writeData(path: path, password: _password, data: data); } catch (e) { print('Error while save bitcoin transaction history: ${e.toString()}'); @@ -57,8 +56,7 @@ abstract class ElectrumTransactionHistoryBase } Future> _read() async { - final dirPath = - await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); + final dirPath = await pathForWalletDir(name: walletInfo.name, type: walletInfo.type); final path = '$dirPath/$transactionsHistoryFileName'; final content = await read(path: path, password: _password); return json.decode(content) as Map; @@ -84,7 +82,5 @@ abstract class ElectrumTransactionHistoryBase } } - void _update(ElectrumTransactionInfo transaction) => - transactions[transaction.id] = transaction; - + void _update(ElectrumTransactionInfo transaction) => transactions[transaction.id] = transaction; } diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index f980bd8842..d06cfe9de8 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -1,9 +1,8 @@ import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; -import 'package:bitcoin_flutter/src/payments/index.dart' show PaymentData; 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'; @@ -19,6 +18,8 @@ class ElectrumTransactionBundle { } class ElectrumTransactionInfo extends TransactionInfo { + List? unspents; + ElectrumTransactionInfo(this.type, {required String id, required int height, @@ -29,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.unspents}) { this.id = id; this.height = height; this.amount = amount; @@ -40,6 +43,7 @@ class ElectrumTransactionInfo extends TransactionInfo { this.date = date; this.isPending = isPending; this.confirmations = confirmations; + this.to = to; } factory ElectrumTransactionInfo.fromElectrumVerbose(Map obj, WalletType type, @@ -153,52 +157,31 @@ 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, - inputAddresses: data['inputAddresses'] as List, - outputAddresses: data['outputAddresses'] as List, - confirmations: data['confirmations'] as int); + final inputAddresses = data['inputAddresses'] as List? ?? []; + final outputAddresses = data['outputAddresses'] as List? ?? []; + final unspents = data['unspents'] as List? ?? []; + + 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, + inputAddresses: + inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), + outputAddresses: + outputAddresses.isEmpty ? [] : outputAddresses.map((e) => e.toString()).toList(), + to: data['to'] as String?, + unspents: unspents + .map((unspent) => + BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map)) + .toList(), + ); } final WalletType type; @@ -244,8 +227,14 @@ class ElectrumTransactionInfo extends TransactionInfo { m['isPending'] = isPending; m['confirmations'] = confirmations; m['fee'] = fee; + m['to'] = to; + m['unspents'] = unspents?.map((e) => e.toJson()).toList() ?? []; m['inputAddresses'] = inputAddresses; m['outputAddresses'] = outputAddresses; 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: $unspents)'; + } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index b899744a6b..96f871a4bf 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -1,11 +1,12 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:isolate'; import 'dart:math'; import 'package:bitcoin_base/bitcoin_base.dart'; -import 'package:bitcoin_base/bitcoin_base.dart' as bitcoin_base; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:collection/collection.dart'; import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; @@ -35,33 +36,39 @@ import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/utils/file.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_core/get_height_by_date.dart'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:http/http.dart' as http; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; +import 'package:sp_scanner/sp_scanner.dart'; part 'electrum_wallet.g.dart'; class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; +const int TWEAKS_COUNT = 25; + abstract class ElectrumWalletBase extends WalletBase with Store { - ElectrumWalletBase( - {required String password, - required WalletInfo walletInfo, - required Box unspentCoinsInfo, - required this.networkType, - String? xpub, - String? mnemonic, - Uint8List? seedBytes, - this.passphrase, - List? initialAddresses, - ElectrumClient? electrumClient, - ElectrumBalance? initialBalance, - CryptoCurrency? currency}) - : accountHD = + ElectrumWalletBase({ + required String password, + required WalletInfo walletInfo, + required Box unspentCoinsInfo, + required this.networkType, + String? xpub, + String? mnemonic, + Uint8List? seedBytes, + this.passphrase, + List? initialAddresses, + ElectrumClient? electrumClient, + ElectrumBalance? initialBalance, + CryptoCurrency? currency, + this.alwaysScan, + }) : accountHD = getAccountHDWallet(currency, networkType, seedBytes, xpub, walletInfo.derivationInfo), syncStatus = NotConnectedSyncStatus(), _password = password, @@ -72,8 +79,12 @@ abstract class ElectrumWalletBase _scripthashesUpdateSubject = {}, balance = ObservableMap.of(currency != null ? { - currency: - initialBalance ?? const ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0) + currency: initialBalance ?? + ElectrumBalance( + confirmed: 0, + unconfirmed: 0, + frozen: 0, + ) } : {}), this.unspentCoinsInfo = unspentCoinsInfo, @@ -84,6 +95,23 @@ abstract class ElectrumWalletBase this.electrumClient = electrumClient ?? ElectrumClient(); this.walletInfo = walletInfo; transactionHistory = ElectrumTransactionHistory(walletInfo: walletInfo, password: password); + + reaction((_) => syncStatus, (SyncStatus syncStatus) { + if (syncStatus is! AttemptingSyncStatus && syncStatus is! SyncedTipSyncStatus) + silentPaymentsScanningActive = syncStatus is SyncingSyncStatus; + + if (syncStatus is NotConnectedSyncStatus) { + // Needs to re-subscribe to all scripthashes when reconnected + _scripthashesUpdateSubject = {}; + } + + // Message is shown on the UI for 3 seconds, revert to synced + if (syncStatus is SyncedTipSyncStatus) { + Timer(Duration(seconds: 3), () { + if (this.syncStatus is SyncedTipSyncStatus) this.syncStatus = SyncedSyncStatus(); + }); + } + }); } static bitcoin.HDWallet getAccountHDWallet( @@ -113,6 +141,8 @@ abstract class ElectrumWalletBase static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 68 + outputsCounts * 34 + 10; + bool? alwaysScan; + final bitcoin.HDWallet accountHD; final String? _mnemonic; @@ -137,6 +167,8 @@ abstract class ElectrumWalletBase @observable SyncStatus syncStatus; + Set get addressesSet => walletAddresses.allAddresses.map((addr) => addr.address).toSet(); + List get scriptHashes => walletAddresses.addressesByReceiveType .map((addr) => scriptHash(addr.address, network: network)) .toList(); @@ -157,6 +189,64 @@ abstract class ElectrumWalletBase @override bool? isTestnet; + bool get hasSilentPaymentsScanning => type == WalletType.bitcoin; + + @observable + bool nodeSupportsSilentPayments = true; + @observable + bool silentPaymentsScanningActive = false; + + @action + Future setSilentPaymentsScanning(bool active) async { + silentPaymentsScanningActive = active; + + if (active) { + syncStatus = AttemptingSyncStatus(); + + final tip = await getUpdatedChainTip(); + + if (tip == walletInfo.restoreHeight) { + syncStatus = SyncedTipSyncStatus(tip); + } + + if (tip > walletInfo.restoreHeight) { + _setListeners(walletInfo.restoreHeight, chainTipParam: _currentChainTip); + } + } else { + alwaysScan = false; + + (await _isolate)?.kill(priority: Isolate.immediate); + + if (electrumClient.isConnected) { + syncStatus = SyncedSyncStatus(); + } else { + if (electrumClient.uri != null) { + await electrumClient.connectToUri(electrumClient.uri!); + startSync(); + } + } + } + } + + int? _currentChainTip; + + Future getCurrentChainTip() async { + if (_currentChainTip != null) { + return _currentChainTip!; + } + _currentChainTip = await electrumClient.getCurrentBlockChainTip() ?? 0; + + return _currentChainTip!; + } + + Future getUpdatedChainTip() async { + final newTip = await electrumClient.getCurrentBlockChainTip(); + if (newTip != null && newTip > (_currentChainTip ?? 0)) { + _currentChainTip = newTip; + } + return _currentChainTip ?? 0; + } + @override BitcoinWalletKeys get keys => BitcoinWalletKeys(wif: hd.wif!, privateKey: hd.privKey!, publicKey: hd.pubKey!); @@ -164,32 +254,179 @@ abstract class ElectrumWalletBase String _password; List unspentCoins; List _feeRates; + + // ignore: prefer_final_fields Map?> _scripthashesUpdateSubject; + + // ignore: prefer_final_fields + 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? chainTipParam, bool? doSingleScan}) async { + final chainTip = chainTipParam ?? await getUpdatedChainTip(); + + if (chainTip == height) { + syncStatus = SyncedSyncStatus(); + return; + } + + 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, + silentAddress: walletAddresses.silentAddress!, + network: network, + height: height, + chainTip: chainTip, + electrumClient: ElectrumClient(), + transactionHistoryIds: transactionHistory.transactions.keys.toList(), + node: ScanNode(node!.uri, node!.useSSL), + labels: walletAddresses.labels, + labelIndexes: walletAddresses.silentAddresses + .where((addr) => addr.type == SilentPaymentsAddresType.p2sp && addr.index >= 1) + .map((addr) => addr.index) + .toList(), + isSingleScan: doSingleScan ?? false, + )); + + await for (var message in receivePort) { + if (message is Map) { + for (final map in message.entries) { + final txid = map.key; + final tx = map.value; + + if (tx.unspents != null) { + final existingTxInfo = transactionHistory.transactions[txid]; + final txAlreadyExisted = existingTxInfo != null; + + // Updating tx after re-scanned + if (txAlreadyExisted) { + existingTxInfo.amount = tx.amount; + existingTxInfo.confirmations = tx.confirmations; + existingTxInfo.height = tx.height; + + final newUnspents = tx.unspents! + .where((unspent) => !(existingTxInfo.unspents?.any((element) => + element.hash.contains(unspent.hash) && + element.vout == unspent.vout && + element.value == unspent.value) ?? + false)) + .toList(); + + if (newUnspents.isNotEmpty) { + newUnspents.forEach(_updateSilentAddressRecord); + + existingTxInfo.unspents ??= []; + existingTxInfo.unspents!.addAll(newUnspents); + + final newAmount = newUnspents.length > 1 + ? newUnspents.map((e) => e.value).reduce((value, unspent) => value + unspent) + : newUnspents[0].value; + + if (existingTxInfo.direction == TransactionDirection.incoming) { + existingTxInfo.amount += newAmount; + } + + // Updates existing TX + transactionHistory.addOne(existingTxInfo); + // Update balance record + balance[currency]!.confirmed += newAmount; + } + } else { + // else: First time seeing this TX after scanning + tx.unspents!.forEach(_updateSilentAddressRecord); + + // Add new TX record + transactionHistory.addMany(message); + // Update balance record + balance[currency]!.confirmed += tx.amount; + } + + await updateAllUnspents(); + } + } + } + + if (message is SyncResponse) { + if (message.syncStatus is UnsupportedSyncStatus) { + nodeSupportsSilentPayments = false; + } + + syncStatus = message.syncStatus; + await walletInfo.updateRestoreHeight(message.height); + } + } + } + + void _updateSilentAddressRecord(BitcoinSilentPaymentsUnspent unspent) { + final silentAddress = walletAddresses.silentAddress!; + final silentPaymentAddress = SilentPaymentAddress( + version: silentAddress.version, + B_scan: silentAddress.B_scan, + B_spend: unspent.silentPaymentLabel != null + ? silentAddress.B_spend.tweakAdd( + BigintUtils.fromBytes(BytesUtils.fromHexString(unspent.silentPaymentLabel!)), + ) + : silentAddress.B_spend, + hrp: silentAddress.hrp, + ); + + final addressRecord = walletAddresses.silentAddresses + .firstWhereOrNull((address) => address.address == silentPaymentAddress.toString()); + addressRecord?.txCount += 1; + addressRecord?.balance += unspent.value; + + walletAddresses.addSilentAddresses( + [unspent.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord], + ); } @action @override Future startSync() async { try { - syncStatus = AttemptingSyncStatus(); + syncStatus = SyncronizingSyncStatus(); + + if (hasSilentPaymentsScanning) { + await _setInitialHeight(); + } + + await _subscribeForUpdates(); + await updateTransactions(); - _subscribeForUpdates(); - await updateUnspent(); + await updateAllUnspents(); await updateBalance(); - _feeRates = await electrumClient.feeRates(network: network); - Timer.periodic( - const Duration(minutes: 1), (timer) async => _feeRates = await electrumClient.feeRates()); + Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); - syncStatus = SyncedSyncStatus(); + if (alwaysScan == true) { + _setListeners(walletInfo.restoreHeight); + } else { + syncStatus = SyncedSyncStatus(); + } } catch (e, stacktrace) { print(stacktrace); print(e.toString()); @@ -197,18 +434,39 @@ abstract class ElectrumWalletBase } } + @action + Future updateFeeRates() async { + final feeRates = await electrumClient.feeRates(network: network); + if (feeRates != [0, 0, 0]) { + _feeRates = feeRates; + } + } + + Node? node; + @action @override Future connectToNode({required Node node}) async { + this.node = node; + try { syncStatus = ConnectingSyncStatus(); - await electrumClient.connectToUri(node.uri); - electrumClient.onConnectionStatusChange = (bool isConnected) { - if (!isConnected) { + + await electrumClient.close(); + + electrumClient.onConnectionStatusChange = (bool? isConnected) async { + if (syncStatus is SyncingSyncStatus) return; + + if (isConnected == true && syncStatus is! SyncedSyncStatus) { + syncStatus = ConnectedSyncStatus(); + } else if (isConnected == false) { syncStatus = LostConnectionSyncStatus(); + } else if (!(isConnected ?? false) && syncStatus is! ConnectingSyncStatus) { + syncStatus = NotConnectedSyncStatus(); } }; - syncStatus = ConnectedSyncStatus(); + + await electrumClient.connectToUri(node.uri, useSSL: node.useSSL); } catch (e) { print(e.toString()); syncStatus = FailedSyncStatus(); @@ -219,60 +477,95 @@ abstract class ElectrumWalletBase bool _isBelowDust(int amount) => amount <= _dustAmount && network != BitcoinNetwork.testnet; - Future estimateSendAllTx( - List outputs, - int feeRate, { - String? memo, - int credentialsAmount = 0, - }) async { - final utxos = []; - final privateKeys = []; + UtxoDetails _createUTXOS({ + required bool sendAll, + required int credentialsAmount, + required bool paysToSilentPayment, + int? inputsCount, + }) { + List utxos = []; + List vinOutpoints = []; + List inputPrivKeyInfos = []; final publicKeys = {}; - int allInputsAmount = 0; - + bool spendsSilentPayment = false; bool spendsUnconfirmedTX = false; - for (int i = 0; i < unspentCoins.length; i++) { - final utx = unspentCoins[i]; + int leftAmount = credentialsAmount; + final availableInputs = unspentCoins.where((utx) => utx.isSending && !utx.isFrozen).toList(); + final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); - if (utx.isSending && !utx.isFrozen) { - if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0; + for (int i = 0; i < availableInputs.length; i++) { + final utx = availableInputs[i]; + if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0; - allInputsAmount += utx.value; + if (paysToSilentPayment) { + // Check inputs for shared secret derivation + if (utx.bitcoinAddressRecord.type == SegwitAddresType.p2wsh) { + throw BitcoinTransactionSilentPaymentsNotSupported(); + } + } - final address = addressTypeFromStr(utx.address, network); - final hd = - utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd; - final derivationPath = - "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? "m/0'")}" - "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}" - "/${utx.bitcoinAddressRecord.index}"; - final pubKeyHex = hd.derive(utx.bitcoinAddressRecord.index).pubKey!; + allInputsAmount += utx.value; + leftAmount = leftAmount - utx.value; - publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); + final address = addressTypeFromStr(utx.address, network); + ECPrivate? privkey; + bool? isSilentPayment = false; - if (!walletInfo.isHardwareWallet) { - final privkey = - generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network); + final hd = + utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd; + final derivationPath = + "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? "m/0'")}" + "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}" + "/${utx.bitcoinAddressRecord.index}"; + final pubKeyHex = hd.derive(utx.bitcoinAddressRecord.index).pubKey!; - privateKeys.add(privkey); - } + publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); - utxos.add( - UtxoWithAddress( - utxo: BitcoinUtxo( - txHash: utx.hash, - value: BigInt.from(utx.value), - vout: utx.vout, - scriptType: _getScriptType(address), - ), - ownerDetails: UtxoAddressDetails( - publicKey: pubKeyHex, - address: address, - ), + if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { + final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; + privkey = walletAddresses.silentAddress!.b_spend.tweakAdd( + BigintUtils.fromBytes( + BytesUtils.fromHexString(unspentAddress.silentPaymentTweak!), ), ); + spendsSilentPayment = true; + isSilentPayment = true; + } else { + privkey = + generateECPrivate(hd: hd, index: utx.bitcoinAddressRecord.index, network: network); + } + + vinOutpoints.add(Outpoint(txid: utx.hash, index: utx.vout)); + inputPrivKeyInfos.add(ECPrivateInfo( + privkey, + address.type == SegwitAddresType.p2tr, + tweak: !isSilentPayment, + )); + + utxos.add( + UtxoWithAddress( + utxo: BitcoinUtxo( + txHash: utx.hash, + value: BigInt.from(utx.value), + vout: utx.vout, + scriptType: _getScriptType(address), + isSilentPayment: isSilentPayment, + ), + ownerDetails: UtxoAddressDetails( + publicKey: privkey.getPublic().toHex(), + address: address, + ), + ), + ); + + // sendAll continues for all inputs + if (!sendAll) { + bool amountIsAcquired = leftAmount <= 0; + if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) { + break; + } } } @@ -280,20 +573,48 @@ abstract class ElectrumWalletBase throw BitcoinTransactionNoInputsException(); } + return UtxoDetails( + availableInputs: availableInputs, + unconfirmedCoins: unconfirmedCoins, + utxos: utxos, + vinOutpoints: vinOutpoints, + inputPrivKeyInfos: inputPrivKeyInfos, + publicKeys: publicKeys, + allInputsAmount: allInputsAmount, + spendsSilentPayment: spendsSilentPayment, + spendsUnconfirmedTX: spendsUnconfirmedTX, + ); + } + + Future estimateSendAllTx( + List outputs, + int feeRate, { + String? memo, + int credentialsAmount = 0, + bool hasSilentPayment = false, + }) async { + final utxoDetails = _createUTXOS( + sendAll: true, + credentialsAmount: credentialsAmount, + paysToSilentPayment: hasSilentPayment, + ); + int estimatedSize; if (network is BitcoinCashNetwork) { estimatedSize = ForkedTransactionBuilder.estimateTransactionSize( - utxos: utxos, + utxos: utxoDetails.utxos, outputs: outputs, network: network as BitcoinCashNetwork, memo: memo, ); } else { estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize( - utxos: utxos, + utxos: utxoDetails.utxos, outputs: outputs, network: network, memo: memo, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + vinOutpoints: utxoDetails.vinOutpoints, ); } @@ -304,7 +625,11 @@ abstract class ElectrumWalletBase } // Here, when sending all, the output amount equals to the input value - fee to fully spend every input on the transaction and have no amount left for change - int amount = allInputsAmount - fee; + int amount = utxoDetails.allInputsAmount - fee; + + if (amount <= 0) { + throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); + } if (amount <= 0) { throw BitcoinTransactionWrongBalanceException(); @@ -323,19 +648,21 @@ abstract class ElectrumWalletBase } } - outputs[outputs.length - 1] = - BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount)); + if (outputs.length == 1) { + outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount)); + } return EstimatedTxResult( - utxos: utxos, - privateKeys: privateKeys, - publicKeys: publicKeys, + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, fee: fee, amount: amount, isSendAll: true, hasChange: false, memo: memo, - spendsUnconfirmedTX: spendsUnconfirmedTX, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } @@ -346,79 +673,22 @@ abstract class ElectrumWalletBase int? inputsCount, String? memo, bool? useUnconfirmed, + bool hasSilentPayment = false, }) async { - final utxos = []; - final privateKeys = []; - final publicKeys = {}; - - int allInputsAmount = 0; - bool spendsUnconfirmedTX = false; - - int leftAmount = credentialsAmount; - final sendingCoins = unspentCoins.where((utx) => utx.isSending && !utx.isFrozen).toList(); - final unconfirmedCoins = sendingCoins.where((utx) => utx.confirmations == 0).toList(); - - for (int i = 0; i < sendingCoins.length; i++) { - final utx = sendingCoins[i]; - - final isUncormirmed = utx.confirmations == 0; - if (useUnconfirmed != true && isUncormirmed) continue; - - if (!spendsUnconfirmedTX) spendsUnconfirmedTX = isUncormirmed; - - allInputsAmount += utx.value; - leftAmount = leftAmount - utx.value; - - final address = addressTypeFromStr(utx.address, network); - - final hd = - utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd; - final derivationPath = - "${_hardenedDerivationPath(walletInfo.derivationInfo?.derivationPath ?? "m/0'")}" - "/${utx.bitcoinAddressRecord.isHidden ? "1" : "0"}" - "/${utx.bitcoinAddressRecord.index}"; - final pubKeyHex = hd.derive(utx.bitcoinAddressRecord.index).pubKey!; - - publicKeys[address.pubKeyHash()] = PublicKeyWithDerivationPath(pubKeyHex, derivationPath); - - if (!walletInfo.isHardwareWallet) { - final privkey = - generateECPrivate(hd: hd, 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: pubKeyHex, - address: address, - ), - ), - ); - - bool amountIsAcquired = leftAmount <= 0; - if ((inputsCount == null && amountIsAcquired) || inputsCount == i + 1) { - break; - } - } - - if (utxos.isEmpty) { - throw BitcoinTransactionNoInputsException(); - } + final utxoDetails = _createUTXOS( + sendAll: false, + credentialsAmount: credentialsAmount, + inputsCount: inputsCount, + paysToSilentPayment: hasSilentPayment, + ); - final spendingAllCoins = sendingCoins.length == utxos.length; - final spendingAllConfirmedCoins = - !spendsUnconfirmedTX && utxos.length == sendingCoins.length - unconfirmedCoins.length; + final spendingAllCoins = utxoDetails.availableInputs.length == utxoDetails.utxos.length; + final spendingAllConfirmedCoins = !utxoDetails.spendsUnconfirmedTX && + utxoDetails.utxos.length == + utxoDetails.availableInputs.length - utxoDetails.unconfirmedCoins.length; // How much is being spent - how much is being sent - int amountLeftForChangeAndFee = allInputsAmount - credentialsAmount; + int amountLeftForChangeAndFee = utxoDetails.allInputsAmount - credentialsAmount; if (amountLeftForChangeAndFee <= 0) { if (!spendingAllCoins) { @@ -426,11 +696,12 @@ abstract class ElectrumWalletBase credentialsAmount, outputs, feeRate, - inputsCount: utxos.length + 1, + inputsCount: utxoDetails.utxos.length + 1, memo: memo, - useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, + hasSilentPayment: hasSilentPayment, ); } + throw BitcoinTransactionWrongBalanceException(); } @@ -444,17 +715,19 @@ abstract class ElectrumWalletBase int estimatedSize; if (network is BitcoinCashNetwork) { estimatedSize = ForkedTransactionBuilder.estimateTransactionSize( - utxos: utxos, + utxos: utxoDetails.utxos, outputs: outputs, network: network as BitcoinCashNetwork, memo: memo, ); } else { estimatedSize = BitcoinTransactionBuilder.estimateTransactionSize( - utxos: utxos, + utxos: utxoDetails.utxos, outputs: outputs, network: network, memo: memo, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + vinOutpoints: utxoDetails.vinOutpoints, ); } @@ -482,7 +755,7 @@ abstract class ElectrumWalletBase credentialsAmount, outputs, feeRate, - inputsCount: utxos.length + 1, + inputsCount: utxoDetails.utxos.length + 1, memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, ); @@ -499,7 +772,7 @@ abstract class ElectrumWalletBase } // Estimate to user how much is needed to send to cover the fee - final maxAmountWithReturningChange = allInputsAmount - _dustAmount - fee - 1; + final maxAmountWithReturningChange = utxoDetails.allInputsAmount - _dustAmount - fee - 1; throw BitcoinTransactionNoDustOnChangeException( bitcoinAmountToString(amount: maxAmountWithReturningChange), bitcoinAmountToString(amount: estimatedSendAll.amount), @@ -517,35 +790,34 @@ abstract class ElectrumWalletBase throw BitcoinTransactionWrongBalanceException(); } - if (totalAmount > allInputsAmount) { + if (totalAmount > utxoDetails.allInputsAmount) { if (spendingAllCoins) { throw BitcoinTransactionWrongBalanceException(); } else { - if (amountLeftForChangeAndFee > fee) { - outputs.removeLast(); - } - + outputs.removeLast(); return estimateTxForAmount( credentialsAmount, outputs, feeRate, - inputsCount: utxos.length + 1, + inputsCount: utxoDetails.utxos.length + 1, memo: memo, useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, + hasSilentPayment: hasSilentPayment, ); } } return EstimatedTxResult( - utxos: utxos, - privateKeys: privateKeys, - publicKeys: publicKeys, + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, fee: fee, amount: amount, hasChange: true, isSendAll: false, memo: memo, - spendsUnconfirmedTX: spendsUnconfirmedTX, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } @@ -559,6 +831,7 @@ abstract class ElectrumWalletBase final memo = transactionCredentials.outputs.first.memo; int credentialsAmount = 0; + bool hasSilentPayment = false; for (final out in transactionCredentials.outputs) { final outputAmount = out.formattedCryptoAmount!; @@ -578,6 +851,10 @@ abstract class ElectrumWalletBase final address = addressTypeFromStr(out.isParsedAddress ? out.extractedAddress! : out.address, network); + if (address is SilentPaymentAddress) { + hasSilentPayment = true; + } + if (sendAll) { // The value will be changed after estimating the Tx size and deducting the fee from the total to be sent outputs.add(BitcoinOutput(address: address, value: BigInt.from(0))); @@ -597,6 +874,7 @@ abstract class ElectrumWalletBase feeRateInt, memo: memo, credentialsAmount: credentialsAmount, + hasSilentPayment: hasSilentPayment, ); } else { estimatedTx = await estimateTxForAmount( @@ -604,6 +882,7 @@ abstract class ElectrumWalletBase outputs, feeRateInt, memo: memo, + hasSilentPayment: hasSilentPayment, ); } @@ -662,8 +941,8 @@ abstract class ElectrumWalletBase bool hasTaprootInputs = false; final transaction = txb.buildTransaction((txDigest, utxo, publicKey, sighash) { - final key = estimatedTx.privateKeys - .firstWhereOrNull((element) => element.getPublic().toHex() == publicKey); + final key = estimatedTx.inputPrivKeyInfos + .firstWhereOrNull((element) => element.privkey.getPublic().toHex() == publicKey); if (key == null) { throw Exception("Cannot find private key"); @@ -671,9 +950,13 @@ abstract class ElectrumWalletBase if (utxo.utxo.isP2tr()) { hasTaprootInputs = true; - return key.signTapRoot(txDigest, sighash: sighash); + return key.privkey.signTapRoot( + txDigest, + sighash: sighash, + tweak: utxo.utxo.isSilentPayment != true, + ); } else { - return key.signInput(txDigest, sigHash: sighash); + return key.privkey.signInput(txDigest, sigHash: sighash); } }); @@ -690,6 +973,14 @@ abstract class ElectrumWalletBase hasTaprootInputs: hasTaprootInputs, )..addListener((transaction) async { transactionHistory.addOne(transaction); + if (estimatedTx.spendsSilentPayment) { + transactionHistory.transactions.values.forEach((tx) { + tx.unspents?.removeWhere( + (unspent) => estimatedTx.utxos.any((e) => e.utxo.txHash == unspent.hash)); + transactionHistory.addOne(tx); + }); + } + await updateBalance(); }); } catch (e) { @@ -723,6 +1014,8 @@ abstract class ElectrumWalletBase 'balance': balance[currency]?.toJSON(), 'derivationTypeIndex': walletInfo.derivationInfo?.derivationType?.index, 'derivationPath': walletInfo.derivationInfo?.derivationPath, + 'silent_addresses': walletAddresses.silentAddresses.map((addr) => addr.toJSON()).toList(), + 'silent_address_index': walletAddresses.currentSilentAddressIndex.toString(), }); int feeRate(TransactionPriority priority) { @@ -827,35 +1120,40 @@ abstract class ElectrumWalletBase await transactionHistory.changePassword(password); } + @action @override - Future rescan({required int height}) async => throw UnimplementedError(); + Future rescan( + {required int height, int? chainTip, ScanData? scanData, bool? doSingleScan}) async { + silentPaymentsScanningActive = true; + _setListeners(height, doSingleScan: doSingleScan); + } @override Future close() async { try { await electrumClient.close(); } catch (_) {} + _autoSaveTimer?.cancel(); } Future makePath() async => pathForWallet(name: walletInfo.name, type: walletInfo.type); - Future updateUnspent() async { + @action + Future updateAllUnspents() async { 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 { - final coin = BitcoinUnspent.fromJSON(address, unspent); - final tx = await fetchTransactionInfo( - hash: coin.hash, height: 0, myAddresses: addressesSet); - coin.isChange = tx?.direction == TransactionDirection.outgoing; - coin.confirmations = tx?.confirmations; - updatedUnspentCoins.add(coin); - } catch (_) {} - })))); + if (hasSilentPaymentsScanning) { + // Update unspents stored from scanned silent payment transactions + transactionHistory.transactions.values.forEach((tx) { + if (tx.unspents != null) { + updatedUnspentCoins.addAll(tx.unspents!); + } + }); + } + + await Future.wait(walletAddresses.allAddresses.map((address) async { + updatedUnspentCoins.addAll(await fetchUnspent(address)); + })); unspentCoins = updatedUnspentCoins; @@ -877,6 +1175,8 @@ abstract class ElectrumWalletBase coin.isFrozen = coinInfo.isFrozen; coin.isSending = coinInfo.isSending; coin.note = coinInfo.note; + if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) + coin.bitcoinAddressRecord.balance += coinInfo.value; } else { _addCoinInfo(coin); } @@ -886,6 +1186,57 @@ abstract class ElectrumWalletBase await _refreshUnspentCoinsInfo(); } + @action + Future updateUnspents(BitcoinAddressRecord address) async { + final newUnspentCoins = await fetchUnspent(address); + + if (newUnspentCoins.isNotEmpty) { + unspentCoins.addAll(newUnspentCoins); + + newUnspentCoins.forEach((coin) { + final coinInfoList = unspentCoinsInfo.values.where( + (element) => + element.walletId.contains(id) && + element.hash.contains(coin.hash) && + element.vout == coin.vout, + ); + + if (coinInfoList.isNotEmpty) { + final coinInfo = coinInfoList.first; + + coin.isFrozen = coinInfo.isFrozen; + coin.isSending = coinInfo.isSending; + coin.note = coinInfo.note; + if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) + coin.bitcoinAddressRecord.balance += coinInfo.value; + } else { + _addCoinInfo(coin); + } + }); + } + } + + @action + Future> fetchUnspent(BitcoinAddressRecord address) async { + final unspents = await electrumClient.getListUnspent(address.getScriptHash(network)); + + List updatedUnspentCoins = []; + + await Future.wait(unspents.map((unspent) async { + try { + final coin = BitcoinUnspent.fromJSON(address, unspent); + final tx = await fetchTransactionInfo(hash: coin.hash, height: 0); + coin.isChange = address.isHidden; + coin.confirmations = tx?.confirmations; + + updatedUnspentCoins.add(coin); + } catch (_) {} + })); + + return updatedUnspentCoins; + } + + @action Future _addCoinInfo(BitcoinUnspent coin) async { final newInfo = UnspentCoinsInfo( walletId: id, @@ -897,6 +1248,7 @@ abstract class ElectrumWalletBase value: coin.value, vout: coin.vout, isChange: coin.isChange, + isSilentPayment: coin is BitcoinSilentPaymentsUnspent, ); await unspentCoinsInfo.add(newInfo); @@ -1112,8 +1464,9 @@ abstract class ElectrumWalletBase (await http.get(Uri.parse("https://blockstream.info/testnet/api/tx/$hash/status"))).body); time = status["block_time"] as int?; - final tip = await electrumClient.getCurrentBlockChainTip() ?? 0; - confirmations = tip - (status["block_height"] as int? ?? 0); + final height = status["block_height"] as int? ?? 0; + final tip = await getCurrentChainTip(); + if (tip > 0) confirmations = height > 0 ? tip - height + 1 : 0; } else { final verboseTransaction = await electrumClient.getTransactionRaw(hash: hash); @@ -1122,13 +1475,11 @@ abstract class ElectrumWalletBase confirmations = verboseTransaction['confirmations'] as int? ?? 0; } - final original = bitcoin_base.BtcTransaction.fromRaw(transactionHex); - final ins = []; + final original = BtcTransaction.fromRaw(transactionHex); + final ins = []; for (final vin in original.inputs) { - final txHex = await electrumClient.getTransactionHex(hash: vin.txId); - final tx = bitcoin_base.BtcTransaction.fromRaw(txHex); - ins.add(tx); + ins.add(BtcTransaction.fromRaw(await electrumClient.getTransactionHex(hash: vin.txId))); } return ElectrumTransactionBundle( @@ -1140,18 +1491,15 @@ abstract class ElectrumWalletBase } Future fetchTransactionInfo( - {required String hash, - required int height, - required Set myAddresses, - bool? retryOnFailure}) async { + {required String hash, required int height, bool? retryOnFailure}) async { try { return ElectrumTransactionInfo.fromElectrumBundle( await getTransactionExpanded(hash: hash), walletInfo.type, network, - addresses: myAddresses, height: height); + addresses: addressesSet, height: height); } catch (e) { if (e is FormatException && retryOnFailure == true) { await Future.delayed(const Duration(seconds: 2)); - return fetchTransactionInfo(hash: hash, height: height, myAddresses: myAddresses); + return fetchTransactionInfo(hash: hash, height: height); } return null; } @@ -1161,39 +1509,15 @@ abstract class ElectrumWalletBase Future> fetchTransactions() async { try { final Map historiesWithDetails = {}; - final addressesSet = walletAddresses.allAddresses.map((addr) => addr.address).toSet(); - final currentHeight = await electrumClient.getCurrentBlockChainTip() ?? 0; - - await Future.wait(ADDRESS_TYPES.map((type) { - final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); - - return Future.wait(addressesByType.map((addressRecord) async { - final history = await _fetchAddressHistory(addressRecord, addressesSet, currentHeight); - final balance = await electrumClient.getBalance(addressRecord.scriptHash!); - - if (history.isNotEmpty) { - addressRecord.txCount = history.length; - addressRecord.balance = balance['confirmed'] as int? ?? 0; - historiesWithDetails.addAll(history); - - final matchedAddresses = - addressesByType.where((addr) => addr.isHidden == addressRecord.isHidden); - - final isLastUsedAddress = - history.isNotEmpty && addressRecord.address == matchedAddresses.last.address; - - if (isLastUsedAddress) { - await walletAddresses.discoverAddresses( - matchedAddresses.toList(), - addressRecord.isHidden, - (address, addressesSet) => - _fetchAddressHistory(address, addressesSet, currentHeight) - .then((history) => history.isNotEmpty ? address.address : null), - type: type); - } - } - })); - })); + + if (type == WalletType.bitcoin) { + await Future.wait(ADDRESS_TYPES + .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); + } else if (type == WalletType.bitcoinCash) { + await fetchTransactionsForAddressType(historiesWithDetails, P2pkhAddressType.p2pkh); + } else if (type == WalletType.litecoin) { + await fetchTransactionsForAddressType(historiesWithDetails, SegwitAddresType.p2wpkh); + } return historiesWithDetails; } catch (e) { @@ -1202,13 +1526,59 @@ abstract class ElectrumWalletBase } } + Future fetchTransactionsForAddressType( + Map historiesWithDetails, + BitcoinAddressType type, + ) async { + final addressesByType = walletAddresses.allAddresses.where((addr) => addr.type == type); + final hiddenAddresses = addressesByType.where((addr) => addr.isHidden == true); + final receiveAddresses = addressesByType.where((addr) => addr.isHidden == false); + + await Future.wait(addressesByType.map((addressRecord) async { + final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); + + if (history.isNotEmpty) { + addressRecord.txCount = history.length; + historiesWithDetails.addAll(history); + + final matchedAddresses = addressRecord.isHidden ? hiddenAddresses : receiveAddresses; + final isUsedAddressUnderGap = matchedAddresses.toList().indexOf(addressRecord) >= + matchedAddresses.length - + (addressRecord.isHidden + ? ElectrumWalletAddressesBase.defaultChangeAddressesCount + : ElectrumWalletAddressesBase.defaultReceiveAddressesCount); + + if (isUsedAddressUnderGap) { + final prevLength = walletAddresses.allAddresses.length; + + // Discover new addresses for the same address type until the gap limit is respected + await walletAddresses.discoverAddresses( + matchedAddresses.toList(), + addressRecord.isHidden, + (address) async { + await _subscribeForUpdates(); + return _fetchAddressHistory(address, await getCurrentChainTip()) + .then((history) => history.isNotEmpty ? address.address : null); + }, + type: type, + ); + + final newLength = walletAddresses.allAddresses.length; + + if (newLength > prevLength) { + await fetchTransactionsForAddressType(historiesWithDetails, type); + } + } + } + })); + } + Future> _fetchAddressHistory( - BitcoinAddressRecord addressRecord, Set addressesSet, int currentHeight) async { + BitcoinAddressRecord addressRecord, int? currentHeight) async { try { final Map historiesWithDetails = {}; - final history = await electrumClient - .getHistory(addressRecord.scriptHash ?? addressRecord.updateScriptHash(network)); + final history = await electrumClient.getHistory(addressRecord.getScriptHash(network)); if (history.isNotEmpty) { addressRecord.setAsUsed(); @@ -1222,14 +1592,13 @@ abstract class ElectrumWalletBase if (height > 0) { storedTx.height = height; // the tx's block itself is the first confirmation so add 1 - storedTx.confirmations = currentHeight - height + 1; + if (currentHeight != null) storedTx.confirmations = currentHeight - height + 1; storedTx.isPending = storedTx.confirmations == 0; } historiesWithDetails[txid] = storedTx; } else { - final tx = await fetchTransactionInfo( - hash: txid, height: height, myAddresses: addressesSet, retryOnFailure: true); + final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true); if (tx != null) { historiesWithDetails[txid] = tx; @@ -1258,6 +1627,12 @@ abstract class ElectrumWalletBase return; } + transactionHistory.transactions.values.forEach((tx) async { + if (tx.unspents != null && tx.unspents!.isNotEmpty && tx.height > 0) { + tx.confirmations = await getCurrentChainTip() - tx.height + 1; + } + }); + _isTransactionUpdating = true; await fetchTransactions(); walletAddresses.updateReceiveAddresses(); @@ -1269,15 +1644,22 @@ abstract class ElectrumWalletBase } } - void _subscribeForUpdates() { - scriptHashes.forEach((sh) async { + Future _subscribeForUpdates() async { + final unsubscribedScriptHashes = walletAddresses.allAddresses.where( + (address) => !_scripthashesUpdateSubject.containsKey(address.getScriptHash(network)), + ); + + await Future.wait(unsubscribedScriptHashes.map((address) async { + final sh = address.getScriptHash(network); await _scripthashesUpdateSubject[sh]?.close(); - _scripthashesUpdateSubject[sh] = electrumClient.scripthashUpdate(sh); + _scripthashesUpdateSubject[sh] = await electrumClient.scripthashUpdate(sh); _scripthashesUpdateSubject[sh]?.listen((event) async { try { - await updateUnspent(); + await updateUnspents(address); + await updateBalance(); - await updateTransactions(); + + await _fetchAddressHistory(address, await getCurrentChainTip()); } catch (e, s) { print(e.toString()); _onError?.call(FlutterErrorDetails( @@ -1287,7 +1669,7 @@ abstract class ElectrumWalletBase )); } }); - }); + })); } Future _fetchBalances() async { @@ -1301,21 +1683,25 @@ abstract class ElectrumWalletBase } var totalFrozen = 0; - unspentCoinsInfo.values.forEach((info) { - unspentCoins.forEach((element) { - if (element.hash == info.hash && - element.vout == info.vout && - info.isFrozen && - element.bitcoinAddressRecord.address == info.address && - element.value == info.value) { - totalFrozen += element.value; + var totalConfirmed = 0; + var totalUnconfirmed = 0; + + if (hasSilentPaymentsScanning) { + // Add values from unspent coins that are not fetched by the address list + // i.e. scanned silent payments + transactionHistory.transactions.values.forEach((tx) { + if (tx.unspents != null) { + tx.unspents!.forEach((unspent) { + if (unspent.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { + if (unspent.isFrozen) totalFrozen += unspent.value; + totalConfirmed += unspent.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]; @@ -1363,6 +1749,29 @@ abstract class ElectrumWalletBase return base64Encode(HD.signMessage(message)); } + Future _setInitialHeight() async { + if (_chainTipUpdateSubject != null) return; + + if ((_currentChainTip == null || _currentChainTip! == 0) && walletInfo.restoreHeight == 0) { + await getUpdatedChainTip(); + await walletInfo.updateRestoreHeight(_currentChainTip!); + } + + _chainTipUpdateSubject = electrumClient.chainTipSubscribe(); + _chainTipUpdateSubject?.listen((e) async { + final event = e as Map; + final height = int.tryParse(event['height'].toString()); + + if (height != null) { + _currentChainTip = height; + + if (alwaysScan == true && syncStatus is SyncedSyncStatus) { + _setListeners(walletInfo.restoreHeight); + } + } + }); + } + static BasedUtxoNetwork _getNetwork(bitcoin.NetworkType networkType, CryptoCurrency? currency) { if (networkType == bitcoin.bitcoin && currency == CryptoCurrency.bch) { return BitcoinCashNetwork.mainnet; @@ -1383,39 +1792,248 @@ abstract class ElectrumWalletBase derivationPath.substring(0, derivationPath.lastIndexOf("'") + 1); } -class EstimateTxParams { - EstimateTxParams( - {required this.amount, - required this.feeRate, - required this.priority, - required this.outputsCount, - required this.size}); +class ScanNode { + final Uri uri; + final bool? useSSL; - final int amount; - final int feeRate; - final TransactionPriority priority; - final int outputsCount; - final int size; + ScanNode(this.uri, this.useSSL); +} + +class ScanData { + final SendPort sendPort; + final SilentPaymentOwner silentAddress; + final int height; + final ScanNode node; + final BasedUtxoNetwork network; + final int chainTip; + final ElectrumClient electrumClient; + final List transactionHistoryIds; + final Map labels; + final List labelIndexes; + final bool isSingleScan; + + ScanData({ + required this.sendPort, + required this.silentAddress, + required this.height, + required this.node, + required this.network, + required this.chainTip, + required this.electrumClient, + required this.transactionHistoryIds, + required this.labels, + required this.labelIndexes, + required this.isSingleScan, + }); + + factory ScanData.fromHeight(ScanData scanData, int newHeight) { + return ScanData( + sendPort: scanData.sendPort, + silentAddress: scanData.silentAddress, + height: newHeight, + node: scanData.node, + network: scanData.network, + chainTip: scanData.chainTip, + transactionHistoryIds: scanData.transactionHistoryIds, + electrumClient: scanData.electrumClient, + labels: scanData.labels, + labelIndexes: scanData.labelIndexes, + isSingleScan: scanData.isSingleScan, + ); + } +} + +class SyncResponse { + final int height; + final SyncStatus syncStatus; + + SyncResponse(this.height, this.syncStatus); +} + +Future startRefresh(ScanData scanData) async { + int syncHeight = scanData.height; + int initialSyncHeight = syncHeight; + + BehaviorSubject? tweaksSubscription = null; + + final syncingStatus = scanData.isSingleScan + ? SyncingSyncStatus(1, 0) + : SyncingSyncStatus.fromHeightValues(scanData.chainTip, initialSyncHeight, syncHeight); + + // Initial status UI update, send how many blocks left to scan + scanData.sendPort.send(SyncResponse(syncHeight, syncingStatus)); + + final electrumClient = scanData.electrumClient; + await electrumClient.connectToUri(scanData.node.uri, useSSL: scanData.node.useSSL); + + if (tweaksSubscription == null) { + final count = scanData.isSingleScan ? 1 : TWEAKS_COUNT; + final receiver = Receiver( + scanData.silentAddress.b_scan.toHex(), + scanData.silentAddress.B_spend.toHex(), + scanData.network == BitcoinNetwork.testnet, + scanData.labelIndexes, + scanData.labelIndexes.length, + ); + + tweaksSubscription = await electrumClient.tweaksSubscribe(height: syncHeight, count: count); + tweaksSubscription?.listen((t) async { + final tweaks = t as Map; + + if (tweaks["message"] != null) { + // re-subscribe to continue receiving messages + electrumClient.tweaksSubscribe(height: syncHeight, count: count); + return; + } + + final blockHeight = tweaks.keys.first; + final tweakHeight = int.parse(blockHeight); + + try { + final blockTweaks = tweaks[blockHeight] as Map; + + for (var j = 0; j < blockTweaks.keys.length; j++) { + final txid = blockTweaks.keys.elementAt(j); + final details = blockTweaks[txid] as Map; + final outputPubkeys = (details["output_pubkeys"] as Map); + final tweak = details["tweak"].toString(); + + try { + // scanOutputs called from rust here + final addToWallet = scanOutputs( + outputPubkeys.values.toList(), + tweak, + receiver, + ); + + if (addToWallet.isEmpty) { + // no results tx, continue to next tx + continue; + } + + // placeholder ElectrumTransactionInfo object to update values based on new scanned unspent(s) + final txInfo = ElectrumTransactionInfo( + WalletType.bitcoin, + id: txid, + height: tweakHeight, + amount: 0, + fee: 0, + direction: TransactionDirection.incoming, + isPending: false, + date: scanData.network == BitcoinNetwork.mainnet + ? getDateByBitcoinHeight(tweakHeight) + : DateTime.now(), + confirmations: scanData.chainTip - tweakHeight + 1, + unspents: [], + ); + + addToWallet.forEach((label, value) { + (value as Map).forEach((output, tweak) { + final t_k = tweak.toString(); + + final receivingOutputAddress = ECPublic.fromHex(output) + .toTaprootAddress(tweak: false) + .toAddress(scanData.network); + + int? amount; + int? pos; + outputPubkeys.entries.firstWhere((k) { + final isMatchingOutput = k.value[0] == output; + if (isMatchingOutput) { + amount = int.parse(k.value[1].toString()); + pos = int.parse(k.key.toString()); + return true; + } + return false; + }); + + final receivedAddressRecord = BitcoinSilentPaymentAddressRecord( + receivingOutputAddress, + index: 0, + isHidden: false, + isUsed: true, + network: scanData.network, + silentPaymentTweak: t_k, + type: SegwitAddresType.p2tr, + txCount: 1, + balance: amount!, + ); + + final unspent = BitcoinSilentPaymentsUnspent( + receivedAddressRecord, + txid, + amount!, + pos!, + silentPaymentTweak: t_k, + silentPaymentLabel: label == "None" ? null : label, + ); + + txInfo.unspents!.add(unspent); + txInfo.amount += unspent.value; + }); + }); + + scanData.sendPort.send({txInfo.id: txInfo}); + } catch (_) {} + } + } catch (_) {} + + syncHeight = tweakHeight; + scanData.sendPort.send( + SyncResponse( + syncHeight, + SyncingSyncStatus.fromHeightValues( + scanData.chainTip, + initialSyncHeight, + syncHeight, + ), + ), + ); + + if (tweakHeight >= scanData.chainTip || scanData.isSingleScan) { + if (tweakHeight >= scanData.chainTip) + scanData.sendPort.send(SyncResponse( + syncHeight, + SyncedTipSyncStatus(scanData.chainTip), + )); + + if (scanData.isSingleScan) { + scanData.sendPort.send(SyncResponse(syncHeight, SyncedSyncStatus())); + } + + await tweaksSubscription!.close(); + await electrumClient.close(); + } + }); + } + + if (tweaksSubscription == null) { + return scanData.sendPort.send( + SyncResponse(syncHeight, UnsupportedSyncStatus()), + ); + } } class EstimatedTxResult { EstimatedTxResult({ required this.utxos, - required this.privateKeys, + required this.inputPrivKeyInfos, required this.publicKeys, required this.fee, required this.amount, required this.hasChange, required this.isSendAll, this.memo, + required this.spendsSilentPayment, required this.spendsUnconfirmedTX, }); final List utxos; - final List privateKeys; + final List inputPrivKeyInfos; final Map publicKeys; // PubKey to derivationPath final int fee; final int amount; + final bool spendsSilentPayment; final bool hasChange; final bool isSendAll; final String? memo; @@ -1447,6 +2065,8 @@ BitcoinBaseAddress addressTypeFromStr(String address, BasedUtxoNetwork network) return P2wshAddress.fromAddress(address: address, network: network); } else if (P2trAddress.regex.hasMatch(address)) { return P2trAddress.fromAddress(address: address, network: network); + } else if (SilentPaymentAddress.regex.hasMatch(address)) { + return SilentPaymentAddress.fromAddress(address); } else { return P2wpkhAddress.fromAddress(address: address, network: network); } @@ -1461,7 +2081,33 @@ BitcoinAddressType _getScriptType(BitcoinBaseAddress type) { return SegwitAddresType.p2wsh; } else if (type is P2trAddress) { return SegwitAddresType.p2tr; + } else if (type is SilentPaymentsAddresType) { + return SilentPaymentsAddresType.p2sp; } else { return SegwitAddresType.p2wpkh; } } + +class UtxoDetails { + final List availableInputs; + final List unconfirmedCoins; + final List utxos; + final List vinOutpoints; + final List inputPrivKeyInfos; + final Map publicKeys; // PubKey to derivationPath + final int allInputsAmount; + final bool spendsSilentPayment; + final bool spendsUnconfirmedTX; + + UtxoDetails({ + required this.availableInputs, + required this.unconfirmedCoins, + required this.utxos, + required this.vinOutpoints, + required this.inputPrivKeyInfos, + required this.publicKeys, + required this.allInputsAmount, + required this.spendsSilentPayment, + required this.spendsUnconfirmedTX, + }); +} diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index c43d4988a3..e0857a6d03 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -1,7 +1,7 @@ import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; +import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; -import 'package:cw_bitcoin/electrum.dart'; import 'package:cw_core/wallet_addresses.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; @@ -24,15 +24,17 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { WalletInfo walletInfo, { required this.mainHd, required this.sideHd, - required this.electrumClient, required this.network, List? initialAddresses, Map? initialRegularAddressIndex, Map? initialChangeAddressIndex, + List? initialSilentAddresses, + int initialSilentAddressIndex = 0, + bitcoin.HDWallet? masterHd, BitcoinAddressType? initialAddressPageType, }) : _addresses = ObservableList.of((initialAddresses ?? []).toSet()), addressesByReceiveType = - ObservableList.of(([]).toSet()), + ObservableList.of(([]).toSet()), receiveAddresses = ObservableList.of((initialAddresses ?? []) .where((addressRecord) => !addressRecord.isHidden && !addressRecord.isUsed) .toSet()), @@ -45,7 +47,38 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { (walletInfo.addressPageType != null ? BitcoinAddressType.fromValue(walletInfo.addressPageType!) : SegwitAddresType.p2wpkh), + silentAddresses = ObservableList.of( + (initialSilentAddresses ?? []).toSet()), + currentSilentAddressIndex = initialSilentAddressIndex, super(walletInfo) { + if (masterHd != null) { + silentAddress = SilentPaymentOwner.fromPrivateKeys( + b_scan: ECPrivate.fromHex(masterHd.derivePath(SCAN_PATH).privKey!), + b_spend: ECPrivate.fromHex(masterHd.derivePath(SPEND_PATH).privKey!), + hrp: network == BitcoinNetwork.testnet ? 'tsp' : 'sp'); + + if (silentAddresses.length == 0) { + silentAddresses.add(BitcoinSilentPaymentAddressRecord( + silentAddress.toString(), + index: 0, + isHidden: false, + name: "", + silentPaymentTweak: null, + network: network, + type: SilentPaymentsAddresType.p2sp, + )); + silentAddresses.add(BitcoinSilentPaymentAddressRecord( + silentAddress!.toLabeledSilentPaymentAddress(0).toString(), + index: 0, + isHidden: true, + name: "", + silentPaymentTweak: BytesUtils.toHexString(silentAddress!.generateLabel(0)), + network: network, + type: SilentPaymentsAddresType.p2sp, + )); + } + } + updateAddressesByMatch(); } @@ -54,27 +87,40 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { static const gap = 20; final ObservableList _addresses; - // Matched by addressPageType - late ObservableList addressesByReceiveType; + late ObservableList addressesByReceiveType; final ObservableList receiveAddresses; final ObservableList changeAddresses; - final ElectrumClient electrumClient; + final ObservableList silentAddresses; final BasedUtxoNetwork network; final bitcoin.HDWallet mainHd; final bitcoin.HDWallet sideHd; + @observable + SilentPaymentOwner? silentAddress; + @observable late BitcoinAddressType _addressPageType; @computed BitcoinAddressType get addressPageType => _addressPageType; + @observable + String? activeSilentAddress; + @computed List get allAddresses => _addresses; @override @computed String get address { + if (addressPageType == SilentPaymentsAddresType.p2sp) { + if (activeSilentAddress != null) { + return activeSilentAddress!; + } + + return silentAddress.toString(); + } + String receiveAddress; final typeMatchingReceiveAddresses = @@ -103,6 +149,18 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { @override set address(String addr) { + if (addressPageType == SilentPaymentsAddresType.p2sp) { + final selected = silentAddresses.firstWhere((addressRecord) => addressRecord.address == addr); + + if (selected.silentPaymentTweak != null && silentAddress != null) { + activeSilentAddress = + silentAddress!.toLabeledSilentPaymentAddress(selected.index).toString(); + } else { + activeSilentAddress = silentAddress!.toString(); + } + return; + } + final addressRecord = _addresses.firstWhere((addressRecord) => addressRecord.address == addr); previousAddressRecord = addressRecord; @@ -129,6 +187,8 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { void set currentChangeAddressIndex(int index) => currentChangeAddressIndexByType[_addressPageType.toString()] = index; + int currentSilentAddressIndex; + @observable BitcoinAddressRecord? previousAddressRecord; @@ -196,7 +256,50 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return address; } - BitcoinAddressRecord generateNewAddress({String label = ''}) { + Map get labels { + final G = ECPublic.fromBytes(BigintUtils.toBytes(Curves.generatorSecp256k1.x, length: 32)); + final labels = {}; + for (int i = 0; i < silentAddresses.length; i++) { + final silentAddressRecord = silentAddresses[i]; + final silentPaymentTweak = silentAddressRecord.silentPaymentTweak; + + if (silentPaymentTweak != null && + SilentPaymentAddress.regex.hasMatch(silentAddressRecord.address)) { + labels[G + .tweakMul(BigintUtils.fromBytes(BytesUtils.fromHexString(silentPaymentTweak))) + .toHex()] = silentPaymentTweak; + } + } + return labels; + } + + @action + BaseBitcoinAddressRecord generateNewAddress({String label = ''}) { + if (addressPageType == SilentPaymentsAddresType.p2sp && silentAddress != null) { + final currentSilentAddressIndex = silentAddresses + .where((addressRecord) => addressRecord.type != SegwitAddresType.p2tr) + .length - + 1; + + this.currentSilentAddressIndex = currentSilentAddressIndex; + + final address = BitcoinSilentPaymentAddressRecord( + silentAddress!.toLabeledSilentPaymentAddress(currentSilentAddressIndex).toString(), + index: currentSilentAddressIndex, + isHidden: false, + name: label, + silentPaymentTweak: + BytesUtils.toHexString(silentAddress!.generateLabel(currentSilentAddressIndex)), + network: network, + type: SilentPaymentsAddresType.p2sp, + ); + + silentAddresses.add(address); + updateAddressesByMatch(); + + return address; + } + final newAddressIndex = addressesByReceiveType.fold( 0, (int acc, addressRecord) => addressRecord.isHidden == false ? acc + 1 : acc); @@ -221,12 +324,70 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { Future updateAddressesInBox() async { try { addressesMap.clear(); - addressesMap[address] = ''; + addressesMap[address] = 'Active'; allAddressesMap.clear(); _addresses.forEach((addressRecord) { allAddressesMap[addressRecord.address] = addressRecord.name; }); + + final lastP2wpkh = _addresses + .where((addressRecord) => + _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh)) + .toList() + .last; + if (lastP2wpkh.address != address) { + addressesMap[lastP2wpkh.address] = 'P2WPKH'; + } else { + addressesMap[address] = 'Active - P2WPKH'; + } + + final lastP2pkh = _addresses.firstWhere( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh)); + if (lastP2pkh.address != address) { + addressesMap[lastP2pkh.address] = 'P2PKH'; + } else { + addressesMap[address] = 'Active - P2PKH'; + } + + final lastP2sh = _addresses.firstWhere((addressRecord) => + _isUnusedReceiveAddressByType(addressRecord, P2shAddressType.p2wpkhInP2sh)); + if (lastP2sh.address != address) { + addressesMap[lastP2sh.address] = 'P2SH'; + } else { + addressesMap[address] = 'Active - P2SH'; + } + + final lastP2tr = _addresses.firstWhere( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr)); + if (lastP2tr.address != address) { + addressesMap[lastP2tr.address] = 'P2TR'; + } else { + addressesMap[address] = 'Active - P2TR'; + } + + final lastP2wsh = _addresses.firstWhere( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh)); + if (lastP2wsh.address != address) { + addressesMap[lastP2wsh.address] = 'P2WSH'; + } else { + addressesMap[address] = 'Active - P2WSH'; + } + + silentAddresses.forEach((addressRecord) { + if (addressRecord.type != SilentPaymentsAddresType.p2sp || addressRecord.isHidden) { + return; + } + + if (addressRecord.address != address) { + addressesMap[addressRecord.address] = addressRecord.name.isEmpty + ? "Silent Payments" + : "Silent Payments - " + addressRecord.name; + } else { + addressesMap[address] = 'Active - Silent Payments'; + } + }); + await saveAddressesInBox(); } catch (e) { print(e.toString()); @@ -235,18 +396,41 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { @action void updateAddress(String address, String label) { - final addressRecord = - _addresses.firstWhere((addressRecord) => addressRecord.address == address); - addressRecord.setNewName(label); - final index = _addresses.indexOf(addressRecord); - _addresses.remove(addressRecord); - _addresses.insert(index, addressRecord); + BaseBitcoinAddressRecord? foundAddress; + _addresses.forEach((addressRecord) { + if (addressRecord.address == address) { + foundAddress = addressRecord; + } + }); + silentAddresses.forEach((addressRecord) { + if (addressRecord.address == address) { + foundAddress = addressRecord; + } + }); - updateAddressesByMatch(); + if (foundAddress != null) { + foundAddress!.setNewName(label); + + if (foundAddress is BitcoinAddressRecord) { + final index = _addresses.indexOf(foundAddress); + _addresses.remove(foundAddress); + _addresses.insert(index, foundAddress as BitcoinAddressRecord); + } else { + final index = silentAddresses.indexOf(foundAddress as BitcoinSilentPaymentAddressRecord); + silentAddresses.remove(foundAddress); + silentAddresses.insert(index, foundAddress as BitcoinSilentPaymentAddressRecord); + } + } } @action void updateAddressesByMatch() { + if (addressPageType == SilentPaymentsAddresType.p2sp) { + addressesByReceiveType.clear(); + addressesByReceiveType.addAll(silentAddresses); + return; + } + addressesByReceiveType.clear(); addressesByReceiveType.addAll(_addresses.where(_isAddressPageTypeMatch).toList()); } @@ -272,7 +456,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { @action Future discoverAddresses(List addressList, bool isHidden, - Future Function(BitcoinAddressRecord, Set) getAddressHistory, + Future Function(BitcoinAddressRecord) getAddressHistory, {BitcoinAddressType type = SegwitAddresType.p2wpkh}) async { if (!isHidden) { _validateSideHdAddresses(addressList.toList()); @@ -282,8 +466,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { startIndex: addressList.length, isHidden: isHidden, type: type); addAddresses(newAddresses); - final addressesWithHistory = await Future.wait(newAddresses - .map((addr) => getAddressHistory(addr, _addresses.map((e) => e.address).toSet()))); + final addressesWithHistory = await Future.wait(newAddresses.map(getAddressHistory)); final isLastAddressUsed = addressesWithHistory.last == addressList.last.address; if (isLastAddressUsed) { @@ -349,6 +532,15 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { updateAddressesByMatch(); } + @action + void addSilentAddresses(Iterable addresses) { + final addressesSet = this.silentAddresses.toSet(); + addressesSet.addAll(addresses); + this.silentAddresses.clear(); + this.silentAddresses.addAll(addressesSet); + updateAddressesByMatch(); + } + void _validateSideHdAddresses(List addrWithTransactions) { addrWithTransactions.forEach((element) { if (element.address != @@ -371,4 +563,15 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { bitcoin.HDWallet _getHd(bool isHidden) => isHidden ? sideHd : mainHd; bool _isAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => addr.type == type; + bool _isUnusedReceiveAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => + !addr.isHidden && !addr.isUsed && addr.type == type; + + @action + void deleteSilentPaymentAddress(String address) { + final addressRecord = silentAddresses.firstWhere((addressRecord) => + addressRecord.type == SilentPaymentsAddresType.p2sp && addressRecord.address == address); + + silentAddresses.remove(addressRecord); + updateAddressesByMatch(); + } } diff --git a/cw_bitcoin/lib/electrum_wallet_snapshot.dart b/cw_bitcoin/lib/electrum_wallet_snapshot.dart index 340b17cfb9..3e3f391318 100644 --- a/cw_bitcoin/lib/electrum_wallet_snapshot.dart +++ b/cw_bitcoin/lib/electrum_wallet_snapshot.dart @@ -19,6 +19,8 @@ class ElectrumWalletSnapshot { required this.regularAddressIndex, required this.changeAddressIndex, required this.addressPageType, + required this.silentAddresses, + required this.silentAddressIndex, this.passphrase, this.derivationType, this.derivationPath, @@ -32,9 +34,11 @@ class ElectrumWalletSnapshot { String? mnemonic; String? xpub; List addresses; + List silentAddresses; ElectrumBalance balance; Map regularAddressIndex; Map changeAddressIndex; + int silentAddressIndex; String? passphrase; DerivationType? derivationType; String? derivationPath; @@ -50,15 +54,23 @@ class ElectrumWalletSnapshot { final passphrase = data['passphrase'] as String? ?? ''; final addresses = addressesTmp .whereType() - .map((addr) => BitcoinAddressRecord.fromJSON(addr, network)) + .map((addr) => BitcoinAddressRecord.fromJSON(addr, network: network)) .toList(); - final balance = ElectrumBalance.fromJSON(data['balance'] as String) ?? + + final silentAddressesTmp = data['silent_addresses'] as List? ?? []; + final silentAddresses = silentAddressesTmp + .whereType() + .map((addr) => BitcoinSilentPaymentAddressRecord.fromJSON(addr, network: network)) + .toList(); + + final balance = ElectrumBalance.fromJSON(data['balance'] as String?) ?? ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); var regularAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; var changeAddressIndexByType = {SegwitAddresType.p2wpkh.toString(): 0}; + var silentAddressIndex = 0; - final derivationType = - DerivationType.values[(data['derivationTypeIndex'] as int?) ?? DerivationType.electrum.index]; + final derivationType = DerivationType + .values[(data['derivationTypeIndex'] as int?) ?? DerivationType.electrum.index]; final derivationPath = data['derivationPath'] as String? ?? "m/0'/0"; try { @@ -69,6 +81,7 @@ class ElectrumWalletSnapshot { 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? ?? {}; @@ -90,6 +103,8 @@ class ElectrumWalletSnapshot { addressPageType: data['address_page_type'] as String?, derivationType: derivationType, derivationPath: derivationPath, + silentAddresses: silentAddresses, + silentAddressIndex: silentAddressIndex, ); } } diff --git a/cw_bitcoin/lib/exceptions.dart b/cw_bitcoin/lib/exceptions.dart index 979c1a4334..3307bfeed2 100644 --- a/cw_bitcoin/lib/exceptions.dart +++ b/cw_bitcoin/lib/exceptions.dart @@ -2,7 +2,7 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/exceptions.dart'; class BitcoinTransactionWrongBalanceException extends TransactionWrongBalanceException { - BitcoinTransactionWrongBalanceException() : super(CryptoCurrency.btc); + BitcoinTransactionWrongBalanceException({super.amount}) : super(CryptoCurrency.btc); } class BitcoinTransactionNoInputsException extends TransactionNoInputsException {} @@ -27,3 +27,7 @@ class BitcoinTransactionCommitFailedDustOutputSendAll extends TransactionCommitFailedDustOutputSendAll {} class BitcoinTransactionCommitFailedVoutNegative extends TransactionCommitFailedVoutNegative {} + +class BitcoinTransactionCommitFailedBIP68Final extends TransactionCommitFailedBIP68Final {} + +class BitcoinTransactionSilentPaymentsNotSupported extends TransactionInputNotSupported {} diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index 9cc2072cad..209ddc774f 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -44,7 +44,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { currency: CryptoCurrency.ltc) { walletAddresses = LitecoinWalletAddresses( walletInfo, - electrumClient: electrumClient, initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, diff --git a/cw_bitcoin/lib/litecoin_wallet_addresses.dart b/cw_bitcoin/lib/litecoin_wallet_addresses.dart index 993d17933a..99b7445fc9 100644 --- a/cw_bitcoin/lib/litecoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/litecoin_wallet_addresses.dart @@ -15,7 +15,6 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with required super.mainHd, required super.sideHd, required super.network, - required super.electrumClient, super.initialAddresses, super.initialRegularAddressIndex, super.initialChangeAddressIndex, diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index a59b4f4293..6a4cd17419 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -73,6 +73,11 @@ class PendingBitcoinTransaction with PendingTransaction { if (error.contains("bad-txns-vout-negative")) { throw BitcoinTransactionCommitFailedVoutNegative(); } + + if (error.contains("non-BIP68-final")) { + throw BitcoinTransactionCommitFailedBIP68Final(); + } + throw BitcoinTransactionCommitFailed(errorMessage: error); } diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index 7690c9c85b..3eadcb112f 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: asn1lib - sha256: c9c85fedbe2188b95133cbe960e16f5f448860f7133330e272edbbca5893ddc6 + sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" async: dependency: transitive description: @@ -79,11 +79,11 @@ packages: dependency: "direct main" description: path: "." - ref: cake-update-v2 - resolved-ref: "01d844a5f5a520a31df5254e34169af4664aa769" - url: "https://github.com/cake-tech/bitcoin_base.git" + ref: cake-update-v3 + resolved-ref: cc99eedb1d28ee9376dda0465ef72aa627ac6149 + url: "https://github.com/cake-tech/bitcoin_base" source: git - version: "4.2.0" + version: "4.2.1" bitcoin_flutter: dependency: "direct main" description: @@ -96,11 +96,12 @@ packages: blockchain_utils: dependency: "direct main" description: - name: blockchain_utils - sha256: "38ef5f4a22441ac4370aed9071dc71c460acffc37c79b344533f67d15f24c13c" - url: "https://pub.dev" - source: hosted - version: "2.1.1" + path: "." + ref: cake-update-v1 + resolved-ref: cabd7e0e16c4da9920338c76eff3aeb8af0211f3 + url: "https://github.com/cake-tech/blockchain_utils" + source: git + version: "2.1.2" boolean_selector: dependency: transitive description: @@ -197,6 +198,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + url: "https://pub.dev" + source: hosted + version: "0.4.1" clock: dependency: transitive description: @@ -241,10 +250,10 @@ packages: dependency: "direct main" description: name: cryptography - sha256: df156c5109286340817d21fa7b62f9140f17915077127dd70f8bd7a2a0997a35 + sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.7.0" cw_core: dependency: "direct main" description: @@ -288,10 +297,18 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" + ffigen: + dependency: transitive + description: + name: ffigen + sha256: d3e76c2ad48a4e7f93a29a162006f00eba46ce7c08194a77bb5c5e97d1b5ff0a + url: "https://pub.dev" + source: hosted + version: "8.0.2" file: dependency: transitive description: @@ -346,10 +363,10 @@ packages: dependency: transitive description: name: functional_data - sha256: aefdec4365452283b2a7cf420a3169654d51d3e9553069a22d76680d7a9d7c3d + sha256: "76d17dc707c40e552014f5a49c0afcc3f1e3f05e800cd6b7872940bfe41a5039" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" glob: dependency: transitive description: @@ -394,10 +411,10 @@ packages: dependency: "direct main" description: name: http - sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" http_multi_server: dependency: transitive description: @@ -442,10 +459,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "4.9.0" leak_tracker: dependency: transitive description: @@ -531,10 +548,10 @@ packages: dependency: transitive description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" mobx: dependency: "direct main" description: @@ -579,26 +596,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.4" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" path_provider_linux: dependency: transitive description: @@ -643,10 +660,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "70fe966348fe08c34bf929582f1d8247d9d9408130723206472b4687227e4333" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "3.9.1" pool: dependency: transitive description: @@ -687,6 +704,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" reactive_ble_mobile: dependency: transitive description: @@ -736,10 +761,10 @@ packages: dependency: transitive description: name: socks5_proxy - sha256: "1d21b5606169654bbf4cfb904e8e6ed897e9f763358709f87310c757096d909a" + sha256: "045cbba84f6e2b01c1c77634a63e926352bf110ef5f07fc462c6d43bbd4b6a83" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5+dev.2" source_gen: dependency: transitive description: @@ -764,6 +789,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sp_scanner: + dependency: "direct main" + description: + path: "." + ref: "sp_v1.0.0" + resolved-ref: a9a4c6d051f37a15a3a52cc2a0094f24c68b62c5 + url: "https://github.com/cake-tech/sp_scanner" + source: git + version: "0.0.1" stack_trace: dependency: transitive description: @@ -860,22 +894,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.5" win32: dependency: transitive description: name: win32 - sha256: "350a11abd2d1d97e0cc7a28a81b781c08002aa2864d9e3f192ca0ffa18b06ed3" + sha256: "0eaf06e3446824099858367950a813472af675116bf63f008a4c2a75ae13e9cb" url: "https://pub.dev" source: hosted - version: "5.0.9" + version: "5.5.0" xdg_directories: dependency: transitive description: @@ -892,6 +934,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: e9c1a3543d2da0db3e90270dbb1e4eebc985ee5e3ffe468d83224472b2194a5f + url: "https://pub.dev" + source: hosted + version: "2.2.1" sdks: - dart: ">=3.2.0-0 <4.0.0" - flutter: ">=3.10.0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.16.6" diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index 265d2f9a22..40f3c6e29b 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -32,13 +32,21 @@ dependencies: cryptography: ^2.0.5 bitcoin_base: git: - url: https://github.com/cake-tech/bitcoin_base.git - ref: cake-update-v2 - blockchain_utils: ^2.1.1 + url: https://github.com/cake-tech/bitcoin_base + ref: cake-update-v3 + blockchain_utils: + git: + url: https://github.com/cake-tech/blockchain_utils + ref: cake-update-v1 ledger_flutter: ^1.0.1 ledger_bitcoin: git: url: https://github.com/cake-tech/ledger-bitcoin + sp_scanner: + git: + url: https://github.com/cake-tech/sp_scanner + ref: sp_v2.0.0 + dev_dependencies: flutter_test: diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart index d58144f1ea..51bd3612d6 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart @@ -46,7 +46,6 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { currency: CryptoCurrency.bch) { walletAddresses = BitcoinCashWalletAddresses( walletInfo, - electrumClient: electrumClient, initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart index 3164651f3c..d543e944cb 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart @@ -15,7 +15,6 @@ abstract class BitcoinCashWalletAddressesBase extends ElectrumWalletAddresses wi required super.mainHd, required super.sideHd, required super.network, - required super.electrumClient, super.initialAddresses, super.initialRegularAddressIndex, super.initialChangeAddressIndex, diff --git a/cw_bitcoin_cash/pubspec.yaml b/cw_bitcoin_cash/pubspec.yaml index ceef539c39..a0ce889c1d 100644 --- a/cw_bitcoin_cash/pubspec.yaml +++ b/cw_bitcoin_cash/pubspec.yaml @@ -31,10 +31,12 @@ dependencies: ref: Add-Support-For-OP-Return-data bitcoin_base: git: - url: https://github.com/cake-tech/bitcoin_base.git - ref: cake-update-v2 - - + url: https://github.com/cake-tech/bitcoin_base + ref: cake-update-v3 + blockchain_utils: + git: + url: https://github.com/cake-tech/blockchain_utils + ref: cake-update-v1 dev_dependencies: flutter_test: diff --git a/cw_core/lib/crypto_currency.dart b/cw_core/lib/crypto_currency.dart index fb702eca1a..2bd4eaf911 100644 --- a/cw_core/lib/crypto_currency.dart +++ b/cw_core/lib/crypto_currency.dart @@ -104,6 +104,7 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen CryptoCurrency.digibyte, CryptoCurrency.usdtSol, CryptoCurrency.usdcTrc20, + CryptoCurrency.tbtc, ]; static const havenCurrencies = [ @@ -218,7 +219,8 @@ class CryptoCurrency extends EnumerableItem with Serializable implemen static const kaspa = CryptoCurrency(title: 'KAS', fullName: 'Kaspa', raw: 89, name: 'kas', iconPath: 'assets/images/kaspa_icon.png', decimals: 8); static const digibyte = CryptoCurrency(title: 'DGB', fullName: 'DigiByte', raw: 90, name: 'dgb', iconPath: 'assets/images/digibyte.png', decimals: 8); static const usdtSol = CryptoCurrency(title: 'USDT', tag: 'SOL', fullName: 'USDT Tether', raw: 91, name: 'usdtsol', iconPath: 'assets/images/usdt_icon.png', decimals: 6); - static const usdcTrc20 = CryptoCurrency(title: 'USDC', tag: 'TRX', fullName: 'USDC Coin', raw: 92, name: 'usdctrc20', iconPath: 'assets/images/usdc_icon.png', decimals: 6); + static const usdcTrc20 = CryptoCurrency(title: 'USDC', tag: 'TRX', fullName: 'USDC Coin', raw: 92, name: 'usdctrc20', iconPath: 'assets/images/usdc_icon.png', decimals: 6); + static const tbtc = CryptoCurrency(title: 'tBTC', fullName: 'Testnet Bitcoin', raw: 93, name: 'tbtc', iconPath: 'assets/images/tbtc.png', decimals: 8); static final Map _rawCurrencyMap = diff --git a/cw_core/lib/currency_for_wallet_type.dart b/cw_core/lib/currency_for_wallet_type.dart index 92e78b2e6d..8cf438769c 100644 --- a/cw_core/lib/currency_for_wallet_type.dart +++ b/cw_core/lib/currency_for_wallet_type.dart @@ -1,9 +1,12 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_type.dart'; -CryptoCurrency currencyForWalletType(WalletType type) { +CryptoCurrency currencyForWalletType(WalletType type, {bool? isTestnet}) { switch (type) { case WalletType.bitcoin: + if (isTestnet == true) { + return CryptoCurrency.tbtc; + } return CryptoCurrency.btc; case WalletType.monero: return CryptoCurrency.xmr; diff --git a/cw_core/lib/exceptions.dart b/cw_core/lib/exceptions.dart index d07da81095..dccacd799f 100644 --- a/cw_core/lib/exceptions.dart +++ b/cw_core/lib/exceptions.dart @@ -1,9 +1,10 @@ import 'package:cw_core/crypto_currency.dart'; class TransactionWrongBalanceException implements Exception { - TransactionWrongBalanceException(this.currency); + TransactionWrongBalanceException(this.currency, {this.amount}); final CryptoCurrency currency; + final int? amount; } class TransactionNoInputsException implements Exception {} @@ -32,3 +33,7 @@ class TransactionCommitFailedDustOutput implements Exception {} class TransactionCommitFailedDustOutputSendAll implements Exception {} class TransactionCommitFailedVoutNegative implements Exception {} + +class TransactionCommitFailedBIP68Final implements Exception {} + +class TransactionInputNotSupported implements Exception {} diff --git a/cw_core/lib/get_height_by_date.dart b/cw_core/lib/get_height_by_date.dart index 6f3ccaf686..a3dd51b684 100644 --- a/cw_core/lib/get_height_by_date.dart +++ b/cw_core/lib/get_height_by_date.dart @@ -242,3 +242,57 @@ Future getHavenCurrentHeight() async { throw Exception('Failed to load current blockchain height'); } } + +// Data taken from https://timechaincalendar.com/ +const bitcoinDates = { + "2024-05": 841590, + "2024-04": 837182, + "2024-03": 832623, + "2024-02": 828319, + "2024-01": 823807, + "2023-12": 819206, + "2023-11": 814765, + "2023-10": 810098, + "2023-09": 805675, + "2023-08": 801140, + "2023-07": 796640, + "2023-06": 792330, + "2023-05": 787733, + "2023-04": 783403, + "2023-03": 778740, + "2023-02": 774525, + "2023-01": 769810, +}; + +int getBitcoinHeightByDate({required DateTime date}) { + String dateKey = '${date.year}-${date.month.toString().padLeft(2, '0')}'; + final closestKey = bitcoinDates.keys + .firstWhere((key) => formatMapKey(key).isBefore(date), orElse: () => bitcoinDates.keys.last); + final beginningBlock = bitcoinDates[dateKey] ?? bitcoinDates[closestKey]!; + + final startOfMonth = DateTime(date.year, date.month); + final daysDifference = date.difference(startOfMonth).inDays; + + // approximately 6 blocks per hour, 24 hours per day + int estimatedBlocksSinceStartOfMonth = (daysDifference * 24 * 6); + + return beginningBlock + estimatedBlocksSinceStartOfMonth; +} + +DateTime getDateByBitcoinHeight(int height) { + final closestEntry = bitcoinDates.entries + .lastWhere((entry) => entry.value >= height, orElse: () => bitcoinDates.entries.first); + final beginningBlock = closestEntry.value; + + final startOfMonth = formatMapKey(closestEntry.key); + final blocksDifference = height - beginningBlock; + final hoursDifference = blocksDifference / 5.5; + + final estimatedDate = startOfMonth.add(Duration(hours: hoursDifference.ceil())); + + if (estimatedDate.isAfter(DateTime.now())) { + return DateTime.now(); + } + + return estimatedDate; +} diff --git a/cw_core/lib/node.dart b/cw_core/lib/node.dart index 1195b6819f..00b2c51f17 100644 --- a/cw_core/lib/node.dart +++ b/cw_core/lib/node.dart @@ -244,8 +244,12 @@ class Node extends HiveObject with Keyable { Future requestElectrumServer() async { try { - await SecureSocket.connect(uri.host, uri.port, - timeout: Duration(seconds: 5), onBadCertificate: (_) => true); + if (useSSL == true) { + await SecureSocket.connect(uri.host, uri.port, + timeout: Duration(seconds: 5), onBadCertificate: (_) => true); + } else { + await Socket.connect(uri.host, uri.port, timeout: Duration(seconds: 5)); + } return true; } catch (_) { return false; diff --git a/cw_core/lib/sync_status.dart b/cw_core/lib/sync_status.dart index 4983967d0b..55c31877f7 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 { @@ -21,6 +31,17 @@ class SyncedSyncStatus extends SyncStatus { double progress() => 1.0; } +class SyncedTipSyncStatus extends SyncedSyncStatus { + SyncedTipSyncStatus(this.tip); + + final int tip; +} + +class SyncronizingSyncStatus extends SyncStatus { + @override + double progress() => 0.0; +} + class NotConnectedSyncStatus extends SyncStatus { const NotConnectedSyncStatus(); @@ -33,10 +54,7 @@ class AttemptingSyncStatus extends SyncStatus { double progress() => 0.0; } -class FailedSyncStatus extends SyncStatus { - @override - double progress() => 1.0; -} +class FailedSyncStatus extends NotConnectedSyncStatus {} class ConnectingSyncStatus extends SyncStatus { @override @@ -48,7 +66,14 @@ class ConnectedSyncStatus extends SyncStatus { double progress() => 0.0; } -class LostConnectionSyncStatus extends SyncStatus { +class UnsupportedSyncStatus extends NotConnectedSyncStatus {} + +class TimedOutSyncStatus extends NotConnectedSyncStatus { @override - double progress() => 1.0; -} \ No newline at end of file + String toString() => 'Timed out'; +} + +class LostConnectionSyncStatus extends NotConnectedSyncStatus { + @override + String toString() => 'Reconnecting'; +} diff --git a/cw_core/lib/unspent_coins_info.dart b/cw_core/lib/unspent_coins_info.dart index 25abd3e485..ed09e17e0c 100644 --- a/cw_core/lib/unspent_coins_info.dart +++ b/cw_core/lib/unspent_coins_info.dart @@ -16,7 +16,8 @@ class UnspentCoinsInfo extends HiveObject { required this.value, this.keyImage = null, this.isChange = false, - this.accountIndex = 0 + this.accountIndex = 0, + this.isSilentPayment = false, }); static const typeId = UNSPENT_COINS_INFO_TYPE_ID; @@ -49,13 +50,16 @@ class UnspentCoinsInfo extends HiveObject { @HiveField(8, defaultValue: null) String? keyImage; - + @HiveField(9, defaultValue: false) bool isChange; @HiveField(10, defaultValue: 0) int accountIndex; + @HiveField(11, defaultValue: false) + bool? isSilentPayment; + String get note => noteRaw ?? ''; set note(String value) => noteRaw = value; diff --git a/cw_core/lib/unspent_transaction_output.dart b/cw_core/lib/unspent_transaction_output.dart index 595df18f47..d225493e98 100644 --- a/cw_core/lib/unspent_transaction_output.dart +++ b/cw_core/lib/unspent_transaction_output.dart @@ -17,5 +17,6 @@ class Unspent { int? confirmations; 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/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 709462fa13..a616b0bfd8 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -24,7 +24,7 @@ abstract class WalletBase walletInfo.type; - CryptoCurrency get currency => currencyForWalletType(type); + CryptoCurrency get currency => currencyForWalletType(type, isTestnet: isTestnet); String get id => walletInfo.id; diff --git a/cw_core/lib/wallet_info.dart b/cw_core/lib/wallet_info.dart index 57cdad81b0..ff0c011bbe 100644 --- a/cw_core/lib/wallet_info.dart +++ b/cw_core/lib/wallet_info.dart @@ -66,21 +66,21 @@ class DerivationInfo extends HiveObject { @HiveType(typeId: WalletInfo.typeId) class WalletInfo extends HiveObject { WalletInfo( - this.id, - this.name, - this.type, - this.isRecovery, - this.restoreHeight, - this.timestamp, - this.dirPath, - this.path, - this.address, - this.yatEid, - this.yatLastUsedAddressRaw, - this.showIntroCakePayCard, - this.derivationInfo, - this.hardwareWalletType, - ): _yatLastUsedAddressController = StreamController.broadcast(); + this.id, + this.name, + this.type, + this.isRecovery, + this.restoreHeight, + this.timestamp, + this.dirPath, + this.path, + this.address, + this.yatEid, + this.yatLastUsedAddressRaw, + this.showIntroCakePayCard, + this.derivationInfo, + this.hardwareWalletType, + ) : _yatLastUsedAddressController = StreamController.broadcast(); factory WalletInfo.external({ required String id, @@ -207,4 +207,9 @@ class WalletInfo extends HiveObject { Stream get yatLastUsedAddressStream => _yatLastUsedAddressController.stream; StreamController _yatLastUsedAddressController; + + Future updateRestoreHeight(int height) async { + restoreHeight = height; + await save(); + } } diff --git a/cw_core/lib/wallet_type.dart b/cw_core/lib/wallet_type.dart index e846093d0f..b3e41a9890 100644 --- a/cw_core/lib/wallet_type.dart +++ b/cw_core/lib/wallet_type.dart @@ -173,11 +173,14 @@ String walletTypeToDisplayName(WalletType type) { } } -CryptoCurrency walletTypeToCryptoCurrency(WalletType type) { +CryptoCurrency walletTypeToCryptoCurrency(WalletType type, {bool isTestnet = false}) { switch (type) { case WalletType.monero: return CryptoCurrency.xmr; case WalletType.bitcoin: + if (isTestnet) { + return CryptoCurrency.tbtc; + } return CryptoCurrency.btc; case WalletType.litecoin: return CryptoCurrency.ltc; diff --git a/cw_shared_external/pubspec.lock b/cw_shared_external/pubspec.lock deleted file mode 100644 index ef01c9f9ab..0000000000 --- a/cw_shared_external/pubspec.lock +++ /dev/null @@ -1,147 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.5.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.15.0" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.10" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.19" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" -sdks: - dart: ">=2.12.0-0.0 <3.0.0" - flutter: ">=1.20.0" diff --git a/cw_tron/pubspec.yaml b/cw_tron/pubspec.yaml index 9d32c4290d..f27e1b460a 100644 --- a/cw_tron/pubspec.yaml +++ b/cw_tron/pubspec.yaml @@ -15,8 +15,14 @@ dependencies: path: ../cw_core cw_evm: path: ../cw_evm - on_chain: ^3.0.1 - blockchain_utils: ^2.1.1 + on_chain: + git: + url: https://github.com/cake-tech/On_chain + ref: cake-update-v1 + blockchain_utils: + git: + url: https://github.com/cake-tech/blockchain_utils + ref: cake-update-v1 mobx: ^2.3.0+1 bip39: ^1.0.6 hive: ^2.2.3 diff --git a/howto-build-android.md b/howto-build-android.md index a2a4e4d9f5..c3fe415ee3 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/ios/Podfile.lock b/ios/Podfile.lock index c4ee98c37d..170db929ce 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -147,6 +147,8 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sp_scanner (0.0.1): + - Flutter - SwiftProtobuf (1.25.2) - SwiftyGif (5.4.4) - Toast (4.1.0) @@ -188,6 +190,7 @@ DEPENDENCIES: - sensitive_clipboard (from `.symlinks/plugins/sensitive_clipboard/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sp_scanner (from `.symlinks/plugins/sp_scanner/ios`) - uni_links (from `.symlinks/plugins/uni_links/ios`) - UnstoppableDomainsResolution (~> 4.0.0) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -259,6 +262,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/share_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sp_scanner: + :path: ".symlinks/plugins/sp_scanner/ios" uni_links: :path: ".symlinks/plugins/uni_links/ios" url_launcher_ios: @@ -302,6 +307,7 @@ SPEC CHECKSUMS: sensitive_clipboard: d4866e5d176581536c27bb1618642ee83adca986 share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sp_scanner: eaa617fa827396b967116b7f1f43549ca62e9a12 SwiftProtobuf: 407a385e97fd206c4fbe880cc84123989167e0d1 SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f Toast: ec33c32b8688982cecc6348adeae667c1b9938da diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 082e9b22fb..6349199527 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -121,20 +121,12 @@ class CWBitcoin extends Bitcoin { 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( + .map((BaseBitcoinAddressRecord addr) => ElectrumSubAddress( id: addr.index, name: addr.name, address: addr.address, @@ -207,12 +199,12 @@ class CWBitcoin extends Bitcoin { Future updateUnspents(Object wallet) async { final bitcoinWallet = wallet as ElectrumWallet; - await bitcoinWallet.updateUnspent(); + await bitcoinWallet.updateAllUnspents(); } WalletService createBitcoinWalletService( - Box walletInfoSource, Box unspentCoinSource) { - return BitcoinWalletService(walletInfoSource, unspentCoinSource); + Box walletInfoSource, Box unspentCoinSource, bool alwaysScan) { + return BitcoinWalletService(walletInfoSource, unspentCoinSource, alwaysScan); } WalletService createLitecoinWalletService( @@ -247,6 +239,12 @@ class CWBitcoin extends Bitcoin { return BitcoinReceivePageOption.fromType(bitcoinWallet.walletAddresses.addressPageType); } + @override + bool hasSelectedSilentPayments(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.addressPageType == SilentPaymentsAddresType.p2sp; + } + @override List getBitcoinReceivePageOptions() => BitcoinReceivePageOption.all; @@ -465,4 +463,137 @@ class CWBitcoin extends Bitcoin { throw err; } } + + @override + List getSilentPaymentAddresses(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.silentAddresses + .where((addr) => addr.type != SegwitAddresType.p2tr) + .map((addr) => ElectrumSubAddress( + id: addr.index, + name: addr.name, + address: addr.address, + txCount: addr.txCount, + balance: addr.balance, + isChange: addr.isHidden)) + .toList(); + } + + @override + List getSilentPaymentReceivedAddresses(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.walletAddresses.silentAddresses + .where((addr) => addr.type == SegwitAddresType.p2tr) + .map((addr) => ElectrumSubAddress( + id: addr.index, + name: addr.name, + address: addr.address, + txCount: addr.txCount, + balance: addr.balance, + isChange: addr.isHidden)) + .toList(); + } + + @override + bool isBitcoinReceivePageOption(ReceivePageOption option) { + return option is BitcoinReceivePageOption; + } + + @override + BitcoinAddressType getOptionToType(ReceivePageOption option) { + return (option as BitcoinReceivePageOption).toType(); + } + + @override + @computed + bool getScanningActive(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.silentPaymentsScanningActive; + } + + @override + Future setScanningActive(Object wallet, bool active) async { + final bitcoinWallet = wallet as ElectrumWallet; + + if (active && !(await getNodeIsElectrsSPEnabled(wallet))) { + final node = Node( + useSSL: false, + uri: 'electrs.cakewallet.com:${(wallet.network == BitcoinNetwork.testnet ? 50002 : 50001)}', + ); + node.type = WalletType.bitcoin; + + await bitcoinWallet.connectToNode(node: node); + } + + bitcoinWallet.setSilentPaymentsScanning(active); + } + + @override + bool isTestnet(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.isTestnet ?? false; + } + + @override + int getHeightByDate({required DateTime date}) => getBitcoinHeightByDate(date: date); + + @override + Future rescan(Object wallet, {required int height, bool? doSingleScan}) async { + final bitcoinWallet = wallet as ElectrumWallet; + if (!(await getNodeIsElectrsSPEnabled(wallet))) { + final node = Node( + useSSL: false, + uri: 'electrs.cakewallet.com:${(wallet.network == BitcoinNetwork.testnet ? 50002 : 50001)}', + ); + node.type = WalletType.bitcoin; + await bitcoinWallet.connectToNode(node: node); + } + bitcoinWallet.rescan(height: height, doSingleScan: doSingleScan); + } + + Future getNodeIsElectrs(Object wallet) async { + final bitcoinWallet = wallet as ElectrumWallet; + + final version = await bitcoinWallet.electrumClient.version(); + + if (version.isEmpty) { + return false; + } + + final server = version[0]; + + if (server.toLowerCase().contains('electrs')) { + return true; + } + + return false; + } + + @override + Future getNodeIsElectrsSPEnabled(Object wallet) async { + if (!(await getNodeIsElectrs(wallet))) { + return false; + } + + final bitcoinWallet = wallet as ElectrumWallet; + final tweaksResponse = await bitcoinWallet.electrumClient.getTweaks(height: 0); + + if (tweaksResponse != null) { + return true; + } + + return false; + } + + @override + void deleteSilentPaymentAddress(Object wallet, String address) { + final bitcoinWallet = wallet as ElectrumWallet; + bitcoinWallet.walletAddresses.deleteSilentPaymentAddress(address); + } + + @override + Future updateFeeRates(Object wallet) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.updateFeeRates(); + } } diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index e507f52128..fe6629f515 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -26,7 +26,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 '^${P2pkhAddress.regex.pattern}\$|^${P2shAddress.regex.pattern}\$|^${P2wpkhAddress.regex.pattern}\$|${P2trAddress.regex.pattern}\$|^${P2wshAddress.regex.pattern}\$'; + return '^${P2pkhAddress.regex.pattern}\$|^${P2shAddress.regex.pattern}\$|^${P2wpkhAddress.regex.pattern}\$|${P2trAddress.regex.pattern}\$|^${P2wshAddress.regex.pattern}\$|^${SilentPaymentAddress.regex.pattern}\$'; case CryptoCurrency.nano: return '[0-9a-zA-Z_]'; case CryptoCurrency.banano: @@ -274,7 +274,9 @@ class AddressValidator extends TextValidator { '|([^0-9a-zA-Z]|^)([23][a-km-zA-HJ-NP-Z1-9]{25,34})([^0-9a-zA-Z]|\$)' //P2shAddress type '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{25,39})([^0-9a-zA-Z]|\$)' //P2wpkhAddress type '|([^0-9a-zA-Z]|^)((bc|tb)1q[ac-hj-np-z02-9]{40,80})([^0-9a-zA-Z]|\$)' //P2wshAddress type - '|([^0-9a-zA-Z]|^)((bc|tb)1p([ac-hj-np-z02-9]{39}|[ac-hj-np-z02-9]{59}|[ac-hj-np-z02-9]{8,89}))([^0-9a-zA-Z]|\$)'; //P2trAddress type + '|([^0-9a-zA-Z]|^)((bc|tb)1p([ac-hj-np-z02-9]{39}|[ac-hj-np-z02-9]{59}|[ac-hj-np-z02-9]{8,89}))([^0-9a-zA-Z]|\$)' //P2trAddress type + '|${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]|\$)' diff --git a/lib/core/sync_status_title.dart b/lib/core/sync_status_title.dart index 66094de2b0..c4cc3929fd 100644 --- a/lib/core/sync_status_title.dart +++ b/lib/core/sync_status_title.dart @@ -3,7 +3,13 @@ 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 + : S.current.Blocks_remaining('${syncStatus.blocksLeft}'); + } + + if (syncStatus is SyncedTipSyncStatus) { + return S.current.silent_payments_scanned_tip(syncStatus.tip.toString()); } if (syncStatus is SyncedSyncStatus) { @@ -34,5 +40,17 @@ String syncStatusTitle(SyncStatus syncStatus) { return S.current.sync_status_failed_connect; } + if (syncStatus is UnsupportedSyncStatus) { + return S.current.sync_status_unsupported; + } + + if (syncStatus is TimedOutSyncStatus) { + return S.current.sync_status_timed_out; + } + + if (syncStatus is SyncronizingSyncStatus) { + return S.current.sync_status_syncronizing; + } + return ''; -} \ No newline at end of file +} diff --git a/lib/di.dart b/lib/di.dart index 6a97cf62c2..bbad4a636a 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -6,6 +6,7 @@ import 'package:cake_wallet/anypay/any_pay_payment_committed_info.dart'; import 'package:cake_wallet/anypay/anypay_api.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; +import 'package:cake_wallet/buy/dfx/dfx_buy_provider.dart'; import 'package:cake_wallet/buy/moonpay/moonpay_provider.dart'; import 'package:cake_wallet/buy/onramper/onramper_buy_provider.dart'; import 'package:cake_wallet/buy/order.dart'; @@ -15,6 +16,7 @@ import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/core/backup_service.dart'; import 'package:cake_wallet/core/key_service.dart'; import 'package:cake_wallet/core/secure_storage.dart'; +import 'package:cake_wallet/core/totp_request_details.dart'; import 'package:cake_wallet/core/wallet_connect/wallet_connect_key_service.dart'; import 'package:cake_wallet/core/wallet_connect/wc_bottom_sheet_service.dart'; import 'package:cake_wallet/core/wallet_connect/web3wallet_service.dart'; @@ -102,12 +104,14 @@ import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart'; import 'package:cake_wallet/src/screens/send/send_page.dart'; import 'package:cake_wallet/src/screens/send/send_template_page.dart'; import 'package:cake_wallet/src/screens/settings/connection_sync_page.dart'; +import 'package:cake_wallet/src/screens/settings/desktop_settings/desktop_settings_page.dart'; import 'package:cake_wallet/src/screens/settings/display_settings_page.dart'; import 'package:cake_wallet/src/screens/settings/domain_lookups_page.dart'; import 'package:cake_wallet/src/screens/settings/manage_nodes_page.dart'; import 'package:cake_wallet/src/screens/settings/other_settings_page.dart'; import 'package:cake_wallet/src/screens/settings/privacy_page.dart'; import 'package:cake_wallet/src/screens/settings/security_backup_page.dart'; +import 'package:cake_wallet/src/screens/settings/silent_payments_settings.dart'; import 'package:cake_wallet/src/screens/settings/tor_page.dart'; import 'package:cake_wallet/src/screens/settings/trocador_providers_page.dart'; import 'package:cake_wallet/src/screens/setup_2fa/modify_2fa_page.dart'; @@ -159,10 +163,10 @@ import 'package:cake_wallet/view_model/buy/buy_view_model.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; import 'package:cake_wallet/view_model/contact_list/contact_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; +import 'package:cake_wallet/view_model/dashboard/cake_features_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/desktop_sidebar_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/home_settings_view_model.dart'; -import 'package:cake_wallet/view_model/dashboard/market_place_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/receive_option_view_model.dart'; import 'package:cake_wallet/view_model/edit_backup_password_view_model.dart'; @@ -199,6 +203,7 @@ import 'package:cake_wallet/view_model/settings/display_settings_view_model.dart import 'package:cake_wallet/view_model/settings/other_settings_view_model.dart'; import 'package:cake_wallet/view_model/settings/privacy_settings_view_model.dart'; import 'package:cake_wallet/view_model/settings/security_settings_view_model.dart'; +import 'package:cake_wallet/view_model/settings/silent_payments_settings_view_model.dart'; import 'package:cake_wallet/view_model/settings/trocador_providers_view_model.dart'; import 'package:cake_wallet/view_model/setup_pin_code_view_model.dart'; import 'package:cake_wallet/view_model/support_view_model.dart'; @@ -235,10 +240,6 @@ import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'buy/dfx/dfx_buy_provider.dart'; -import 'core/totp_request_details.dart'; -import 'src/screens/settings/desktop_settings/desktop_settings_page.dart'; - final getIt = GetIt.instance; var _isSetupFinished = false; @@ -745,6 +746,9 @@ Future setup({ return DisplaySettingsViewModel(getIt.get()); }); + getIt.registerFactory(() => + SilentPaymentsSettingsViewModel(getIt.get(), getIt.get().wallet!)); + getIt.registerFactory(() { return PrivacySettingsViewModel(getIt.get(), getIt.get().wallet!); }); @@ -806,6 +810,9 @@ Future setup({ getIt.registerFactory(() => DisplaySettingsPage(getIt.get())); + getIt.registerFactory( + () => SilentPaymentsSettingsPage(getIt.get())); + getIt.registerFactory(() => OtherSettingsPage(getIt.get())); getIt.registerFactory(() => NanoChangeRepPage( @@ -893,7 +900,11 @@ Future setup({ case WalletType.monero: return monero!.createMoneroWalletService(_walletInfoSource, _unspentCoinsInfoSource); case WalletType.bitcoin: - return bitcoin!.createBitcoinWalletService(_walletInfoSource, _unspentCoinsInfoSource); + return bitcoin!.createBitcoinWalletService( + _walletInfoSource, + _unspentCoinsInfoSource, + getIt.get().silentPaymentsAlwaysScan, + ); case WalletType.litecoin: return bitcoin!.createLitecoinWalletService(_walletInfoSource, _unspentCoinsInfoSource); case WalletType.ethereum: @@ -1089,7 +1100,7 @@ Future setup({ getIt.registerFactory(() => IoniaGiftCardsListViewModel(ioniaService: getIt.get())); - getIt.registerFactory(() => MarketPlaceViewModel(getIt.get())); + getIt.registerFactory(() => CakeFeaturesViewModel(getIt.get())); getIt.registerFactory(() => IoniaAuthViewModel(ioniaService: getIt.get())); diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index e67bd2fc60..6976857673 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -24,8 +24,9 @@ import 'package:collection/collection.dart'; const newCakeWalletMoneroUri = 'xmr-node.cakewallet.com:18081'; const cakeWalletBitcoinElectrumUri = 'electrum.cakewallet.com:50002'; -const publicBitcoinTestnetElectrumAddress = 'electrum.blockstream.info'; -const publicBitcoinTestnetElectrumPort = '60002'; +const cakeWalletSilentPaymentsElectrsUri = 'electrs.cakewallet.com:50001'; +const publicBitcoinTestnetElectrumAddress = 'electrs.cakewallet.com'; +const publicBitcoinTestnetElectrumPort = '50002'; const publicBitcoinTestnetElectrumUri = '$publicBitcoinTestnetElectrumAddress:$publicBitcoinTestnetElectrumPort'; const cakeWalletLitecoinElectrumUri = 'ltc-electrum.cakewallet.com:50002'; @@ -224,6 +225,9 @@ Future defaultSettingsMigration( await addTronNodeList(nodes: nodes); await changeTronCurrentNodeToDefault(sharedPreferences: sharedPreferences, nodes: nodes); break; + case 34: + await _addElectRsNode(nodes, sharedPreferences); + break; default: break; } @@ -790,7 +794,8 @@ Future changeDefaultBitcoinNode( final needToReplaceCurrentBitcoinNode = currentBitcoinNode.uri.toString().contains(cakeWalletBitcoinNodeUriPattern); - final newCakeWalletBitcoinNode = Node(uri: newCakeWalletBitcoinUri, type: WalletType.bitcoin); + final newCakeWalletBitcoinNode = + Node(uri: newCakeWalletBitcoinUri, type: WalletType.bitcoin, useSSL: false); await nodeSource.add(newCakeWalletBitcoinNode); @@ -800,6 +805,26 @@ Future changeDefaultBitcoinNode( } } +Future _addElectRsNode(Box nodeSource, SharedPreferences sharedPreferences) async { + const cakeWalletBitcoinNodeUriPattern = '.cakewallet.com'; + final currentBitcoinNodeId = + sharedPreferences.getInt(PreferencesKey.currentBitcoinElectrumSererIdKey); + final currentBitcoinNode = + nodeSource.values.firstWhere((node) => node.key == currentBitcoinNodeId); + final needToReplaceCurrentBitcoinNode = + currentBitcoinNode.uri.toString().contains(cakeWalletBitcoinNodeUriPattern); + + final newElectRsBitcoinNode = + Node(uri: cakeWalletSilentPaymentsElectrsUri, type: WalletType.bitcoin, useSSL: false); + + await nodeSource.add(newElectRsBitcoinNode); + + if (needToReplaceCurrentBitcoinNode) { + await sharedPreferences.setInt( + PreferencesKey.currentBitcoinElectrumSererIdKey, newElectRsBitcoinNode.key as int); + } +} + Future checkCurrentNodes( Box nodeSource, Box powNodeSource, SharedPreferences sharedPreferences) async { final currentMoneroNodeId = sharedPreferences.getInt(PreferencesKey.currentNodeIdKey); @@ -845,14 +870,19 @@ Future checkCurrentNodes( } if (currentBitcoinElectrumServer == null) { - final cakeWalletElectrum = Node(uri: cakeWalletBitcoinElectrumUri, type: WalletType.bitcoin); + final cakeWalletElectrum = + Node(uri: cakeWalletBitcoinElectrumUri, type: WalletType.bitcoin, useSSL: false); await nodeSource.add(cakeWalletElectrum); + final cakeWalletElectrumTestnet = + Node(uri: publicBitcoinTestnetElectrumUri, type: WalletType.bitcoin, useSSL: false); + await nodeSource.add(cakeWalletElectrumTestnet); await sharedPreferences.setInt( PreferencesKey.currentBitcoinElectrumSererIdKey, cakeWalletElectrum.key as int); } if (currentLitecoinElectrumServer == null) { - final cakeWalletElectrum = Node(uri: cakeWalletLitecoinElectrumUri, type: WalletType.litecoin); + final cakeWalletElectrum = + Node(uri: cakeWalletLitecoinElectrumUri, type: WalletType.litecoin, useSSL: false); await nodeSource.add(cakeWalletElectrum); await sharedPreferences.setInt( PreferencesKey.currentLitecoinElectrumSererIdKey, cakeWalletElectrum.key as int); @@ -887,7 +917,8 @@ Future checkCurrentNodes( } if (currentBitcoinCashNodeServer == null) { - final node = Node(uri: cakeWalletBitcoinCashDefaultNodeUri, type: WalletType.bitcoinCash); + final node = + Node(uri: cakeWalletBitcoinCashDefaultNodeUri, type: WalletType.bitcoinCash, useSSL: false); await nodeSource.add(node); await sharedPreferences.setInt(PreferencesKey.currentBitcoinCashNodeIdKey, node.key as int); } @@ -921,7 +952,11 @@ Future resetBitcoinElectrumServer( .firstWhereOrNull((node) => node.uriRaw.toString() == cakeWalletBitcoinElectrumUri); if (cakeWalletNode == null) { - cakeWalletNode = Node(uri: cakeWalletBitcoinElectrumUri, type: WalletType.bitcoin); + cakeWalletNode = + Node(uri: cakeWalletBitcoinElectrumUri, type: WalletType.bitcoin, useSSL: false); + // final cakeWalletElectrumTestnet = + // Node(uri: publicBitcoinTestnetElectrumUri, type: WalletType.bitcoin, useSSL: false); + // await nodeSource.add(cakeWalletElectrumTestnet); await nodeSource.add(cakeWalletNode); } diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index d184c74b13..aebf9ccd53 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -44,6 +44,8 @@ class PreferencesKey { static const polygonTransactionPriority = 'current_fee_priority_polygon'; static const bitcoinCashTransactionPriority = 'current_fee_priority_bitcoin_cash'; static const customBitcoinFeeRate = 'custom_electrum_fee_rate'; + static const silentPaymentsCardDisplay = 'silentPaymentsCardDisplay'; + static const silentPaymentsAlwaysScan = 'silentPaymentsAlwaysScan'; static const shouldShowReceiveWarning = 'should_show_receive_warning'; static const shouldShowYatPopup = 'should_show_yat_popup'; static const shouldShowRepWarning = 'should_show_rep_warning'; diff --git a/lib/main.dart b/lib/main.dart index eeee4fbc31..776c2aa698 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -202,7 +202,7 @@ Future initializeAppConfigs() async { transactionDescriptions: transactionDescriptions, secureStorage: secureStorage, anonpayInvoiceInfo: anonpayInvoiceInfo, - initialMigrationVersion: 33, + initialMigrationVersion: 34, ); } diff --git a/lib/reactions/check_connection.dart b/lib/reactions/check_connection.dart index 9185ffe152..3252797ddd 100644 --- a/lib/reactions/check_connection.dart +++ b/lib/reactions/check_connection.dart @@ -3,15 +3,19 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:cake_wallet/store/settings_store.dart'; + Timer? _checkConnectionTimer; -void startCheckConnectionReaction( - WalletBase wallet, SettingsStore settingsStore, +void startCheckConnectionReaction(WalletBase wallet, SettingsStore settingsStore, {int timeInterval = 5}) { _checkConnectionTimer?.cancel(); - _checkConnectionTimer = - Timer.periodic(Duration(seconds: timeInterval), (_) async { + _checkConnectionTimer = Timer.periodic(Duration(seconds: timeInterval), (_) async { + if (wallet.type == WalletType.bitcoin && wallet.syncStatus is SyncingSyncStatus) { + return; + } + try { final connectivityResult = await (Connectivity().checkConnectivity()); @@ -20,14 +24,11 @@ void startCheckConnectionReaction( return; } - if (wallet.syncStatus is LostConnectionSyncStatus || - wallet.syncStatus is FailedSyncStatus) { - final alive = - await settingsStore.getCurrentNode(wallet.type).requestNode(); + if (wallet.syncStatus is LostConnectionSyncStatus || wallet.syncStatus is FailedSyncStatus) { + final alive = await settingsStore.getCurrentNode(wallet.type).requestNode(); if (alive) { - await wallet.connectToNode( - node: settingsStore.getCurrentNode(wallet.type)); + await wallet.connectToNode(node: settingsStore.getCurrentNode(wallet.type)); } } } catch (e) { diff --git a/lib/reactions/on_wallet_sync_status_change.dart b/lib/reactions/on_wallet_sync_status_change.dart index 9a13db5979..96305de04c 100644 --- a/lib/reactions/on_wallet_sync_status_change.dart +++ b/lib/reactions/on_wallet_sync_status_change.dart @@ -12,12 +12,10 @@ import 'package:wakelock_plus/wakelock_plus.dart'; ReactionDisposer? _onWalletSyncStatusChangeReaction; void startWalletSyncStatusChangeReaction( - WalletBase, - TransactionInfo> wallet, + WalletBase, TransactionInfo> wallet, FiatConversionStore fiatConversionStore) { _onWalletSyncStatusChangeReaction?.reaction.dispose(); - _onWalletSyncStatusChangeReaction = - reaction((_) => wallet.syncStatus, (SyncStatus status) async { + _onWalletSyncStatusChangeReaction = reaction((_) => wallet.syncStatus, (SyncStatus status) async { try { if (status is ConnectedSyncStatus) { await wallet.startSync(); @@ -32,7 +30,7 @@ void startWalletSyncStatusChangeReaction( if (status is SyncedSyncStatus || status is FailedSyncStatus) { await WakelockPlus.disable(); } - } catch(e) { + } catch (e) { print(e.toString()); } }); diff --git a/lib/router.dart b/lib/router.dart index 741597731a..e113e42f9b 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -76,6 +76,7 @@ import 'package:cake_wallet/src/screens/settings/manage_nodes_page.dart'; import 'package:cake_wallet/src/screens/settings/other_settings_page.dart'; import 'package:cake_wallet/src/screens/settings/privacy_page.dart'; import 'package:cake_wallet/src/screens/settings/security_backup_page.dart'; +import 'package:cake_wallet/src/screens/settings/silent_payments_settings.dart'; import 'package:cake_wallet/src/screens/settings/tor_page.dart'; import 'package:cake_wallet/src/screens/settings/trocador_providers_page.dart'; import 'package:cake_wallet/src/screens/setup_2fa/modify_2fa_page.dart'; @@ -366,6 +367,10 @@ Route createRoute(RouteSettings settings) { param1: settings.arguments as OnAuthenticationFinished, param2: false), onWillPop: () async => false)); + case Routes.silentPaymentsSettings: + return CupertinoPageRoute( + fullscreenDialog: true, builder: (_) => getIt.get()); + case Routes.connectionSync: return CupertinoPageRoute( fullscreenDialog: true, builder: (_) => getIt.get()); diff --git a/lib/routes.dart b/lib/routes.dart index 1b518d3289..b5208416fb 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -81,6 +81,7 @@ class Routes { static const ioniaMoreOptionsPage = '/ionia_more_options_page'; static const ioniaCustomRedeemPage = '/ionia_custom_redeem_page'; static const webViewPage = '/web_view_page'; + static const silentPaymentsSettings = '/silent_payments_settings'; static const connectionSync = '/connection_sync_page'; static const securityBackupPage = '/security_and_backup_page'; static const privacyPage = '/privacy_page'; diff --git a/lib/src/screens/dashboard/dashboard_page.dart b/lib/src/screens/dashboard/dashboard_page.dart index ec97d191f7..bec10435e6 100644 --- a/lib/src/screens/dashboard/dashboard_page.dart +++ b/lib/src/screens/dashboard/dashboard_page.dart @@ -4,7 +4,7 @@ import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/main_actions.dart'; import 'package:cake_wallet/src/screens/dashboard/desktop_widgets/desktop_sidebar_wrapper.dart'; -import 'package:cake_wallet/src/screens/dashboard/pages/market_place_page.dart'; +import 'package:cake_wallet/src/screens/dashboard/pages/cake_features_page.dart'; import 'package:cake_wallet/src/screens/wallet_connect/widgets/modals/bottom_sheet_listener.dart'; import 'package:cake_wallet/src/widgets/gradient_background.dart'; import 'package:cake_wallet/src/widgets/services_updates_widget.dart'; @@ -12,7 +12,7 @@ import 'package:cake_wallet/src/widgets/vulnerable_seeds_popup.dart'; import 'package:cake_wallet/themes/extensions/sync_indicator_theme.dart'; import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/utils/version_comparator.dart'; -import 'package:cake_wallet/view_model/dashboard/market_place_view_model.dart'; +import 'package:cake_wallet/view_model/dashboard/cake_features_view_model.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/yat_emoji_id.dart'; @@ -330,10 +330,10 @@ class _DashboardPageView extends BasePage { if (dashboardViewModel.shouldShowMarketPlaceInDashboard) { pages.add( Semantics( - label: S.of(context).market_place, - child: MarketPlacePage( + label: 'Cake ${S.of(context).features}', + child: CakeFeaturesPage( dashboardViewModel: dashboardViewModel, - marketPlaceViewModel: getIt.get(), + cakeFeaturesViewModel: getIt.get(), ), ), ); diff --git a/lib/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart b/lib/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart index 20ddea361e..d36c06013f 100644 --- a/lib/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart +++ b/lib/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart @@ -1,9 +1,9 @@ import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/main_actions.dart'; import 'package:cake_wallet/src/screens/dashboard/desktop_widgets/desktop_action_button.dart'; -import 'package:cake_wallet/src/screens/dashboard/pages/market_place_page.dart'; +import 'package:cake_wallet/src/screens/dashboard/pages/cake_features_page.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cake_wallet/view_model/dashboard/market_place_view_model.dart'; +import 'package:cake_wallet/view_model/dashboard/cake_features_view_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; @@ -74,9 +74,9 @@ class DesktopDashboardActions extends StatelessWidget { ], ), Expanded( - child: MarketPlacePage( + child: CakeFeaturesPage( dashboardViewModel: dashboardViewModel, - marketPlaceViewModel: getIt.get(), + cakeFeaturesViewModel: getIt.get(), ), ), ], diff --git a/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart b/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart index adf0840c93..663675849d 100644 --- a/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart +++ b/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart @@ -30,6 +30,7 @@ class DesktopWalletSelectionDropDown extends StatefulWidget { class _DesktopWalletSelectionDropDownState extends State { final moneroIcon = Image.asset('assets/images/monero_logo.png', height: 24, width: 24); final bitcoinIcon = Image.asset('assets/images/bitcoin.png', height: 24, width: 24); + final tBitcoinIcon = Image.asset('assets/images/tbtc.png', height: 24, width: 24); final litecoinIcon = Image.asset('assets/images/litecoin_icon.png', height: 24, width: 24); final havenIcon = Image.asset('assets/images/haven_logo.png', height: 24, width: 24); final ethereumIcon = Image.asset('assets/images/eth_icon.png', height: 24, width: 24); @@ -68,8 +69,11 @@ class _DesktopWalletSelectionDropDownState extends State _onSelectedWallet(wallet), )) @@ -120,16 +124,16 @@ class _DesktopWalletSelectionDropDownState extends State( - context: context, - builder: (dialogContext) { - return AlertWithTwoActions( - alertTitle: S.of(context).change_wallet_alert_title, - alertContent: S.of(context).change_wallet_alert_content(selectedWallet.name), - leftButtonText: S.of(context).cancel, - rightButtonText: S.of(context).change, - actionLeftButton: () => Navigator.of(dialogContext).pop(false), - actionRightButton: () => Navigator.of(dialogContext).pop(true)); - }) ?? + context: context, + builder: (dialogContext) { + return AlertWithTwoActions( + alertTitle: S.of(context).change_wallet_alert_title, + alertContent: S.of(context).change_wallet_alert_content(selectedWallet.name), + leftButtonText: S.of(context).cancel, + rightButtonText: S.of(context).change, + actionLeftButton: () => Navigator.of(dialogContext).pop(false), + actionRightButton: () => Navigator.of(dialogContext).pop(true)); + }) ?? false; if (confirmed) { @@ -138,9 +142,12 @@ class _DesktopWalletSelectionDropDownState extends State _loadWallet(WalletListItem wallet) async { - widget._authService.authenticateAction(context, - onAuthSuccess: (isAuthenticatedSuccessfully) async { - if (!isAuthenticatedSuccessfully) { - return; - } - - try { - if (context.mounted) { - changeProcessText(S.of(context).wallet_list_loading_wallet(wallet.name)); + widget._authService.authenticateAction( + context, + onAuthSuccess: (isAuthenticatedSuccessfully) async { + if (!isAuthenticatedSuccessfully) { + return; } - await widget.walletListViewModel.loadWallet(wallet); - hideProgressText(); - setState(() {}); - } catch (e) { - if (context.mounted) { - changeProcessText(S.of(context).wallet_list_failed_to_load(wallet.name, e.toString())); + + try { + if (context.mounted) { + changeProcessText(S.of(context).wallet_list_loading_wallet(wallet.name)); + } + await widget.walletListViewModel.loadWallet(wallet); + hideProgressText(); + setState(() {}); + } catch (e) { + if (context.mounted) { + changeProcessText(S.of(context).wallet_list_failed_to_load(wallet.name, e.toString())); + } } - } }, conditionToDetermineIfToUse2FA: widget.walletListViewModel.shouldRequireTOTP2FAForAccessingWallet, @@ -198,17 +206,16 @@ class _DesktopWalletSelectionDropDownState extends State receiveOptionViewModel.selectedReceiveOption, (ReceivePageOption option) { + if (bitcoin!.isBitcoinReceivePageOption(option)) { + addressListViewModel.setAddressType(bitcoin!.getOptionToType(option)); + return; + } + switch (option) { case ReceivePageOption.anonPayInvoice: Navigator.pushNamed( diff --git a/lib/src/screens/dashboard/pages/balance_page.dart b/lib/src/screens/dashboard/pages/balance_page.dart index 7f9256c51b..7ffcf918d1 100644 --- a/lib/src/screens/dashboard/pages/balance_page.dart +++ b/lib/src/screens/dashboard/pages/balance_page.dart @@ -1,15 +1,18 @@ import 'dart:math'; import 'package:auto_size_text/auto_size_text.dart'; +import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/nft_listing_page.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/home_screen_account_widget.dart'; +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cake_wallet/src/screens/exchange_trade/information_page.dart'; import 'package:cake_wallet/src/widgets/dashboard_card_widget.dart'; import 'package:cake_wallet/src/widgets/introducing_card.dart'; +import 'package:cake_wallet/src/widgets/standard_switch.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/themes/extensions/balance_page_theme.dart'; import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart'; @@ -21,6 +24,7 @@ import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:url_launcher/url_launcher.dart'; class BalancePage extends StatelessWidget { BalancePage({ @@ -221,30 +225,136 @@ class CryptoBalanceWidget extends StatelessWidget { itemBuilder: (__, index) { final balance = dashboardViewModel.balanceViewModel.formattedBalances.elementAt(index); - return BalanceRowWidget( - availableBalanceLabel: - '${dashboardViewModel.balanceViewModel.availableBalanceLabel}', - availableBalance: balance.availableBalance, - availableFiatBalance: balance.fiatAvailableBalance, - additionalBalanceLabel: - '${dashboardViewModel.balanceViewModel.additionalBalanceLabel}', - additionalBalance: balance.additionalBalance, - additionalFiatBalance: balance.fiatAdditionalBalance, - frozenBalance: balance.frozenBalance, - frozenFiatBalance: balance.fiatFrozenBalance, - currency: balance.asset, - hasAdditionalBalance: - dashboardViewModel.balanceViewModel.hasAdditionalBalance, - ); + return Observer(builder: (_) { + return BalanceRowWidget( + availableBalanceLabel: + '${dashboardViewModel.balanceViewModel.availableBalanceLabel}', + availableBalance: balance.availableBalance, + availableFiatBalance: balance.fiatAvailableBalance, + additionalBalanceLabel: + '${dashboardViewModel.balanceViewModel.additionalBalanceLabel}', + additionalBalance: balance.additionalBalance, + additionalFiatBalance: balance.fiatAdditionalBalance, + frozenBalance: balance.frozenBalance, + frozenFiatBalance: balance.fiatFrozenBalance, + currency: balance.asset, + hasAdditionalBalance: + dashboardViewModel.balanceViewModel.hasAdditionalBalance, + isTestnet: dashboardViewModel.isTestnet, + ); + }); }, ); }, - ) + ), + Observer(builder: (context) { + return Column( + children: [ + if (dashboardViewModel.showSilentPaymentsCard) ...[ + SizedBox(height: 10), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: DashBoardRoundedCardWidget( + customBorder: 30, + title: S.of(context).silent_payments, + subTitle: S.of(context).enable_silent_payments_scanning, + hint: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => launchUrl( + Uri.parse( + "https://guides.cakewallet.com/docs/cryptos/bitcoin/#silent-payments"), + mode: LaunchMode.externalApplication, + ), + child: Row( + children: [ + Text( + S.of(context).what_is_silent_payments, + style: TextStyle( + fontSize: 12, + fontFamily: 'Lato', + fontWeight: FontWeight.w400, + color: Theme.of(context) + .extension()! + .labelTextColor, + height: 1, + ), + softWrap: true, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.help_outline, + size: 16, + color: Theme.of(context) + .extension()! + .labelTextColor), + ) + ], + ), + ), + Observer( + builder: (_) => StandardSwitch( + value: dashboardViewModel.silentPaymentsScanningActive, + onTaped: () => _toggleSilentPaymentsScanning(context), + ), + ) + ], + ), + ], + ), + onTap: () => _toggleSilentPaymentsScanning(context), + icon: Icon( + Icons.lock, + color: + Theme.of(context).extension()!.pageTitleTextColor, + size: 50, + ), + ), + ), + ] + ], + ); + }), ], ), ), ); } + + Future _toggleSilentPaymentsScanning(BuildContext context) async { + final isSilentPaymentsScanningActive = dashboardViewModel.silentPaymentsScanningActive; + final newValue = !isSilentPaymentsScanningActive; + + dashboardViewModel.silentPaymentsScanningActive = newValue; + + final needsToSwitch = !isSilentPaymentsScanningActive && + await bitcoin!.getNodeIsElectrsSPEnabled(dashboardViewModel.wallet) == false; + + if (needsToSwitch) { + return showPopUp( + context: context, + builder: (BuildContext context) => AlertWithTwoActions( + alertTitle: S.of(context).change_current_node_title, + alertContent: S.of(context).confirm_silent_payments_switch_node, + rightButtonText: S.of(context).ok, + leftButtonText: S.of(context).cancel, + actionRightButton: () { + dashboardViewModel.setSilentPaymentsScanning(newValue); + Navigator.of(context).pop(); + }, + actionLeftButton: () { + dashboardViewModel.silentPaymentsScanningActive = isSilentPaymentsScanningActive; + Navigator.of(context).pop(); + }, + )); + } + + return dashboardViewModel.setSilentPaymentsScanning(newValue); + } } class BalanceRowWidget extends StatelessWidget { @@ -259,6 +369,7 @@ class BalanceRowWidget extends StatelessWidget { required this.frozenFiatBalance, required this.currency, required this.hasAdditionalBalance, + required this.isTestnet, super.key, }); @@ -272,6 +383,7 @@ class BalanceRowWidget extends StatelessWidget { final String frozenFiatBalance; final CryptoCurrency currency; final bool hasAdditionalBalance; + final bool isTestnet; // void _showBalanceDescription(BuildContext context) { // showPopUp( @@ -346,14 +458,24 @@ class BalanceRowWidget extends StatelessWidget { maxLines: 1, textAlign: TextAlign.start), SizedBox(height: 6), - Text('${availableFiatBalance}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - fontFamily: 'Lato', - fontWeight: FontWeight.w500, - color: Theme.of(context).extension()!.textColor, - height: 1)), + if (isTestnet) + Text(S.current.testnet_coins_no_value, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontFamily: 'Lato', + fontWeight: FontWeight.w400, + color: Theme.of(context).extension()!.textColor, + height: 1)), + if (!isTestnet) + Text('${availableFiatBalance}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontFamily: 'Lato', + fontWeight: FontWeight.w500, + color: Theme.of(context).extension()!.textColor, + height: 1)), ], ), ), @@ -362,27 +484,23 @@ class BalanceRowWidget extends StatelessWidget { child: Center( child: Column( children: [ - Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration(shape: BoxShape.circle), - child: CakeImageWidget( - imageUrl: currency.iconPath, - height: 40, - width: 40, - displayOnError: Container( - height: 30.0, - width: 30.0, - child: Center( - child: Text( - currency.title.substring(0, min(currency.title.length, 2)), - style: TextStyle(fontSize: 11), - ), - ), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.grey.shade400, + CakeImageWidget( + imageUrl: currency.iconPath, + height: 40, + width: 40, + displayOnError: Container( + height: 30.0, + width: 30.0, + child: Center( + child: Text( + currency.title.substring(0, min(currency.title.length, 2)), + style: TextStyle(fontSize: 11), ), ), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.grey.shade400, + ), ), ), const SizedBox(height: 10), @@ -449,17 +567,18 @@ class BalanceRowWidget extends StatelessWidget { textAlign: TextAlign.center, ), SizedBox(height: 4), - Text( - frozenFiatBalance, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - fontFamily: 'Lato', - fontWeight: FontWeight.w400, - color: Theme.of(context).extension()!.textColor, - height: 1, + if (!isTestnet) + Text( + frozenFiatBalance, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontFamily: 'Lato', + fontWeight: FontWeight.w400, + color: Theme.of(context).extension()!.textColor, + height: 1, + ), ), - ), ], ), ), @@ -493,17 +612,18 @@ class BalanceRowWidget extends StatelessWidget { textAlign: TextAlign.center, ), SizedBox(height: 4), - Text( - '${additionalFiatBalance}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - fontFamily: 'Lato', - fontWeight: FontWeight.w400, - color: Theme.of(context).extension()!.textColor, - height: 1, + if (!isTestnet) + Text( + '${additionalFiatBalance}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontFamily: 'Lato', + fontWeight: FontWeight.w400, + color: Theme.of(context).extension()!.textColor, + height: 1, + ), ), - ), ], ), ], diff --git a/lib/src/screens/dashboard/pages/market_place_page.dart b/lib/src/screens/dashboard/pages/cake_features_page.dart similarity index 60% rename from lib/src/screens/dashboard/pages/market_place_page.dart rename to lib/src/screens/dashboard/pages/cake_features_page.dart index d280488446..9ccb7833c5 100644 --- a/lib/src/screens/dashboard/pages/market_place_page.dart +++ b/lib/src/screens/dashboard/pages/cake_features_page.dart @@ -1,23 +1,20 @@ -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/src/widgets/dashboard_card_widget.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cake_wallet/view_model/dashboard/market_place_view_model.dart'; -import 'package:cw_core/wallet_type.dart'; +import 'package:cake_wallet/view_model/dashboard/cake_features_view_model.dart'; import 'package:flutter/material.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart'; +import 'package:flutter_svg/flutter_svg.dart'; -class MarketPlacePage extends StatelessWidget { - MarketPlacePage({ +class CakeFeaturesPage extends StatelessWidget { + CakeFeaturesPage({ required this.dashboardViewModel, - required this.marketPlaceViewModel, + required this.cakeFeaturesViewModel, }); final DashboardViewModel dashboardViewModel; - final MarketPlaceViewModel marketPlaceViewModel; + final CakeFeaturesViewModel cakeFeaturesViewModel; final _scrollController = ScrollController(); @override @@ -37,7 +34,7 @@ class MarketPlacePage extends StatelessWidget { children: [ SizedBox(height: 50), Text( - S.of(context).market_place, + 'Cake ${S.of(context).features}', style: TextStyle( fontSize: 24, fontWeight: FontWeight.w500, @@ -59,15 +56,21 @@ class MarketPlacePage extends StatelessWidget { // ), SizedBox(height: 20), DashBoardRoundedCardWidget( + onTap: () => _launchUrl("buy.cakepay.com"), title: S.of(context).cake_pay_web_cards_title, subTitle: S.of(context).cake_pay_web_cards_subtitle, - onTap: () => _launchMarketPlaceUrl("buy.cakepay.com"), + svgPicture: SvgPicture.asset( + 'assets/images/cards.svg', + height: 125, + width: 125, + fit: BoxFit.cover, + ), ), - const SizedBox(height: 20), + SizedBox(height: 10), DashBoardRoundedCardWidget( title: "NanoGPT", subTitle: S.of(context).nanogpt_subtitle, - onTap: () => _launchMarketPlaceUrl("cake.nano-gpt.com"), + onTap: () => _launchUrl("cake.nano-gpt.com"), ), ], ), @@ -79,41 +82,12 @@ class MarketPlacePage extends StatelessWidget { ); } - void _launchMarketPlaceUrl(String url) async { + void _launchUrl(String url) { try { launchUrl( Uri.https(url), mode: LaunchMode.externalApplication, ); - } catch (e) { - print(e); - } - } - - // TODO: Remove ionia flow/files if we will discard it - void _navigatorToGiftCardsPage(BuildContext context) { - final walletType = dashboardViewModel.type; - - switch (walletType) { - case WalletType.haven: - showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: S.of(context).error, - alertContent: S.of(context).gift_cards_unavailable, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop()); - }); - break; - default: - marketPlaceViewModel.isIoniaUserAuthenticated().then((value) { - if (value) { - Navigator.pushNamed(context, Routes.ioniaManageCardsPage); - return; - } - Navigator.of(context).pushNamed(Routes.ioniaWelcomePage); - }); - } + } catch (_) {} } } diff --git a/lib/src/screens/dashboard/widgets/menu_widget.dart b/lib/src/screens/dashboard/widgets/menu_widget.dart index d9e03dbf90..7eda20bffe 100644 --- a/lib/src/screens/dashboard/widgets/menu_widget.dart +++ b/lib/src/screens/dashboard/widgets/menu_widget.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/widgets/setting_action_button.dart'; import 'package:cake_wallet/src/widgets/setting_actions.dart'; import 'package:cake_wallet/themes/extensions/menu_theme.dart'; @@ -180,6 +181,11 @@ class MenuWidgetState extends State { final item = SettingActions.all[index]; + if (!widget.dashboardViewModel.hasSilentPayments && + item.name(context) == S.of(context).silent_payments_settings) { + return Container(); + } + final isLastTile = index == itemCount - 1; return SettingActionButton( 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 33bceeb5c0..bebb58107e 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,17 @@ 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( + receiveOptionViewModel.selectedReceiveOption + .toString() + .replaceAll(RegExp(r'silent payments', caseSensitive: false), + S.current.silent_payments) + .replaceAll( + RegExp(r'default', caseSensitive: false), S.current.string_default), + style: TextStyle(fontSize: 10.0, fontWeight: FontWeight.w500, color: color))) ], ), SizedBox(width: 5), @@ -73,65 +72,75 @@ 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( + option + .toString() + .replaceAll( + RegExp(r'silent payments', caseSensitive: false), + S.current.silent_payments) + .replaceAll(RegExp(r'default', caseSensitive: false), + S.current.string_default), + 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 ecba4acf53..789fb42bfc 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) { @@ -99,115 +98,144 @@ class ReceivePage extends BasePage { @override Widget body(BuildContext context) { - return KeyboardActions( - config: KeyboardActionsConfig( - keyboardActionsPlatform: KeyboardActionsPlatform.IOS, - keyboardBarColor: Theme.of(context).extension()!.keyboardBarColor, - nextFocus: false, - actions: [ - KeyboardActionsItem( - focusNode: _cryptoAmountFocus, - toolbarButtons: [(_) => KeyboardDoneButton()], - ) - ]), - child: SingleChildScrollView( - child: Column( - children: [ - Padding( - padding: EdgeInsets.fromLTRB(24, 50, 24, 24), - child: QRWidget( - addressListViewModel: addressListViewModel, - formKey: _formKey, - heroTag: _heroTag, - amountTextFieldFocusNode: _cryptoAmountFocus, - amountController: _amountController, - isLight: currentTheme.type == ThemeType.light), - ), - Observer( - builder: (_) => ListView.separated( - padding: EdgeInsets.all(0), - separatorBuilder: (context, _) => const HorizontalSectionDivider(), - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemCount: addressListViewModel.items.length, - itemBuilder: (context, index) { - final item = addressListViewModel.items[index]; - Widget cell = Container(); - - if (item is WalletAccountListHeader) { - cell = HeaderTile( - showTrailingButton: true, - walletAddressListViewModel: addressListViewModel, - trailingButtonTap: () async { - if (addressListViewModel.type == WalletType.monero || - addressListViewModel.type == WalletType.haven) { - await showPopUp( - context: context, - builder: (_) => getIt.get()); - } else { - await showPopUp( - context: context, - builder: (_) => getIt.get()); - } - }, - title: S.of(context).accounts, - trailingIcon: Icon( - Icons.arrow_forward_ios, - size: 14, - color: Theme.of(context).extension()!.iconsColor, - )); - } - - if (item is WalletAddressListHeader) { - cell = HeaderTile( - title: S.of(context).addresses, - walletAddressListViewModel: addressListViewModel, - showTrailingButton: !addressListViewModel.isAutoGenerateSubaddressEnabled, - showSearchButton: true, - trailingButtonTap: () => - Navigator.of(context).pushNamed(Routes.newSubaddress), - trailingIcon: Icon( - Icons.add, - size: 20, - color: Theme.of(context) - .extension()! - .iconsColor, - )); - } - - if (item is WalletAddressListItem) { - cell = Observer(builder: (_) { - final isCurrent = - item.address == addressListViewModel.address.address; - final backgroundColor = isCurrent - ? Theme.of(context).extension()!.currentTileBackgroundColor - : Theme.of(context).extension()!.tilesBackgroundColor; - final textColor = isCurrent - ? Theme.of(context).extension()!.currentTileTextColor - : Theme.of(context).extension()!.tilesTextColor; - - return AddressCell.fromItem(item, - isCurrent: isCurrent, - hasBalance: addressListViewModel.isElectrumWallet, - backgroundColor: backgroundColor, - textColor: textColor, - onTap: (_) => addressListViewModel.setAddress(item), - onEdit: () => Navigator.of(context) - .pushNamed(Routes.newSubaddress, arguments: item)); - }); - } - - return index != 0 - ? cell - : ClipRRect( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(30), - topRight: Radius.circular(30)), - child: cell, - ); - })), - ], + return KeyboardActions( + config: KeyboardActionsConfig( + keyboardActionsPlatform: KeyboardActionsPlatform.IOS, + keyboardBarColor: Theme.of(context).extension()!.keyboardBarColor, + nextFocus: false, + actions: [ + KeyboardActionsItem( + focusNode: _cryptoAmountFocus, + toolbarButtons: [(_) => KeyboardDoneButton()], + ) + ]), + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: EdgeInsets.fromLTRB(24, 50, 24, 24), + child: QRWidget( + addressListViewModel: addressListViewModel, + formKey: _formKey, + heroTag: _heroTag, + amountTextFieldFocusNode: _cryptoAmountFocus, + amountController: _amountController, + isLight: currentTheme.type == ThemeType.light), ), - )); + Observer( + builder: (_) => ListView.separated( + padding: EdgeInsets.all(0), + separatorBuilder: (context, _) => const HorizontalSectionDivider(), + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemCount: addressListViewModel.items.length, + itemBuilder: (context, index) { + final item = addressListViewModel.items[index]; + Widget cell = Container(); + + if (item is WalletAccountListHeader) { + cell = HeaderTile( + showTrailingButton: true, + walletAddressListViewModel: addressListViewModel, + trailingButtonTap: () async { + if (addressListViewModel.type == WalletType.monero || + addressListViewModel.type == WalletType.haven) { + await showPopUp( + context: context, + builder: (_) => getIt.get()); + } else { + await showPopUp( + context: context, + builder: (_) => getIt.get()); + } + }, + title: S.of(context).accounts, + trailingIcon: Icon( + Icons.arrow_forward_ios, + size: 14, + color: Theme.of(context).extension()!.iconsColor, + )); + } + + if (item is WalletAddressListHeader) { + final hasTitle = item.title != null; + + cell = HeaderTile( + title: hasTitle ? item.title! : S.of(context).addresses, + walletAddressListViewModel: addressListViewModel, + showTrailingButton: + !addressListViewModel.isAutoGenerateSubaddressEnabled && !hasTitle, + showSearchButton: true, + trailingButtonTap: () => + Navigator.of(context).pushNamed(Routes.newSubaddress), + trailingIcon: hasTitle + ? null + : Icon( + Icons.add, + size: 20, + color: + Theme.of(context).extension()!.iconsColor, + ), + ); + } + + if (item is WalletAddressListItem) { + cell = Observer(builder: (_) { + final isCurrent = item.address == addressListViewModel.address.address; + final backgroundColor = isCurrent + ? Theme.of(context) + .extension()! + .currentTileBackgroundColor + : Theme.of(context) + .extension()! + .tilesBackgroundColor; + final textColor = isCurrent + ? Theme.of(context) + .extension()! + .currentTileTextColor + : Theme.of(context).extension()!.tilesTextColor; + + return AddressCell.fromItem( + item, + isCurrent: isCurrent, + hasBalance: addressListViewModel.isElectrumWallet, + backgroundColor: backgroundColor, + textColor: textColor, + onTap: item.isOneTimeReceiveAddress == true + ? null + : (_) => addressListViewModel.setAddress(item), + onEdit: item.isOneTimeReceiveAddress == true || item.isPrimary + ? null + : () => Navigator.of(context) + .pushNamed(Routes.newSubaddress, arguments: item), + onDelete: !addressListViewModel.isSilentPayments || item.isPrimary + ? null + : () => addressListViewModel.deleteAddress(item), + ); + }); + } + + return index != 0 + ? cell + : ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(30), topRight: Radius.circular(30)), + child: cell, + ); + })), + Padding( + padding: EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Text( + addressListViewModel.isSilentPayments + ? S.of(context).silent_payments_disclaimer + : 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/receive/widgets/address_cell.dart b/lib/src/screens/receive/widgets/address_cell.dart index 9385a4df8a..850c082099 100644 --- a/lib/src/screens/receive/widgets/address_cell.dart +++ b/lib/src/screens/receive/widgets/address_cell.dart @@ -15,18 +15,22 @@ class AddressCell extends StatelessWidget { required this.textColor, this.onTap, this.onEdit, + this.onDelete, this.txCount, this.balance, this.isChange = false, this.hasBalance = false}); - factory AddressCell.fromItem(WalletAddressListItem item, - {required bool isCurrent, - required Color backgroundColor, - required Color textColor, - Function(String)? onTap, - bool hasBalance = false, - Function()? onEdit}) => + factory AddressCell.fromItem( + WalletAddressListItem item, { + required bool isCurrent, + required Color backgroundColor, + required Color textColor, + Function(String)? onTap, + bool hasBalance = false, + Function()? onEdit, + Function()? onDelete, + }) => AddressCell( address: item.address, name: item.name ?? '', @@ -36,6 +40,7 @@ class AddressCell extends StatelessWidget { textColor: textColor, onTap: onTap, onEdit: onEdit, + onDelete: onDelete, txCount: item.txCount, balance: item.balance, isChange: item.isChange, @@ -49,6 +54,7 @@ class AddressCell extends StatelessWidget { final Color textColor; final Function(String)? onTap; final Function()? onEdit; + final Function()? onDelete; final int? txCount; final String? balance; final bool isChange; @@ -64,7 +70,8 @@ class AddressCell extends StatelessWidget { } else { return formatIfCashAddr.substring(0, addressPreviewLength) + '...' + - formatIfCashAddr.substring(formatIfCashAddr.length - addressPreviewLength, formatIfCashAddr.length); + formatIfCashAddr.substring( + formatIfCashAddr.length - addressPreviewLength, formatIfCashAddr.length); } } @@ -139,7 +146,7 @@ class AddressCell extends StatelessWidget { mainAxisSize: MainAxisSize.max, children: [ Text( - 'Balance: $balance', + '${S.of(context).balance}: $balance', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, @@ -180,7 +187,7 @@ class AddressCell extends StatelessWidget { ActionPane _actionPane(BuildContext context) => ActionPane( motion: const ScrollMotion(), - extentRatio: 0.3, + extentRatio: onDelete != null ? 0.4 : 0.3, children: [ SlidableAction( onPressed: (_) => onEdit?.call(), @@ -189,6 +196,14 @@ class AddressCell extends StatelessWidget { icon: Icons.edit, label: S.of(context).edit, ), + if (onDelete != null) + SlidableAction( + onPressed: (_) => onDelete!.call(), + backgroundColor: Colors.red, + foregroundColor: Colors.white, + icon: Icons.delete, + label: S.of(context).delete, + ), ], ); } diff --git a/lib/src/screens/rescan/rescan_page.dart b/lib/src/screens/rescan/rescan_page.dart index 3a0ba24730..c59ae4ad02 100644 --- a/lib/src/screens/rescan/rescan_page.dart +++ b/lib/src/screens/rescan/rescan_page.dart @@ -1,3 +1,7 @@ +import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/main.dart'; +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:cake_wallet/view_model/rescan_view_model.dart'; @@ -11,7 +15,8 @@ class RescanPage extends BasePage { : _blockchainHeightWidgetKey = GlobalKey(); @override - String get title => S.current.rescan; + String get title => + _rescanViewModel.isSilentPaymentsScan ? S.current.silent_payments_scanning : S.current.rescan; final GlobalKey _blockchainHeightWidgetKey; final RescanViewModel _rescanViewModel; @@ -19,20 +24,28 @@ class RescanPage extends BasePage { Widget body(BuildContext context) { return Padding( padding: EdgeInsets.only(left: 24, right: 24, bottom: 24), - child: - Column(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - BlockchainHeightWidget(key: _blockchainHeightWidgetKey, - onHeightOrDateEntered: (value) => - _rescanViewModel.isButtonEnabled = value), + child: Column(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Observer( + builder: (_) => BlockchainHeightWidget( + key: _blockchainHeightWidgetKey, + onHeightOrDateEntered: (value) => _rescanViewModel.isButtonEnabled = value, + isSilentPaymentsScan: _rescanViewModel.isSilentPaymentsScan, + doSingleScan: _rescanViewModel.doSingleScan, + toggleSingleScan: () => + _rescanViewModel.doSingleScan = !_rescanViewModel.doSingleScan, + )), Observer( builder: (_) => LoadingPrimaryButton( - isLoading: - _rescanViewModel.state == RescanWalletState.rescaning, + isLoading: _rescanViewModel.state == RescanWalletState.rescaning, text: S.of(context).rescan, onPressed: () async { - await _rescanViewModel.rescanCurrentWallet( - restoreHeight: - _blockchainHeightWidgetKey.currentState!.height); + if (_rescanViewModel.isSilentPaymentsScan) { + return _toggleSilentPaymentsScanning(context); + } + + _rescanViewModel.rescanCurrentWallet( + restoreHeight: _blockchainHeightWidgetKey.currentState!.height); + Navigator.of(context).pop(); }, color: Theme.of(context).primaryColor, @@ -42,4 +55,32 @@ class RescanPage extends BasePage { ]), ); } + + Future _toggleSilentPaymentsScanning(BuildContext context) async { + final height = _blockchainHeightWidgetKey.currentState!.height; + + Navigator.of(context).pop(); + + final needsToSwitch = + await bitcoin!.getNodeIsElectrsSPEnabled(_rescanViewModel.wallet) == false; + + if (needsToSwitch) { + return showPopUp( + context: navigatorKey.currentState!.context, + builder: (BuildContext _dialogContext) => AlertWithTwoActions( + alertTitle: S.of(_dialogContext).change_current_node_title, + alertContent: S.of(_dialogContext).confirm_silent_payments_switch_node, + rightButtonText: S.of(_dialogContext).ok, + leftButtonText: S.of(_dialogContext).cancel, + actionRightButton: () async { + Navigator.of(_dialogContext).pop(); + + _rescanViewModel.rescanCurrentWallet(restoreHeight: height); + }, + actionLeftButton: () => Navigator.of(_dialogContext).pop(), + )); + } + + _rescanViewModel.rescanCurrentWallet(restoreHeight: height); + } } diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index 65c5a07f64..438c22c1de 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/core/execution_state.dart'; @@ -420,6 +421,10 @@ class SendPage extends BasePage { return; } + if (sendViewModel.isElectrumWallet) { + bitcoin!.updateFeeRates(sendViewModel.wallet); + } + reaction((_) => sendViewModel.state, (ExecutionState state) { if (dialogContext != null && dialogContext?.mounted == true) { Navigator.of(dialogContext!).pop(); diff --git a/lib/src/screens/settings/connection_sync_page.dart b/lib/src/screens/settings/connection_sync_page.dart index 7b4fb3b1ca..c4d85a3a5b 100644 --- a/lib/src/screens/settings/connection_sync_page.dart +++ b/lib/src/screens/settings/connection_sync_page.dart @@ -39,7 +39,9 @@ class ConnectionSyncPage extends BasePage { ), if (dashboardViewModel.hasRescan) ...[ SettingsCellWithArrow( - title: S.current.rescan, + title: dashboardViewModel.hasSilentPayments + ? S.current.silent_payments_scanning + : S.current.rescan, handler: (context) => Navigator.of(context).pushNamed(Routes.rescan), ), if (DeviceInfo.instance.isMobile && FeatureFlag.isBackgroundSyncEnabled) ...[ diff --git a/lib/src/screens/settings/silent_payments_settings.dart b/lib/src/screens/settings/silent_payments_settings.dart new file mode 100644 index 0000000000..bc0ecece1c --- /dev/null +++ b/lib/src/screens/settings/silent_payments_settings.dart @@ -0,0 +1,50 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/screens/settings/widgets/settings_cell_with_arrow.dart'; +import 'package:cake_wallet/src/screens/settings/widgets/settings_switcher_cell.dart'; +import 'package:cake_wallet/view_model/settings/silent_payments_settings_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class SilentPaymentsSettingsPage extends BasePage { + SilentPaymentsSettingsPage(this._silentPaymentsSettingsViewModel); + + @override + String get title => S.current.silent_payments_settings; + + final SilentPaymentsSettingsViewModel _silentPaymentsSettingsViewModel; + + @override + Widget body(BuildContext context) { + return SingleChildScrollView( + child: Observer(builder: (_) { + return Container( + padding: EdgeInsets.only(top: 10), + child: Column( + children: [ + SettingsSwitcherCell( + title: S.current.silent_payments_display_card, + value: _silentPaymentsSettingsViewModel.silentPaymentsCardDisplay, + onValueChange: (_, bool value) { + _silentPaymentsSettingsViewModel.setSilentPaymentsCardDisplay(value); + }, + ), + SettingsSwitcherCell( + title: S.current.silent_payments_always_scan, + value: _silentPaymentsSettingsViewModel.silentPaymentsAlwaysScan, + onValueChange: (_, bool value) { + _silentPaymentsSettingsViewModel.setSilentPaymentsAlwaysScan(value); + }, + ), + SettingsCellWithArrow( + title: S.current.silent_payments_scanning, + handler: (BuildContext context) => Navigator.of(context).pushNamed(Routes.rescan), + ), + ], + ), + ); + }), + ); + } +} diff --git a/lib/src/screens/unspent_coins/unspent_coins_list_page.dart b/lib/src/screens/unspent_coins/unspent_coins_list_page.dart index 70ae7ce3f4..ee6d6dc730 100644 --- a/lib/src/screens/unspent_coins/unspent_coins_list_page.dart +++ b/lib/src/screens/unspent_coins/unspent_coins_list_page.dart @@ -57,6 +57,7 @@ class UnspentCoinsListFormState extends State { isSending: item.isSending, isFrozen: item.isFrozen, isChange: item.isChange, + isSilentPayment: item.isSilentPayment, onCheckBoxTap: item.isFrozen ? null : () async { diff --git a/lib/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart b/lib/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart index e160260736..60a23c99b9 100644 --- a/lib/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart +++ b/lib/src/screens/unspent_coins/widgets/unspent_coins_list_item.dart @@ -12,6 +12,7 @@ class UnspentCoinsListItem extends StatelessWidget { required this.isSending, required this.isFrozen, required this.isChange, + required this.isSilentPayment, this.onCheckBoxTap, }); @@ -21,18 +22,16 @@ class UnspentCoinsListItem extends StatelessWidget { final bool isSending; final bool isFrozen; final bool isChange; + final bool isSilentPayment; final Function()? onCheckBoxTap; @override Widget build(BuildContext context) { final unselectedItemColor = Theme.of(context).cardColor; final selectedItemColor = Theme.of(context).primaryColor; - final itemColor = isSending - ? selectedItemColor - : unselectedItemColor; - final amountColor = isSending - ? Colors.white - : Theme.of(context).extension()!.buttonTextColor; + final itemColor = isSending ? selectedItemColor : unselectedItemColor; + final amountColor = + isSending ? Colors.white : Theme.of(context).extension()!.buttonTextColor; final addressColor = isSending ? Colors.white.withOpacity(0.5) : Theme.of(context).extension()!.buttonSecondaryTextColor; @@ -121,6 +120,23 @@ class UnspentCoinsListItem extends StatelessWidget { ), ), ), + if (isSilentPayment) + Container( + height: 17, + padding: EdgeInsets.only(left: 6, right: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8.5)), + color: Colors.white), + alignment: Alignment.center, + child: Text( + S.of(context).silent_payments, + style: TextStyle( + color: itemColor, + fontSize: 7, + fontWeight: FontWeight.w600, + ), + ), + ), ], ), ), diff --git a/lib/src/screens/wallet_list/wallet_list_page.dart b/lib/src/screens/wallet_list/wallet_list_page.dart index 9a0c295647..a89d5e66f4 100644 --- a/lib/src/screens/wallet_list/wallet_list_page.dart +++ b/lib/src/screens/wallet_list/wallet_list_page.dart @@ -96,6 +96,7 @@ class WalletListBody extends StatefulWidget { class WalletListBodyState extends State { final moneroIcon = Image.asset('assets/images/monero_logo.png', height: 24, width: 24); final bitcoinIcon = Image.asset('assets/images/bitcoin.png', height: 24, width: 24); + final tBitcoinIcon = Image.asset('assets/images/tbtc.png', height: 24, width: 24); final litecoinIcon = Image.asset('assets/images/litecoin_icon.png', height: 24, width: 24); final nonWalletTypeIcon = Image.asset('assets/images/close.png', height: 24, width: 24); final havenIcon = Image.asset('assets/images/haven_logo.png', height: 24, width: 24); @@ -162,7 +163,10 @@ class WalletListBodyState extends State { crossAxisAlignment: CrossAxisAlignment.center, children: [ wallet.isEnabled - ? _imageFor(type: wallet.type) + ? _imageFor( + type: wallet.type, + isTestnet: wallet.isTestnet, + ) : nonWalletTypeIcon, SizedBox(width: 10), Flexible( @@ -297,9 +301,12 @@ class WalletListBodyState extends State { ); } - Image _imageFor({required WalletType type}) { + Image _imageFor({required WalletType type, bool? isTestnet}) { switch (type) { case WalletType.bitcoin: + if (isTestnet == true) { + return tBitcoinIcon; + } return bitcoinIcon; case WalletType.monero: return moneroIcon; diff --git a/lib/src/widgets/blockchain_height_widget.dart b/lib/src/widgets/blockchain_height_widget.dart index 221f874468..d85680cc8d 100644 --- a/lib/src/widgets/blockchain_height_widget.dart +++ b/lib/src/widgets/blockchain_height_widget.dart @@ -1,3 +1,5 @@ +import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/src/widgets/standard_switch.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; import 'package:cake_wallet/utils/date_picker.dart'; import 'package:flutter/material.dart'; @@ -12,13 +14,19 @@ class BlockchainHeightWidget extends StatefulWidget { this.onHeightChange, this.focusNode, this.onHeightOrDateEntered, - this.hasDatePicker = true}) - : super(key: key); + this.hasDatePicker = true, + this.isSilentPaymentsScan = false, + this.toggleSingleScan, + this.doSingleScan = false, + }) : super(key: key); final Function(int)? onHeightChange; final Function(bool)? onHeightOrDateEntered; final FocusNode? focusNode; final bool hasDatePicker; + final bool isSilentPaymentsScan; + final bool doSingleScan; + final Function()? toggleSingleScan; @override State createState() => BlockchainHeightState(); @@ -64,9 +72,10 @@ class BlockchainHeightState extends State { child: BaseTextFormField( focusNode: widget.focusNode, controller: restoreHeightController, - keyboardType: TextInputType.numberWithOptions( - signed: false, decimal: false), - hintText: S.of(context).widgets_restore_from_blockheight, + keyboardType: TextInputType.numberWithOptions(signed: false, decimal: false), + hintText: widget.isSilentPaymentsScan + ? S.of(context).silent_payments_scan_from_height + : S.of(context).widgets_restore_from_blockheight, ))) ], ), @@ -78,8 +87,7 @@ class BlockchainHeightState extends State { style: TextStyle( fontSize: 16.0, fontWeight: FontWeight.w500, - color: - Theme.of(context).extension()!.titleColor), + color: Theme.of(context).extension()!.titleColor), ), ), Row( @@ -91,22 +99,47 @@ class BlockchainHeightState extends State { child: IgnorePointer( child: BaseTextFormField( controller: dateController, - hintText: S.of(context).widgets_restore_from_date, + hintText: widget.isSilentPaymentsScan + ? S.of(context).silent_payments_scan_from_date + : S.of(context).widgets_restore_from_date, )), ), )) ], ), + if (widget.isSilentPaymentsScan) + Padding( + padding: EdgeInsets.only(top: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + S.of(context).scan_one_block, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Theme.of(context).extension()!.titleColor, + ), + ), + Padding( + padding: const EdgeInsets.only(right: 8), + child: StandardSwitch( + value: widget.doSingleScan, + onTaped: () => widget.toggleSingleScan?.call(), + ), + ) + ], + ), + ), Padding( padding: EdgeInsets.only(left: 40, right: 40, top: 24), child: Text( - S.of(context).restore_from_date_or_blockheight, + widget.isSilentPaymentsScan + ? S.of(context).silent_payments_scan_from_date_or_blockheight + : S.of(context).restore_from_date_or_blockheight, textAlign: TextAlign.center, style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.normal, - color: Theme.of(context).hintColor - ), + fontSize: 12, fontWeight: FontWeight.normal, color: Theme.of(context).hintColor), ), ) ] @@ -123,7 +156,12 @@ class BlockchainHeightState extends State { lastDate: now); if (date != null) { - final height = monero!.getHeightByDate(date: date); + int height; + if (widget.isSilentPaymentsScan) { + height = bitcoin!.getHeightByDate(date: date); + } else { + height = monero!.getHeightByDate(date: date); + } setState(() { dateController.text = DateFormat('yyyy-MM-dd').format(date); restoreHeightController.text = '$height'; diff --git a/lib/src/widgets/dashboard_card_widget.dart b/lib/src/widgets/dashboard_card_widget.dart index 74f2d598ba..5a8ca14a49 100644 --- a/lib/src/widgets/dashboard_card_widget.dart +++ b/lib/src/widgets/dashboard_card_widget.dart @@ -2,19 +2,28 @@ import 'package:cake_wallet/themes/extensions/balance_page_theme.dart'; import 'package:cake_wallet/themes/extensions/sync_indicator_theme.dart'; import 'package:flutter/material.dart'; import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart'; +import 'package:flutter_svg/flutter_svg.dart'; class DashBoardRoundedCardWidget extends StatelessWidget { DashBoardRoundedCardWidget({ required this.onTap, required this.title, required this.subTitle, + this.hint, + this.svgPicture, + this.icon, this.onClose, + this.customBorder, }); final VoidCallback onTap; final VoidCallback? onClose; final String title; final String subTitle; + final Widget? hint; + final SvgPicture? svgPicture; + final Icon? icon; + final double? customBorder; @override Widget build(BuildContext context) { @@ -26,34 +35,56 @@ class DashBoardRoundedCardWidget extends StatelessWidget { child: Stack( children: [ Container( - padding: EdgeInsets.fromLTRB(20, 20, 40, 20), + padding: EdgeInsets.all(20), width: double.infinity, decoration: BoxDecoration( color: Theme.of(context).extension()!.syncedBackgroundColor, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(customBorder ?? 20), border: Border.all( color: Theme.of(context).extension()!.cardBorderColor, ), ), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: TextStyle( - color: Theme.of(context).extension()!.cardTextColor, - fontSize: 24, - fontWeight: FontWeight.w900, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: + Theme.of(context).extension()!.cardTextColor, + fontSize: 24, + fontWeight: FontWeight.w900, + ), + softWrap: true, + ), + SizedBox(height: 5), + Text( + subTitle, + style: TextStyle( + color: Theme.of(context) + .extension()! + .cardTextColor, + fontWeight: FontWeight.w500, + fontFamily: 'Lato'), + softWrap: true, + ), + ], + ), + ), + if (svgPicture != null) svgPicture!, + if (icon != null) icon! + ], ), - SizedBox(height: 5), - Text( - subTitle, - style: TextStyle( - color: Theme.of(context).extension()!.cardTextColor, - fontWeight: FontWeight.w500, - fontFamily: 'Lato'), - ) + if (hint != null) ...[ + SizedBox(height: 10), + hint!, + ] ], ), ), diff --git a/lib/src/widgets/setting_actions.dart b/lib/src/widgets/setting_actions.dart index e12397ed0e..6fbdb6868c 100644 --- a/lib/src/widgets/setting_actions.dart +++ b/lib/src/widgets/setting_actions.dart @@ -17,6 +17,7 @@ class SettingActions { connectionSettingAction, walletSettingAction, addressBookSettingAction, + silentPaymentsSettingAction, securityBackupSettingAction, privacySettingAction, displaySettingAction, @@ -35,6 +36,15 @@ class SettingActions { supportSettingAction, ]; + static SettingActions silentPaymentsSettingAction = SettingActions._( + name: (context) => S.of(context).silent_payments_settings, + image: 'assets/images/bitcoin_menu.png', + onTap: (BuildContext context) { + Navigator.pop(context); + Navigator.of(context).pushNamed(Routes.silentPaymentsSettings); + }, + ); + static SettingActions connectionSettingAction = SettingActions._( name: (context) => S.of(context).connection_sync, image: 'assets/images/nodes_menu.png', diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index ec9820944b..05af3f3b11 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -1,5 +1,4 @@ import 'dart:io'; - import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; import 'package:cake_wallet/core/secure_storage.dart'; @@ -108,6 +107,8 @@ abstract class SettingsStoreBase with Store { required this.lookupsOpenAlias, required this.lookupsENS, required this.customBitcoinFeeRate, + required this.silentPaymentsCardDisplay, + required this.silentPaymentsAlwaysScan, TransactionPriority? initialBitcoinTransactionPriority, TransactionPriority? initialMoneroTransactionPriority, TransactionPriority? initialHavenTransactionPriority, @@ -518,6 +519,16 @@ abstract class SettingsStoreBase with Store { (int customBitcoinFeeRate) => _sharedPreferences.setInt(PreferencesKey.customBitcoinFeeRate, customBitcoinFeeRate)); + reaction((_) => silentPaymentsCardDisplay, (bool silentPaymentsCardDisplay) { + _sharedPreferences.setBool( + PreferencesKey.silentPaymentsCardDisplay, silentPaymentsCardDisplay); + }); + + reaction( + (_) => silentPaymentsAlwaysScan, + (bool silentPaymentsAlwaysScan) => _sharedPreferences.setBool( + PreferencesKey.silentPaymentsAlwaysScan, silentPaymentsAlwaysScan)); + this.nodes.observe((change) { if (change.newValue != null && change.key != null) { _saveCurrentNode(change.newValue!, change.key!); @@ -713,6 +724,12 @@ abstract class SettingsStoreBase with Store { @observable int customBitcoinFeeRate; + @observable + bool silentPaymentsCardDisplay; + + @observable + bool silentPaymentsAlwaysScan; + final SecureStorage _secureStorage; final SharedPreferences _sharedPreferences; final BackgroundTasks _backgroundTasks; @@ -859,6 +876,10 @@ abstract class SettingsStoreBase with Store { final lookupsOpenAlias = sharedPreferences.getBool(PreferencesKey.lookupsOpenAlias) ?? true; final lookupsENS = sharedPreferences.getBool(PreferencesKey.lookupsENS) ?? true; final customBitcoinFeeRate = sharedPreferences.getInt(PreferencesKey.customBitcoinFeeRate) ?? 1; + final silentPaymentsCardDisplay = + sharedPreferences.getBool(PreferencesKey.silentPaymentsCardDisplay) ?? true; + final silentPaymentsAlwaysScan = + sharedPreferences.getBool(PreferencesKey.silentPaymentsAlwaysScan) ?? false; // If no value if (pinLength == null || pinLength == 0) { @@ -1103,6 +1124,8 @@ abstract class SettingsStoreBase with Store { lookupsOpenAlias: lookupsOpenAlias, lookupsENS: lookupsENS, customBitcoinFeeRate: customBitcoinFeeRate, + silentPaymentsCardDisplay: silentPaymentsCardDisplay, + silentPaymentsAlwaysScan: silentPaymentsAlwaysScan, initialMoneroTransactionPriority: moneroTransactionPriority, initialBitcoinTransactionPriority: bitcoinTransactionPriority, initialHavenTransactionPriority: havenTransactionPriority, @@ -1242,6 +1265,10 @@ abstract class SettingsStoreBase with Store { lookupsOpenAlias = sharedPreferences.getBool(PreferencesKey.lookupsOpenAlias) ?? true; lookupsENS = sharedPreferences.getBool(PreferencesKey.lookupsENS) ?? true; customBitcoinFeeRate = sharedPreferences.getInt(PreferencesKey.customBitcoinFeeRate) ?? 1; + silentPaymentsCardDisplay = + sharedPreferences.getBool(PreferencesKey.silentPaymentsCardDisplay) ?? true; + silentPaymentsAlwaysScan = + sharedPreferences.getBool(PreferencesKey.silentPaymentsAlwaysScan) ?? false; final nodeId = sharedPreferences.getInt(PreferencesKey.currentNodeIdKey); final bitcoinElectrumServerId = sharedPreferences.getInt(PreferencesKey.currentBitcoinElectrumSererIdKey); diff --git a/lib/view_model/contact_list/contact_list_view_model.dart b/lib/view_model/contact_list/contact_list_view_model.dart index 6c3169be1f..8dbd97bb95 100644 --- a/lib/view_model/contact_list/contact_list_view_model.dart +++ b/lib/view_model/contact_list/contact_list_view_model.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; import 'package:cake_wallet/entities/contact_base.dart'; import 'package:cake_wallet/entities/wallet_contact.dart'; +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; @@ -40,16 +41,19 @@ abstract class ContactListViewModelBase with Store { }); } else if (info.addresses?.isNotEmpty == true) { info.addresses!.forEach((address, label) { + if (label.isEmpty) { + return; + } final name = _createName(info.name, label); walletContacts.add(WalletContact( address, name, - walletTypeToCryptoCurrency(info.type), + walletTypeToCryptoCurrency(info.type, + isTestnet: + info.network == null ? false : info.network!.toLowerCase().contains("testnet")), )); - // Only one contact address per wallet - return; }); - } else if (info.address != null) { + } else { walletContacts.add(WalletContact( info.address, info.name, @@ -64,7 +68,9 @@ abstract class ContactListViewModelBase with Store { } String _createName(String walletName, String label) { - return label.isNotEmpty ? '$walletName ($label)' : walletName; + return label.isNotEmpty + ? '$walletName (${label.replaceAll(RegExp(r'active', caseSensitive: false), S.current.active).replaceAll(RegExp(r'silent payments', caseSensitive: false), S.current.silent_payments)})' + : walletName; } final bool isAutoGenerateEnabled; diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart index 6f4db52a67..5ae532bb64 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -60,6 +60,9 @@ abstract class BalanceViewModelBase with Store { @observable WalletBase, TransactionInfo> wallet; + @computed + bool get hasSilentPayments => wallet.type == WalletType.bitcoin; + @computed double get price { final price = fiatConvertationStore.prices[appStore.wallet!.currency]; diff --git a/lib/view_model/dashboard/cake_features_view_model.dart b/lib/view_model/dashboard/cake_features_view_model.dart new file mode 100644 index 0000000000..0a8fbc6408 --- /dev/null +++ b/lib/view_model/dashboard/cake_features_view_model.dart @@ -0,0 +1,16 @@ +import 'package:cake_wallet/ionia/ionia_service.dart'; +import 'package:mobx/mobx.dart'; + +part 'cake_features_view_model.g.dart'; + +class CakeFeaturesViewModel = CakeFeaturesViewModelBase with _$CakeFeaturesViewModel; + +abstract class CakeFeaturesViewModelBase with Store { + final IoniaService _ioniaService; + + CakeFeaturesViewModelBase(this._ioniaService); + + Future isIoniaUserAuthenticated() async { + return await _ioniaService.isLogined(); + } +} diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index f438c57247..b59dd15920 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -45,6 +45,7 @@ import 'package:cw_core/wallet_type.dart'; import 'package:eth_sig_util/util/utils.dart'; import 'package:flutter/services.dart'; import 'package:mobx/mobx.dart'; +import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; @@ -201,6 +202,14 @@ abstract class DashboardViewModelBase with Store { return true; }); + + if (hasSilentPayments) { + silentPaymentsScanningActive = bitcoin!.getScanningActive(wallet); + + reaction((_) => wallet.syncStatus, (SyncStatus syncStatus) { + silentPaymentsScanningActive = bitcoin!.getScanningActive(wallet); + }); + } } @observable @@ -287,11 +296,36 @@ abstract class DashboardViewModelBase with Store { @observable WalletBase, TransactionInfo> wallet; - bool get hasRescan => wallet.type == WalletType.monero || wallet.type == WalletType.haven; + @computed + bool get isTestnet => wallet.type == WalletType.bitcoin && bitcoin!.isTestnet(wallet); + + @computed + bool get hasRescan => + wallet.type == WalletType.bitcoin || + wallet.type == WalletType.monero || + wallet.type == WalletType.haven; + + @computed + bool get hasSilentPayments => wallet.type == WalletType.bitcoin; + + @computed + bool get showSilentPaymentsCard => hasSilentPayments && settingsStore.silentPaymentsCardDisplay; final KeyService keyService; final SharedPreferences sharedPreferences; + @observable + bool silentPaymentsScanningActive = false; + + @action + void setSilentPaymentsScanning(bool active) { + silentPaymentsScanningActive = active; + + if (hasSilentPayments) { + bitcoin!.setScanningActive(wallet, active); + } + } + BalanceViewModel balanceViewModel; AppStore appStore; diff --git a/lib/view_model/dashboard/market_place_view_model.dart b/lib/view_model/dashboard/market_place_view_model.dart deleted file mode 100644 index 4700411277..0000000000 --- a/lib/view_model/dashboard/market_place_view_model.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:cake_wallet/ionia/ionia_service.dart'; -import 'package:mobx/mobx.dart'; - -part 'market_place_view_model.g.dart'; - -class MarketPlaceViewModel = MarketPlaceViewModelBase with _$MarketPlaceViewModel; - -abstract class MarketPlaceViewModelBase with Store { - final IoniaService _ioniaService; - - MarketPlaceViewModelBase(this._ioniaService); - - - Future isIoniaUserAuthenticated() async { - return await _ioniaService.isLogined(); - } -} \ No newline at end of file diff --git a/lib/view_model/rescan_view_model.dart b/lib/view_model/rescan_view_model.dart index c973b7b3f0..dcc81c0a00 100644 --- a/lib/view_model/rescan_view_model.dart +++ b/lib/view_model/rescan_view_model.dart @@ -1,4 +1,6 @@ +import 'package:cake_wallet/bitcoin/bitcoin.dart'; 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'; @@ -8,11 +10,12 @@ class RescanViewModel = RescanViewModelBase with _$RescanViewModel; enum RescanWalletState { rescaning, none } abstract class RescanViewModelBase with Store { - RescanViewModelBase(this._wallet) - : state = RescanWalletState.none, - isButtonEnabled = false; + RescanViewModelBase(this.wallet) + : state = RescanWalletState.none, + isButtonEnabled = false, + doSingleScan = false; - final WalletBase _wallet; + final WalletBase wallet; @observable RescanWalletState state; @@ -20,11 +23,21 @@ abstract class RescanViewModelBase with Store { @observable bool isButtonEnabled; + @observable + bool doSingleScan; + + @computed + bool get isSilentPaymentsScan => wallet.type == WalletType.bitcoin; + @action Future rescanCurrentWallet({required int restoreHeight}) async { state = RescanWalletState.rescaning; - await _wallet.rescan(height: restoreHeight); - _wallet.transactionHistory.clear(); + if (wallet.type != WalletType.bitcoin) { + wallet.rescan(height: restoreHeight); + wallet.transactionHistory.clear(); + } else { + bitcoin!.rescan(wallet, height: restoreHeight, doSingleScan: doSingleScan); + } state = RescanWalletState.none; } -} \ No newline at end of file +} diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 534e501ddc..a00cfe0cc4 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -229,7 +229,10 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor isFiatDisabled ? '' : pendingTransactionFeeFiatAmount + ' ' + fiat.title; @computed - bool get isReadyForSend => wallet.syncStatus is SyncedSyncStatus; + bool get isReadyForSend => + wallet.syncStatus is SyncedSyncStatus || + // If silent payments scanning, can still send payments + (wallet.type == WalletType.bitcoin && wallet.syncStatus is SyncingSyncStatus); @computed List