dev
dart 1,804 lines 56.6 KB
Raw
1 import 'dart:async';
2 import 'dart:io';
3 import 'dart:math';
4
5 import 'package:cw_core/amount/money.dart';
6 import 'package:cw_core/crypto_currency.dart';
7 import 'package:cw_core/get_height_by_date_zec.dart';
8 import 'package:cw_core/monero_transaction_priority.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_direction.dart';
14 import 'package:cw_core/transaction_priority.dart';
15 import 'package:cw_core/utils/print_verbose.dart';
16 import 'package:cw_core/wallet_base.dart';
17 import 'package:cw_core/wallet_credentials.dart';
18 import 'package:cw_core/wallet_info.dart';
19 import 'package:cw_core/wallet_type.dart';
20 import 'package:bip39/bip39.dart' as bip39;
21 import 'package:cw_zcash/cw_zcash.dart';
22 import 'package:cw_zcash/src/util/crc32.dart';
23 import 'package:cw_zcash/src/zcash_mempool.dart';
24 import 'package:cw_zcash/src/zcash_taddress_rotation.dart';
25 import 'package:cw_zcash/src/zcash_wallet_addresses.dart';
26 import 'package:cw_zcash/src/zcash_network.dart';
27 import 'package:cw_zcash/src/zkool_compat.dart';
28 import 'package:cw_zcash/src/zkooltx.dart';
29 import 'package:mobx/mobx.dart';
30 import 'package:mutex/mutex.dart';
31 import 'package:zkool/src/rust/api/account.dart' as zkool_account;
32 import 'package:zkool/src/rust/api/coin.dart' as zkool_coin;
33 import 'package:zkool/src/rust/api/mempool.dart' as zkool_mempool;
34 import 'package:zkool/src/rust/api/sync.dart' as zkool_sync;
35 import 'package:zkool/src/rust/api/pay.dart' as zkool_pay;
36 import 'package:zkool/src/rust/api/migrate.dart' as zkool_migrate;
37 import 'package:zkool/src/rust/api/network.dart' as zkool_network;
38 import 'package:zkool/src/rust/pay.dart' as zkool_paydart;
39 import 'package:zkool/src/rust/frb_generated.dart' as zkool_frb;
40
41 part 'zcash_wallet.g.dart';
42
43 class ZcashWallet = ZcashWalletBase with _$ZcashWallet;
44
45 abstract class ZcashWalletBase
46 extends WalletBase<ZcashBalance, ZcashTransactionHistory, ZcashTransactionInfo>
47 with Store {
48 ZcashWalletBase(super.walletInfo, super.derivationInfo, {required this.accountId}) {
49 transactionHistory = ZcashTransactionHistory();
50 walletsByAccountId[accountId] = this;
51 }
52
53 static final Map<int, ZcashWalletBase> walletsByAccountId = {};
54
55 static Future<void> refreshWalletForAccount(final int accountId) async {
56 final wallet = walletsByAccountId[accountId];
57 if (wallet == null) {
58 return;
59 }
60 await wallet.updateTransactions();
61 await wallet.updateBalance();
62 }
63
64 int accountId;
65
66 final Map<String, BigInt> _pendingOutgoingAmounts = {};
67
68 void rememberPendingOutgoingAmount(final String txId, final Money amount) {
69 if (amount.isZero) {
70 return;
71 }
72 _pendingOutgoingAmounts[ZcashWalletService.normalizeTxId(txId)] = amount.amount;
73 }
74
75 @override
76 @observable
77 SyncStatus syncStatus = NotConnectedSyncStatus();
78
79 @override
80 ObservableMap<CryptoCurrency, ZcashBalance> balance = ObservableMap.of({
81 CryptoCurrency.zec: ZcashBalance.zero(),
82 });
83
84 static const int _autoShieldMinSweep = 30000;
85
86 // zkool's migrate::MIN_SD.
87 static const int _ironwoodMigrateMinNote = 500000;
88
89 static int _minSweepThreshold({required final bool ironwood}) =>
90 ironwood ? _ironwoodMigrateMinNote : _autoShieldMinSweep;
91
92 Money _feeFromTxPlan(
93 final zkool_pay.PcztPackage txPlan,
94 final TransactionPriority priority,
95 final int tryReduceFeeAmount, {
96 final zkool_coin.Coin? coin,
97 }) {
98 try {
99 return Money(zkool_pay.toPlan(package: txPlan, c: coin ?? c).fee, currency);
100 } catch (_) {
101 return Money.fromInt(
102 tryReduceFeeAmount != 0
103 ? tryReduceFeeAmount
104 : internalCalculateEstimatedFee(priority, null),
105 currency,
106 );
107 }
108 }
109
110 static int internalCalculateEstimatedFee(final TransactionPriority priority, final int? amount) {
111 const baseFee = 10000;
112 switch (priority) {
113 case MoneroTransactionPriority.slow:
114 case MoneroTransactionPriority.automatic:
115 return baseFee;
116 case MoneroTransactionPriority.medium:
117 return baseFee * 2;
118 case MoneroTransactionPriority.fast:
119 return baseFee * 4;
120 case MoneroTransactionPriority.fastest:
121 return baseFee * 10;
122 }
123 ;
124 return internalCalculateEstimatedFee(MoneroTransactionPriority.automatic, amount);
125 }
126
127 @override
128 int calculateEstimatedFee(final TransactionPriority priority, final int? amount) {
129 return internalCalculateEstimatedFee(priority, amount);
130 }
131
132 @override
133 Future<void> changePassword(final String password) async {
134 // throw UnimplementedError();
135 }
136
137 static bool isNodeWorking = true;
138
139 @override
140 Future<bool> checkNodeHealth() {
141 return Future.value(isNodeWorking);
142 }
143
144 @override
145 Future<void> close({final bool shouldCleanup = false}) async {
146 _syncLoopRunning = false;
147 walletsByAccountId.remove(accountId);
148 }
149
150 Node? lastNode;
151 @override
152 @action
153 Future<void> connectToNode({required final Node node}) async {
154 lastNode = node;
155 printV("connecting to node: ${node.uriRaw}");
156 syncStatus = ConnectingSyncStatus();
157 try {
158 String lwdUrl = node.uriRaw;
159 if (!lwdUrl.startsWith('http://') && !lwdUrl.startsWith('https://')) {
160 final protocol = node.useSSL == true ? 'https://' : 'http://';
161 lwdUrl = '$protocol$lwdUrl';
162 }
163 printV("Setting LWD URL to: $lwdUrl");
164 c = c.setLwd(url: lwdUrl, serverType: 0);
165 syncStatus = ConnectedSyncStatus();
166 unawaited(ZcashMempoolService.instance.ensureRunning(c));
167 unawaited(_updateIronwoodActive());
168 _ensureSyncLoopRunning();
169 unawaited(_refreshSyncStatus());
170 unawaited(_oneshotSync());
171 } catch (e) {
172 printV("Connection error: $e");
173 syncStatus = FailedSyncStatus(error: e.toString());
174 rethrow;
175 }
176 }
177
178 bool _syncLoopRunning = false;
179
180 void _ensureSyncLoopRunning() {
181 if (_syncLoopRunning) {
182 return;
183 }
184 _syncLoopRunning = true;
185 unawaited(_runSyncLoop());
186 }
187
188 static const _syncedPollInterval = Duration(seconds: 5);
189 static const _activePollInterval = Duration(seconds: 1);
190
191 Future<void> _runSyncLoop() async {
192 var pollInterval = _activePollInterval;
193 while (_syncLoopRunning) {
194 await Future.delayed(pollInterval);
195 try {
196 final alreadySynced = await _oneshotSync();
197 pollInterval = alreadySynced ? _syncedPollInterval : _activePollInterval;
198 } catch (e) {
199 printV("zcash sync failed: $e");
200 pollInterval = _activePollInterval;
201 }
202 }
203 }
204
205 int _syncCheckpointHeight = 0;
206
207 bool get isSyncing {
208 return _isSyncing;
209 }
210
211 set isSyncing(final bool value) {
212 _isSyncing = value;
213 }
214
215 bool _isSyncing = false;
216
217 static int oneshotSyncCount = 0;
218
219 Future<int> _getLowestSyncHeight() async {
220 final accounts = await zkool_account.listAccounts(c: c);
221 var lowest = await _getWalletDbHeight();
222 for (final acc in accounts) {
223 if (acc.id == accountId) continue;
224 c = await c.setAccount(account: acc.id);
225 lowest = min(lowest, await _getWalletDbHeight());
226 }
227 c = await c.setAccount(account: accountId);
228 return lowest;
229 }
230
231 Future<bool> _anyAccountNeedsSync(final int currentHeight) =>
232 withSharedCoinLock(() => _anyAccountNeedsSyncUnlocked(currentHeight));
233
234 Future<bool> _anyAccountNeedsSyncUnlocked(final int currentHeight) async {
235 final accounts = await zkool_account.listAccounts(c: c);
236 for (final acc in accounts) {
237 c = await c.setAccount(account: acc.id);
238 if (currentHeight > await _getWalletDbHeight()) {
239 c = await c.setAccount(account: accountId);
240 return true;
241 }
242 }
243 c = await c.setAccount(account: accountId);
244 return false;
245 }
246
247 @action
248 void _applySyncProgress(final int currentHeight, final int walletHeight) {
249 final blocksLeft = (currentHeight - walletHeight).clamp(0, currentHeight);
250 _syncCheckpointHeight = walletHeight;
251 if (blocksLeft <= 0) {
252 syncStatus = _isSyncing ? SyncingSyncStatus(1, 0.999) : SyncedSyncStatus();
253 return;
254 }
255 final ptc = currentHeight > 0 ? (walletHeight / currentHeight).clamp(0.0, 1.0) : 0.0;
256 syncStatus = SyncingSyncStatus(blocksLeft, ptc);
257 }
258
259 void _broadcastSyncProgress(final int currentHeight, final int walletHeight) {
260 runInAction(() {
261 for (final wallet in walletsByAccountId.values) {
262 wallet._applySyncProgress(currentHeight, walletHeight);
263 }
264 });
265 }
266
267 Future<int> _getWalletDbHeight() async {
268 try {
269 return (await zkool_sync.getDbHeight(c: c)).height;
270 } catch (_) {
271 final accounts = await zkool_account.listAccounts(c: c);
272 final account = accounts.where((final a) => a.id == accountId).firstOrNull;
273 if (account != null) {
274 return account.height;
275 }
276 rethrow;
277 }
278 }
279
280 void _onSyncCheckpoint(final int currentHeight, final int checkpointHeight) {
281 final height = checkpointHeight > _syncCheckpointHeight
282 ? checkpointHeight
283 : _syncCheckpointHeight;
284 _broadcastSyncProgress(currentHeight, height);
285 }
286
287 @action
288 Future<void> _refreshSyncStatus() async {
289 try {
290 await withSharedCoinLock(() async {
291 c = await c.setAccount(account: accountId);
292 final currentHeight = await zkool_network.getCurrentHeight(c: c);
293 final walletDbHeight = await _getWalletDbHeight();
294 _broadcastSyncProgress(currentHeight, walletDbHeight);
295 });
296 } catch (e) {
297 printV("refresh sync status: $e");
298 }
299 }
300
301 @action
302 Future<bool> _oneshotSync() async {
303 try {
304 if (isSyncing) {
305 return syncStatus is SyncedSyncStatus;
306 }
307 isSyncing = true;
308 late final int currentHeight;
309 late final int walletDbHeight;
310 await withSharedCoinLock(() async {
311 c = await c.setAccount(account: accountId);
312 currentHeight = await zkool_network.getCurrentHeight(c: c);
313 walletDbHeight = await _getWalletDbHeight();
314 });
315 if (!await _anyAccountNeedsSync(currentHeight)) {
316 _syncCheckpointHeight = walletDbHeight;
317 if (syncStatus is! SyncedSyncStatus) {
318 syncStatus = SyncedSyncStatus();
319 }
320 isSyncing = false;
321 return true;
322 }
323 await zkool_sync.cancelSync();
324 late final List<int> accountList;
325 late final int lagHeight;
326 await withSharedCoinLock(() async {
327 final accounts = await zkool_account.listAccounts(c: c);
328 accountList = accounts.map((final a) => a.id).toList()
329 ..removeWhere((final a) => a == c.account);
330 c = await c.setAccount(account: accountId);
331 lagHeight = await _getLowestSyncHeight();
332 });
333 _broadcastSyncProgress(currentHeight, lagHeight);
334 final sync = zkool_sync.synchronize(
335 accounts: [c.account, ...accountList],
336 currentHeight: currentHeight,
337 actionsPerSync: 10000,
338 transparentLimit: 100,
339 checkpointAge: 200,
340 c: c,
341 fast: false,
342 );
343 await withSharedCoinLock(() async {
344 c = await c.setAccount(account: accountId);
345 });
346 final randInt = CRC32.compute("${DateTime.now().microsecondsSinceEpoch}").toRadixString(16);
347 oneshotSyncCount++;
348 final completer = Completer<void>();
349 var lastLoggedHeight = walletDbHeight;
350 var chainTip = currentHeight;
351 late final StreamSubscription<zkool_sync.SyncProgress> subscription;
352 subscription = sync.listen(
353 (final syncProgress) {
354 if (syncProgress.height > chainTip) {
355 chainTip = syncProgress.height;
356 }
357 if (syncProgress.height >= lastLoggedHeight + 5000) {
358 lastLoggedHeight = syncProgress.height;
359 printV(
360 "[${c.account} ($accountList)] [$oneshotSyncCount/$randInt] sync: ${syncProgress.height}",
361 );
362 unawaited(
363 zkool_network.getCurrentHeight(c: c).then((final tip) {
364 if (tip > chainTip) {
365 chainTip = tip;
366 _onSyncCheckpoint(chainTip, syncProgress.height);
367 }
368 }),
369 );
370 }
371 _onSyncCheckpoint(chainTip, syncProgress.height);
372 },
373 onError: (final e) {
374 printV("[${c.account} ($accountList)] [$oneshotSyncCount/$randInt] error syncing: $e");
375 runInAction(() {
376 syncStatus = FailedSyncStatus(
377 error:
378 e.toString().replaceAll("AnyhowException(", "").split("\n").firstOrNull ??
379 "Unknown error",
380 );
381 });
382 isSyncing = false;
383 if (!completer.isCompleted) {
384 completer.complete();
385 }
386 },
387 onDone: () async {
388 printV("[${c.account} ($accountList)] [$oneshotSyncCount/$randInt] synchronized");
389 oneshotSyncCount--;
390 isSyncing = false;
391 try {
392 await withSharedCoinLock(() async {
393 c = await c.setAccount(account: accountId);
394 if (await _anyAccountNeedsSyncUnlocked(currentHeight)) {
395 final lagHeight = await _getLowestSyncHeight();
396 _broadcastSyncProgress(currentHeight, lagHeight);
397 return;
398 }
399 runInAction(() {
400 for (final wallet in walletsByAccountId.values) {
401 wallet.syncStatus = SyncedSyncStatus();
402 }
403 });
404 for (final wallet in walletsByAccountId.values) {
405 unawaited(wallet.updateBalance());
406 unawaited(wallet.updateTransactions());
407 unawaited(
408 ZcashTaddressRotation.updateCache(mainAccountId: wallet.accountId)
409 .catchError((final e) {
410 printV("rotation cache refresh: $e");
411 }),
412 );
413 }
414 });
415 } catch (e) {
416 printV("sync done height refresh: $e");
417 }
418 if (!completer.isCompleted) {
419 completer.complete();
420 }
421 },
422 );
423 await completer.future;
424 await subscription.cancel();
425 return syncStatus is SyncedSyncStatus;
426 } catch (e) {
427 syncStatus = FailedSyncStatus(error: e.toString());
428 isSyncing = false;
429 printV("error syncing: $e");
430 return false;
431 }
432 }
433
434 @override
435 Future<PendingTransaction> createTransaction(final Object credentials) =>
436 _createTransaction(credentials);
437
438 Future<PendingTransaction> _createTransaction(
439 final Object credentials, {
440 final int tryReduceFeeAmount = 0,
441 }) async {
442 final creds = credentials as ZcashTransactionCredentials;
443 await updateBalance();
444
445 final zcashBalance = balance[CryptoCurrency.zec];
446 final availableBalance = zcashBalance?.available ?? Money.zero(currency);
447
448 final recipients = <zkool_paydart.Recipient>[];
449
450 bool receipientPaysFee = false;
451
452 for (final output in creds.outputs) {
453 receipientPaysFee = receipientPaysFee || output.sendAll;
454 var amount = output.cryptoAmount;
455 if (output.sendAll) {
456 amount = availableBalance - Money.fromInt(tryReduceFeeAmount, currency);
457 }
458 final recipientAddress = output.isParsedAddress ? output.extractedAddress! : output.address;
459 recipients.add(
460 zkool_paydart.Recipient(
461 assetBase: zecBase,
462 address: recipientAddress,
463 amount: amount.amount,
464 userMemo: output.memo,
465 ),
466 );
467 }
468
469 final sendAmount = creds.outputs
470 .map((final out) => out.cryptoAmount)
471 .reduce((final a, final b) => a + b);
472
473 if (availableBalance == sendAmount) {
474 receipientPaysFee = true;
475 }
476
477 // pools parameter: bitmask for which pools to use for sending
478 // 1=Transparent, 2=Sapling, 4=Orchard, 8=Ironwood
479 try {
480 return await runWithCoin(
481 accountId: accountId,
482 func: (coin) async {
483 final ironwood = await zkool_network.isIronwoodActive(c: coin);
484 final txPlan = await zkool_pay.prepare(
485 recipients: recipients,
486 options: zkool_pay.PaymentOptions(
487 srcPools: ironwood ? 8 : 4,
488 recipientPaysFee: receipientPaysFee,
489 smartTransparent: false,
490 mode: 0,
491 ),
492 c: coin,
493 );
494 final txFee = _feeFromTxPlan(txPlan, creds.priority, tryReduceFeeAmount, coin: coin);
495 return PendingZcashTransaction(
496 zcashWallet: this as ZcashWallet,
497 credentials: creds,
498 txPlan: txPlan,
499 fee: txFee,
500 availableBalance: availableBalance,
501 );
502 },
503 );
504 } catch (e) {
505 if (tryReduceFeeAmount != 0) rethrow;
506 final estr = e.toString();
507 const prefix = "Not enough funds, ";
508 const suffix = " more ZEC required";
509 if (estr.contains(prefix) && estr.contains(suffix)) {
510 final start = estr.indexOf(prefix) + prefix.length;
511 final end = estr.indexOf(suffix, start);
512 final amtStr = estr.substring(start, end);
513 final amt = double.tryParse(amtStr);
514 if (amt == null) rethrow;
515 final feeInt = (amt * 100000000).ceil();
516 return _createTransaction(credentials, tryReduceFeeAmount: feeInt);
517 }
518 rethrow;
519 }
520 }
521
522 static const _dispPhrase = "Received to disposable address";
523
524 bool _hasExternalOutputs(final ZkoolTx tx, final Set<String> ownedAddresses) =>
525 tx.outputsWithAddress.any((final o) => !_isOwnedAddress(o.address, ownedAddresses));
526
527 bool _isIronwoodMigrationTx(final ZkoolTx tx, final Set<String> ownedAddresses) {
528 // zkool classifies migration as selfTransfer with fee-sized net value.
529 if (tx.type != TxType.selfTransfer) {
530 return false;
531 }
532 final orchardSpent = tx.orchardSpent;
533 final spentFromOrchard = orchardSpent > BigInt.zero ||
534 tx.spendPools.contains(NotePool.orchard.index);
535 if (!spentFromOrchard) {
536 return false;
537 }
538 if (tx.ironwoodReceived > BigInt.zero ||
539 tx.notePools.contains(NotePool.ironwood.index)) {
540 return true;
541 }
542 // Orchard → Orchard split step (SD notes created, all outputs are ours).
543 if (tx.orchardReceived > BigInt.zero && !_hasExternalOutputs(tx, ownedAddresses)) {
544 return true;
545 }
546 // Orchard → Ironwood before the IW note is attached to tx details.
547 return orchardSpent > tx.value;
548 }
549
550 BigInt _migrationDisplayAmount(final ZkoolTx tx) {
551 if (tx.ironwoodReceived > BigInt.zero) {
552 return tx.ironwoodReceived;
553 }
554 if (tx.orchardReceived > BigInt.zero) {
555 return tx.orchardReceived;
556 }
557 return tx.orchardSpent;
558 }
559
560 ZcashTransactionInfo _zcashInfoFromMempoolTx(
561 final zkool_mempool.MempoolTx tx,
562 final int accountId,
563 ) {
564 final accountNotes = tx.notes.where((final n) => n.account == accountId);
565 final txHash = ZcashWalletService.normalizeTxId(tx.txid);
566 final pendingAmount = _pendingOutgoingAmounts[txHash];
567 final netValue = accountNotes.fold<BigInt>(
568 BigInt.zero,
569 (final sum, final note) => sum + BigInt.from(note.value),
570 );
571 final direction = pendingAmount != null || netValue < BigInt.zero
572 ? TransactionDirection.outgoing
573 : TransactionDirection.incoming;
574 final displayAmount = pendingAmount ?? netValue.abs();
575 final memo = accountNotes
576 .map((final n) => n.memo)
577 .whereType<String>()
578 .where((final m) => m.isNotEmpty)
579 .firstOrNull;
580 final recipientAddresses = const <String>[];
581
582 final info = ZcashTransactionInfo(
583 id: txHash,
584 amount: Money(displayAmount, currency),
585 fee: Money.zero(currency),
586 direction: direction,
587 isPending: true,
588 date: DateTime.now(),
589 height: 0,
590 confirmations: 0,
591 to: recipientAddresses.isEmpty ? '' : recipientAddresses.first,
592 memo: memo,
593 );
594 if (recipientAddresses.isNotEmpty) {
595 info.outputAddresses = recipientAddresses;
596 }
597 return info;
598 }
599
600 ZcashTransactionInfo _zcashInfoFromZkoolTx(
601 final ZkoolTx tx,
602 final int currentHeight, {
603 final String? extraMemo,
604 final bool isRotationReceive = false,
605 final bool isShieldAction = false,
606 final TransactionDirection? directionOverride,
607 final BigInt? amountOverride,
608 required final Set<String> ownedAddresses,
609 }) {
610 final confirmations = tx.height > 0 && currentHeight >= tx.height
611 ? currentHeight - tx.height + 1
612 : 0;
613 final memo = extraMemo != null ? "${tx.memo ?? ''}\n$extraMemo".trim() : tx.memo;
614 final isMigration = directionOverride == null && _isIronwoodMigrationTx(tx, ownedAddresses);
615 final direction = directionOverride ??
616 (isMigration ? TransactionDirection.outgoing : tx.direction);
617 final amount = amountOverride ??
618 (isMigration ? _migrationDisplayAmount(tx) : tx.value);
619 final recipientAddresses = direction == TransactionDirection.outgoing
620 ? _outgoingRecipientAddresses(tx, ownedAddresses: ownedAddresses)
621 : const <String>[];
622 final info = ZcashTransactionInfo(
623 id: tx.txHash,
624 amount: Money(amount, currency),
625 fee: Money(
626 direction == TransactionDirection.outgoing ? tx.fee : BigInt.zero,
627 currency,
628 ),
629 direction: direction,
630 isPending: tx.height == 0,
631 date: tx.time,
632 height: tx.height,
633 confirmations: confirmations,
634 to: recipientAddresses.isEmpty ? '' : recipientAddresses.first,
635 memo: memo?.isNotEmpty == true ? memo : null,
636 txType: tx.type,
637 isRotationReceive: isRotationReceive,
638 isShieldAction: isShieldAction,
639 isIronwoodMigration: isMigration,
640 );
641 if (recipientAddresses.isNotEmpty) {
642 info.outputAddresses = recipientAddresses;
643 }
644 return info;
645 }
646
647 bool _isShieldActionTx(
648 final ZkoolTx tx, {
649 required final Set<String> rotationSweepHashes,
650 required final Set<String> ownedAddresses,
651 }) {
652 if (ZcashWalletService.isAutoshieldTx(tx.txHash)) {
653 return true;
654 }
655 if (rotationSweepHashes.contains(tx.txHash)) {
656 return true;
657 }
658 if (_isPayToSelfAutoshield(tx, ownedAddresses)) {
659 return true;
660 }
661 if (tx.direction == TransactionDirection.outgoing &&
662 (tx.type == TxType.shield || tx.type == TxType.transparentSelfTransfer)) {
663 return true;
664 }
665 return false;
666 }
667
668 bool _isPayToSelfAutoshield(final ZkoolTx tx, final Set<String> ownedAddresses) {
669 if (tx.type != TxType.shield && tx.type != TxType.transparentSelfTransfer) {
670 return false;
671 }
672 if (tx.transparentOrSaplingSpent <= BigInt.zero) {
673 return false;
674 }
675 if (tx.orchardReceived <= BigInt.zero) {
676 return false;
677 }
678 for (final dest in tx.outputAddresses) {
679 if (_isOwnedAddress(dest, ownedAddresses)) {
680 return true;
681 }
682 }
683 return tx.orchardReceived > BigInt.zero;
684 }
685
686 bool _shouldSplitAutoshieldTx(
687 final ZkoolTx tx, {
688 required final bool isShield,
689 required final Set<String> ownedAddresses,
690 }) {
691 if (!isShield) {
692 return false;
693 }
694 if (ZcashWalletService.isAutoshieldTx(tx.txHash) ||
695 _isPayToSelfAutoshield(tx, ownedAddresses)) {
696 return tx.transparentOrSaplingSpent > BigInt.zero && tx.orchardReceived > BigInt.zero;
697 }
698 return false;
699 }
700
701 static String _txResultKey(final String txHash, {final String suffix = ''}) =>
702 'tx_$txHash$suffix';
703
704 static int _txDisplayPriority(final ZcashTransactionInfo info) {
705 if (info.additionalInfo['isIronwoodMigration'] == true) {
706 return 4;
707 }
708 if (info.additionalInfo['isAutoShield'] == true) {
709 return 3;
710 }
711 if (info.additionalInfo['isRotationReceive'] == true) {
712 return 2;
713 }
714 return 1;
715 }
716
717 void _offerTx(final Map<String, ZcashTransactionInfo> byHash, final ZcashTransactionInfo info) {
718 final hash = info.txHash;
719 final existing = byHash[hash];
720 if (existing == null) {
721 byHash[hash] = info;
722 return;
723 }
724 final infoPriority = _txDisplayPriority(info);
725 final existingPriority = _txDisplayPriority(existing);
726 if (infoPriority > existingPriority) {
727 byHash[hash] = info;
728 return;
729 }
730 if (infoPriority == existingPriority &&
731 (info.additionalInfo['isAutoShield'] == true ||
732 info.additionalInfo['isIronwoodMigration'] == true) &&
733 info.direction == TransactionDirection.outgoing &&
734 existing.direction == TransactionDirection.incoming) {
735 byHash[hash] = info;
736 }
737 }
738
739 Future<Set<String>> _ownedAddressSet(final zkool_coin.Coin coin) async {
740 final owned = <String>{};
741 for (final address in await zkool_account.listOwnedAddresses(c: coin)) {
742 if (address.isEmpty) {
743 continue;
744 }
745 owned.add(address);
746 if (address.startsWith('u')) {
747 owned.addAll(_uaReceivers(address));
748 }
749 }
750 final addrs = walletAddresses;
751 for (final infos in addrs.addressInfos.values) {
752 for (final info in infos) {
753 owned.add(info.address);
754 }
755 }
756 owned.addAll(addrs.hiddenAddresses);
757 owned.addAll(addrs.usedAddresses);
758 return owned;
759 }
760
761 bool _isOwnedAddress(final String addr, final Set<String> ownedAddresses) =>
762 ownedAddresses.contains(addr);
763
764 List<String> _outgoingRecipientAddresses(
765 final ZkoolTx tx, {
766 required final Set<String> ownedAddresses,
767 }) {
768 var outputs = tx.outputsWithAddress
769 .where((final o) => !_isOwnedAddress(o.address, ownedAddresses))
770 .toList();
771 if (outputs.isEmpty) {
772 return [];
773 }
774
775 final transparent =
776 outputs.where((final o) => o.pool == NotePool.transparent.index).toList();
777 if (transparent.length >= 2) {
778 outputs = transparent;
779 }
780
781 return _dedupeAddresses(outputs.map((final o) => o.address));
782 }
783
784 List<String> _dedupeAddresses(final Iterable<String> raw) {
785 final seen = <String>{};
786 return [
787 for (final address in raw)
788 if (address.trim().isNotEmpty && seen.add(address.trim())) address.trim(),
789 ];
790 }
791
792 Set<String> _uaReceivers(final String ua) {
793 try {
794 final receivers = zkool_account.receiversFromUa(ua: ua, c: ZcashWalletBase.c);
795 return {
796 for (final address in [receivers.taddr, receivers.saddr, receivers.oaddr])
797 if (address != null && address.isNotEmpty) address,
798 };
799 } catch (_) {
800 return {};
801 }
802 }
803
804 @override
805 Future<Map<String, ZcashTransactionInfo>> fetchTransactions() async {
806 await ZcashWalletService.loadShieldTxs();
807 final (txs, currentHeight, ownedAddresses) = await runWithCoin(
808 accountId: accountId,
809 func: (coin) async {
810 final owned = await _ownedAddressSet(coin);
811 final txsI = await zkool_account.listTxHistory(c: coin);
812 final txsA = await Future.wait(
813 txsI.map((final tx) => zkool_account.getTxDetails(idTx: tx.id, c: coin)),
814 );
815 final txs = <ZkoolTx>[];
816 for (int i = 0; i < txsI.length; i++) {
817 txs.add(ZkoolTx(txsI[i], txsA[i]));
818 }
819 txs.sort((final a, final b) => a.height.compareTo(b.height));
820 var currentHeight = 1;
821 try {
822 currentHeight = await zkool_network.getCurrentHeight(c: coin);
823 } catch (e) {
824 printV("failed to get height: $e");
825 }
826 return (txs, currentHeight, owned);
827 },
828 );
829 final Map<String, ZcashTransactionInfo> byHash = {};
830 final rotationTxs = ZcashTaddressRotation.rotationTxsForMainAccount(accountId);
831 final rotationSweepHashes = <String>{
832 for (final tx in rotationTxs)
833 if (tx.direction == TransactionDirection.outgoing) tx.txHash,
834 };
835
836 for (final tx in rotationTxs) {
837 if (tx.direction == TransactionDirection.incoming) {
838 _offerTx(
839 byHash,
840 _zcashInfoFromZkoolTx(
841 tx,
842 currentHeight,
843 extraMemo: _dispPhrase,
844 isRotationReceive: true,
845 ownedAddresses: ownedAddresses,
846 ),
847 );
848 continue;
849 }
850 _offerTx(
851 byHash,
852 _zcashInfoFromZkoolTx(
853 tx,
854 currentHeight,
855 isShieldAction: true,
856 ownedAddresses: ownedAddresses,
857 ),
858 );
859 }
860
861 final Map<String, ZcashTransactionInfo> splitEntries = {};
862 for (final tx in txs) {
863 _pendingOutgoingAmounts.remove(ZcashWalletService.normalizeTxId(tx.txHash));
864 if (tx.height > 0) {
865 ZcashMempoolService.instance.removeTx(tx.txHash);
866 }
867 final isShield = _isShieldActionTx(
868 tx,
869 rotationSweepHashes: rotationSweepHashes,
870 ownedAddresses: ownedAddresses,
871 );
872 if (_shouldSplitAutoshieldTx(tx, isShield: isShield, ownedAddresses: ownedAddresses)) {
873 byHash.remove(tx.txHash);
874 splitEntries[_txResultKey(tx.txHash, suffix: '_shield')] = _zcashInfoFromZkoolTx(
875 tx,
876 currentHeight,
877 isShieldAction: true,
878 directionOverride: TransactionDirection.outgoing,
879 amountOverride: tx.transparentOrSaplingSpent,
880 ownedAddresses: ownedAddresses,
881 );
882 splitEntries[_txResultKey(tx.txHash, suffix: '_recv')] = _zcashInfoFromZkoolTx(
883 tx,
884 currentHeight,
885 directionOverride: TransactionDirection.incoming,
886 amountOverride: tx.orchardReceived,
887 ownedAddresses: ownedAddresses,
888 );
889 continue;
890 }
891 _offerTx(
892 byHash,
893 _zcashInfoFromZkoolTx(
894 tx,
895 currentHeight,
896 isShieldAction: isShield,
897 ownedAddresses: ownedAddresses,
898 ),
899 );
900 }
901
902 final knownHashes = {
903 for (final tx in txs) ZcashWalletService.normalizeTxId(tx.txHash),
904 for (final hash in byHash.keys) ZcashWalletService.normalizeTxId(hash),
905 };
906 for (final mempoolTx in ZcashMempoolService.instance.txsForAccount(accountId)) {
907 final hash = ZcashWalletService.normalizeTxId(mempoolTx.txid);
908 if (knownHashes.contains(hash)) {
909 ZcashMempoolService.instance.removeTx(hash);
910 _pendingOutgoingAmounts.remove(hash);
911 continue;
912 }
913 final info = _zcashInfoFromMempoolTx(mempoolTx, accountId);
914 if (info.amount.isZero) {
915 continue;
916 }
917 _offerTx(byHash, info);
918 }
919
920 return {
921 for (final entry in byHash.entries) _txResultKey(entry.key): entry.value,
922 ...splitEntries,
923 };
924 }
925
926 Future<void> _initKeys() async {
927 try {
928 c = await c.setAccount(account: accountId);
929 final ufvk = await zkool_account.getAccountUfvk(account: accountId, c: c, pools: 7);
930
931 keys = {
932 "privateViewKey": ufvk,
933 if (lastKnownRestoreHeight != null) "restoreHeight": lastKnownRestoreHeight.toString(),
934 };
935 } catch (e) {
936 keys = {"privateViewKey": e.toString()};
937 }
938 try {
939 c = await c.setAccount(account: accountId);
940 final s = (await zkool_account.getAccountSeed(account: accountId, c: c));
941
942 if (s == null) {
943 throw Exception("seed not found");
944 }
945 final seedPhrase = s.mnemonic.split(" ");
946 if ([13, 25].contains(seedPhrase.length)) {
947 passphrase = seedPhrase.removeLast();
948 } else {
949 passphrase = s.phrase;
950 }
951 seed = s.mnemonic.trim();
952 } catch (e) {
953 seed = e.toString();
954 }
955 }
956
957 @override
958 Object keys = {};
959
960 @override
961 String get password => _password!;
962
963 @override
964 Future<void> renameWalletFiles(final String newWalletName) async {
965 await renameWalletFilesForName(fromName: name, toName: newWalletName);
966 }
967
968 static Future<void> renameWalletFilesForName({
969 required final String fromName,
970 required final String toName,
971 }) async {
972 if (fromName == toName) {
973 return;
974 }
975 final currentWalletDir = Directory(await pathForWalletDir(name: fromName, type: _type));
976 if (!currentWalletDir.existsSync()) {
977 throw Exception('Wallet directory not found: $fromName');
978 }
979 final newWalletDirPath = '${await pathForWalletTypeDir(type: _type)}/$toName';
980 if (Directory(newWalletDirPath).existsSync()) {
981 throw Exception('Cannot rename wallet: "$toName" already exists');
982 }
983 await currentWalletDir.rename(newWalletDirPath);
984 for (final suffix in const ['', '.v2']) {
985 final oldFile = File('$newWalletDirPath/$fromName$suffix');
986 if (oldFile.existsSync()) {
987 await oldFile.rename('$newWalletDirPath/$toName$suffix');
988 }
989 }
990 }
991
992 @override
993 bool get hasRescan => true;
994
995 static int? lastKnownRestoreHeight = null;
996
997 static int zashiAnnouncedBlockHeight = 2419420;
998
999 Future<dynamic> _getAddressesForAccount(final int id) async {
1000 return runWithCoin(
1001 accountId: id,
1002 func: (final coin) => zkool_account.getAddresses(c: coin, uaPools: 7),
1003 );
1004 }
1005
1006 bool _addressesMatch(final dynamic old, final dynamic new_) {
1007 return old.ua == new_.ua &&
1008 old.oaddr == new_.oaddr &&
1009 old.saddr == new_.saddr &&
1010 old.taddr == new_.taddr;
1011 }
1012
1013 Future<void> _switchToAccount(final int newAccountId, final int height) async {
1014 walletsByAccountId.remove(accountId);
1015 accountId = newAccountId;
1016 walletsByAccountId[newAccountId] = this;
1017 walletAddresses.accountId = newAccountId;
1018 c = await c.setAccount(account: newAccountId);
1019 lastKnownRestoreHeight = height;
1020 await walletAddresses.init();
1021 await _initKeys();
1022 }
1023
1024 @override
1025 @action
1026 Future<void> rescan({required final int height}) async {
1027 syncStatus = StartingScanSyncStatus(height);
1028 try {
1029 await zkool_sync.cancelSync();
1030 isSyncing = false;
1031
1032 await runWithCoin(
1033 accountId: accountId,
1034 func: (final coin) async {
1035 await zkool_account.updateAccount(
1036 update: zkool_account.AccountUpdate(
1037 coin: coin.coin,
1038 id: accountId,
1039 birth: height,
1040 folder: 0,
1041 ),
1042 c: coin,
1043 );
1044
1045 final accounts = await zkool_account.listAccounts(c: coin);
1046 final updated =
1047 accounts.where((final a) => a.id == accountId).firstOrNull;
1048 if (updated == null) {
1049 throw Exception('account $accountId not found after update');
1050 }
1051 if (updated.birth != height) {
1052 throw Exception(
1053 'birth height did not persist: wanted $height, '
1054 'database still has ${updated.birth}',
1055 );
1056 }
1057
1058 await zkool_account.resetSync(id: accountId, c: coin);
1059 },
1060 );
1061
1062 lastKnownRestoreHeight = height;
1063 await save();
1064
1065 syncStatus = ConnectedSyncStatus();
1066 unawaited(startSync());
1067 } catch (e) {
1068 printV('Zcash rescan failed: $e');
1069 syncStatus = FailedSyncStatus(error: e.toString());
1070 }
1071 // try {
1072 // syncStatus = StartingScanSyncStatus(height);
1073 // printV("rescanning from: $height");
1074 // await zkool_sync.cancelSync();
1075 // isSyncing = false;
1076
1077 // await runWithCoinMutex.acquire();
1078 // try {
1079 // final oldAddresses = await _getAddressesForAccount(accountId);
1080
1081 // c = await c.setAccount(account: accountId);
1082 // final accountSeed = await zkool_account.getAccountSeed(account: accountId, c: c);
1083 // if (accountSeed == null) {
1084 // throw Exception('Cannot rescan: seed not available');
1085 // }
1086
1087 // final newAccountId = await restoreZcashWalletFromSeed(
1088 // name: name,
1089 // seed: accountSeed.mnemonic,
1090 // passphrase: accountSeed.phrase,
1091 // birthHeight: height,
1092 // );
1093
1094 // final newAddresses = await _getAddressesForAccount(newAccountId);
1095 // if (!_addressesMatch(oldAddresses, newAddresses)) {
1096 // throw Exception('Rescan address verification failed');
1097 // }
1098
1099 // await saveAccountId(name, newAccountId);
1100 // await _switchToAccount(newAccountId, height);
1101 // } finally {
1102 // runWithCoinMutex.release();
1103 // }
1104
1105 // syncStatus = ConnectedSyncStatus();
1106 // } catch (e) {
1107 // printV("Rescan error: $e");
1108 // syncStatus = FailedSyncStatus(error: e.toString());
1109 // rethrow;
1110 // }
1111 }
1112
1113 bool _isTransactionUpdating = false;
1114 bool _transactionUpdateQueued = false;
1115
1116 Future<void> updateTransactions() async {
1117 if (_isTransactionUpdating) {
1118 _transactionUpdateQueued = true;
1119 return;
1120 }
1121
1122 _isTransactionUpdating = true;
1123 try {
1124 do {
1125 _transactionUpdateQueued = false;
1126 final transactions = await fetchTransactions();
1127
1128 final currentIds = transactionHistory.transactions.keys.toSet();
1129 final newIds = transactions.keys.toSet();
1130
1131 currentIds
1132 .difference(newIds)
1133 .forEach((final id) => transactionHistory.transactions.remove(id));
1134
1135 transactions.forEach((final key, final tx) {
1136 transactionHistory.transactions[key] = tx;
1137 });
1138 await transactionHistory.save();
1139 } while (_transactionUpdateQueued);
1140 } catch (e, stackTrace) {
1141 printV("Update transactions error: $e");
1142 printV("Stack trace: $stackTrace");
1143 } finally {
1144 _isTransactionUpdating = false;
1145 }
1146 }
1147
1148 @override
1149 Future<void> save() async {}
1150
1151 Future<void> init() async {
1152 try {
1153 await ZcashTaddressRotation.init();
1154 await walletAddresses.init();
1155
1156 await updateBalance();
1157 await updateTransactions();
1158 unawaited(
1159 ZcashTaddressRotation.updateCache(mainAccountId: accountId)
1160 .catchError((final e) => printV("rotation cache refresh: $e")),
1161 );
1162 await _initKeys();
1163 } catch (e) {
1164 printV("Wallet init error: $e");
1165 }
1166 }
1167
1168 @override
1169 String? seed = "";
1170
1171 @override
1172 String? passphrase = "";
1173
1174 @override
1175 Future<String> signMessage(final String message, {final String? address = null}) {
1176 throw UnimplementedError();
1177 }
1178
1179 @override
1180 @action
1181 Future<void> startSync() async {
1182 if (syncStatus is AttemptingSyncStatus ||
1183 syncStatus is SyncronizingSyncStatus ||
1184 syncStatus is SyncingSyncStatus) {
1185 return;
1186 }
1187 try {
1188 _ensureSyncLoopRunning();
1189 unawaited(_oneshotSync());
1190 } catch (e) {
1191 isNodeWorking = false;
1192 printV("Sync error: $e");
1193 syncStatus = FailedSyncStatus(error: e.toString());
1194 rethrow;
1195 }
1196 }
1197
1198 static Mutex warpSyncMutex = Mutex();
1199
1200 static final autoShieldMutex = Mutex();
1201 static DateTime? _lastAutoShieldAt;
1202 static final ironwoodMigrateMutex = Mutex();
1203 static DateTime? _lastIronwoodMigrateAt;
1204 Future<void> _autoShield() async {
1205 if (_lastAutoShieldAt != null &&
1206 _lastAutoShieldAt!.isAfter(DateTime.now().subtract(const Duration(seconds: 75)))) {
1207 return;
1208 }
1209 try {
1210 await autoShieldMutex.acquire();
1211 await _$autoShield();
1212 } catch (e, s) {
1213 printV("shielding failed: $e");
1214 s.toString().split("\n").forEach(printV);
1215 } finally {
1216 autoShieldMutex.release();
1217 }
1218 }
1219
1220 /// Total of transparent + sapling notes at or above the per-note spendable floor.
1221 static Future<BigInt> _sweepableTotal(final zkool_coin.Coin coin) async {
1222 final notes = await zkool_account.listNotes(c: coin);
1223 BigInt sweepable = BigInt.zero;
1224 for (int i = 0; i < notes.length; i++) {
1225 final note = notes[i];
1226 if (note.pool < 0 || note.pool >= NotePool.values.length) {
1227 continue;
1228 }
1229 final noteType = NotePool.values[note.pool];
1230 if ((noteType == NotePool.transparent || noteType == NotePool.sapling) &&
1231 note.value >= BigInt.from(ZcashTaddressRotation.minSpendableNote)) {
1232 sweepable += note.value;
1233 }
1234 }
1235 return sweepable;
1236 }
1237
1238 /// Orchard notes that migration will actually split or move to Ironwood.
1239 static Future<BigInt> _migratableOrchardTotal(final zkool_coin.Coin coin) async {
1240 final notes = await zkool_account.listNotes(c: coin);
1241 BigInt migratable = BigInt.zero;
1242 for (int i = 0; i < notes.length; i++) {
1243 final note = notes[i];
1244 if (note.pool != NotePool.orchard.index || note.locked) {
1245 continue;
1246 }
1247 if (note.value >= BigInt.from(_ironwoodMigrateMinNote)) {
1248 migratable += note.value;
1249 }
1250 }
1251 return migratable;
1252 }
1253
1254 Future<bool> hasOrchardMigratableBalance() async {
1255
1256 final (active, migratableOrchard) = await runWithCoin(
1257 accountId: accountId,
1258 func: (final coin) async => (
1259 await zkool_network.isIronwoodActive(c: coin),
1260 await _migratableOrchardTotal(coin),
1261 ),
1262 );
1263
1264 if(!active) {
1265 return false;
1266 }
1267
1268 return migratableOrchard > BigInt.zero;
1269 }
1270
1271 Future<void> _$autoShield() async {
1272 if (syncStatus is! SyncedSyncStatus) {
1273 return;
1274 }
1275 final txId = await runWithCoin(
1276 accountId: accountId,
1277 func: (coin) async {
1278 final sweepable = await _sweepableTotal(coin);
1279 final ironwood = await zkool_network.isIronwoodActive(c: coin);
1280
1281 if (sweepable <= BigInt.from(_minSweepThreshold(ironwood: ironwood))) {
1282 return null;
1283 }
1284 final txPlan = await zkool_pay.prepare(
1285 recipients: [
1286 zkool_paydart.Recipient(
1287 assetBase: zecBase,
1288 address: walletAddresses.orchardAddress!,
1289 amount: sweepable,
1290 pools: ironwood ? ironwoodPoolMask : null,
1291 ),
1292 ],
1293 options: zkool_pay.PaymentOptions(
1294 srcPools: 3,
1295 recipientPaysFee: true,
1296 smartTransparent: false,
1297 mode: 0,
1298 ),
1299 c: coin,
1300 );
1301
1302 final signTx = await zkool_pay.signTransaction(pczt: txPlan, c: coin);
1303 final txBytes = await zkool_pay.extractTransaction(package: signTx);
1304 final currentHeight = await zkool_network.getCurrentHeight(c: coin);
1305 return await zkool_pay.broadcastTransaction(
1306 height: currentHeight,
1307 txBytes: txBytes,
1308 c: coin,
1309 );
1310 },
1311 );
1312 if (txId == null) {
1313 return;
1314 }
1315
1316 await ZcashWalletService.addShieldedTx(txId);
1317 _lastAutoShieldAt = DateTime.now();
1318 printV("shielded: $txId");
1319 await updateTransactions();
1320 await _refreshBalance(runAutoShield: false, runIronwoodMigrate: false);
1321 }
1322
1323 Future<void> _ironwoodMigrate() async {
1324 if (_lastIronwoodMigrateAt != null &&
1325 _lastIronwoodMigrateAt!.isAfter(DateTime.now().subtract(const Duration(seconds: 75)))) {
1326 return;
1327 }
1328 try {
1329 await ironwoodMigrateMutex.acquire();
1330 await _$ironwoodMigrate();
1331 } catch (e, s) {
1332 printV("ironwood migration failed: $e");
1333 s.toString().split("\n").forEach(printV);
1334 } finally {
1335 ironwoodMigrateMutex.release();
1336 }
1337 }
1338
1339 Future<void> _$ironwoodMigrate() async {
1340 if (syncStatus is! SyncedSyncStatus) {
1341 return;
1342 }
1343 final event = await runWithCoin(
1344 accountId: accountId,
1345 func: (coin) async {
1346 if (!await zkool_network.isIronwoodActive(c: coin)) {
1347 return null;
1348 }
1349 final bal = await zkool_sync.balance(c: coin);
1350 if (bal.field0.length <= 2 || bal.field0[2] <= BigInt.zero) {
1351 return null;
1352 }
1353 return zkool_migrate.stepMigration(c: coin);
1354 },
1355 );
1356 if (event == null) {
1357 return;
1358 }
1359 switch (event) {
1360 case zkool_migrate.MigrationEvent_Complete():
1361 case zkool_migrate.MigrationEvent_NothingToDo():
1362 return;
1363 case zkool_migrate.MigrationEvent_SplitComplete(:final fee):
1364 printV("ironwood split step complete, fee: $fee");
1365 case zkool_migrate.MigrationEvent_MigrateComplete(:final fee):
1366 printV("ironwood migrate step complete, fee: $fee");
1367 case zkool_migrate.MigrationEvent_Error(:final message):
1368 printV("ironwood migration error: $message");
1369 return;
1370 }
1371
1372 _lastIronwoodMigrateAt = DateTime.now();
1373 await updateTransactions();
1374 await _refreshBalance(runAutoShield: false, runIronwoodMigrate: false);
1375 }
1376
1377 Future<void> _updateIronwoodActive() async {
1378 bool? active;
1379 try {
1380 active = await runWithCoin(
1381 accountId: accountId,
1382 func: (final coin) => zkool_network.isIronwoodActive(c: coin),
1383 );
1384 } catch (e) {
1385 printV("isIronwoodActive: $e");
1386 }
1387
1388 if (active == null && networkFor(walletInfo) == ZcashNetwork.regtest) {
1389 try {
1390 final height = await runWithCoin(
1391 accountId: accountId,
1392 func: (final coin) => zkool_network.getCurrentHeight(c: coin),
1393 );
1394 active = height >= ZcashNetwork.regtestNu63Height;
1395 printV("regtest ironwood inferred from height $height: $active");
1396 } catch (e) {
1397 printV("regtest height check failed: $e");
1398 }
1399 }
1400
1401 if (active == null) {
1402 return;
1403 }
1404
1405 ironwoodActive = active;
1406 runInAction(() => walletAddresses.setIronwoodActive(active!));
1407 printV("ironwoodActive=$active (account $accountId)");
1408 }
1409
1410 Future<void> _refreshBalance({
1411 required final bool runAutoShield,
1412 final bool runIronwoodMigrate = true,
1413 }) async {
1414 try {
1415 await _updateIronwoodActive();
1416 if (runAutoShield) {
1417 await _autoShield();
1418 }
1419 if (runIronwoodMigrate) {
1420 await _ironwoodMigrate();
1421 }
1422
1423 final (bal, sweepable, migratableOrchard) = await runWithCoin(
1424 accountId: accountId,
1425 func: (final coin) async => (
1426 await zkool_sync.balance(c: coin),
1427 await _sweepableTotal(coin),
1428 await _migratableOrchardTotal(coin),
1429 ),
1430 );
1431
1432 // 0 - transparent, 1 - sapling, 2 - orchard, 3 - ironwood
1433 final orchard = bal.field0.length > 2 ? bal.field0[2] : BigInt.zero;
1434 final ironwood = bal.field0.length > 3 ? bal.field0[3] : BigInt.zero;
1435
1436 // After NU6.3, Orchard notes are migrated to Ironwood - show them as unconfirmed.
1437 // Unavailable uses the same per-note totals and thresholds as auto-shield/migration guards.
1438 final BigInt availableAmount;
1439 final BigInt unavailableAmount;
1440 if (ironwoodActive == true && orchard > BigInt.zero) {
1441 final sweepableUnavailable = sweepable <= BigInt.from(_ironwoodMigrateMinNote)
1442 ? BigInt.zero
1443 : sweepable;
1444 availableAmount = ironwood;
1445 unavailableAmount = migratableOrchard + sweepableUnavailable;
1446 } else {
1447 final minSweep = _minSweepThreshold(ironwood: ironwoodActive == true);
1448 availableAmount = orchard + ironwood;
1449 unavailableAmount = sweepable <= BigInt.from(minSweep) ? BigInt.zero : sweepable;
1450 }
1451
1452 balance[currency] = ZcashBalance(
1453 Money(availableAmount, currency),
1454 Money(unavailableAmount, currency),
1455 frozen: Money.zero(currency),
1456 );
1457 } catch (e, stackTrace) {
1458 printV("Balance update error: $e");
1459 printV("Stack trace: $stackTrace");
1460 }
1461 }
1462
1463 @override
1464 @action
1465 Future<void> updateBalance() async {
1466 await _refreshBalance(runAutoShield: true);
1467 }
1468
1469 @override
1470 Future<bool> verifyMessage(
1471 final String message,
1472 final String signature, {
1473 final String? address = null,
1474 }) {
1475 throw UnimplementedError();
1476 }
1477
1478 @override
1479 late ZcashWalletAddresses walletAddresses = ZcashWalletAddresses(accountId, walletInfo);
1480
1481 static Future<ZcashWallet> create(final WalletCredentials credentials) async {
1482 final network = networkForCredentials(credentials);
1483 await $init(network: network);
1484 credentials.walletInfo?.network = network.value;
1485 final newWalletCredentials = credentials as ZcashNewWalletCredentials;
1486
1487 String mnemonic;
1488 if (newWalletCredentials.mnemonic?.isNotEmpty == true) {
1489 mnemonic = newWalletCredentials.mnemonic!;
1490 } else {
1491 final strength = (newWalletCredentials.seedPhraseLength == 24) ? 256 : 128;
1492 mnemonic = bip39.generateMnemonic(strength: strength);
1493 }
1494
1495 final birthHeight = await birthHeightForNetwork(network);
1496
1497 final accountId = await restoreZcashWalletFromSeed(
1498 name: credentials.name,
1499 seed: mnemonic,
1500 passphrase: newWalletCredentials.passphrase,
1501 birthHeight: birthHeight,
1502 );
1503 await saveAccountId(credentials.name, accountId);
1504 final wallet = await open(
1505 name: credentials.name,
1506 password: credentials.password!,
1507 walletInfo: credentials.walletInfo!,
1508 );
1509 await wallet.init();
1510 return wallet;
1511 }
1512
1513 static Future<ZcashWallet> restore(final WalletCredentials credentials) async {
1514 final network = networkForCredentials(credentials);
1515 await $init(network: network);
1516 credentials.walletInfo?.network = network.value;
1517 final fromSeedCredentials = credentials as ZcashFromSeedWalletCredentials;
1518 final String? seed = fromSeedCredentials.seed;
1519 if (seed == null || seed.isEmpty) {
1520 throw Exception('Seed phrase is required for wallet restoration');
1521 }
1522
1523 final accountId = await restoreZcashWalletFromSeed(
1524 name: credentials.name,
1525 seed: seed,
1526 passphrase: fromSeedCredentials.passphrase,
1527 birthHeight: credentials.height!,
1528 );
1529 await saveAccountId(credentials.name, accountId);
1530 final wallet = await open(
1531 name: credentials.name,
1532 password: credentials.password!,
1533 walletInfo: credentials.walletInfo!,
1534 );
1535 await wallet.init();
1536 return wallet;
1537 }
1538
1539 static Future<ZcashWallet> restoreKeys(final WalletCredentials credentials) async {
1540 final network = networkForCredentials(credentials);
1541 await $init(network: network);
1542 credentials.walletInfo?.network = network.value;
1543 final fromKeysCredentials = credentials as ZcashFromKeysWalletCredentials;
1544 final String? keys = fromKeysCredentials.privateKey;
1545 if (keys == null || keys.isEmpty) {
1546 throw Exception('Key is required for wallet restoration');
1547 }
1548
1549 final zcashSecretExtendedKeyRegex = RegExp(r'^secret-extended-key-main1[a-z0-9]+$');
1550 if (!zcashSecretExtendedKeyRegex.hasMatch(keys)) {
1551 throw Exception('Key is not in secret-extended-key-main1 format');
1552 }
1553
1554 final accountId = await restoreZcashWalletFromSeed(
1555 name: credentials.name,
1556 seed: keys,
1557 passphrase: fromKeysCredentials.passphrase,
1558 birthHeight: credentials.height!,
1559 );
1560 await saveAccountId(credentials.name, accountId);
1561 final wallet = await open(
1562 name: credentials.name,
1563 password: credentials.password!,
1564 walletInfo: credentials.walletInfo!,
1565 );
1566 await wallet.init();
1567 printV("height: ${credentials.height}");
1568 return wallet;
1569 }
1570
1571 static Future<ZcashWallet> open({
1572 required final String name,
1573 required final String password,
1574 required final WalletInfo walletInfo,
1575 }) async {
1576 final network = networkFor(walletInfo);
1577 await $init(network: network);
1578 // if (password.isNotEmpty) {
1579 // setDbPasswd(coin, password);
1580 // }
1581 final accountId = await getZcashAccountIdForName(name);
1582 if (accountId == null) {
1583 throw Exception("accountId is null");
1584 }
1585 c = await c.setAccount(account: accountId);
1586 final wallet = ZcashWallet(
1587 walletInfo,
1588 await walletInfo.getDerivationInfo(),
1589 accountId: accountId,
1590 );
1591 await wallet._initKeys();
1592 return wallet;
1593 }
1594
1595 static Future<int> restoreZcashWalletFromSeed({
1596 required final String name,
1597 required final String seed,
1598 required final String? passphrase,
1599 required final int birthHeight,
1600 }) async {
1601 // if (passphrase?.isNotEmpty == true) {
1602 // passphrase = passphrase!.replaceAll(" ", "_");
1603 // seed = "${seed} ${passphrase}";
1604 // }
1605
1606 final accountId = await newAccount(
1607 name: name,
1608 height: birthHeight,
1609 seed: seed,
1610 passphrase: passphrase ?? '',
1611 );
1612 return accountId;
1613 }
1614
1615 static Future<int?> getLegacyZcashAccountIdForName(final String name) async {
1616 final wPath = (await pathForWallet(name: name, type: _type));
1617 final f = File(wPath);
1618 if (!f.existsSync()) {
1619 final accs = await zkool_account.listAccounts(c: c);
1620 for (final acc in accs) {
1621 if (acc.name == name) {
1622 return acc.id;
1623 }
1624 }
1625 }
1626 final content = f.readAsStringSync();
1627 return int.tryParse(content.trim());
1628 }
1629
1630 static Future<int?> getZcashAccountIdForName(final String name) async {
1631 final wPath = (await pathForWallet(name: name, type: _type)) + ".v2";
1632 final f = File(wPath);
1633 if (!f.existsSync()) {
1634 final accs = await zkool_account.listAccounts(c: c);
1635 for (final acc in accs) {
1636 if (acc.name == name) {
1637 return acc.id;
1638 }
1639 }
1640 }
1641 final content = f.readAsStringSync();
1642 return int.tryParse(content.trim());
1643 }
1644
1645 static Future<void> saveAccountId(final String name, final int accountId) async {
1646 final wPath = (await pathForWallet(name: name, type: _type)) + ".v2";
1647 final dirName = Directory(wPath).parent.path;
1648 if (!Directory(dirName).existsSync()) {
1649 Directory(dirName).createSync(recursive: true);
1650 }
1651 final f = File(wPath);
1652 f.writeAsStringSync(accountId.toString());
1653 }
1654
1655 static WalletType get _type => WalletType.zcash;
1656
1657 static ZcashNetwork networkFor(final WalletInfo? walletInfo) =>
1658 ZcashNetwork.fromName(walletInfo?.network ?? ZcashNetwork.mainnet.value);
1659
1660 static ZcashNetwork networkForCredentials(final WalletCredentials credentials) {
1661 if (credentials is ZcashNewWalletCredentials) {
1662 return ZcashNetwork.fromIndex(credentials.network);
1663 }
1664 if (credentials is ZcashFromSeedWalletCredentials) {
1665 return ZcashNetwork.fromIndex(credentials.network);
1666 }
1667 if (credentials is ZcashFromKeysWalletCredentials) {
1668 return ZcashNetwork.fromIndex(credentials.network);
1669 }
1670 return ZcashNetwork.mainnet;
1671 }
1672
1673 static Future<int> birthHeightForNetwork(final ZcashNetwork network) async {
1674 if (network != ZcashNetwork.mainnet) {
1675 return 1;
1676 }
1677 return ZcashHeight.getBlockHeightByTime(DateTime.now());
1678 }
1679
1680 static Future<String> getDbDataPath({final ZcashNetwork network = ZcashNetwork.mainnet}) async {
1681 final pathForWalletType = await pathForWalletTypeDir(type: _type);
1682 final dbDataPath = "${pathForWalletType}/${network.dbFileName}";
1683 if (!Directory(pathForWalletType).existsSync()) {
1684 Directory(pathForWalletType).createSync(recursive: true);
1685 }
1686 return dbDataPath;
1687 }
1688
1689 static Future<String> getDbDataPathLegacyYwallet() async {
1690 final pathForWalletType = await pathForWalletTypeDir(type: _type);
1691 final dbDataPath = "${pathForWalletType}/zec.db";
1692 if (!Directory(pathForWalletType).existsSync()) {
1693 Directory(pathForWalletType).createSync(recursive: true);
1694 }
1695 return dbDataPath;
1696 }
1697
1698 static bool _initialized = false;
1699 static bool _rustInitialized = false;
1700 static ZcashNetwork? _activeNetwork;
1701
1702 static void unlockDatabase(final String password) {
1703 _password = password;
1704 }
1705
1706 static var c = zkool_coin.Coin();
1707
1708 static String? _password;
1709 static Future<void> $init({final ZcashNetwork network = ZcashNetwork.mainnet}) async {
1710 if (!_rustInitialized) {
1711 await zkool_frb.RustLib.init();
1712 _rustInitialized = true;
1713 }
1714 if (_initialized && _activeNetwork == network) {
1715 return;
1716 }
1717 printV(r".$init($network)");
1718 ZcashMempoolService.instance.onAccountsUpdated = (final accountIds) {
1719 for (final accountId in accountIds) {
1720 unawaited(refreshWalletForAccount(accountId));
1721 }
1722 };
1723 final dbFile = File(await getDbDataPath(network: network));
1724 final ywalletDbFile = File(await getDbDataPathLegacyYwallet());
1725 await zkool_network.initDatadir(directory: dbFile.parent.path);
1726 c = await c.openDatabase(dbFilepath: dbFile.path, password: null);
1727 printV("initWallet: ${dbFile.path}");
1728 if (_password == null) {
1729 throw Exception("Zcash wallet locked! Please contact support");
1730 }
1731 if (!dbFile.existsSync()) {
1732 //TODO(mrcyjanek): copy-encrypt
1733 }
1734 if (!ywalletDbFile.existsSync()) {
1735 //TODO(mrcyjanek): migrate to zkool
1736 }
1737
1738 _activeNetwork = network;
1739 _initialized = true;
1740 }
1741
1742 static Future<int> getHeightByDate(final DateTime date) async {
1743 final height = await ZcashHeight.getBlockHeightByTime(date);
1744 return height;
1745 }
1746
1747 static Future<int> newAccount({
1748 required final String name,
1749 required final int height,
1750 required final String seed,
1751 required final String passphrase,
1752 }) async {
1753 final id = await zkool_account.newAccount(
1754 na: zkool_account.NewAccount(
1755 name: name,
1756 restore: true,
1757 passphrase: passphrase,
1758 key: seed,
1759 aindex: 0,
1760 birth: height,
1761 folder: '',
1762 useInternal: true,
1763 internal: false,
1764 ledger: false,
1765 ),
1766 c: c,
1767 );
1768 return id;
1769 }
1770
1771 static final runWithCoinMutex = Mutex();
1772 static int runWithCoinCount = 0;
1773
1774 static Future<T> withSharedCoinLock<T>(final FutureOr<T> Function() func) async {
1775 await runWithCoinMutex.acquire();
1776 try {
1777 return await func();
1778 } finally {
1779 runWithCoinMutex.release();
1780 }
1781 }
1782
1783 static FutureOr<T> runWithCoin<T>({
1784 required final int accountId,
1785 required final FutureOr<T> Function(zkool_coin.Coin c) func,
1786 }) async {
1787 var newC = zkool_coin.Coin();
1788 newC = await newC.openDatabase(dbFilepath: c.dbFilepath);
1789 newC = await newC.setAccount(account: accountId);
1790 newC = await newC.setLwd(serverType: c.serverType, url: c.url);
1791 newC = await newC.setUseTor(useTor: c.useTor);
1792
1793 runWithCoinCount++;
1794 printV("run with coin: $runWithCoinCount");
1795 await runWithCoinMutex.acquire();
1796 try {
1797 newC = await newC.setAccount(account: accountId);
1798 return await func(newC);
1799 } finally {
1800 runWithCoinMutex.release();
1801 runWithCoinCount--;
1802 }
1803 }
1804 }