mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-28 06:28:41 +01:00
* feat: initial hive -> SQLite Includes migration script and initial work done to remove dependency on hive generators. Also contains some refactoring to make sync code async. Features new dev screen that lists all SQLite tables Aspects that need testing: - backups (from this version, and from previous versions as well) - wallet loading / wallet list - almost everything else - renaming - deletion - sorting in wallet list - wallet groups - wallet seed/keys - wallet switching - wallet creation / restore - all methods - wallet address page - hardware wallet connection (and prompts from mall screens) - contacts - advanced settings in wallet creation - derivation paths - some minor wownero fixes - base, hardware wallets * fix: build errors * fix: Bad state: No element in backup restore * address comments from review * fix: HardwareWalletType
989 lines
32 KiB
Dart
989 lines
32 KiB
Dart
import 'dart:async';
|
|
import 'dart:ffi';
|
|
import 'dart:io';
|
|
import 'dart:isolate';
|
|
|
|
import 'package:cw_core/monero_amount_format.dart';
|
|
import 'package:cw_core/pathForWallet.dart';
|
|
import 'package:cw_core/transaction_priority.dart';
|
|
import 'package:cw_core/account.dart';
|
|
import 'package:cw_core/crypto_currency.dart';
|
|
import 'package:cw_core/monero_balance.dart';
|
|
import 'package:cw_core/monero_transaction_priority.dart';
|
|
import 'package:cw_core/monero_wallet_keys.dart';
|
|
import 'package:cw_core/monero_wallet_utils.dart';
|
|
import 'package:cw_core/node.dart';
|
|
import 'package:cw_core/pending_transaction.dart';
|
|
import 'package:cw_core/sync_status.dart';
|
|
import 'package:cw_core/transaction_direction.dart';
|
|
import 'package:cw_core/unspent_coins_info.dart';
|
|
import 'package:cw_core/utils/proxy_wrapper.dart';
|
|
import 'package:cw_core/utils/print_verbose.dart';
|
|
import 'package:cw_core/wallet_base.dart';
|
|
import 'package:cw_core/wallet_info.dart';
|
|
import 'package:cw_monero/api/account_list.dart';
|
|
import 'package:cw_monero/api/coins_info.dart';
|
|
import 'package:cw_monero/api/monero_output.dart';
|
|
import 'package:cw_monero/api/structs/pending_transaction.dart';
|
|
import 'package:cw_monero/api/transaction_history.dart' as transaction_history;
|
|
import 'package:cw_monero/api/wallet.dart' as monero_wallet;
|
|
import 'package:cw_monero/api/wallet_manager.dart';
|
|
import 'package:cw_monero/exceptions/monero_transaction_creation_exception.dart';
|
|
import 'package:cw_monero/ledger.dart';
|
|
import 'package:cw_monero/monero_transaction_creation_credentials.dart';
|
|
import 'package:cw_monero/monero_transaction_history.dart';
|
|
import 'package:cw_monero/monero_transaction_info.dart';
|
|
import 'package:cw_monero/monero_unspent.dart';
|
|
import 'package:cw_monero/monero_wallet_addresses.dart';
|
|
import 'package:cw_monero/monero_wallet_service.dart';
|
|
import 'package:cw_monero/pending_monero_transaction.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hive/hive.dart';
|
|
import 'package:ledger_flutter_plus/ledger_flutter_plus.dart';
|
|
import 'package:mobx/mobx.dart';
|
|
import 'package:monero/monero.dart' as monero;
|
|
|
|
part 'monero_wallet.g.dart';
|
|
|
|
const moneroBlockSize = 1000;
|
|
// not sure if this should just be 0 but setting it higher feels safer / should catch more cases:
|
|
const MIN_RESTORE_HEIGHT = 1000;
|
|
|
|
class MoneroWallet = MoneroWalletBase with _$MoneroWallet;
|
|
|
|
abstract class MoneroWalletBase extends WalletBase<MoneroBalance,
|
|
MoneroTransactionHistory, MoneroTransactionInfo> with Store {
|
|
MoneroWalletBase(
|
|
{required WalletInfo walletInfo,
|
|
required DerivationInfo derivationInfo,
|
|
required Box<UnspentCoinsInfo> unspentCoinsInfo,
|
|
required String password})
|
|
: balance = ObservableMap<CryptoCurrency, MoneroBalance>.of({
|
|
CryptoCurrency.xmr: MoneroBalance(
|
|
fullBalance: monero_wallet.getFullBalance(accountIndex: 0),
|
|
unlockedBalance: monero_wallet.getUnlockedBalance(accountIndex: 0),
|
|
)
|
|
}),
|
|
_isTransactionUpdating = false,
|
|
_hasSyncAfterStartup = false,
|
|
isEnabledAutoGenerateSubaddress = true,
|
|
_password = password,
|
|
syncStatus = NotConnectedSyncStatus(),
|
|
unspentCoins = [],
|
|
this.unspentCoinsInfo = unspentCoinsInfo,
|
|
super(walletInfo, derivationInfo) {
|
|
transactionHistory = MoneroTransactionHistory();
|
|
walletAddresses = MoneroWalletAddresses(walletInfo, transactionHistory);
|
|
|
|
_onAccountChangeReaction =
|
|
reaction((_) => walletAddresses.account, (Account? account) {
|
|
if (account == null) return;
|
|
|
|
balance = ObservableMap<CryptoCurrency, MoneroBalance>.of(<CryptoCurrency,
|
|
MoneroBalance>{
|
|
currency: MoneroBalance(
|
|
fullBalance: monero_wallet.getFullBalance(accountIndex: account.id),
|
|
unlockedBalance:
|
|
monero_wallet.getUnlockedBalance(accountIndex: account.id))
|
|
});
|
|
_updateSubAddress(isEnabledAutoGenerateSubaddress, account: account);
|
|
unawaited(updateTransactions());
|
|
});
|
|
|
|
reaction((_) => isEnabledAutoGenerateSubaddress, (bool enabled) {
|
|
_updateSubAddress(enabled, account: walletAddresses.account);
|
|
});
|
|
_onTxHistoryChangeReaction = reaction((_) => transactionHistory, (__) {
|
|
_updateSubAddress(isEnabledAutoGenerateSubaddress, account: walletAddresses.account);
|
|
});
|
|
}
|
|
|
|
static const int _autoSaveInterval = 30;
|
|
|
|
Box<UnspentCoinsInfo> unspentCoinsInfo;
|
|
|
|
void Function(FlutterErrorDetails)? onError;
|
|
|
|
@override
|
|
late MoneroWalletAddresses walletAddresses;
|
|
|
|
@override
|
|
@observable
|
|
bool isEnabledAutoGenerateSubaddress;
|
|
|
|
@override
|
|
@observable
|
|
SyncStatus syncStatus;
|
|
|
|
@override
|
|
@observable
|
|
ObservableMap<CryptoCurrency, MoneroBalance> balance;
|
|
|
|
@override
|
|
bool get hasRescan => true;
|
|
|
|
@override
|
|
String get seed => monero_wallet.getSeed();
|
|
String seedLegacy(String? language) => monero_wallet.getSeedLegacy(language);
|
|
|
|
@override
|
|
String get password => _password;
|
|
|
|
@override
|
|
String get passphrase => monero_wallet.getPassphrase();
|
|
|
|
@override
|
|
MoneroWalletKeys get keys => MoneroWalletKeys(
|
|
primaryAddress: monero_wallet.getAddress(accountIndex: 0, addressIndex: 0),
|
|
privateSpendKey: monero_wallet.getSecretSpendKey(),
|
|
privateViewKey: monero_wallet.getSecretViewKey(),
|
|
publicSpendKey: monero_wallet.getPublicSpendKey(),
|
|
publicViewKey: monero_wallet.getPublicViewKey(),
|
|
passphrase: monero_wallet.getPassphrase());
|
|
|
|
int? get restoreHeight =>
|
|
transactionHistory.transactions.values.firstOrNull?.height ?? currentWallet?.getRefreshFromBlockHeight();
|
|
|
|
monero_wallet.SyncListener? _listener;
|
|
ReactionDisposer? _onAccountChangeReaction;
|
|
ReactionDisposer? _onTxHistoryChangeReaction;
|
|
bool _isTransactionUpdating;
|
|
bool _hasSyncAfterStartup;
|
|
Timer? _autoSaveTimer;
|
|
List<MoneroUnspent> unspentCoins;
|
|
String _password;
|
|
|
|
Future<void> init() async {
|
|
await walletAddresses.init();
|
|
balance = ObservableMap<CryptoCurrency, MoneroBalance>.of(<CryptoCurrency,
|
|
MoneroBalance>{
|
|
currency: MoneroBalance(
|
|
fullBalance: monero_wallet.getFullBalance(
|
|
accountIndex: walletAddresses.account!.id),
|
|
unlockedBalance: monero_wallet.getUnlockedBalance(
|
|
accountIndex: walletAddresses.account!.id))
|
|
});
|
|
_setListeners();
|
|
await updateTransactions();
|
|
|
|
if (walletInfo.isRecovery) {
|
|
monero_wallet.setRecoveringFromSeed(isRecovery: walletInfo.isRecovery);
|
|
|
|
if (monero_wallet.getCurrentHeight() <= 1) {
|
|
monero_wallet.setRefreshFromBlockHeight(
|
|
height: walletInfo.restoreHeight);
|
|
setupBackgroundSync(password, currentWallet!);
|
|
}
|
|
}
|
|
|
|
_autoSaveTimer = Timer.periodic(
|
|
Duration(seconds: _autoSaveInterval), (_) async => await save());
|
|
// update transaction details after restore
|
|
walletAddresses.subaddressList.update(accountIndex: walletAddresses.account?.id??0);
|
|
}
|
|
|
|
@override
|
|
Future<void>? updateBalance() => null;
|
|
|
|
@override
|
|
Future<bool> checkNodeHealth() async {
|
|
try {
|
|
// Check if the wallet is currently connected to the daemon
|
|
final isConnected = await monero_wallet.isConnected();
|
|
|
|
if (!isConnected) {
|
|
return false; // It's not connected to daemon
|
|
}
|
|
|
|
// Check to get current node height to ensure daemon is responsive
|
|
final nodeHeight = await monero_wallet.getNodeHeight();
|
|
return nodeHeight > 0;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> close({bool shouldCleanup = false}) async {
|
|
if (isHardwareWallet) {
|
|
disableLedgerExchange();
|
|
final currentWalletDirPath = await pathForWalletDir(name: name, type: type);
|
|
if (openedWalletsByPath["$currentWalletDirPath/$name"] != null) {
|
|
printV("closing wallet");
|
|
final wmaddr = wmPtr.ffiAddress();
|
|
final waddr = openedWalletsByPath["$currentWalletDirPath/$name"]!.ffiAddress();
|
|
openedWalletsByPath.remove("$currentWalletDirPath/$name");
|
|
closeWalletAwaitIfShould(wmaddr, waddr);
|
|
if (currentWallet?.ffiAddress() == waddr) {
|
|
currentWallet = null;
|
|
}
|
|
printV("wallet closed");
|
|
}
|
|
}
|
|
|
|
_listener?.stop();
|
|
_onAccountChangeReaction?.reaction.dispose();
|
|
_onTxHistoryChangeReaction?.reaction.dispose();
|
|
_autoSaveTimer?.cancel();
|
|
}
|
|
|
|
@override
|
|
Future<void> connectToNode({required Node node}) async {
|
|
String socksProxy = node.socksProxyAddress ?? '';
|
|
printV("bootstrapped: ${CakeTor.instance!.bootstrapped}");
|
|
printV(" enabled: ${CakeTor.instance!.enabled}");
|
|
printV(" port: ${CakeTor.instance!.port}");
|
|
printV(" started: ${CakeTor.instance!.started}");
|
|
if (CakeTor.instance!.enabled) {
|
|
socksProxy = "127.0.0.1:${CakeTor.instance!.port}";
|
|
}
|
|
try {
|
|
syncStatus = ConnectingSyncStatus();
|
|
await monero_wallet.setupNodeSync(
|
|
address: node.uri.toString(),
|
|
login: node.login,
|
|
password: node.password,
|
|
useSSL: node.isSSL,
|
|
isLightWallet: false,
|
|
// FIXME: hardcoded value
|
|
socksProxyAddress: socksProxy);
|
|
|
|
await monero_wallet.setTrustedDaemon(node.trusted);
|
|
syncStatus = ConnectedSyncStatus();
|
|
} catch (e) {
|
|
syncStatus = FailedSyncStatus();
|
|
printV(e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> startBackgroundSync() async {
|
|
if (isBackgroundSyncRunning) {
|
|
printV("Background sync already running");
|
|
return;
|
|
}
|
|
isBackgroundSyncRunning = true;
|
|
await save();
|
|
|
|
currentWallet!.startBackgroundSync();
|
|
final status = currentWallet!.status();
|
|
if (status != 0) {
|
|
final err = currentWallet!.errorString();
|
|
isBackgroundSyncRunning = false;
|
|
printV("startBackgroundSync: $err");
|
|
}
|
|
await save();
|
|
await init();
|
|
await startSync();
|
|
}
|
|
|
|
bool isBackgroundSyncRunning = false;
|
|
|
|
@action
|
|
@override
|
|
Future<void> stopSync() async {
|
|
if (isBackgroundSyncRunning) {
|
|
printV("Stopping background sync");
|
|
currentWallet!.store();
|
|
currentWallet!.stopBackgroundSync('');
|
|
currentWallet!.store();
|
|
isBackgroundSyncRunning = false;
|
|
}
|
|
await save();
|
|
}
|
|
|
|
@action
|
|
@override
|
|
Future<void> stopBackgroundSync(String password) async {
|
|
if (isBackgroundSyncRunning) {
|
|
printV("Stopping background sync");
|
|
currentWallet!.store();
|
|
currentWallet!.stopBackgroundSync(password);
|
|
currentWallet!.store();
|
|
isBackgroundSyncRunning = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> startSync() async {
|
|
try {
|
|
_assertInitialHeight();
|
|
} catch (_) {
|
|
// our restore height wasn't correct, so lets see if using the backup works:
|
|
try {
|
|
await resetCache(name); // Resetting the cache removes the TX Keys and Polyseed
|
|
_assertInitialHeight();
|
|
} catch (e) {
|
|
// we still couldn't get a valid height from the backup?!:
|
|
// try to use the date instead:
|
|
try {
|
|
_setHeightFromDate();
|
|
} catch (_) {
|
|
// we still couldn't get a valid sync height :/
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
syncStatus = AttemptingSyncStatus();
|
|
monero_wallet.startRefresh();
|
|
_setListeners();
|
|
} catch (e) {
|
|
syncStatus = FailedSyncStatus();
|
|
printV(e);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<bool> submitTransactionUR(String ur) async {
|
|
final retStatus = currentWallet!.submitTransactionUR(ur);
|
|
final status = currentWallet!.status();
|
|
if (status != 0) {
|
|
final err = currentWallet!.errorString();
|
|
throw MoneroTransactionCreationException("unable to broadcast signed transaction: $err");
|
|
}
|
|
return retStatus;
|
|
}
|
|
|
|
bool importKeyImagesUR(String ur) {
|
|
final retStatus = currentWallet!.importKeyImagesUR(ur);
|
|
final status = currentWallet!.status();
|
|
if (status != 0) {
|
|
final err = currentWallet!.errorString();
|
|
throw Exception("unable to import key images: $err");
|
|
}
|
|
return retStatus;
|
|
}
|
|
|
|
Map<String, String> exportOutputsUR() {
|
|
final str = currentWallet!.exportOutputsUR(all: false);
|
|
int status = currentWallet!.status();
|
|
if (status != 0) {
|
|
final err = currentWallet!.errorString();
|
|
throw MoneroTransactionCreationException("unable to export outputs: $err");
|
|
}
|
|
final strAll = currentWallet!.exportOutputsUR(all: true);
|
|
status = currentWallet!.status();
|
|
if (status != 0) {
|
|
final err = currentWallet!.errorString();
|
|
throw MoneroTransactionCreationException("unable to export outputs: $err");
|
|
}
|
|
return {
|
|
"Outputs (partial)": str,
|
|
"Outputs (all)": strAll,
|
|
};
|
|
}
|
|
|
|
bool needExportOutputs(int amount) {
|
|
if (int.tryParse(currentWallet!.secretSpendKey()) != 0) {
|
|
return false;
|
|
}
|
|
// viewOnlyBalance - balance that we can spend
|
|
// TODO(mrcyjanek): remove hasUnknownKeyImages when we cleanup coin control
|
|
return (currentWallet!.viewOnlyBalance(
|
|
accountIndex: walletAddresses.account!.id) < amount) ||
|
|
currentWallet!.hasUnknownKeyImages();
|
|
}
|
|
|
|
@override
|
|
Future<PendingTransaction> createTransaction(Object credentials) async {
|
|
final _credentials = credentials as MoneroTransactionCreationCredentials;
|
|
final inputs = <String>[];
|
|
final outputs = _credentials.outputs;
|
|
final hasMultiDestination = outputs.length > 1;
|
|
final unlockedBalance = monero_wallet.getUnlockedBalance(
|
|
accountIndex: walletAddresses.account!.id);
|
|
|
|
PendingTransactionDescription pendingTransactionDescription;
|
|
|
|
if (!(syncStatus is SyncedSyncStatus)) {
|
|
throw MoneroTransactionCreationException('The wallet is not synced.');
|
|
}
|
|
|
|
if (unspentCoins.isEmpty) {
|
|
await updateUnspent();
|
|
}
|
|
|
|
for (final utx in unspentCoins) {
|
|
if (utx.isSending) {
|
|
inputs.add(utx.keyImage!);
|
|
}
|
|
}
|
|
|
|
if (hasMultiDestination) {
|
|
if (outputs.any(
|
|
(item) => item.sendAll || (item.formattedCryptoAmount ?? 0) <= 0)) {
|
|
throw MoneroTransactionCreationException(
|
|
'You do not have enough XMR to send this amount.');
|
|
}
|
|
|
|
final int totalAmount = outputs.fold(
|
|
0, (acc, value) => acc + (value.formattedCryptoAmount ?? 0));
|
|
|
|
if (unlockedBalance < totalAmount) {
|
|
throw MoneroTransactionCreationException(
|
|
'You do not have enough XMR to send this amount.');
|
|
}
|
|
|
|
if (inputs.isEmpty) MoneroTransactionCreationException(
|
|
'No inputs selected');
|
|
|
|
final moneroOutputs = outputs.map((output) {
|
|
final outputAddress =
|
|
output.isParsedAddress ? output.extractedAddress : output.address;
|
|
|
|
return MoneroOutput(
|
|
address: outputAddress!,
|
|
amount: output.cryptoAmount!.replaceAll(',', '.'));
|
|
}).toList();
|
|
|
|
pendingTransactionDescription =
|
|
await transaction_history.createTransactionMultDest(
|
|
outputs: moneroOutputs,
|
|
priorityRaw: _credentials.priority.serialize(),
|
|
accountIndex: walletAddresses.account!.id,
|
|
paymentId: "",
|
|
preferredInputs: inputs);
|
|
} else {
|
|
final output = outputs.first;
|
|
final address =
|
|
output.isParsedAddress ? output.extractedAddress : output.address;
|
|
final amount =
|
|
output.sendAll ? null : output.cryptoAmount!.replaceAll(',', '.');
|
|
|
|
// if ((formattedAmount != null && unlockedBalance < formattedAmount) ||
|
|
// (formattedAmount == null && unlockedBalance <= 0)) {
|
|
// final formattedBalance = moneroAmountToString(amount: unlockedBalance);
|
|
//
|
|
// throw MoneroTransactionCreationException(
|
|
// 'You do not have enough unlocked balance. Unlocked: $formattedBalance. Transaction amount: ${output.cryptoAmount}.');
|
|
// }
|
|
|
|
if (inputs.isEmpty) MoneroTransactionCreationException(
|
|
'No inputs selected');
|
|
pendingTransactionDescription =
|
|
await transaction_history.createTransactionSync(
|
|
address: address!,
|
|
amount: amount,
|
|
priorityRaw: _credentials.priority.serialize(),
|
|
accountIndex: walletAddresses.account!.id,
|
|
preferredInputs: inputs,
|
|
paymentId: '');
|
|
}
|
|
|
|
// final status = monero.PendingTransaction_status(pendingTransactionDescription);
|
|
|
|
return PendingMoneroTransaction(pendingTransactionDescription, this);
|
|
}
|
|
|
|
@override
|
|
int calculateEstimatedFee(TransactionPriority priority, int? amount) {
|
|
// FIXME: hardcoded value;
|
|
|
|
if (priority is MoneroTransactionPriority) {
|
|
switch (priority) {
|
|
case MoneroTransactionPriority.slow:
|
|
return 24590000;
|
|
case MoneroTransactionPriority.automatic:
|
|
return 123050000;
|
|
case MoneroTransactionPriority.medium:
|
|
return 245029999;
|
|
case MoneroTransactionPriority.fast:
|
|
return 614530000;
|
|
case MoneroTransactionPriority.fastest:
|
|
return 26021600000;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
@override
|
|
Future<void> save() async {
|
|
await walletAddresses.updateUsedSubaddress();
|
|
|
|
if (isEnabledAutoGenerateSubaddress) {
|
|
walletAddresses.updateUnusedSubaddress(
|
|
accountIndex: walletAddresses.account?.id ?? 0,
|
|
defaultLabel: walletAddresses.account?.label ?? '');
|
|
}
|
|
|
|
await walletAddresses.updateAddressesInBox();
|
|
await monero_wallet.store();
|
|
try {
|
|
await backupWalletFiles(name);
|
|
} catch (e) {
|
|
printV("¯\\_(ツ)_/¯");
|
|
printV(e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> renameWalletFiles(String newWalletName) async {
|
|
final currentWalletDirPath = await pathForWalletDir(name: name, type: type);
|
|
if (openedWalletsByPath["$currentWalletDirPath/$name"] != null) {
|
|
// NOTE: this is realistically only required on windows.
|
|
// That's why we await it only on that platform - other platforms actually understand
|
|
// the concept of a file properly...
|
|
printV("closing wallet");
|
|
final wmaddr = wmPtr.ffiAddress();
|
|
final waddr = openedWalletsByPath["$currentWalletDirPath/$name"]!.ffiAddress();
|
|
openedWalletsByPath.remove("$currentWalletDirPath/$name");
|
|
if (Platform.isWindows) {
|
|
await Isolate.run(() {
|
|
monero.WalletManager_closeWallet(
|
|
Pointer.fromAddress(wmaddr), Pointer.fromAddress(waddr), true);
|
|
monero.WalletManager_errorString(Pointer.fromAddress(wmaddr));
|
|
});
|
|
} else {
|
|
unawaited(Isolate.run(() {
|
|
monero.WalletManager_closeWallet(
|
|
Pointer.fromAddress(wmaddr), Pointer.fromAddress(waddr), true);
|
|
monero.WalletManager_errorString(Pointer.fromAddress(wmaddr));
|
|
}));
|
|
}
|
|
printV("wallet closed");
|
|
}
|
|
try {
|
|
// -- rename the waller folder --
|
|
final currentWalletDir =
|
|
Directory(await pathForWalletDir(name: name, type: type));
|
|
final newWalletDirPath =
|
|
await pathForWalletDir(name: newWalletName, type: type);
|
|
|
|
// Create new directory if it doesn't exist
|
|
await Directory(newWalletDirPath).create(recursive: true);
|
|
|
|
// -- use new waller folder to copy files with old names still --
|
|
final currentWalletPath = currentWalletDir.path + '/$name';
|
|
|
|
final currentCacheFile = File(currentWalletPath);
|
|
final currentKeysFile = File('$currentWalletPath.keys');
|
|
final currentAddressListFile = File('$currentWalletPath.address.txt');
|
|
final backgroundSyncFile = File('$currentWalletPath.background');
|
|
|
|
if (currentCacheFile.existsSync()) {
|
|
await currentCacheFile.copy("${newWalletDirPath}/$newWalletName");
|
|
}
|
|
if (currentKeysFile.existsSync()) {
|
|
await currentKeysFile.copy("${newWalletDirPath}/$newWalletName.keys");
|
|
}
|
|
if (currentAddressListFile.existsSync()) {
|
|
await currentAddressListFile.copy("${newWalletDirPath}/$newWalletName.address.txt");
|
|
}
|
|
if (backgroundSyncFile.existsSync()) {
|
|
await backgroundSyncFile.copy("${newWalletDirPath}/$newWalletName.background");
|
|
}
|
|
|
|
await currentWalletDir.delete(recursive: true);
|
|
|
|
await backupWalletFiles(newWalletName);
|
|
} catch (e) {
|
|
final currentWalletPath = await pathForWallet(name: name, type: type);
|
|
|
|
final currentCacheFile = File(currentWalletPath);
|
|
final currentKeysFile = File('$currentWalletPath.keys');
|
|
final currentAddressListFile = File('$currentWalletPath.address.txt');
|
|
|
|
final newWalletPath =
|
|
await pathForWallet(name: newWalletName, type: type);
|
|
|
|
// Copies current wallet files into new wallet name's dir and files
|
|
if (currentCacheFile.existsSync()) {
|
|
await currentCacheFile.copy(newWalletPath);
|
|
}
|
|
if (currentKeysFile.existsSync()) {
|
|
await currentKeysFile.copy('$newWalletPath.keys');
|
|
}
|
|
if (currentAddressListFile.existsSync()) {
|
|
await currentAddressListFile.copy('$newWalletPath.address.txt');
|
|
}
|
|
|
|
// Delete old name's dir and files
|
|
await Directory(currentWalletDirPath).delete(recursive: true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> changePassword(String password) async =>
|
|
monero_wallet.setPasswordSync(password);
|
|
|
|
Future<int> getNodeHeight() async => monero_wallet.getNodeHeight();
|
|
|
|
Future<bool> isConnected() async => monero_wallet.isConnected();
|
|
|
|
Future<void> setAsRecovered() async {
|
|
walletInfo.isRecovery = false;
|
|
await walletInfo.save();
|
|
}
|
|
|
|
@override
|
|
Future<void> rescan({required int height}) async {
|
|
walletInfo.restoreHeight = height;
|
|
walletInfo.isRecovery = true;
|
|
monero_wallet.setRefreshFromBlockHeight(height: height);
|
|
setupBackgroundSync(password, currentWallet!);
|
|
monero_wallet.rescanBlockchainAsync();
|
|
await startSync();
|
|
_askForUpdateBalance();
|
|
walletAddresses.accountList.update();
|
|
await updateTransactions();
|
|
await save();
|
|
await walletInfo.save();
|
|
}
|
|
|
|
Future<void> updateUnspent() async {
|
|
try {
|
|
refreshCoins(walletAddresses.account!.id);
|
|
|
|
unspentCoins.clear();
|
|
|
|
final coinCount = await countOfCoins();
|
|
for (var i = 0; i < coinCount; i++) {
|
|
final coin = await getCoin(i);
|
|
final coinSpent = coin.spent();
|
|
if (coinSpent == false && coin.subaddrAccount() == walletAddresses.account!.id) {
|
|
final unspent = await MoneroUnspent.fromUnspent(
|
|
address: coin.address(),
|
|
hash: coin.hash(),
|
|
keyImage: coin.keyImage(),
|
|
value: coin.amount(),
|
|
isFrozen: coin.frozen(),
|
|
isUnlocked: coin.unlocked(),
|
|
isSpent: coinSpent,
|
|
);
|
|
// TODO: double-check the logic here
|
|
if (unspent.hash.isNotEmpty) {
|
|
final tx = await transaction_history.getTransaction(unspent.hash);
|
|
unspent.isChange = tx.isSpend == true;
|
|
}
|
|
unspentCoins.add(unspent);
|
|
}
|
|
}
|
|
|
|
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.accountIndex == walletAddresses.account!.id &&
|
|
element.keyImage!.contains(coin.keyImage!));
|
|
|
|
if (coinInfoList.isNotEmpty) {
|
|
final coinInfo = coinInfoList.first;
|
|
|
|
coin.isFrozen = coinInfo.isFrozen;
|
|
coin.isSending = coinInfo.isSending;
|
|
coin.note = coinInfo.note;
|
|
} else {
|
|
_addCoinInfo(coin);
|
|
}
|
|
});
|
|
}
|
|
|
|
await _refreshUnspentCoinsInfo();
|
|
_askForUpdateBalance();
|
|
} catch (e, s) {
|
|
printV(e.toString());
|
|
onError?.call(FlutterErrorDetails(
|
|
exception: e,
|
|
stack: s,
|
|
library: this.runtimeType.toString(),
|
|
));
|
|
}
|
|
}
|
|
|
|
Future<void> _addCoinInfo(MoneroUnspent coin) async {
|
|
final newInfo = UnspentCoinsInfo(
|
|
walletId: id,
|
|
hash: coin.hash,
|
|
isFrozen: coin.isFrozen,
|
|
isSending: coin.isSending,
|
|
noteRaw: coin.note,
|
|
address: coin.address,
|
|
value: coin.value,
|
|
vout: 0,
|
|
keyImage: coin.keyImage,
|
|
isChange: coin.isChange,
|
|
accountIndex: walletAddresses.account!.id);
|
|
|
|
await unspentCoinsInfo.add(newInfo);
|
|
}
|
|
|
|
Future<void> _refreshUnspentCoinsInfo() async {
|
|
try {
|
|
final List<dynamic> keys = <dynamic>[];
|
|
final currentWalletUnspentCoins = unspentCoinsInfo.values.where(
|
|
(element) =>
|
|
element.walletId.contains(id) &&
|
|
element.accountIndex == walletAddresses.account!.id);
|
|
|
|
if (currentWalletUnspentCoins.isNotEmpty) {
|
|
currentWalletUnspentCoins.forEach((element) {
|
|
final existUnspentCoins = unspentCoins
|
|
.where((coin) => element.keyImage!.contains(coin.keyImage!));
|
|
|
|
if (existUnspentCoins.isEmpty) {
|
|
keys.add(element.key);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (keys.isNotEmpty) {
|
|
await unspentCoinsInfo.deleteAll(keys);
|
|
}
|
|
} catch (e) {
|
|
printV(e.toString());
|
|
}
|
|
}
|
|
|
|
String getTransactionAddress(int accountIndex, int addressIndex) =>
|
|
monero_wallet.getAddress(
|
|
accountIndex: accountIndex, addressIndex: addressIndex);
|
|
|
|
@override
|
|
Future<Map<String, MoneroTransactionInfo>> fetchTransactions() async {
|
|
await transaction_history.refreshTransactions();
|
|
final resp = (await _getAllTransactionsOfAccount(walletAddresses.account?.id))
|
|
.fold<Map<String, MoneroTransactionInfo>>(
|
|
<String, MoneroTransactionInfo>{},
|
|
(Map<String, MoneroTransactionInfo> acc, MoneroTransactionInfo tx) {
|
|
acc[tx.id] = tx;
|
|
return acc;
|
|
});
|
|
// This is needed to update the transaction history when new transaction is made.
|
|
unawaited(updateTransactions());
|
|
return resp;
|
|
}
|
|
|
|
Future<void> updateTransactions() async {
|
|
try {
|
|
if (_isTransactionUpdating) {
|
|
return;
|
|
}
|
|
|
|
_isTransactionUpdating = true;
|
|
final transactions = await fetchTransactions();
|
|
|
|
final currentIds = transactionHistory.transactions.keys.toSet();
|
|
final newIds = transactions.keys.toSet();
|
|
|
|
// Remove transactions that no longer exist
|
|
currentIds.difference(newIds).forEach((id) =>
|
|
transactionHistory.transactions.remove(id));
|
|
|
|
// Add or update transactions
|
|
transactions.forEach((key, tx) =>
|
|
transactionHistory.transactions[key] = tx);
|
|
await transactionHistory.save();
|
|
_isTransactionUpdating = false;
|
|
} catch (e) {
|
|
printV(e);
|
|
_isTransactionUpdating = false;
|
|
}
|
|
}
|
|
|
|
String getSubaddressLabel(int accountIndex, int addressIndex) =>
|
|
monero_wallet.getSubaddressLabel(accountIndex, addressIndex);
|
|
|
|
Future<List<MoneroTransactionInfo>> _getAllTransactionsOfAccount(int? accountIndex) async =>
|
|
(await transaction_history
|
|
.getAllTransactions())
|
|
.map(
|
|
(row) => MoneroTransactionInfo(
|
|
row.hash,
|
|
row.blockheight,
|
|
row.isSpend
|
|
? TransactionDirection.outgoing
|
|
: TransactionDirection.incoming,
|
|
row.timeStamp,
|
|
row.isPending,
|
|
row.amount,
|
|
row.accountIndex,
|
|
0,
|
|
row.fee,
|
|
row.confirmations,
|
|
)..additionalInfo = <String, dynamic>{
|
|
'key': row.key,
|
|
'accountIndex': row.accountIndex,
|
|
'addressIndex': row.addressIndex
|
|
},
|
|
)
|
|
.where((element) => element.accountIndex == (accountIndex ?? 0))
|
|
.toList();
|
|
|
|
void _setListeners() {
|
|
_listener?.stop();
|
|
_listener = monero_wallet.setListeners(_onNewBlock, _onNewTransaction);
|
|
}
|
|
|
|
/// Asserts the current height to be above [MIN_RESTORE_HEIGHT]
|
|
void _assertInitialHeight() {
|
|
if (walletInfo.isRecovery) return;
|
|
|
|
final height = monero_wallet.getCurrentHeight();
|
|
|
|
// the restore height is probably correct, so we do nothing:
|
|
if (height > MIN_RESTORE_HEIGHT) return;
|
|
|
|
throw Exception("height isn't > $MIN_RESTORE_HEIGHT!");
|
|
}
|
|
|
|
void _setHeightFromDate({int tryNum = 0}) {
|
|
if (walletInfo.isRecovery) {
|
|
return;
|
|
}
|
|
|
|
int height = 0;
|
|
try {
|
|
height = _getHeightByDate(walletInfo.date.subtract(Duration(days: 14)));
|
|
if (height <= 0) {
|
|
throw Exception("height is <= 0");
|
|
}
|
|
monero_wallet.setRefreshFromBlockHeight(height: height);
|
|
} catch (_) {
|
|
if (tryNum <= 3) {
|
|
printV("Failed to set height from date, retrying... $tryNum");
|
|
unawaited(() async {
|
|
await Future.delayed(Duration(seconds: 10));
|
|
_setHeightFromDate(tryNum: tryNum + 1);
|
|
}());
|
|
}
|
|
}
|
|
|
|
monero_wallet.setRecoveringFromSeed(isRecovery: true);
|
|
setupBackgroundSync(password, currentWallet!);
|
|
}
|
|
|
|
int _getHeightDistance(DateTime date) {
|
|
final distance =
|
|
DateTime.now().difference(date).inSeconds;
|
|
final daysTmp = (distance / 86400).round();
|
|
final days = daysTmp < 1 ? 1 : daysTmp;
|
|
|
|
return days * 720; // there are720 blocks per day on xmr
|
|
}
|
|
|
|
int _getHeightByDate(DateTime date) {
|
|
final nodeHeight = monero_wallet.getNodeHeightSync();
|
|
final heightDistance = _getHeightDistance(date);
|
|
|
|
if (nodeHeight <= 0) {
|
|
// the node returned 0 (an error state)
|
|
throw Exception("nodeHeight is <= 0!");
|
|
}
|
|
|
|
return nodeHeight - heightDistance;
|
|
}
|
|
|
|
void _askForUpdateBalance() {
|
|
final unlockedBalance = _getUnlockedBalance();
|
|
final fullBalance = monero_wallet.getFullBalance(
|
|
accountIndex: walletAddresses.account!.id);
|
|
final frozenBalance = _getFrozenBalance();
|
|
if (balance[currency]!.fullBalance != fullBalance ||
|
|
balance[currency]!.unlockedBalance != unlockedBalance ||
|
|
balance[currency]!.frozenBalance != frozenBalance) {
|
|
balance[currency] = MoneroBalance(
|
|
fullBalance: fullBalance,
|
|
unlockedBalance: unlockedBalance,
|
|
frozenBalance: frozenBalance);
|
|
}
|
|
}
|
|
|
|
int _getUnlockedBalance() => monero_wallet.getUnlockedBalance(
|
|
accountIndex: walletAddresses.account!.id);
|
|
|
|
int _getFrozenBalance() {
|
|
var frozenBalance = 0;
|
|
|
|
for (var coin in unspentCoinsInfo.values.where((element) =>
|
|
element.walletId == id && element.accountIndex == walletAddresses.account!.id)) {
|
|
if (coin.isFrozen && !coin.isSending) frozenBalance += coin.value;
|
|
}
|
|
|
|
return frozenBalance;
|
|
}
|
|
|
|
void _onNewBlock(int height, int blocksLeft, double ptc) async {
|
|
printV("onNewBlock: $height, $blocksLeft, $ptc");
|
|
try {
|
|
if (walletInfo.isRecovery) {
|
|
await updateTransactions();
|
|
_askForUpdateBalance();
|
|
walletAddresses.accountList.update();
|
|
}
|
|
|
|
if (blocksLeft < 100) {
|
|
await updateTransactions();
|
|
_askForUpdateBalance();
|
|
walletAddresses.accountList.update();
|
|
syncStatus = SyncedSyncStatus();
|
|
|
|
if (!_hasSyncAfterStartup) {
|
|
_hasSyncAfterStartup = true;
|
|
await save();
|
|
}
|
|
|
|
if (walletInfo.isRecovery) {
|
|
await setAsRecovered();
|
|
}
|
|
} else {
|
|
syncStatus = SyncingSyncStatus(blocksLeft, ptc);
|
|
}
|
|
} catch (e) {
|
|
printV(e.toString());
|
|
}
|
|
}
|
|
|
|
void _onNewTransaction() async {
|
|
try {
|
|
await updateTransactions();
|
|
_askForUpdateBalance();
|
|
await Future<void>.delayed(Duration(seconds: 1));
|
|
} catch (e) {
|
|
printV(e.toString());
|
|
}
|
|
}
|
|
|
|
void _updateSubAddress(bool enableAutoGenerate, {Account? account}) {
|
|
if (enableAutoGenerate) {
|
|
walletAddresses.updateUnusedSubaddress(
|
|
accountIndex: account?.id ?? 0,
|
|
defaultLabel: account?.label ?? '',
|
|
);
|
|
} else {
|
|
walletAddresses.updateSubaddressList(accountIndex: account?.id ?? 0);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void setExceptionHandler(void Function(FlutterErrorDetails) e) => onError = e;
|
|
|
|
@override
|
|
Future<String> signMessage(String message, {String? address}) async {
|
|
final useAddress = address ?? "";
|
|
return monero_wallet.signMessage(message, address: useAddress);
|
|
}
|
|
|
|
@override
|
|
Future<bool> verifyMessage(String message, String signature, {String? address = null}) async {
|
|
if (address == null) return false;
|
|
|
|
return monero_wallet.verifyMessage(message, address, signature);
|
|
}
|
|
|
|
Future<void> setLedgerConnection(LedgerConnection connection) async {
|
|
await enableLedgerExchange(connection);
|
|
}
|
|
|
|
@override
|
|
String formatCryptoAmount(String amount) {
|
|
return moneroAmountToString(amount: int.parse(amount));
|
|
}
|
|
}
|