dev
dart 597 lines 20.3 KB
Raw
1 import 'dart:async';
2 import 'dart:core';
3 import 'dart:io';
4 import 'dart:math';
5
6 import 'package:cw_core/amount/money.dart';
7 import 'package:cw_core/cake_hive.dart';
8 import 'package:cw_core/crypto_currency.dart';
9 import 'package:cw_core/node.dart';
10 import 'package:cw_core/pathForWallet.dart';
11 import 'package:cw_core/pending_transaction.dart';
12 import 'package:cw_core/sync_status.dart';
13 import 'package:cw_core/transaction_priority.dart';
14 import 'package:cw_core/utils/print_verbose.dart';
15 import 'package:cw_core/wallet_base.dart';
16 import 'package:cw_core/wallet_credentials.dart';
17 import 'package:cw_core/wallet_info.dart';
18 import 'package:cw_core/zano_asset.dart';
19 import 'package:cw_zano/api/model/create_wallet_result.dart';
20 import 'package:cw_zano/api/model/destination.dart';
21 import 'package:cw_zano/api/model/get_recent_txs_and_info_result.dart';
22 import 'package:cw_zano/api/model/get_wallet_status_result.dart';
23 import 'package:cw_zano/api/model/transfer.dart';
24 import 'package:cw_zano/model/pending_zano_transaction.dart';
25 import 'package:cw_zano/model/zano_balance.dart';
26 import 'package:cw_zano/model/zano_transaction_creation_exception.dart';
27 import 'package:cw_zano/model/zano_transaction_credentials.dart';
28 import 'package:cw_zano/model/zano_transaction_info.dart';
29 import 'package:cw_zano/model/zano_wallet_keys.dart';
30 import 'package:cw_zano/zano_transaction_history.dart';
31 import 'package:cw_zano/zano_wallet_addresses.dart';
32 import 'package:cw_zano/zano_wallet_api.dart';
33 import 'package:cw_zano/zano_wallet_exceptions.dart';
34 import 'package:cw_zano/zano_wallet_service.dart';
35 import 'package:cw_zano/api/model/balance.dart';
36
37 import 'package:mobx/mobx.dart';
38
39 part 'zano_wallet.g.dart';
40
41 class ZanoWallet = ZanoWalletBase with _$ZanoWallet;
42
43 abstract class ZanoWalletBase
44 extends WalletBase<ZanoBalance, ZanoTransactionHistory, ZanoTransactionInfo>
45 with Store, ZanoWalletApi {
46 static const int _autoSaveIntervalSeconds = 30;
47 static const int _pollIntervalMilliseconds = 5000;
48 static const int _maxLoadAssetsRetries = 5;
49
50 @override
51 void setPassword(String password) {
52 _password = password;
53 super.setPassword(password);
54 }
55
56 String _password;
57
58 @override
59 String get password => _password;
60
61 @override
62 Future<String> signMessage(String message, {String? address = null}) =>
63 super.signMessage(message, address: address);
64
65 @override
66 Future<bool> verifyMessage(String message, String signature, {String? address = null}) {
67 throw UnimplementedError();
68 }
69
70 @override
71 ZanoWalletAddresses walletAddresses;
72
73 @override
74 @observable
75 SyncStatus syncStatus;
76
77 @override
78 @observable
79 ObservableMap<CryptoCurrency, ZanoBalance> balance;
80
81 @override
82 String seed = '';
83
84 @override
85 String? passphrase = '';
86
87 @override
88 ZanoWalletKeys keys = ZanoWalletKeys(
89 privateSpendKey: '', privateViewKey: '', publicSpendKey: '', publicViewKey: '');
90
91 static const String zanoAssetId =
92 'd6329b5b1f7c0805b5c345f4957554002a2f557845f64d7645dae0e051a6498a';
93
94 Map<String, ZanoAsset> zanoAssets = {};
95
96 Timer? _updateSyncInfoTimer;
97
98 int _lastKnownBlockHeight = 0;
99 int _initialSyncHeight = 0;
100 int currentDaemonHeight = 0;
101 bool _isTransactionUpdating;
102 bool _hasSyncAfterStartup;
103 Timer? _autoSaveTimer;
104
105 /// number of transactions in each request
106 static final int _txChunkSize = (pow(2, 32) - 1).toInt();
107
108 ZanoWalletBase(WalletInfo walletInfo, DerivationInfo derivationInfo, String password)
109 : balance = ObservableMap.of({CryptoCurrency.zano: ZanoBalance.empty(CryptoCurrency.zano)}),
110 _isTransactionUpdating = false,
111 _hasSyncAfterStartup = false,
112 walletAddresses = ZanoWalletAddresses(walletInfo),
113 syncStatus = NotConnectedSyncStatus(),
114 _password = password,
115 super(walletInfo, derivationInfo) {
116 transactionHistory = ZanoTransactionHistory();
117 if (!CakeHive.isAdapterRegistered(ZanoAsset.typeId)) {
118 CakeHive.registerAdapter(ZanoAssetAdapter());
119 }
120 }
121
122 @override
123 int calculateEstimatedFee(TransactionPriority priority, [int? amount = null]) =>
124 getCurrentTxFee(priority);
125
126 @override
127 Future<void> changePassword(String password) async {
128 setPassword(password);
129 }
130
131 static Future<ZanoWallet> create({required WalletCredentials credentials}) async {
132 final wallet = ZanoWallet(credentials.walletInfo!,
133 await credentials.walletInfo!.getDerivationInfo(), credentials.password!);
134 await wallet.initWallet();
135 final path = await pathForWallet(name: credentials.name, type: credentials.walletInfo!.type);
136 final createWalletResult = await wallet.createWallet(path, credentials.password!);
137 await wallet.initWallet();
138 await wallet.parseCreateWalletResult(createWalletResult);
139 if (credentials.passphrase != null) {
140 await wallet.setPassphrase(credentials.passphrase!);
141 wallet.seed = await createWalletResult.seed(wallet);
142 wallet.passphrase = await wallet.getPassphrase();
143 }
144 await wallet.init(createWalletResult.wi.address);
145 return wallet;
146 }
147
148 static Future<ZanoWallet> restore(
149 {required ZanoRestoreWalletFromSeedCredentials credentials}) async {
150 final wallet = ZanoWallet(credentials.walletInfo!,
151 await credentials.walletInfo!.getDerivationInfo(), credentials.password!);
152 await wallet.initWallet();
153 final path = await pathForWallet(name: credentials.name, type: credentials.walletInfo!.type);
154 final createWalletResult = await wallet.restoreWalletFromSeed(
155 path, credentials.password!, credentials.mnemonic, credentials.passphrase);
156 await wallet.initWallet();
157 await wallet.parseCreateWalletResult(createWalletResult);
158 if (credentials.passphrase != null) {
159 await wallet.setPassphrase(credentials.passphrase!);
160 wallet.seed = await createWalletResult.seed(wallet);
161 wallet.passphrase = await wallet.getPassphrase();
162 }
163 await wallet.init(createWalletResult.wi.address);
164 return wallet;
165 }
166
167 static Future<ZanoWallet> open(
168 {required String name, required String password, required WalletInfo walletInfo}) async {
169 final path = await pathForWallet(name: name, type: walletInfo.type);
170 if (ZanoWalletApi.openWalletCache[path] != null) {
171 final wallet = ZanoWallet(walletInfo, await walletInfo.getDerivationInfo(), password);
172 await wallet.parseCreateWalletResult(ZanoWalletApi.openWalletCache[path]!).then((_) {
173 unawaited(wallet.init(ZanoWalletApi.openWalletCache[path]!.wi.address));
174 });
175 return wallet;
176 } else {
177 final wallet = ZanoWallet(walletInfo, await walletInfo.getDerivationInfo(), password);
178 await wallet.initWallet();
179 final createWalletResult = await wallet.loadWallet(path, password);
180 await wallet.parseCreateWalletResult(createWalletResult).then((_) {
181 unawaited(wallet.init(createWalletResult.wi.address));
182 });
183 return wallet;
184 }
185 }
186
187 Future<void> parseCreateWalletResult(CreateWalletResult result) async {
188 hWallet = result.walletId;
189 seed = await result.seed(this);
190 keys = ZanoWalletKeys(
191 privateSpendKey: result.privateSpendKey,
192 privateViewKey: result.privateViewKey,
193 publicSpendKey: result.publicSpendKey,
194 publicViewKey: result.publicViewKey,
195 );
196 passphrase = await getPassphrase();
197
198 printV('setting hWallet = ${result.walletId}');
199 walletAddresses.address = result.wi.address;
200 await loadAssets(result.wi.balances, maxRetries: _maxLoadAssetsRetries);
201 for (final item in result.wi.balances) {
202 if (item.assetInfo.assetId == zanoAssetId) {
203 balance[CryptoCurrency.zano] = ZanoBalance(
204 total: Money(item.total, CryptoCurrency.zano),
205 unlocked: Money(item.unlocked, CryptoCurrency.zano),
206 );
207 }
208 }
209 if (result.recentHistory.history != null) {
210 final transfers = result.recentHistory.history!;
211 final transactions = Transfer.makeMap(transfers, zanoAssets, currentDaemonHeight);
212 transactionHistory.addMany(transactions);
213 await transactionHistory.save();
214 }
215 }
216
217 @override
218 Future<void> close({bool shouldCleanup = true}) async {
219 closeWallet(null);
220 _updateSyncInfoTimer?.cancel();
221 _autoSaveTimer?.cancel();
222 }
223
224 @override
225 Future<void> connectToNode({required Node node}) async {
226 syncStatus = ConnectingSyncStatus();
227 await setupNode(node.uriRaw);
228 syncStatus = ConnectedSyncStatus();
229 }
230
231 @override
232 Future<PendingTransaction> createTransaction(Object credentials) async {
233 credentials as ZanoTransactionCredentials;
234 final isZano = credentials.currency == CryptoCurrency.zano;
235 final outputs = credentials.outputs;
236 final hasMultiDestination = outputs.length > 1;
237 final unlockedBalanceZano =
238 balance[CryptoCurrency.zano]?.unlocked ?? Money.zero(CryptoCurrency.zano);
239 final unlockedBalanceCurrency =
240 balance[credentials.currency]?.unlocked ?? Money.zero(credentials.currency);
241 final fee =
242 Money(BigInt.from(calculateEstimatedFee(credentials.priority)), CryptoCurrency.zano);
243
244 var totalAmount = Money.zero(credentials.currency);
245 void checkForEnoughBalances() {
246 if (isZano) {
247 if (totalAmount + fee > unlockedBalanceZano) {
248 throw ZanoTransactionCreationException(
249 "You don't have enough coins (required: ${(totalAmount + fee).toStringWithSymbol()}, unlocked ${unlockedBalanceZano.toStringWithSymbol()}).");
250 }
251 } else {
252 if (fee > unlockedBalanceZano) {
253 throw ZanoTransactionCreationException(
254 "You don't have enough coins (required: ${fee.toStringWithSymbol()}, unlocked ${unlockedBalanceZano.toStringWithSymbol()}).");
255 }
256 if (totalAmount > unlockedBalanceCurrency) {
257 throw ZanoTransactionCreationException(
258 "You don't have enough coins (required: ${totalAmount.toStringWithSymbol()}, unlocked ${unlockedBalanceCurrency.toStringWithSymbol()}).");
259 }
260 }
261 }
262
263 final assetId = isZano ? zanoAssetId : (credentials.currency as ZanoAsset).assetId;
264 late List<Destination> destinations;
265 if (hasMultiDestination) {
266 if (outputs.any((output) => output.sendAll || output.cryptoAmount.amount <= BigInt.zero)) {
267 throw ZanoTransactionCreationException("You don't have enough coins.");
268 }
269 totalAmount =
270 outputs.fold(Money.zero(credentials.currency), (acc, value) => acc + value.cryptoAmount);
271 checkForEnoughBalances();
272 destinations = outputs
273 .map((output) => Destination(
274 amount: output.cryptoAmount.amount,
275 address: output.isParsedAddress ? output.extractedAddress! : output.address,
276 assetId: assetId,
277 ))
278 .toList();
279 } else {
280 final output = outputs.first;
281 if (output.sendAll) {
282 if (isZano) {
283 totalAmount = unlockedBalanceZano - fee;
284 } else {
285 totalAmount = unlockedBalanceCurrency;
286 }
287 } else {
288 totalAmount = output.cryptoAmount;
289 }
290 checkForEnoughBalances();
291 destinations = [
292 Destination(
293 amount: totalAmount.amount,
294 address: output.isParsedAddress ? output.extractedAddress! : output.address,
295 assetId: assetId,
296 )
297 ];
298 }
299 return PendingZanoTransaction(
300 zanoWallet: this,
301 destinations: destinations,
302 fee: fee,
303 comment: outputs.first.note ?? '',
304 assetId: assetId,
305 amount: totalAmount,
306 );
307 }
308
309 @override
310 Future<Map<String, ZanoTransactionInfo>> fetchTransactions() async {
311 try {
312 final transfers = <Transfer>[];
313 late GetRecentTxsAndInfoResult result;
314 do {
315 result = await getRecentTxsAndInfo(offset: 0, count: _txChunkSize);
316 // _lastTxIndex += result.transfers.length;
317 transfers.addAll(result.transfers);
318 } while (result.lastItemIndex + 1 < result.totalTransfers);
319 return Transfer.makeMap(transfers, zanoAssets, currentDaemonHeight);
320 } catch (e) {
321 printV((e.toString()));
322 return {};
323 }
324 }
325
326 Future<void> init(String address) async {
327 await walletAddresses.init();
328 await walletAddresses.updateAddress(address);
329 await updateTransactions();
330 _autoSaveTimer = Timer.periodic(Duration(seconds: _autoSaveIntervalSeconds), (_) async {
331 await save();
332 });
333 }
334
335 @override
336 Future<void> renameWalletFiles(String newWalletName) async {
337 final currentWalletPath = await pathForWallet(name: name, type: type);
338 final currentCacheFile = File(currentWalletPath);
339 final currentKeysFile = File('$currentWalletPath.keys');
340 final currentAddressListFile = File('$currentWalletPath.address.txt');
341
342 final newWalletPath = await pathForWallet(name: newWalletName, type: type);
343
344 // Copies current wallet files into new wallet name's dir and files
345 if (currentCacheFile.existsSync()) {
346 await currentCacheFile.copy(newWalletPath);
347 }
348 if (currentKeysFile.existsSync()) {
349 await currentKeysFile.copy('$newWalletPath.keys');
350 }
351 if (currentAddressListFile.existsSync()) {
352 await currentAddressListFile.copy('$newWalletPath.address.txt');
353 }
354
355 // Delete old name's dir and files
356 await Directory(currentWalletPath).delete(recursive: true);
357 }
358
359 @override
360 Future<void> rescan({required int height}) => throw UnimplementedError();
361
362 @override
363 Future<void> save() async {
364 try {
365 await store();
366 await walletAddresses.updateAddressesInBox();
367 } catch (e) {
368 printV(('Error while saving Zano wallet file ${e.toString()}'));
369 }
370 }
371
372 Future<void> loadAssets(List<Balance> balances, {int maxRetries = 1}) async {
373 List<ZanoAsset> assets = [];
374 int retryCount = 0;
375
376 while (retryCount < maxRetries) {
377 try {
378 assets = await getAssetsWhitelist();
379 break;
380 } on ZanoWalletBusyException {
381 if (retryCount < maxRetries - 1) {
382 retryCount++;
383 await Future.delayed(Duration(seconds: 1));
384 } else {
385 printV(('failed to load assets after $retryCount retries'));
386 break;
387 }
388 }
389 }
390 zanoAssets = {};
391 for (final asset in assets) {
392 final newAsset = ZanoAsset.copyWith(
393 asset,
394 enabled: balances.any((element) => element.assetId == asset.assetId),
395 );
396 zanoAssets.putIfAbsent(asset.assetId, () => newAsset);
397 }
398 }
399
400 @override
401 Future<void> startSync() async {
402 try {
403 syncStatus = AttemptingSyncStatus();
404 _lastKnownBlockHeight = 0;
405 _initialSyncHeight = 0;
406 _updateSyncInfoTimer ??= Timer.periodic(
407 Duration(milliseconds: _pollIntervalMilliseconds), (_) => _updateSyncInfo());
408 } catch (e) {
409 syncStatus = FailedSyncStatus();
410 printV((e.toString()));
411 }
412 }
413
414 @override
415 Future<void>? updateBalance() => null;
416
417 @override
418 Future<bool> checkNodeHealth() async {
419 try {
420 final status = await getWalletStatus();
421
422 return status.isDaemonConnected;
423 } catch (_) {
424 return false;
425 }
426 }
427
428 Future<void> updateTransactions() async {
429 try {
430 if (_isTransactionUpdating) {
431 return;
432 }
433 _isTransactionUpdating = true;
434 final transactions = await fetchTransactions();
435 transactionHistory.clear();
436 transactionHistory.addMany(transactions);
437 await transactionHistory.save();
438 _isTransactionUpdating = false;
439 } catch (e) {
440 printV("e: $e");
441 printV((e.toString()));
442 _isTransactionUpdating = false;
443 }
444 }
445
446 Future<CryptoCurrency> addZanoAssetById(String assetId) async {
447 if (zanoAssets.containsKey(assetId)) {
448 throw ZanoWalletException('zano asset with id $assetId already added');
449 }
450 final assetDescriptor = await addAssetsWhitelist(assetId);
451 if (assetDescriptor == null) {
452 throw ZanoWalletException("there's no zano asset with id $assetId");
453 }
454 final asset = ZanoAsset.copyWith(
455 assetDescriptor,
456 assetId: assetId,
457 enabled: true,
458 );
459 zanoAssets[asset.assetId] = asset;
460 balance[asset] = ZanoBalance.empty(asset);
461 return asset;
462 }
463
464 Future<void> changeZanoAssetAvailability(ZanoAsset asset) async {
465 if (asset.enabled) {
466 final assetDescriptor = await addAssetsWhitelist(asset.assetId);
467 if (assetDescriptor == null) {
468 printV(('Error adding zano asset'));
469 }
470 } else {
471 final result = await removeAssetsWhitelist(asset.assetId);
472 if (result == false) {
473 printV(('Error removing zano asset'));
474 }
475 }
476 }
477
478 Future<void> deleteZanoAsset(ZanoAsset asset) async {
479 final _ = await removeAssetsWhitelist(asset.assetId);
480 }
481
482 Future<ZanoAsset?> getZanoAsset(String assetId) async {
483 // wallet api is not available while the wallet is syncing so only call it if it's synced
484 if (syncStatus is SyncedSyncStatus) {
485 return await getAssetInfo(assetId);
486 }
487 return null;
488 }
489
490 Future<void> _askForUpdateTransactionHistory() async => await updateTransactions();
491
492 void _onNewBlock(int height, int blocksLeft, double ptc) async {
493 try {
494 if (blocksLeft < 1000) {
495 await _askForUpdateTransactionHistory();
496 syncStatus = SyncedSyncStatus();
497
498 if (!_hasSyncAfterStartup) {
499 _hasSyncAfterStartup = true;
500 await save();
501 }
502 } else {
503 syncStatus = SyncingSyncStatus(blocksLeft, ptc);
504 }
505 } catch (e) {
506 printV((e.toString()));
507 }
508 }
509
510 void _updateSyncProgress(GetWalletStatusResult walletStatus) {
511 final syncHeight = walletStatus.currentWalletHeight;
512 if (_initialSyncHeight <= 0) {
513 _initialSyncHeight = syncHeight;
514 }
515 final bchHeight = walletStatus.currentDaemonHeight;
516
517 if (_lastKnownBlockHeight == syncHeight) {
518 return;
519 }
520
521 _lastKnownBlockHeight = syncHeight;
522 final track = bchHeight - _initialSyncHeight;
523 final diff = track - (bchHeight - syncHeight);
524 final ptc = diff <= 0 ? 0.0 : diff / track;
525 final left = bchHeight - syncHeight;
526
527 if (syncHeight < 0 || left < 0) {
528 return;
529 }
530
531 // 1. Actual new height; 2. Blocks left to finish; 3. Progress in percents;
532 _onNewBlock.call(syncHeight, left, ptc);
533 }
534
535 void _updateSyncInfo() async {
536 GetWalletStatusResult walletStatus;
537 // ignoring get wallet status exception (in case of wrong wallet id)
538 try {
539 walletStatus = await getWalletStatus();
540 } on ZanoWalletException {
541 return;
542 }
543 currentDaemonHeight = walletStatus.currentDaemonHeight;
544 _updateSyncProgress(walletStatus);
545
546 // we can call getWalletInfo ONLY if getWalletStatus returns NOT is in long refresh and wallet state is 2 (ready)
547 if (!walletStatus.isInLongRefresh && walletStatus.walletState == 2) {
548 final walletInfo = await getWalletInfo();
549 seed = await walletInfo.wiExtended.seed(this);
550 keys = ZanoWalletKeys(
551 privateSpendKey: walletInfo.wiExtended.spendPrivateKey,
552 privateViewKey: walletInfo.wiExtended.viewPrivateKey,
553 publicSpendKey: walletInfo.wiExtended.spendPublicKey,
554 publicViewKey: walletInfo.wiExtended.viewPublicKey,
555 );
556 loadAssets(walletInfo.wi.balances);
557 // matching balances and whitelists
558 // 1. show only balances available in whitelists
559 // 2. set whitelists available in balances as 'enabled' ('disabled' by default)
560 for (final b in walletInfo.wi.balances) {
561 if (b.assetId == zanoAssetId) {
562 balance[CryptoCurrency.zano] = ZanoBalance(
563 total: Money(b.total, CryptoCurrency.zano),
564 unlocked: Money(b.unlocked, CryptoCurrency.zano),
565 );
566 } else {
567 final asset = zanoAssets[b.assetId];
568 if (asset == null) {
569 printV('balance for an unknown asset ${b.assetInfo.assetId}');
570 continue;
571 }
572
573 final assetBalanceKey =
574 balance.keys.where((e) => e is ZanoAsset && e.assetId == asset.assetId).firstOrNull;
575 if (assetBalanceKey != null) {
576 balance[assetBalanceKey] = ZanoBalance(
577 total: Money(b.total, assetBalanceKey),
578 unlocked: Money(b.unlocked, assetBalanceKey),
579 );
580 } else {
581 balance[asset] = ZanoBalance(
582 total: Money(b.total, asset),
583 unlocked: Money(b.unlocked, asset),
584 );
585 }
586 }
587 }
588 await updateTransactions();
589 // removing balances for assets missing in wallet info balances
590 balance.removeWhere(
591 (key, _) =>
592 key != CryptoCurrency.zano &&
593 !walletInfo.wi.balances.any((element) => element.assetId == (key as ZanoAsset).assetId),
594 );
595 }
596 }
597 }