dev
dart 991 lines 33 KB
Raw
1 import 'dart:async';
2 import 'dart:ffi';
3 import 'dart:io';
4 import 'dart:isolate';
5
6 import 'package:cw_core/amount/money.dart';
7 import 'package:cw_core/pathForWallet.dart';
8 import 'package:cw_core/transaction_priority.dart';
9 import 'package:cw_core/account.dart';
10 import 'package:cw_core/crypto_currency.dart';
11 import 'package:cw_core/monero_transaction_priority.dart';
12 import 'package:cw_core/monero_wallet_keys.dart';
13 import 'package:cw_core/monero_wallet_utils.dart';
14 import 'package:cw_core/node.dart';
15 import 'package:cw_core/pending_transaction.dart';
16 import 'package:cw_core/sync_status.dart';
17 import 'package:cw_core/transaction_direction.dart';
18 import 'package:cw_core/unspent_coins_info.dart';
19 import 'package:cw_core/utils/proxy_wrapper.dart';
20 import 'package:cw_core/utils/print_verbose.dart';
21 import 'package:cw_core/wallet_base.dart';
22 import 'package:cw_core/wallet_info.dart';
23 import 'package:cw_monero/api/account_list.dart';
24 import 'package:cw_monero/api/coins_info.dart';
25 import 'package:cw_monero/api/monero_output.dart';
26 import 'package:cw_monero/api/structs/pending_transaction.dart';
27 import 'package:cw_monero/api/transaction_history.dart' as transaction_history;
28 import 'package:cw_monero/api/wallet.dart' as monero_wallet;
29 import 'package:cw_monero/api/wallet_manager.dart';
30 import 'package:cw_monero/exceptions/monero_transaction_creation_exception.dart';
31 import 'package:cw_monero/ledger.dart';
32 import 'package:cw_monero/monero_balance.dart';
33 import 'package:cw_monero/monero_transaction_creation_credentials.dart';
34 import 'package:cw_monero/monero_transaction_history.dart';
35 import 'package:cw_monero/monero_transaction_info.dart';
36 import 'package:cw_monero/monero_unspent.dart';
37 import 'package:cw_monero/monero_wallet_addresses.dart';
38 import 'package:cw_monero/monero_wallet_service.dart';
39 import 'package:cw_monero/pending_monero_transaction.dart';
40 import 'package:cw_monero/trezor.dart';
41 import 'package:flutter/foundation.dart';
42 import 'package:hive/hive.dart';
43 import 'package:ledger_flutter_plus/ledger_flutter_plus.dart';
44 import 'package:mobx/mobx.dart';
45 import 'package:monero/monero.dart' as monero;
46
47 part 'monero_wallet.g.dart';
48
49 const moneroBlockSize = 1000;
50 // not sure if this should just be 0 but setting it higher feels safer / should catch more cases:
51 const MIN_RESTORE_HEIGHT = 1000;
52
53 class MoneroWallet = MoneroWalletBase with _$MoneroWallet;
54
55 abstract class MoneroWalletBase
56 extends WalletBase<MoneroBalance, MoneroTransactionHistory, MoneroTransactionInfo> with Store {
57 MoneroWalletBase(
58 {required WalletInfo walletInfo,
59 required DerivationInfo derivationInfo,
60 required Box<UnspentCoinsInfo> unspentCoinsInfo,
61 required String password})
62 : balance = ObservableMap<CryptoCurrency, MoneroBalance>.of({
63 CryptoCurrency.xmr: MoneroBalance(
64 fullBalance: monero_wallet.getFullBalance(accountIndex: 0),
65 unlockedBalance: monero_wallet.getUnlockedBalance(accountIndex: 0),
66 )
67 }),
68 _isTransactionUpdating = false,
69 _hasSyncAfterStartup = false,
70 isEnabledAutoGenerateSubaddress = true,
71 _password = password,
72 syncStatus = NotConnectedSyncStatus(),
73 unspentCoins = [],
74 this.unspentCoinsInfo = unspentCoinsInfo,
75 super(walletInfo, derivationInfo) {
76 transactionHistory = MoneroTransactionHistory();
77 walletAddresses = MoneroWalletAddresses(walletInfo, transactionHistory);
78
79 _onAccountChangeReaction = reaction((_) => walletAddresses.account, (Account? account) {
80 if (account == null) return;
81
82 balance = ObservableMap<CryptoCurrency, MoneroBalance>.of(<CryptoCurrency, MoneroBalance>{
83 currency: MoneroBalance(
84 fullBalance: monero_wallet.getFullBalance(accountIndex: account.id),
85 unlockedBalance: monero_wallet.getUnlockedBalance(accountIndex: account.id))
86 });
87 _updateSubAddress(isEnabledAutoGenerateSubaddress, account: account);
88 unawaited(updateTransactions());
89 });
90
91 reaction((_) => isEnabledAutoGenerateSubaddress, (bool enabled) {
92 _updateSubAddress(enabled, account: walletAddresses.account);
93 });
94 _onTxHistoryChangeReaction = reaction((_) => transactionHistory, (__) {
95 _updateSubAddress(isEnabledAutoGenerateSubaddress, account: walletAddresses.account);
96 });
97 }
98
99 static const int _autoSaveInterval = 30;
100
101 Box<UnspentCoinsInfo> unspentCoinsInfo;
102
103 void Function(FlutterErrorDetails)? onError;
104
105 @override
106 late MoneroWalletAddresses walletAddresses;
107
108 @override
109 @observable
110 bool isEnabledAutoGenerateSubaddress;
111
112 @override
113 @observable
114 SyncStatus syncStatus;
115
116 @override
117 @observable
118 ObservableMap<CryptoCurrency, MoneroBalance> balance;
119
120 @override
121 bool get hasRescan => true;
122
123 @override
124 String get seed => monero_wallet.getSeed();
125 String seedLegacy(String? language) => monero_wallet.getSeedLegacy(language);
126
127 @override
128 String get password => _password;
129
130 @override
131 String get passphrase => monero_wallet.getPassphrase();
132
133 @override
134 MoneroWalletKeys get keys => MoneroWalletKeys(
135 primaryAddress: monero_wallet.getAddress(accountIndex: 0, addressIndex: 0),
136 privateSpendKey: monero_wallet.getSecretSpendKey(),
137 privateViewKey: monero_wallet.getSecretViewKey(),
138 publicSpendKey: monero_wallet.getPublicSpendKey(),
139 publicViewKey: monero_wallet.getPublicViewKey(),
140 passphrase: monero_wallet.getPassphrase());
141
142 int? get restoreHeight =>
143 transactionHistory.transactions.values.firstOrNull?.height ??
144 currentWallet?.getRefreshFromBlockHeight();
145
146 monero_wallet.SyncListener? _listener;
147 ReactionDisposer? _onAccountChangeReaction;
148 ReactionDisposer? _onTxHistoryChangeReaction;
149 bool _isTransactionUpdating;
150 bool _hasSyncAfterStartup;
151 Timer? _autoSaveTimer;
152 List<MoneroUnspent> unspentCoins;
153 String _password;
154
155 Future<void> init() async {
156 await walletAddresses.init();
157 balance = ObservableMap<CryptoCurrency, MoneroBalance>.of(<CryptoCurrency, MoneroBalance>{
158 currency: MoneroBalance(
159 fullBalance: monero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id),
160 unlockedBalance:
161 monero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id))
162 });
163 _setListeners();
164 await updateTransactions();
165
166 if (walletInfo.isRecovery) {
167 monero_wallet.setRecoveringFromSeed(isRecovery: walletInfo.isRecovery);
168
169 if (monero_wallet.getCurrentHeight() <= 1) {
170 monero_wallet.setRefreshFromBlockHeight(height: walletInfo.restoreHeight);
171 setupBackgroundSync(password, currentWallet!);
172 }
173 }
174
175 _autoSaveTimer =
176 Timer.periodic(Duration(seconds: _autoSaveInterval), (_) async => await save());
177 // update transaction details after restore
178 await walletAddresses.subaddressList.update(accountIndex: walletAddresses.account?.id ?? 0);
179 }
180
181 @override
182 Future<void>? updateBalance() => null;
183
184 @override
185 Future<bool> checkNodeHealth() async {
186 try {
187 // Check if the wallet is currently connected to the daemon
188 final isConnected = await monero_wallet.isConnected();
189
190 if (!isConnected) {
191 return false; // It's not connected to daemon
192 }
193
194 // Check to get current node height to ensure daemon is responsive
195 final nodeHeight = await monero_wallet.getNodeHeight();
196 return nodeHeight > 0;
197 } catch (e) {
198 return false;
199 }
200 }
201
202 @override
203 Future<void> close({bool shouldCleanup = false}) async {
204 if (isHardwareWallet) {
205 disableLedgerExchange();
206 final currentWalletDirPath = await pathForWalletDir(name: name, type: type);
207 if (openedWalletsByPath["$currentWalletDirPath/$name"] != null) {
208 printV("closing wallet");
209 final wmaddr = wmPtr.ffiAddress();
210 final waddr = openedWalletsByPath["$currentWalletDirPath/$name"]!.ffiAddress();
211 openedWalletsByPath.remove("$currentWalletDirPath/$name");
212 closeWalletAwaitIfShould(wmaddr, waddr);
213 if (currentWallet?.ffiAddress() == waddr) {
214 currentWallet = null;
215 }
216 printV("wallet closed");
217 }
218 }
219
220 _listener?.stop();
221 _onAccountChangeReaction?.reaction.dispose();
222 _onTxHistoryChangeReaction?.reaction.dispose();
223 _autoSaveTimer?.cancel();
224 }
225
226 @override
227 Future<void> connectToNode({required Node node}) async {
228 String socksProxy = node.socksProxyAddress ?? '';
229 printV("bootstrapped: ${CakeTor.instance!.bootstrapped}");
230 printV(" enabled: ${CakeTor.instance!.enabled}");
231 printV(" port: ${CakeTor.instance!.port}");
232 printV(" started: ${CakeTor.instance!.started}");
233 if (CakeTor.instance!.enabled) {
234 socksProxy = "127.0.0.1:${CakeTor.instance!.port}";
235 }
236 try {
237 syncStatus = ConnectingSyncStatus();
238 await monero_wallet.setupNodeSync(
239 address: node.uri.toString(),
240 login: node.login,
241 password: node.password,
242 useSSL: node.isSSL,
243 isLightWallet: false,
244 // FIXME: hardcoded value
245 socksProxyAddress: socksProxy);
246
247 await monero_wallet.setTrustedDaemon(node.trusted);
248 syncStatus = ConnectedSyncStatus();
249 } catch (e) {
250 syncStatus = FailedSyncStatus();
251 printV(e);
252 }
253 }
254
255 @override
256 Future<void> startBackgroundSync() async {
257 if (isBackgroundSyncRunning) {
258 printV("Background sync already running");
259 return;
260 }
261 isBackgroundSyncRunning = true;
262 await save();
263
264 currentWallet!.startBackgroundSync();
265 final status = currentWallet!.status();
266 if (status != 0) {
267 final err = currentWallet!.errorString();
268 isBackgroundSyncRunning = false;
269 printV("startBackgroundSync: $err");
270 }
271 await save();
272 await init();
273 await startSync();
274 }
275
276 bool isBackgroundSyncRunning = false;
277
278 @action
279 @override
280 Future<void> stopSync() async {
281 if (isBackgroundSyncRunning) {
282 printV("Stopping background sync");
283 currentWallet!.store();
284 currentWallet!.stopBackgroundSync('');
285 currentWallet!.store();
286 isBackgroundSyncRunning = false;
287 }
288 await save();
289 }
290
291 @action
292 @override
293 Future<void> stopBackgroundSync(String password) async {
294 if (isBackgroundSyncRunning) {
295 printV("Stopping background sync");
296 currentWallet!.store();
297 currentWallet!.stopBackgroundSync(password);
298 currentWallet!.store();
299 isBackgroundSyncRunning = false;
300 }
301 }
302
303 @override
304 Future<void> startSync() async {
305 try {
306 _assertInitialHeight();
307 } catch (_) {
308 // our restore height wasn't correct, so lets see if using the backup works:
309 try {
310 await resetCache(name); // Resetting the cache removes the TX Keys and Polyseed
311 _assertInitialHeight();
312 } catch (e) {
313 // we still couldn't get a valid height from the backup?!:
314 // try to use the date instead:
315 try {
316 _setHeightFromDate();
317 } catch (_) {
318 // we still couldn't get a valid sync height :/
319 }
320 }
321 }
322
323 try {
324 syncStatus = AttemptingSyncStatus();
325 monero_wallet.startRefresh();
326 _setListeners();
327 } catch (e) {
328 syncStatus = FailedSyncStatus();
329 printV(e);
330 rethrow;
331 }
332 }
333
334 Future<bool> submitTransactionUR(String ur) async {
335 final retStatus = currentWallet!.submitTransactionUR(ur);
336 final status = currentWallet!.status();
337 if (status != 0) {
338 final err = currentWallet!.errorString();
339 throw MoneroTransactionCreationException("unable to broadcast signed transaction: $err");
340 }
341 return retStatus;
342 }
343
344 bool importKeyImagesUR(String ur) {
345 final retStatus = currentWallet!.importKeyImagesUR(ur);
346 final status = currentWallet!.status();
347 if (status != 0) {
348 final err = currentWallet!.errorString();
349 throw Exception("unable to import key images: $err");
350 }
351 return retStatus;
352 }
353
354 Map<String, String> exportOutputsUR() {
355 final str = currentWallet!.exportOutputsUR(all: false);
356 int status = currentWallet!.status();
357 if (status != 0) {
358 final err = currentWallet!.errorString();
359 throw MoneroTransactionCreationException("unable to export outputs: $err");
360 }
361 final strAll = currentWallet!.exportOutputsUR(all: true);
362 status = currentWallet!.status();
363 if (status != 0) {
364 final err = currentWallet!.errorString();
365 throw MoneroTransactionCreationException("unable to export outputs: $err");
366 }
367 return {
368 "Outputs (partial)": str,
369 "Outputs (all)": strAll,
370 };
371 }
372
373 bool hasUnknownKeyImages() => currentWallet!.hasUnknownKeyImages();
374
375 bool needExportOutputs(Money amount) {
376 if (int.tryParse(currentWallet!.secretSpendKey()) != 0) {
377 return false;
378 }
379 // viewOnlyBalance - balance that we can spend
380 // TODO(mrcyjanek): remove hasUnknownKeyImages when we cleanup coin control
381 return (currentWallet!.viewOnlyBalance(accountIndex: walletAddresses.account!.id) <
382 amount.amount.toInt()) ||
383 currentWallet!.hasUnknownKeyImages();
384 }
385
386 MoneroTrezorService? trezorService;
387
388 Future<Trezor> _getTrezor() async {
389 if (trezorService == null) throw Exception("Trezor not connected");
390
391 final trezor = Trezor(trezorService!);
392 await trezor.newPassphraseSession(passphrase);
393 return trezor;
394 }
395
396 Future<void> syncTrezor() async {
397 if (trezorService == null) throw Exception("Trezor not connected");
398
399 final ptr = Pointer<Void>.fromAddress(currentWallet!.ffiAddress());
400 final tdis = monero.Wallet_exportTrezorTdis(ptr);
401 final trezor = await _getTrezor();
402 final response = await trezor.keyImageSync(tdis);
403 final success = monero.Wallet_importTrezorEncryptedKeyImagesJson(ptr, response);
404
405 if (!success) throw Exception(monero.Wallet_errorString(ptr));
406 }
407
408 Future<String> signTrezorTransaction(String json) async {
409 final trezor = await _getTrezor();
410 return trezor.signTransaction(json);
411 }
412
413 @override
414 Future<PendingTransaction> createTransaction(Object credentials) async {
415 if (hardwareWalletType == HardwareWalletType.trezor) {
416 for (int i = 0; i < 2; i++) {
417 try {
418 return await _createTransaction(credentials);
419 } catch (e) {
420 printV(e);
421 }
422 await save();
423 await Future.delayed(Duration(seconds: i));
424 }
425 }
426 return await _createTransaction(credentials);
427 }
428
429 Future<PendingTransaction> _createTransaction(Object credentials) async {
430 final _credentials = credentials as MoneroTransactionCreationCredentials;
431 final inputs = <String>[];
432 final outputs = _credentials.outputs;
433 final hasMultiDestination = outputs.length > 1;
434 final unlockedBalance =
435 monero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id);
436
437 PendingTransactionDescription pendingTransactionDescription;
438
439 if (!(syncStatus is SyncedSyncStatus)) {
440 throw MoneroTransactionCreationException('The wallet is not synced.');
441 }
442
443 await updateUnspent();
444
445 for (final utx in unspentCoins) {
446 if (utx.isSending) {
447 inputs.add(utx.keyImage!);
448 }
449 }
450
451 if (hasMultiDestination) {
452 if (outputs.any((item) => item.sendAll || item.cryptoAmount.amount <= BigInt.zero)) {
453 throw MoneroTransactionCreationException('You do not have enough XMR to send this amount.');
454 }
455
456 final totalAmount = outputs.fold(0, (acc, value) => acc + value.cryptoAmount.amount.toInt());
457
458 if (unlockedBalance < Money.fromInt(totalAmount, CryptoCurrency.xmr)) {
459 throw MoneroTransactionCreationException('You do not have enough XMR to send this amount.');
460 }
461
462 if (inputs.isEmpty) MoneroTransactionCreationException('No inputs selected');
463
464 final moneroOutputs = outputs.map((output) {
465 final outputAddress = output.isParsedAddress ? output.extractedAddress : output.address;
466
467 return MoneroOutput(address: outputAddress!, amount: output.cryptoAmount.toString());
468 }).toList();
469
470 pendingTransactionDescription = await transaction_history.createTransactionMultDest(
471 outputs: moneroOutputs,
472 priorityRaw: _credentials.priority.serialize(),
473 accountIndex: walletAddresses.account!.id,
474 paymentId: "",
475 preferredInputs: inputs);
476 } else {
477 final output = outputs.first;
478 final address = output.isParsedAddress ? output.extractedAddress : output.address;
479 final amount = output.sendAll ? null : output.cryptoAmount.toString();
480
481 // if ((formattedAmount != null && unlockedBalance < formattedAmount) ||
482 // (formattedAmount == null && unlockedBalance <= 0)) {
483 // final formattedBalance = moneroAmountToString(amount: unlockedBalance);
484 //
485 // throw MoneroTransactionCreationException(
486 // 'You do not have enough unlocked balance. Unlocked: $formattedBalance. Transaction amount: ${output.cryptoAmount}.');
487 // }
488
489 if (inputs.isEmpty) MoneroTransactionCreationException('No inputs selected');
490 pendingTransactionDescription = await transaction_history.createTransactionSync(
491 address: address!,
492 amount: amount,
493 priorityRaw: _credentials.priority.serialize(),
494 accountIndex: walletAddresses.account!.id,
495 preferredInputs: inputs,
496 paymentId: '');
497 }
498
499 // final status = monero.PendingTransaction_status(pendingTransactionDescription);
500
501 return PendingMoneroTransaction(pendingTransactionDescription, this);
502 }
503
504 @override
505 int calculateEstimatedFee(TransactionPriority priority, int? amount) {
506 // FIXME: hardcoded value;
507
508 if (priority is MoneroTransactionPriority) {
509 switch (priority) {
510 case MoneroTransactionPriority.slow:
511 return 24590000;
512 case MoneroTransactionPriority.automatic:
513 return 123050000;
514 case MoneroTransactionPriority.medium:
515 return 245029999;
516 case MoneroTransactionPriority.fast:
517 return 614530000;
518 case MoneroTransactionPriority.fastest:
519 return 26021600000;
520 }
521 }
522
523 return 0;
524 }
525
526 @override
527 Future<void> save() async {
528 await walletAddresses.updateUsedSubaddress();
529
530 if (isEnabledAutoGenerateSubaddress) {
531 await walletAddresses.updateUnusedSubaddress(
532 accountIndex: walletAddresses.account?.id ?? 0,
533 defaultLabel: walletAddresses.account?.label ?? '');
534 }
535
536 await walletAddresses.updateAddressesInBox();
537 await monero_wallet.store();
538 try {
539 await backupWalletFiles(name);
540 } catch (e) {
541 printV(\\_(ツ)_/¯");
542 printV(e);
543 }
544 }
545
546 @override
547 Future<void> renameWalletFiles(String newWalletName) async {
548 final currentWalletDirPath = await pathForWalletDir(name: name, type: type);
549 if (openedWalletsByPath["$currentWalletDirPath/$name"] != null) {
550 // NOTE: this is realistically only required on windows.
551 // That's why we await it only on that platform - other platforms actually understand
552 // the concept of a file properly...
553 printV("closing wallet");
554 final wmaddr = wmPtr.ffiAddress();
555 final waddr = openedWalletsByPath["$currentWalletDirPath/$name"]!.ffiAddress();
556 openedWalletsByPath.remove("$currentWalletDirPath/$name");
557 if (Platform.isWindows) {
558 await Isolate.run(() {
559 monero.WalletManager_closeWallet(
560 Pointer.fromAddress(wmaddr), Pointer.fromAddress(waddr), true);
561 monero.WalletManager_errorString(Pointer.fromAddress(wmaddr));
562 });
563 } else {
564 unawaited(Isolate.run(() {
565 monero.WalletManager_closeWallet(
566 Pointer.fromAddress(wmaddr), Pointer.fromAddress(waddr), true);
567 monero.WalletManager_errorString(Pointer.fromAddress(wmaddr));
568 }));
569 }
570 printV("wallet closed");
571 }
572 try {
573 // -- rename the waller folder --
574 final currentWalletDir = Directory(await pathForWalletDir(name: name, type: type));
575 final newWalletDirPath = await pathForWalletDir(name: newWalletName, type: type);
576
577 // Create new directory if it doesn't exist
578 await Directory(newWalletDirPath).create(recursive: true);
579
580 // -- use new waller folder to copy files with old names still --
581 final currentWalletPath = currentWalletDir.path + '/$name';
582
583 final currentCacheFile = File(currentWalletPath);
584 final currentKeysFile = File('$currentWalletPath.keys');
585 final currentAddressListFile = File('$currentWalletPath.address.txt');
586 final backgroundSyncFile = File('$currentWalletPath.background');
587
588 if (currentCacheFile.existsSync()) {
589 await currentCacheFile.copy("${newWalletDirPath}/$newWalletName");
590 }
591 if (currentKeysFile.existsSync()) {
592 await currentKeysFile.copy("${newWalletDirPath}/$newWalletName.keys");
593 }
594 if (currentAddressListFile.existsSync()) {
595 await currentAddressListFile.copy("${newWalletDirPath}/$newWalletName.address.txt");
596 }
597 if (backgroundSyncFile.existsSync()) {
598 await backgroundSyncFile.copy("${newWalletDirPath}/$newWalletName.background");
599 }
600
601 await currentWalletDir.delete(recursive: true);
602
603 await backupWalletFiles(newWalletName);
604 } catch (e) {
605 final currentWalletPath = await pathForWallet(name: name, type: type);
606
607 final currentCacheFile = File(currentWalletPath);
608 final currentKeysFile = File('$currentWalletPath.keys');
609 final currentAddressListFile = File('$currentWalletPath.address.txt');
610
611 final newWalletPath = await pathForWallet(name: newWalletName, type: type);
612
613 // Copies current wallet files into new wallet name's dir and files
614 if (currentCacheFile.existsSync()) {
615 await currentCacheFile.copy(newWalletPath);
616 }
617 if (currentKeysFile.existsSync()) {
618 await currentKeysFile.copy('$newWalletPath.keys');
619 }
620 if (currentAddressListFile.existsSync()) {
621 await currentAddressListFile.copy('$newWalletPath.address.txt');
622 }
623
624 // Delete old name's dir and files
625 await Directory(currentWalletDirPath).delete(recursive: true);
626 }
627 }
628
629 @override
630 Future<void> changePassword(String password) async => monero_wallet.setPasswordSync(password);
631
632 Future<int> getNodeHeight() async => monero_wallet.getNodeHeight();
633
634 Future<bool> isConnected() async => monero_wallet.isConnected();
635
636 Future<void> setAsRecovered() async {
637 walletInfo.isRecovery = false;
638 await walletInfo.save();
639 }
640
641 @override
642 Future<void> rescan({required int height}) async {
643 walletInfo.restoreHeight = height;
644 walletInfo.isRecovery = true;
645 monero_wallet.setRefreshFromBlockHeight(height: height);
646 setupBackgroundSync(password, currentWallet!);
647 monero_wallet.rescanBlockchainAsync();
648 await startSync();
649 _askForUpdateBalance();
650 walletAddresses.accountList.update();
651 await updateTransactions();
652 await save();
653 await walletInfo.save();
654 }
655
656 Future<void> updateUnspent() async {
657 try {
658 refreshCoins(walletAddresses.account!.id);
659
660 unspentCoins.clear();
661
662 final coinCount = await countOfCoins();
663 for (var i = 0; i < coinCount; i++) {
664 final coin = await getCoin(i);
665 final coinSpent = coin.spent();
666 if (coinSpent == false && coin.subaddrAccount() == walletAddresses.account!.id) {
667 final unspent = await MoneroUnspent.fromUnspent(
668 address: coin.address(),
669 hash: coin.hash(),
670 keyImage: coin.keyImage(),
671 value: coin.amount(),
672 isFrozen: coin.frozen(),
673 isUnlocked: coin.unlocked(),
674 isSpent: coinSpent,
675 );
676 // TODO: double-check the logic here
677 if (unspent.hash.isNotEmpty) {
678 final tx = await transaction_history.getTransaction(unspent.hash);
679 unspent.isChange = tx.isSpend == true;
680 }
681 unspentCoins.add(unspent);
682 }
683 }
684
685 if (unspentCoinsInfo.isEmpty) {
686 unspentCoins.forEach((coin) => _addCoinInfo(coin));
687 return;
688 }
689
690 if (unspentCoins.isNotEmpty) {
691 unspentCoins.forEach((coin) {
692 final coinInfoList = unspentCoinsInfo.values.where((element) =>
693 element.walletId.contains(id) &&
694 element.accountIndex == walletAddresses.account!.id &&
695 element.keyImage!.contains(coin.keyImage!));
696
697 if (coinInfoList.isNotEmpty) {
698 final coinInfo = coinInfoList.first;
699
700 coin.isFrozen = coinInfo.isFrozen;
701 coin.isSending = coinInfo.isSending;
702 coin.note = coinInfo.note;
703 } else {
704 _addCoinInfo(coin);
705 }
706 });
707 }
708
709 await _refreshUnspentCoinsInfo();
710 _askForUpdateBalance();
711 } catch (e, s) {
712 printV(e.toString());
713 onError?.call(FlutterErrorDetails(
714 exception: e,
715 stack: s,
716 library: this.runtimeType.toString(),
717 ));
718 }
719 }
720
721 Future<void> _addCoinInfo(MoneroUnspent coin) async {
722 final newInfo = UnspentCoinsInfo(
723 walletId: id,
724 hash: coin.hash,
725 isFrozen: coin.isFrozen,
726 isSending: coin.isSending,
727 noteRaw: coin.note,
728 address: coin.address,
729 value: coin.value,
730 vout: 0,
731 keyImage: coin.keyImage,
732 isChange: coin.isChange,
733 accountIndex: walletAddresses.account!.id);
734
735 await unspentCoinsInfo.add(newInfo);
736 }
737
738 Future<void> _refreshUnspentCoinsInfo() async {
739 try {
740 final List<dynamic> keys = <dynamic>[];
741 final currentWalletUnspentCoins = unspentCoinsInfo.values.where((element) =>
742 element.walletId.contains(id) && element.accountIndex == walletAddresses.account!.id);
743
744 if (currentWalletUnspentCoins.isNotEmpty) {
745 currentWalletUnspentCoins.forEach((element) {
746 final existUnspentCoins =
747 unspentCoins.where((coin) => element.keyImage!.contains(coin.keyImage!));
748
749 if (existUnspentCoins.isEmpty) {
750 keys.add(element.key);
751 }
752 });
753 }
754
755 if (keys.isNotEmpty) {
756 await unspentCoinsInfo.deleteAll(keys);
757 }
758 } catch (e) {
759 printV(e.toString());
760 }
761 }
762
763 String getTransactionAddress(int accountIndex, int addressIndex) =>
764 monero_wallet.getAddress(accountIndex: accountIndex, addressIndex: addressIndex);
765
766 @override
767 Future<Map<String, MoneroTransactionInfo>> fetchTransactions() async {
768 await transaction_history.refreshTransactions();
769 final resp = (await _getAllTransactionsOfAccount(walletAddresses.account?.id))
770 .fold<Map<String, MoneroTransactionInfo>>(<String, MoneroTransactionInfo>{},
771 (Map<String, MoneroTransactionInfo> acc, MoneroTransactionInfo tx) {
772 acc[tx.id] = tx;
773 return acc;
774 });
775 // This is needed to update the transaction history when new transaction is made.
776 unawaited(updateTransactions());
777 return resp;
778 }
779
780 Future<void> updateTransactions() async {
781 try {
782 if (_isTransactionUpdating) {
783 return;
784 }
785
786 _isTransactionUpdating = true;
787 final transactions = await fetchTransactions();
788
789 final currentIds = transactionHistory.transactions.keys.toSet();
790 final newIds = transactions.keys.toSet();
791
792 // Remove transactions that no longer exist
793 currentIds.difference(newIds).forEach((id) => transactionHistory.transactions.remove(id));
794
795 // Add or update transactions
796 transactions.forEach((key, tx) => transactionHistory.transactions[key] = tx);
797 await transactionHistory.save();
798 _isTransactionUpdating = false;
799 } catch (e) {
800 printV(e);
801 _isTransactionUpdating = false;
802 }
803 }
804
805 String getSubaddressLabel(int accountIndex, int addressIndex) =>
806 monero_wallet.getSubaddressLabel(accountIndex, addressIndex);
807
808 Future<List<MoneroTransactionInfo>> _getAllTransactionsOfAccount(int? accountIndex) async =>
809 (await transaction_history.getAllTransactions())
810 .map(
811 (row) => MoneroTransactionInfo(
812 row.hash,
813 row.blockheight,
814 row.isSpend ? TransactionDirection.outgoing : TransactionDirection.incoming,
815 row.timeStamp,
816 row.isPending,
817 Money.fromInt(row.amount, currency),
818 row.accountIndex,
819 0,
820 Money.fromInt(row.fee, currency),
821 row.confirmations,
822 )..additionalInfo = <String, dynamic>{
823 'key': row.key,
824 'accountIndex': row.accountIndex,
825 'addressIndex': row.addressIndex
826 },
827 )
828 .where((element) => element.accountIndex == (accountIndex ?? 0))
829 .toList();
830
831 void _setListeners() {
832 _listener?.stop();
833 _listener = monero_wallet.setListeners(_onNewBlock, _onNewTransaction);
834 }
835
836 /// Asserts the current height to be above [MIN_RESTORE_HEIGHT]
837 void _assertInitialHeight() {
838 if (walletInfo.isRecovery) return;
839
840 final height = monero_wallet.getCurrentHeight();
841
842 // the restore height is probably correct, so we do nothing:
843 if (height > MIN_RESTORE_HEIGHT) return;
844
845 throw Exception("height isn't > $MIN_RESTORE_HEIGHT!");
846 }
847
848 void _setHeightFromDate({int tryNum = 0}) {
849 if (walletInfo.isRecovery) {
850 return;
851 }
852
853 int height = 0;
854 try {
855 height = _getHeightByDate(walletInfo.date.subtract(Duration(days: 14)));
856 if (height <= 0) {
857 throw Exception("height is <= 0");
858 }
859 monero_wallet.setRefreshFromBlockHeight(height: height);
860 } catch (_) {
861 if (tryNum <= 3) {
862 printV("Failed to set height from date, retrying... $tryNum");
863 unawaited(() async {
864 await Future.delayed(Duration(seconds: 10));
865 _setHeightFromDate(tryNum: tryNum + 1);
866 }());
867 }
868 }
869
870 monero_wallet.setRecoveringFromSeed(isRecovery: true);
871 setupBackgroundSync(password, currentWallet!);
872 }
873
874 int _getHeightDistance(DateTime date) {
875 final distance = DateTime.now().difference(date).inSeconds;
876 final daysTmp = (distance / 86400).round();
877 final days = daysTmp < 1 ? 1 : daysTmp;
878
879 return days * 720; // there are720 blocks per day on xmr
880 }
881
882 int _getHeightByDate(DateTime date) {
883 final nodeHeight = monero_wallet.getNodeHeightSync();
884 final heightDistance = _getHeightDistance(date);
885
886 if (nodeHeight <= 0) {
887 // the node returned 0 (an error state)
888 throw Exception("nodeHeight is <= 0!");
889 }
890
891 return nodeHeight - heightDistance;
892 }
893
894 void _askForUpdateBalance() {
895 final unlockedBalance = _getUnlockedBalance();
896 final fullBalance = monero_wallet.getFullBalance(accountIndex: walletAddresses.account!.id);
897 final frozenBalance = _getFrozenBalance();
898 if (balance[currency]!.fullBalance != fullBalance ||
899 balance[currency]!.available != unlockedBalance ||
900 balance[currency]!.frozen != frozenBalance) {
901 balance[currency] = MoneroBalance(
902 fullBalance: fullBalance, unlockedBalance: unlockedBalance, frozen: frozenBalance);
903 }
904 }
905
906 Money _getUnlockedBalance() =>
907 monero_wallet.getUnlockedBalance(accountIndex: walletAddresses.account!.id);
908
909 Money _getFrozenBalance() {
910 var frozenBalance = 0;
911
912 for (final coin in unspentCoinsInfo.values.where((element) =>
913 element.walletId == id && element.accountIndex == walletAddresses.account!.id)) {
914 if (coin.isFrozen && !coin.isSending) frozenBalance += coin.value;
915 }
916
917 return Money.fromInt(frozenBalance, CryptoCurrency.xmr);
918 }
919
920 void _onNewBlock(int height, int blocksLeft, double ptc) async {
921 printV("onNewBlock: $height, $blocksLeft, $ptc");
922 try {
923 if (walletInfo.isRecovery) {
924 await updateTransactions();
925 _askForUpdateBalance();
926 walletAddresses.accountList.update();
927 }
928
929 if (blocksLeft < 100) {
930 await updateTransactions();
931 _askForUpdateBalance();
932 walletAddresses.accountList.update();
933 syncStatus = SyncedSyncStatus();
934
935 if (!_hasSyncAfterStartup) {
936 _hasSyncAfterStartup = true;
937 await save();
938 }
939
940 if (walletInfo.isRecovery) {
941 await setAsRecovered();
942 }
943 } else {
944 syncStatus = SyncingSyncStatus(blocksLeft, ptc);
945 }
946 } catch (e) {
947 printV(e.toString());
948 }
949 }
950
951 void _onNewTransaction() async {
952 try {
953 await updateTransactions();
954 _askForUpdateBalance();
955 await Future<void>.delayed(Duration(seconds: 1));
956 } catch (e) {
957 printV(e.toString());
958 }
959 }
960
961 Future<void> _updateSubAddress(bool enableAutoGenerate, {Account? account}) async {
962 if (enableAutoGenerate) {
963 await walletAddresses.updateUnusedSubaddress(
964 accountIndex: account?.id ?? 0,
965 defaultLabel: account?.label ?? '',
966 );
967 } else {
968 await walletAddresses.updateSubaddressList(accountIndex: account?.id ?? 0);
969 }
970 }
971
972 @override
973 void setExceptionHandler(void Function(FlutterErrorDetails) e) => onError = e;
974
975 @override
976 Future<String> signMessage(String message, {String? address}) async {
977 final useAddress = address ?? "";
978 return monero_wallet.signMessage(message, address: useAddress);
979 }
980
981 @override
982 Future<bool> verifyMessage(String message, String signature, {String? address = null}) async {
983 if (address == null) return false;
984
985 return monero_wallet.verifyMessage(message, address, signature);
986 }
987
988 Future<void> setLedgerConnection(LedgerConnection connection) async {
989 await enableLedgerExchange(connection);
990 }
991 }