stack_wallet/lib/wallets/wallet/wallet.dart

833 lines
26 KiB
Dart

import 'dart:async';
import 'package:isar/isar.dart';
import 'package:meta/meta.dart';
import 'package:mutex/mutex.dart';
import '../../db/isar/main_db.dart';
import '../../models/isar/models/blockchain_data/address.dart';
import '../../models/isar/models/ethereum/eth_contract.dart';
import '../../models/keys/view_only_wallet_data.dart';
import '../../models/node_model.dart';
import '../../models/paymint/fee_object_model.dart';
import '../../services/event_bus/events/global/node_connection_status_changed_event.dart';
import '../../services/event_bus/events/global/refresh_percent_changed_event.dart';
import '../../services/event_bus/events/global/wallet_sync_status_changed_event.dart';
import '../../services/event_bus/global_event_bus.dart';
import '../../services/node_service.dart';
import '../../utilities/amount/amount.dart';
import '../../utilities/constants.dart';
import '../../utilities/enums/sync_type_enum.dart';
import '../../utilities/flutter_secure_storage_interface.dart';
import '../../utilities/logger.dart';
import '../../utilities/paynym_is_api.dart';
import '../../utilities/prefs.dart';
import '../crypto_currency/crypto_currency.dart';
import '../isar/models/wallet_info.dart';
import '../models/tx_data.dart';
import 'impl/banano_wallet.dart';
import 'impl/bitcoin_frost_wallet.dart';
import 'impl/bitcoin_wallet.dart';
import 'impl/bitcoincash_wallet.dart';
import 'impl/cardano_wallet.dart';
import 'impl/dash_wallet.dart';
import 'impl/dogecoin_wallet.dart';
import 'impl/ecash_wallet.dart';
import 'impl/epiccash_wallet.dart';
import 'impl/ethereum_wallet.dart';
import 'impl/firo_wallet.dart';
import 'impl/litecoin_wallet.dart';
import 'impl/monero_wallet.dart';
import 'impl/namecoin_wallet.dart';
import 'impl/nano_wallet.dart';
import 'impl/particl_wallet.dart';
import 'impl/peercoin_wallet.dart';
import 'impl/solana_wallet.dart';
import 'impl/stellar_wallet.dart';
import 'impl/sub_wallets/eth_token_wallet.dart';
import 'impl/tezos_wallet.dart';
import 'impl/wownero_wallet.dart';
import 'intermediate/cryptonote_wallet.dart';
import 'wallet_mixin_interfaces/electrumx_interface.dart';
import 'wallet_mixin_interfaces/lelantus_interface.dart';
import 'wallet_mixin_interfaces/mnemonic_interface.dart';
import 'wallet_mixin_interfaces/multi_address_interface.dart';
import 'wallet_mixin_interfaces/paynym_interface.dart';
import 'wallet_mixin_interfaces/private_key_interface.dart';
import 'wallet_mixin_interfaces/spark_interface.dart';
import 'wallet_mixin_interfaces/view_only_option_interface.dart';
abstract class Wallet<T extends CryptoCurrency> {
// default to Transaction class. For TransactionV2 set to 2
int get isarTransactionVersion => 1;
// whether the wallet currently supports multiple recipients per tx
bool get supportsMultiRecipient => false;
Wallet(this.cryptoCurrency);
//============================================================================
// ========== Properties =====================================================
final T cryptoCurrency;
late final MainDB mainDB;
late final SecureStorageInterface secureStorageInterface;
late final NodeService nodeService;
late final Prefs prefs;
final refreshMutex = Mutex();
late final String _walletId;
WalletInfo get info =>
mainDB.isar.walletInfo.where().walletIdEqualTo(walletId).findFirstSync()!;
bool get isConnected => _isConnected;
bool get shouldAutoSync => _shouldAutoSync;
set shouldAutoSync(bool shouldAutoSync) {
if (_shouldAutoSync != shouldAutoSync) {
_shouldAutoSync = shouldAutoSync;
if (!shouldAutoSync) {
_periodicRefreshTimer?.cancel();
_periodicRefreshTimer = null;
_stopNetworkAlivePinging();
} else {
_startNetworkAlivePinging();
refresh();
}
}
}
// ===== private properties ===========================================
Timer? _periodicRefreshTimer;
Timer? _networkAliveTimer;
bool _shouldAutoSync = false;
bool _isConnected = false;
void xmrAndWowSyncSpecificFunctionThatShouldBeGottenRidOfInTheFuture(
bool flag,
) {
_isConnected = flag;
}
//============================================================================
// ========== Wallet Info Convenience Getters ================================
String get walletId => _walletId;
/// Attempt to fetch the most recent chain height.
/// On failure return the last cached height.
Future<int> get chainHeight async {
try {
// attempt updating the walletInfo's cached height
await updateChainHeight();
} catch (e, s) {
// do nothing on failure (besides logging)
Logging.instance.log("$e\n$s", level: LogLevel.Warning);
}
// return regardless of whether it was updated or not as we want a
// number even if it isn't the most recent
return info.cachedChainHeight;
}
//============================================================================
// ========== Static Main ====================================================
/// Create a new wallet and save [walletInfo] to db.
static Future<Wallet> create({
required WalletInfo walletInfo,
required MainDB mainDB,
required SecureStorageInterface secureStorageInterface,
required NodeService nodeService,
required Prefs prefs,
String? mnemonic,
String? mnemonicPassphrase,
String? privateKey,
ViewOnlyWalletData? viewOnlyData,
}) async {
// TODO: rework soon?
if (walletInfo.isViewOnly && viewOnlyData == null) {
throw Exception("Missing view key while creating view only wallet!");
}
final Wallet wallet = await _construct(
walletInfo: walletInfo,
mainDB: mainDB,
secureStorageInterface: secureStorageInterface,
nodeService: nodeService,
prefs: prefs,
);
if (wallet is ViewOnlyOptionInterface && walletInfo.isViewOnly) {
await secureStorageInterface.write(
key: getViewOnlyWalletDataSecStoreKey(walletId: walletInfo.walletId),
value: viewOnlyData!.toJsonEncodedString(),
);
} else if (wallet is MnemonicInterface) {
if (wallet is CryptonoteWallet) {
// currently a special case due to the xmr/wow libraries handling their
// own mnemonic generation on new wallet creation
// if its a restore we must set them
if (mnemonic != null) {
if ((await secureStorageInterface.read(
key: mnemonicKey(walletId: walletInfo.walletId),
)) ==
null) {
await secureStorageInterface.write(
key: mnemonicKey(walletId: walletInfo.walletId),
value: mnemonic,
);
}
if (mnemonicPassphrase != null) {
if ((await secureStorageInterface.read(
key: mnemonicPassphraseKey(walletId: walletInfo.walletId),
)) ==
null) {
await secureStorageInterface.write(
key: mnemonicPassphraseKey(walletId: walletInfo.walletId),
value: mnemonicPassphrase,
);
}
}
}
} else {
await secureStorageInterface.write(
key: mnemonicKey(walletId: walletInfo.walletId),
value: mnemonic!,
);
await secureStorageInterface.write(
key: mnemonicPassphraseKey(walletId: walletInfo.walletId),
value: mnemonicPassphrase!,
);
}
}
// TODO [prio=low] handle eth differently?
// This would need to be changed if we actually end up allowing eth wallets
// to be created with a private key instead of mnemonic only
if (wallet is PrivateKeyInterface && wallet is! EthereumWallet) {
await secureStorageInterface.write(
key: privateKeyKey(walletId: walletInfo.walletId),
value: privateKey!,
);
}
// Store in db after wallet creation
await wallet.mainDB.isar.writeTxn(() async {
await wallet.mainDB.isar.walletInfo.put(walletInfo);
});
return wallet;
}
/// Load an existing wallet via [WalletInfo] using [walletId].
static Future<Wallet> load({
required String walletId,
required MainDB mainDB,
required SecureStorageInterface secureStorageInterface,
required NodeService nodeService,
required Prefs prefs,
}) async {
final walletInfo = await mainDB.isar.walletInfo
.where()
.walletIdEqualTo(walletId)
.findFirst();
if (walletInfo == null) {
throw Exception(
"WalletInfo not found for $walletId when trying to call Wallet.load()",
);
}
return await _construct(
walletInfo: walletInfo,
mainDB: mainDB,
secureStorageInterface: secureStorageInterface,
nodeService: nodeService,
prefs: prefs,
);
}
// TODO: [prio=low] refactor to more generalized token rather than eth specific
static Wallet loadTokenWallet({
required EthereumWallet ethWallet,
required EthContract contract,
}) {
final Wallet wallet = EthTokenWallet(
ethWallet,
contract,
);
wallet.prefs = ethWallet.prefs;
wallet.nodeService = ethWallet.nodeService;
wallet.secureStorageInterface = ethWallet.secureStorageInterface;
wallet.mainDB = ethWallet.mainDB;
return wallet.._walletId = ethWallet.info.walletId;
}
//============================================================================
// ========== Static Util ====================================================
// secure storage key
static String mnemonicKey({
required String walletId,
}) =>
"${walletId}_mnemonic";
// secure storage key
static String mnemonicPassphraseKey({
required String walletId,
}) =>
"${walletId}_mnemonicPassphrase";
// secure storage key
static String privateKeyKey({
required String walletId,
}) =>
"${walletId}_privateKey";
// secure storage key
static String getViewOnlyWalletDataSecStoreKey({
required String walletId,
}) =>
"${walletId}_viewOnlyWalletData";
//============================================================================
// ========== Private ========================================================
/// Construct wallet instance by [WalletType] from [walletInfo]
static Future<Wallet> _construct({
required WalletInfo walletInfo,
required MainDB mainDB,
required SecureStorageInterface secureStorageInterface,
required NodeService nodeService,
required Prefs prefs,
}) async {
final Wallet wallet = _loadWallet(
walletInfo: walletInfo,
);
wallet.prefs = prefs;
wallet.nodeService = nodeService;
if (wallet is ElectrumXInterface || wallet is BitcoinFrostWallet) {
// initialize electrumx instance
await wallet.updateNode();
}
return wallet
..secureStorageInterface = secureStorageInterface
..mainDB = mainDB
.._walletId = walletInfo.walletId;
}
static Wallet _loadWallet({
required WalletInfo walletInfo,
}) {
final net = walletInfo.coin.network;
switch (walletInfo.coin.runtimeType) {
case const (Banano):
return BananoWallet(net);
case const (Bitcoin):
return BitcoinWallet(net);
case const (BitcoinFrost):
return BitcoinFrostWallet(net);
case const (Bitcoincash):
return BitcoincashWallet(net);
case const (Cardano):
return CardanoWallet(net);
case const (Dash):
return DashWallet(net);
case const (Dogecoin):
return DogecoinWallet(net);
case const (Ecash):
return EcashWallet(net);
case const (Epiccash):
return EpiccashWallet(net);
case const (Ethereum):
return EthereumWallet(net);
case const (Firo):
return FiroWallet(net);
case const (Litecoin):
return LitecoinWallet(net);
case const (Monero):
return MoneroWallet(net);
case const (Namecoin):
return NamecoinWallet(net);
case const (Nano):
return NanoWallet(net);
case const (Particl):
return ParticlWallet(net);
case const (Peercoin):
return PeercoinWallet(net);
case const (Solana):
return SolanaWallet(net);
case const (Stellar):
return StellarWallet(net);
case const (Tezos):
return TezosWallet(net);
case const (Wownero):
return WowneroWallet(net);
default:
// should never hit in reality
throw Exception("Unknown crypto currency: ${walletInfo.coin}");
}
}
void _startNetworkAlivePinging() {
// call once on start right away
_periodicPingCheck();
// then periodically check
_networkAliveTimer = Timer.periodic(
Constants.networkAliveTimerDuration,
(_) async {
_periodicPingCheck();
},
);
}
void _periodicPingCheck() async {
final bool hasNetwork = await pingCheck();
if (_isConnected != hasNetwork) {
final NodeConnectionStatus status = hasNetwork
? NodeConnectionStatus.connected
: NodeConnectionStatus.disconnected;
GlobalEventBus.instance.fire(
NodeConnectionStatusChangedEvent(
status,
walletId,
cryptoCurrency,
),
);
_isConnected = hasNetwork;
if (status == NodeConnectionStatus.disconnected) {
GlobalEventBus.instance.fire(
WalletSyncStatusChangedEvent(
WalletSyncStatus.unableToSync,
walletId,
cryptoCurrency,
),
);
}
if (hasNetwork) {
unawaited(refresh());
}
}
}
void _stopNetworkAlivePinging() {
_networkAliveTimer?.cancel();
_networkAliveTimer = null;
}
//============================================================================
// ========== Must override ==================================================
/// Create and sign a transaction in preparation to submit to network
Future<TxData> prepareSend({required TxData txData});
/// Broadcast transaction to network. On success update local wallet state to
/// reflect updated balance, transactions, utxos, etc.
Future<TxData> confirmSend({required TxData txData});
/// Recover a wallet by scanning the blockchain. If called on a new wallet a
/// normal recovery should occur. When called on an existing wallet and
/// [isRescan] is false then it should throw. Otherwise this function should
/// delete all locally stored blockchain data and refetch it.
Future<void> recover({required bool isRescan});
Future<void> updateNode();
Future<void> updateTransactions();
Future<void> updateBalance();
/// returns true if new utxos were added to local db
Future<bool> updateUTXOs();
/// updates the wallet info's cachedChainHeight
Future<void> updateChainHeight();
Future<Amount> estimateFeeFor(Amount amount, int feeRate);
Future<FeeObject> get fees;
Future<bool> pingCheck();
Future<void> checkSaveInitialReceivingAddress();
//===========================================
/// add transaction to local db temporarily. Used for quickly updating ui
/// before refresh can fetch data from server
Future<TxData> updateSentCachedTxData({required TxData txData}) async {
if (txData.tempTx != null) {
await mainDB.updateOrPutTransactionV2s([txData.tempTx!]);
}
return txData;
}
NodeModel getCurrentNode() {
final node = nodeService.getPrimaryNodeFor(currency: cryptoCurrency) ??
cryptoCurrency.defaultNode;
return node;
}
// Should fire events
Future<void> refresh() async {
final refreshCompleter = Completer<void>();
final future = refreshCompleter.future.then(
(_) {
GlobalEventBus.instance.fire(
WalletSyncStatusChangedEvent(
WalletSyncStatus.synced,
walletId,
cryptoCurrency,
),
);
if (shouldAutoSync) {
_periodicRefreshTimer ??=
Timer.periodic(const Duration(seconds: 150), (timer) async {
// chain height check currently broken
// if ((await chainHeight) != (await storedChainHeight)) {
// TODO: [prio=med] some kind of quick check if wallet needs to refresh to replace the old refreshIfThereIsNewData call
// if (await refreshIfThereIsNewData()) {
unawaited(refresh());
// }
// }
});
}
},
onError: (Object error, StackTrace strace) {
GlobalEventBus.instance.fire(
NodeConnectionStatusChangedEvent(
NodeConnectionStatus.disconnected,
walletId,
cryptoCurrency,
),
);
GlobalEventBus.instance.fire(
WalletSyncStatusChangedEvent(
WalletSyncStatus.unableToSync,
walletId,
cryptoCurrency,
),
);
Logging.instance.log(
"Caught exception in refreshWalletData(): $error\n$strace",
level: LogLevel.Error,
);
},
);
unawaited(_refresh(refreshCompleter));
return future;
}
// Should fire events
Future<void> _refresh(Completer<void> completer) async {
// Awaiting this lock could be dangerous.
// Since refresh is periodic (generally)
if (refreshMutex.isLocked) {
return;
}
final start = DateTime.now();
bool tAlive = true;
final t = Timer.periodic(const Duration(seconds: 1), (timer) async {
if (tAlive) {
final pingSuccess = await pingCheck();
if (!pingSuccess) {
tAlive = false;
}
} else {
timer.cancel();
}
});
void _checkAlive() {
if (!tAlive) throw Exception("refresh alive ping failure");
}
final viewOnly = this is ViewOnlyOptionInterface &&
(this as ViewOnlyOptionInterface).isViewOnly;
try {
// this acquire should be almost instant due to above check.
// Slight possibility of race but should be irrelevant
await refreshMutex.acquire();
GlobalEventBus.instance.fire(
WalletSyncStatusChangedEvent(
WalletSyncStatus.syncing,
walletId,
cryptoCurrency,
),
);
_checkAlive();
// add some small buffer before making calls.
// this can probably be removed in the future but was added as a
// debugging feature
await Future<void>.delayed(const Duration(milliseconds: 300));
_checkAlive();
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
final Set<String> codesToCheck = {};
_checkAlive();
if (this is PaynymInterface && !viewOnly) {
// isSegwit does not matter here at all
final myCode =
await (this as PaynymInterface).getPaymentCode(isSegwit: false);
_checkAlive();
final nym = await PaynymIsApi().nym(myCode.toString());
_checkAlive();
if (nym.value != null) {
for (final follower in nym.value!.followers) {
codesToCheck.add(follower.code);
}
_checkAlive();
for (final following in nym.value!.following) {
codesToCheck.add(following.code);
}
}
_checkAlive();
}
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.0, walletId));
_checkAlive();
await updateChainHeight();
_checkAlive();
if (this is BitcoinFrostWallet) {
await (this as BitcoinFrostWallet).lookAhead();
}
_checkAlive();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.1, walletId));
_checkAlive();
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
if (this is MultiAddressInterface) {
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
await (this as MultiAddressInterface)
.checkReceivingAddressForTransactions();
}
_checkAlive();
}
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.2, walletId));
_checkAlive();
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
if (this is MultiAddressInterface) {
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
await (this as MultiAddressInterface)
.checkChangeAddressForTransactions();
}
}
_checkAlive();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.3, walletId));
if (this is SparkInterface && !viewOnly) {
// this should be called before updateTransactions()
await (this as SparkInterface).refreshSparkData();
}
_checkAlive();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.50, walletId));
_checkAlive();
final fetchFuture = updateTransactions();
_checkAlive();
final utxosRefreshFuture = updateUTXOs();
// if (currentHeight != storedHeight) {
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.60, walletId));
_checkAlive();
await utxosRefreshFuture;
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.70, walletId));
_checkAlive();
await fetchFuture;
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
if (!viewOnly && this is PaynymInterface && codesToCheck.isNotEmpty) {
_checkAlive();
await (this as PaynymInterface)
.checkForNotificationTransactionsTo(codesToCheck);
// check utxos again for notification outputs
_checkAlive();
await updateUTXOs();
}
_checkAlive();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.80, walletId));
// await getAllTxsToWatch();
_checkAlive();
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
if (this is LelantusInterface && !viewOnly) {
if (info.otherData[WalletInfoKeys.enableLelantusScanning] as bool? ??
false) {
await (this as LelantusInterface).refreshLelantusData();
_checkAlive();
}
}
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(0.90, walletId));
_checkAlive();
await updateBalance();
_checkAlive();
GlobalEventBus.instance.fire(RefreshPercentChangedEvent(1.0, walletId));
tAlive = false; // interrupt timer as its not needed anymore
completer.complete();
} catch (error, strace) {
completer.completeError(error, strace);
} finally {
t.cancel();
refreshMutex.release();
if (!completer.isCompleted) {
completer.completeError(
"finally block hit before completer completed",
StackTrace.current,
);
}
Logging.instance.log(
"Refresh for "
"${info.name}: ${DateTime.now().difference(start)}",
level: LogLevel.Info,
);
}
}
Future<void> exit() async {
_periodicRefreshTimer?.cancel();
_networkAliveTimer?.cancel();
// If the syncing pref is currentWalletOnly or selectedWalletsAtStartup (and
// this wallet isn't in walletIdsSyncOnStartup), then we close subscriptions.
switch (prefs.syncType) {
case SyncingType.currentWalletOnly:
// Close the subscription for this coin's chain height.
// NOTE: This does not work now that the subscription is shared
// await (await ChainHeightServiceManager.getService(cryptoCurrency))
// ?.cancelListen();
case SyncingType.selectedWalletsAtStartup:
// Close the subscription if this wallet is not in the list to be synced.
if (!prefs.walletIdsSyncOnStartup.contains(walletId)) {
// Check if there's another wallet of this coin on the sync list.
final List<String> walletIds = [];
for (final id in prefs.walletIdsSyncOnStartup) {
final wallet = mainDB.isar.walletInfo
.where()
.walletIdEqualTo(id)
.findFirstSync()!;
if (wallet.coin == cryptoCurrency) {
walletIds.add(id);
}
}
// TODO [prio=low]: use a query instead of iterating thru wallets.
// If there are no other wallets of this coin, then close the sub.
if (walletIds.isEmpty) {
// NOTE: This does not work now that the subscription is shared
// await (await ChainHeightServiceManager.getService(
// cryptoCurrency))
// ?.cancelListen();
}
}
case SyncingType.allWalletsOnStartup:
// Do nothing.
break;
}
}
@mustCallSuper
Future<void> init() async {
await checkSaveInitialReceivingAddress();
final address = await getCurrentReceivingAddress();
if (address != null) {
await info.updateReceivingAddress(
newAddress: address.value,
isar: mainDB.isar,
);
}
}
// ===========================================================================
FilterOperation? get transactionFilterOperation => null;
FilterOperation? get receivingAddressFilterOperation;
FilterOperation? get changeAddressFilterOperation;
Future<Address?> getCurrentReceivingAddress() async {
return await _addressQuery(receivingAddressFilterOperation);
}
Future<Address?> getCurrentChangeAddress() async {
return await _addressQuery(changeAddressFilterOperation);
}
Future<Address?> _addressQuery(FilterOperation? filterOperation) async {
return await mainDB.isar.addresses
.buildQuery<Address>(
whereClauses: [
IndexWhereClause.equalTo(
indexName: r"walletId",
value: [walletId],
),
],
filter: filterOperation,
sortBy: [
const SortProperty(
property: r"derivationIndex",
sort: Sort.desc,
),
],
)
.findFirst();
}
}