import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:hive/hive.dart'; import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:mobx/mobx.dart'; import 'package:rxdart/subjects.dart'; import 'package:flutter/foundation.dart'; import 'package:bitcoin_flutter/bitcoin_flutter.dart' as bitcoin; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_core/pathForWallet.dart'; import 'package:cw_bitcoin/address_to_output_script.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; import 'package:cw_bitcoin/electrum_balance.dart'; import 'package:cw_bitcoin/bitcoin_mnemonic.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/electrum_transaction_history.dart'; import 'package:cw_bitcoin/bitcoin_transaction_no_inputs_exception.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/bitcoin_transaction_wrong_balance_exception.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; import 'package:cw_bitcoin/bitcoin_wallet_keys.dart'; import 'package:cw_bitcoin/file.dart'; import 'package:cw_bitcoin/pending_bitcoin_transaction.dart'; import 'package:cw_bitcoin/script_hash.dart'; import 'package:cw_bitcoin/utils.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_bitcoin/electrum.dart'; import 'package:hex/hex.dart'; import 'package:cw_core/crypto_currency.dart'; part 'electrum_wallet.g.dart'; class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; abstract class ElectrumWalletBase extends WalletBase with Store { ElectrumWalletBase( {@required String password, @required WalletInfo walletInfo, @required Box unspentCoinsInfo, @required List initialAddresses, @required this.networkType, @required this.mnemonic, ElectrumClient electrumClient, ElectrumBalance initialBalance}) : balance = ObservableMap.of({ CryptoCurrency.btc: initialBalance ?? const ElectrumBalance(confirmed: 0, unconfirmed: 0)}), hd = bitcoin.HDWallet.fromSeed(mnemonicToSeedBytes(mnemonic), network: networkType) .derivePath("m/0'/0"), syncStatus = NotConnectedSyncStatus(), _password = password, _feeRates = [], _isTransactionUpdating = false, super(walletInfo) { this.electrumClient = electrumClient ?? ElectrumClient(); this.walletInfo = walletInfo; this.unspentCoinsInfo = unspentCoinsInfo; transactionHistory = ElectrumTransactionHistory(walletInfo: walletInfo, password: password); unspentCoins = []; _scripthashesUpdateSubject = {}; } static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 146 + outputsCounts * 33 + 8; final bitcoin.HDWallet hd; final String mnemonic; ElectrumClient electrumClient; Box unspentCoinsInfo; @override ElectrumWalletAddresses walletAddresses; @override @observable ObservableMap balance; @override @observable SyncStatus syncStatus; List get scriptHashes => walletAddresses.addresses .map((addr) => scriptHash(addr.address, networkType: networkType)) .toList(); List get publicScriptHashes => walletAddresses.addresses .where((addr) => !addr.isHidden) .map((addr) => scriptHash(addr.address, networkType: networkType)) .toList(); String get xpub => hd.base58; @override String get seed => mnemonic; bitcoin.NetworkType networkType; @override BitcoinWalletKeys get keys => BitcoinWalletKeys( wif: hd.wif, privateKey: hd.privKey, publicKey: hd.pubKey); final String _password; List unspentCoins; List _feeRates; Map> _scripthashesUpdateSubject; bool _isTransactionUpdating; Future init() async { await walletAddresses.init(); await transactionHistory.init(); await save(); } @action @override Future startSync() async { try { syncStatus = StartingSyncStatus(); await walletAddresses.discoverAddresses(); await updateTransactions(); _subscribeForUpdates(); await _updateBalance(); await updateUnspent(); _feeRates = await electrumClient.feeRates(); Timer.periodic(const Duration(minutes: 1), (timer) async => _feeRates = await electrumClient.feeRates()); syncStatus = SyncedSyncStatus(); } catch (e) { print(e.toString()); syncStatus = FailedSyncStatus(); } } @action @override Future connectToNode({@required Node node}) async { try { syncStatus = ConnectingSyncStatus(); await electrumClient.connectToUri(node.uri); electrumClient.onConnectionStatusChange = (bool isConnected) { if (!isConnected) { syncStatus = LostConnectionSyncStatus(); } }; syncStatus = ConnectedSyncStatus(); } catch (e) { print(e.toString()); syncStatus = FailedSyncStatus(); } } @override Future createTransaction( Object credentials) async { const minAmount = 546; final transactionCredentials = credentials as BitcoinTransactionCredentials; final inputs = []; final outputs = transactionCredentials.outputs; final hasMultiDestination = outputs.length > 1; var allInputsAmount = 0; if (unspentCoins.isEmpty) { await updateUnspent(); } for (final utx in unspentCoins) { if (utx.isSending) { allInputsAmount += utx.value; inputs.add(utx); } } if (inputs.isEmpty) { throw BitcoinTransactionNoInputsException(); } final allAmountFee = feeAmountForPriority( transactionCredentials.priority, inputs.length, outputs.length); final allAmount = allInputsAmount - allAmountFee; var credentialsAmount = 0; var amount = 0; var fee = 0; if (hasMultiDestination) { if (outputs.any((item) => item.sendAll || item.formattedCryptoAmount <= 0)) { throw BitcoinTransactionWrongBalanceException(currency); } credentialsAmount = outputs.fold(0, (acc, value) => acc + value.formattedCryptoAmount); if (allAmount - credentialsAmount < minAmount) { throw BitcoinTransactionWrongBalanceException(currency); } amount = credentialsAmount; fee = calculateEstimatedFee(transactionCredentials.priority, amount, outputsCount: outputs.length + 1); } else { final output = outputs.first; credentialsAmount = !output.sendAll ? output.formattedCryptoAmount : 0; if (credentialsAmount > allAmount) { throw BitcoinTransactionWrongBalanceException(currency); } amount = output.sendAll || allAmount - credentialsAmount < minAmount ? allAmount : credentialsAmount; fee = output.sendAll || amount == allAmount ? allAmountFee : calculateEstimatedFee(transactionCredentials.priority, amount); } if (fee == 0) { throw BitcoinTransactionWrongBalanceException(currency); } final totalAmount = amount + fee; if (totalAmount > balance[currency].confirmed || totalAmount > allInputsAmount) { throw BitcoinTransactionWrongBalanceException(currency); } final txb = bitcoin.TransactionBuilder(network: networkType); final changeAddress = await walletAddresses.getChangeAddress(); var leftAmount = totalAmount; var totalInputAmount = 0; inputs.clear(); for (final utx in unspentCoins) { if (utx.isSending) { leftAmount = leftAmount - utx.value; totalInputAmount += utx.value; inputs.add(utx); if (leftAmount <= 0) { break; } } } if (inputs.isEmpty) { throw BitcoinTransactionNoInputsException(); } if (amount <= 0 || totalInputAmount < totalAmount) { throw BitcoinTransactionWrongBalanceException(currency); } txb.setVersion(1); inputs.forEach((input) { if (input.isP2wpkh) { final p2wpkh = bitcoin .P2WPKH( data: generatePaymentData( hd: input.address.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, index: input.address.index), network: networkType) .data; txb.addInput(input.hash, input.vout, null, p2wpkh.output); } else { txb.addInput(input.hash, input.vout); } }); outputs.forEach((item) { final outputAmount = hasMultiDestination ? item.formattedCryptoAmount : amount; final outputAddress = item.isParsedAddress ? item.extractedAddress : item.address; txb.addOutput( addressToOutputScript(outputAddress, networkType), outputAmount); }); final estimatedSize = estimatedTransactionSize(inputs.length, outputs.length + 1); final feeAmount = feeRate(transactionCredentials.priority) * estimatedSize; final changeValue = totalInputAmount - amount - feeAmount; if (changeValue > minAmount) { txb.addOutput(changeAddress, changeValue); } for (var i = 0; i < inputs.length; i++) { final input = inputs[i]; final keyPair = generateKeyPair( hd: input.address.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, index: input.address.index, network: networkType); final witnessValue = input.isP2wpkh ? input.value : null; txb.sign(vin: i, keyPair: keyPair, witnessValue: witnessValue); } return PendingBitcoinTransaction(txb.build(), type, electrumClient: electrumClient, amount: amount, fee: fee) ..addListener((transaction) async { transactionHistory.addOne(transaction); await _updateBalance(); }); } String toJSON() => json.encode({ 'mnemonic': mnemonic, 'account_index': walletAddresses.currentReceiveAddressIndex.toString(), 'change_address_index': walletAddresses.currentChangeAddressIndex.toString(), 'addresses': walletAddresses.addresses.map((addr) => addr.toJSON()).toList(), 'balance': balance[currency]?.toJSON() }); int feeRate(TransactionPriority priority) { try { if (priority is BitcoinTransactionPriority) { return _feeRates[priority.raw]; } return 0; } catch(_) { return 0; } } int feeAmountForPriority(BitcoinTransactionPriority priority, int inputsCount, int outputsCount) => feeRate(priority) * estimatedTransactionSize(inputsCount, outputsCount); @override int calculateEstimatedFee(TransactionPriority priority, int amount, {int outputsCount}) { if (priority is BitcoinTransactionPriority) { int inputsCount = 0; if (amount != null) { int totalValue = 0; for (final input in unspentCoins) { if (totalValue >= amount) { break; } if (input.isSending) { totalValue += input.value; inputsCount += 1; } } if (totalValue < amount) return 0; } else { for (final input in unspentCoins) { if (input.isSending) { inputsCount += 1; } } } // If send all, then we have no change value final _outputsCount = outputsCount ?? (amount != null ? 2 : 1); return feeAmountForPriority( priority, inputsCount, _outputsCount); } return 0; } @override Future save() async { final path = await makePath(); await write(path: path, password: _password, data: toJSON()); await transactionHistory.save(); } bitcoin.ECPair keyPairFor({@required int index}) => generateKeyPair(hd: hd, index: index, network: networkType); @override Future rescan({int height}) async => throw UnimplementedError(); @override Future close() async { try { await electrumClient?.close(); } catch (_) {} } Future makePath() async => pathForWallet(name: walletInfo.name, type: walletInfo.type); Future updateUnspent() async { final unspent = await Future.wait(walletAddresses .addresses.map((address) => electrumClient .getListUnspentWithAddress(address.address, networkType) .then((unspent) => unspent .map((unspent) => BitcoinUnspent.fromJSON(address, unspent))))); unspentCoins = unspent.expand((e) => e).toList(); if (unspentCoinsInfo.isEmpty) { unspentCoins.forEach((coin) => _addCoinInfo(coin)); return; } if (unspentCoins.isNotEmpty) { unspentCoins.forEach((coin) { final coinInfoList = unspentCoinsInfo.values.where((element) => element.walletId.contains(id) && element.hash.contains(coin.hash)); if (coinInfoList.isNotEmpty) { final coinInfo = coinInfoList.first; coin.isFrozen = coinInfo.isFrozen; coin.isSending = coinInfo.isSending; coin.note = coinInfo.note; } else { _addCoinInfo(coin); } }); } await _refreshUnspentCoinsInfo(); } Future _addCoinInfo(BitcoinUnspent coin) async { final newInfo = UnspentCoinsInfo( walletId: id, hash: coin.hash, isFrozen: coin.isFrozen, isSending: coin.isSending, note: coin.note ); await unspentCoinsInfo.add(newInfo); } Future _refreshUnspentCoinsInfo() async { try { final List keys = []; final currentWalletUnspentCoins = unspentCoinsInfo.values .where((element) => element.walletId.contains(id)); if (currentWalletUnspentCoins.isNotEmpty) { currentWalletUnspentCoins.forEach((element) { final existUnspentCoins = unspentCoins ?.where((coin) => element.hash.contains(coin?.hash)); if (existUnspentCoins?.isEmpty ?? true) { keys.add(element.key); } }); } if (keys.isNotEmpty) { await unspentCoinsInfo.deleteAll(keys); } } catch (e) { print(e.toString()); } } Future getTransactionExpanded( {@required String hash, @required int height}) async { final verboseTransaction = await electrumClient.getTransactionRaw(hash: hash); final transactionHex = verboseTransaction['hex'] as String; final original = bitcoin.Transaction.fromHex(transactionHex); final ins = []; final time = verboseTransaction['time'] as int; final confirmations = verboseTransaction['confirmations'] as int ?? 0; for (final vin in original.ins) { final id = HEX.encode(vin.hash.reversed.toList()); final txHex = await electrumClient.getTransactionHex(hash: id); final tx = bitcoin.Transaction.fromHex(txHex); ins.add(tx); } return ElectrumTransactionBundle( original, ins: ins, time: time, confirmations: confirmations); } Future fetchTransactionInfo( {@required String hash, @required int height}) async { final tx = await getTransactionExpanded(hash: hash, height: height); final addresses = walletAddresses.addresses.map((addr) => addr.address).toSet(); return ElectrumTransactionInfo.fromElectrumBundle( tx, walletInfo.type, networkType, addresses: addresses, height: height); } @override Future> fetchTransactions() async { final addressHashes = {}; final normalizedHistories = >[]; walletAddresses.addresses.forEach((addressRecord) { if (addressRecord.isHidden) { return; } final sh = scriptHash(addressRecord.address, networkType: networkType); addressHashes[sh] = addressRecord; }); final histories = addressHashes.keys.map((scriptHash) => electrumClient .getHistory(scriptHash) .then((history) => {scriptHash: history})); final historyResults = await Future.wait(histories); historyResults.forEach((history) { history.entries.forEach((historyItem) { if (historyItem.value.isNotEmpty) { final address = addressHashes[historyItem.key]; address.setAsUsed(); normalizedHistories.addAll(historyItem.value); } }); }); final historiesWithDetails = await Future.wait( normalizedHistories .map((transaction) => fetchTransactionInfo( hash: transaction['tx_hash'] as String, height: transaction['height'] as int))); return historiesWithDetails.fold>( {}, (acc, tx) { acc[tx.id] = acc[tx.id]?.updated(tx) ?? tx; return acc; }); } Future updateTransactions() async { try { if (_isTransactionUpdating) { return; } _isTransactionUpdating = true; final transactions = await fetchTransactions(); transactionHistory.addMany(transactions); walletAddresses.updateReceiveAddresses(); await transactionHistory.save(); _isTransactionUpdating = false; } catch (e) { print(e); _isTransactionUpdating = false; } } void _subscribeForUpdates() { scriptHashes.forEach((sh) async { await _scripthashesUpdateSubject[sh]?.close(); _scripthashesUpdateSubject[sh] = electrumClient.scripthashUpdate(sh); _scripthashesUpdateSubject[sh]?.listen((event) async { try { await _updateBalance(); await updateUnspent(); await updateTransactions(); } catch (e) { print(e.toString()); } }); }); } Future _fetchBalances() async { final addresses = walletAddresses.addresses.toList(); final balanceFutures = >>[]; for (var i = 0; i < addresses.length; i++) { final addressRecord = addresses[i]; final sh = scriptHash(addressRecord.address, networkType: networkType); final balanceFuture = electrumClient.getBalance(sh); balanceFutures.add(balanceFuture); } final balances = await Future.wait(balanceFutures); var totalConfirmed = 0; var totalUnconfirmed = 0; for (var i = 0; i < balances.length; i++) { final addressRecord = addresses[i]; final balance = balances[i]; final confirmed = balance['confirmed'] as int ?? 0; final unconfirmed = balance['unconfirmed'] as int ?? 0; totalConfirmed += confirmed; totalUnconfirmed += unconfirmed; if (confirmed > 0 || unconfirmed > 0) { addressRecord.setAsUsed(); } } return ElectrumBalance(confirmed: totalConfirmed, unconfirmed: totalUnconfirmed); } Future _updateBalance() async { balance[currency] = await _fetchBalances(); await save(); } String getChangeAddress() { const minCountOfHiddenAddresses = 5; final random = Random(); var addresses = walletAddresses.addresses .where((addr) => addr.isHidden) .toList(); if (addresses.length < minCountOfHiddenAddresses) { addresses = walletAddresses.addresses.toList(); } return addresses[random.nextInt(addresses.length)].address; } }