dev
dart 599 lines 20.5 KB
Raw
1 // create offset wallets for T address rotation.
2 // Each address is considered one time use, if it receives less than 15000 zatoshi balance will be essentially burned.
3 //
4 // This class takes address index as constructor, and will create 10 addresses by default, if any of the addresses gets used
5 // it will be moved out of the pool
6 //
7 // T addresses are generated by creating new wallets with an extra seed word (or appending to "passphrase") the following
8 // :tgen:${CRC32(seed)}
9 // It is highly unlikely for users to create a wallet with that specific last word and add index to it
10 // We could leverage other fields such as native account index alone, but users may want to set that in future
11 // This solution is rather simple and if standard would get estabilished for this purpose it can be easily
12 // turned off and replaced with proper solution.
13
14 import 'dart:async';
15 import 'dart:math' show max;
16
17 import 'package:cw_core/sync_status.dart';
18 import 'package:cw_core/transaction_direction.dart';
19 import 'package:cw_core/utils/print_verbose.dart';
20 import 'package:cw_zcash/src/util/crc32.dart';
21 import 'package:cw_zcash/src/zcash_wallet.dart';
22 import 'package:cw_zcash/src/zcash_wallet_service.dart';
23 import 'package:cw_zcash/src/zkool_compat.dart';
24 import 'package:cw_zcash/src/zkooltx.dart';
25 import 'package:zkool/src/rust/api/account.dart' as zkool_account;
26 import 'package:zkool/src/rust/api/coin.dart' as zkool_coin;
27 import 'package:zkool/src/rust/api/sync.dart' as zkool_sync;
28 import 'package:zkool/src/rust/api/pay.dart' as zkool_pay;
29 import 'package:zkool/src/rust/api/network.dart' as zkool_network;
30 import 'package:zkool/src/rust/api/sweep.dart' as zkool_sweep;
31 import 'package:zkool/src/rust/pay.dart' as zkool_paydart;
32
33 class ZcashTaddressRotation {
34 static bool _isStarted = false;
35 static zkool_coin.Coin get c => ZcashWalletBase.c;
36 static const int _sweepThreshold = 30000;
37 static const int minSpendableNote = 5000;
38 static const int _lookahead = 5;
39 // Matches transparentLimit in ZcashWalletBase._oneshotSync.
40 static const int _transparentSyncLimit = 100;
41
42 static zkool_account.Seed seedForOffset(final zkool_account.Seed seed) {
43 final seedStr = "${seed.mnemonic} ${seed.phrase.replaceAll(" ", "_")}".trim();
44 final seedWords = seedStr.split(" ");
45 if ([12, 24].contains(seedWords.length)) {
46 seedWords.add("");
47 }
48 final lastI = seedWords.length - 1;
49 final crc = CRC32.compute(seedStr);
50 seedWords[lastI] += ":tgen:$crc";
51 final phrase = seedWords.removeLast();
52 return zkool_account.Seed(mnemonic: seedWords.join(" "), phrase: phrase, aindex: 0);
53 }
54
55 static bool zkoolAccountSeedIsEqual(final zkool_account.Seed a, final zkool_account.Seed b) {
56 return a.mnemonic == b.mnemonic && a.phrase == b.phrase;
57 }
58
59 static bool isSeedForWallet(
60 final zkool_account.Seed? mainWallet,
61 final zkool_account.Seed? subWallet,
62 ) {
63 if (mainWallet == null || subWallet == null) return false;
64 return zkoolAccountSeedIsEqual(seedForOffset(mainWallet), subWallet);
65 }
66
67 static Map<int, List<ZkoolTx>> shieldedAccountsTx = {};
68 static final Map<int, int> _rotationAccountByMain = {};
69 static final Set<int> _resyncedRotationAccounts = {};
70 static final Set<String> _poolRescanAttempted = {};
71 static bool _sweepJobRunning = false;
72 static final Map<int, DateTime> _lastSweepBroadcastAt = {};
73
74 static bool _poolNeedsRescan(final List<zkool_account.TAddressTxCount> slots) {
75 if (slots.isEmpty) {
76 return false;
77 }
78 final unused = slots.where((final e) => e.txCount == 0);
79 if (unused.isEmpty) {
80 return false;
81 }
82 final head = unused.first;
83 final tail = slots.last.dindex;
84 if (slots.any((final e) => e.dindex > head.dindex && e.txCount > 0)) {
85 return true;
86 }
87 return head.dindex + _transparentSyncLimit <= tail;
88 }
89
90 static Future<void> _scanTransparentChain(final int rotationAccountId) async {
91 await ZcashWalletBase.runWithCoin(
92 accountId: rotationAccountId,
93 func: (final coin) async {
94 final all = await zkool_account.fetchAddressTxCount(
95 c: coin,
96 aggregate: false,
97 poolFilter: 1,
98 );
99 final external = all.where((final e) => e.scope == 0).toList();
100 if (external.isEmpty) {
101 return;
102 }
103 final maxD = external.map((final e) => e.dindex).reduce(max);
104 final scanner = await zkool_sweep.TransparentScanner.newInstance();
105 final height = await zkool_network.getCurrentHeight(c: coin);
106 final completer = Completer<void>();
107 final sub = scanner
108 .run(endHeight: height, gapLimit: maxD + _lookahead + 1, c: coin)
109 .listen(
110 (_) {},
111 onError: (final e) {
112 printV("transparent chain scan: $e");
113 if (!completer.isCompleted) {
114 completer.complete();
115 }
116 },
117 onDone: () {
118 if (!completer.isCompleted) {
119 completer.complete();
120 }
121 },
122 );
123 await Future.any([
124 completer.future,
125 Future.delayed(Duration(seconds: (maxD + 2).clamp(8, 45))),
126 ]);
127 await sub.cancel();
128 },
129 );
130 }
131
132 static Future<void> _resyncRotationPool(
133 final int rotationAccountId,
134 final int transparentLimit,
135 ) async {
136 await ZcashWalletBase.runWithCoin(
137 accountId: rotationAccountId,
138 func: (final coin) async {
139 await zkool_sync.cancelSync();
140 await zkool_account.resetSync(id: rotationAccountId, c: coin);
141 final height = await zkool_network.getCurrentHeight(c: coin);
142 final sync = zkool_sync.synchronize(
143 accounts: [rotationAccountId],
144 currentHeight: height,
145 actionsPerSync: 10000,
146 transparentLimit: transparentLimit,
147 checkpointAge: 200,
148 c: coin,
149 fast: false,
150 );
151 final completer = Completer<void>();
152 sync.listen(
153 (_) {},
154 onError: (final e) {
155 printV("rotation pool resync error: $e");
156 if (!completer.isCompleted) {
157 completer.complete();
158 }
159 },
160 onDone: () {
161 if (!completer.isCompleted) {
162 completer.complete();
163 }
164 },
165 );
166 await completer.future;
167 },
168 );
169 }
170
171 static Future<void> _ensurePoolScanned(final int rotationAccountId) async {
172 var slots = await _transparentSlots(rotationAccountId);
173 final firstSession = _resyncedRotationAccounts.add(rotationAccountId);
174 if (!firstSession && !_poolNeedsRescan(slots)) {
175 return;
176 }
177 if (!firstSession) {
178 final head = slots.where((final e) => e.txCount == 0).firstOrNull?.dindex;
179 final key = '$rotationAccountId:$head';
180 if (_poolRescanAttempted.contains(key)) {
181 return;
182 }
183 _poolRescanAttempted.add(key);
184 }
185 await _ensureLookahead(rotationAccountId);
186 await _scanTransparentChain(rotationAccountId);
187 slots = await _transparentSlots(rotationAccountId);
188 final limit = slots.isEmpty ? _transparentSyncLimit : slots.last.dindex + 1;
189 printV("rotation pool resync account $rotationAccountId limit $limit");
190 await _resyncRotationPool(rotationAccountId, limit);
191 }
192
193 static Future<void> init() async {
194 if (_isStarted) {
195 return;
196 }
197 _isStarted = true;
198 unawaited(
199 (() async {
200 await Future.delayed(const Duration(seconds: 2));
201 return _jobRunner();
202 })(),
203 );
204 }
205
206 static Future<int?> getRotationAccountForMainAccount(final int mainAccountId) async {
207 final cached = _rotationAccountByMain[mainAccountId];
208 if (cached != null) {
209 return cached;
210 }
211 try {
212 await ZcashWalletBase.runWithCoinMutex.acquire();
213 final wSeed = await zkool_account.getAccountSeed(c: c, account: mainAccountId);
214 if (wSeed == null) {
215 printV("Not running Taddr rotation - seed not found");
216 return null;
217 }
218 final seed = seedForOffset(wSeed);
219 final accs = await zkool_account.listAccounts(c: c);
220 for (int i = 0; i < accs.length; i++) {
221 final accSeed = await zkool_account.getAccountSeed(c: c, account: accs[i].id);
222 if (accSeed?.mnemonic == seed.mnemonic &&
223 accSeed?.phrase == seed.phrase &&
224 accSeed?.aindex == seed.aindex) {
225 _rotationAccountByMain[mainAccountId] = accs[i].id;
226 return accs[i].id;
227 }
228 }
229
230 zkool_account.Account? acc;
231 for (final a in accs) {
232 if (a.id == mainAccountId) {
233 acc = a;
234 break;
235 }
236 }
237 if (acc == null) {
238 printV("Main account $mainAccountId not found for Taddr rotation");
239 return null;
240 }
241
242 final name = CRC32.compute(wSeed.mnemonic).toString();
243 final id = await newAccount(
244 name: name,
245 seed: seed.mnemonic,
246 passphrase: seed.phrase,
247 height: acc.birth,
248 aindex: seed.aindex,
249 );
250 _rotationAccountByMain[mainAccountId] = id;
251 return id;
252 } finally {
253 ZcashWalletBase.runWithCoinMutex.release();
254 }
255 }
256
257 // External (scope 0) transparent slots for the rotation account, ordered by
258 // dindex. zkool derives every slot in 0..=dindex, so unused ones (txCount==0)
259 // form the disposable look-ahead pool.
260 static Future<List<zkool_account.TAddressTxCount>> _transparentSlots(
261 final int rotationAccountId,
262 ) async {
263 final all = await ZcashWalletBase.runWithCoin(
264 accountId: rotationAccountId,
265 func: (final coin) =>
266 zkool_account.fetchAddressTxCount(c: coin, aggregate: false, poolFilter: 1),
267 );
268 return all.where((final e) => e.scope == 0).toList()
269 ..sort((final a, final b) => a.dindex.compareTo(b.dindex));
270 }
271
272 // Keep _lookahead unused disposable addresses ready, generating more whenever
273 // the pool runs low. Newly generated addresses are scanned by ongoing sync
274 // (the latest addresses are always in the scan window), so no resync needed.
275 static Future<void> _ensureLookahead(final int rotationAccountId) async {
276 final slots = await _transparentSlots(rotationAccountId);
277 final unused = slots.where((final e) => e.txCount == 0).length;
278 final toGenerate = _lookahead - unused;
279 for (var i = 0; i < toGenerate; i++) {
280 await ZcashWalletBase.runWithCoin(
281 accountId: rotationAccountId,
282 func: (final coin) => zkool_account.generateNextDindex(c: coin),
283 );
284 }
285 if (toGenerate > 0) {
286 printV("topped up disposable taddr pool by $toGenerate (had $unused)");
287 }
288 }
289
290 static bool _isWalletSynced(final int mainAccountId) {
291 final wallet = ZcashWalletBase.walletsByAccountId[mainAccountId];
292 return wallet != null && wallet.syncStatus is SyncedSyncStatus;
293 }
294
295 static Future<BigInt> _rotationTransparentBalance(final int rotationAccount) async {
296 return ZcashWalletBase.runWithCoin(
297 accountId: rotationAccount,
298 func: (final coin) async {
299 final b = await zkool_sync.balance(c: coin);
300 return b.field0.elementAt(0);
301 },
302 );
303 }
304
305 static Future<List<ZkoolTx>> _loadTxHistory(final int accountId) async {
306 return ZcashWalletBase.runWithCoin(
307 accountId: accountId,
308 func: (final coin) async {
309 final txsI = await zkool_account.listTxHistory(c: coin);
310 final txs = <ZkoolTx>[];
311 for (int i = 0; i < txsI.length; i++) {
312 final tx = txsI[i];
313 try {
314 final details = await zkool_account.getTxDetails(idTx: tx.id, c: coin);
315 txs.add(ZkoolTx(tx, details));
316 } catch (e) {
317 printV("getTxDetails skipped for tx ${tx.id}: $e");
318 }
319 }
320 return txs;
321 },
322 );
323 }
324
325 static bool _canBroadcastSweep(final int mainAccountId) {
326 final lastSweep = _lastSweepBroadcastAt[mainAccountId];
327 if (lastSweep == null) {
328 return true;
329 }
330 return !lastSweep.isAfter(DateTime.now().subtract(const Duration(seconds: 120)));
331 }
332
333 static Future<void> createAndSweepTAddresses() async {
334 if (_sweepJobRunning) {
335 return;
336 }
337 _sweepJobRunning = true;
338 try {
339 final mainAccountIds = ZcashWalletBase.walletsByAccountId.keys.toList();
340 if (mainAccountIds.isEmpty) {
341 await createAndSweepTAddressesForAccount(c.account);
342 return;
343 }
344 for (final mainAccountId in mainAccountIds) {
345 try {
346 await createAndSweepTAddressesForAccount(mainAccountId);
347 } catch (e, s) {
348 printV("createAndSweepTAddresses for $mainAccountId error: $e");
349 s.toString().split("\n").forEach(printV);
350 }
351 }
352 } finally {
353 _sweepJobRunning = false;
354 }
355 }
356
357 static Future<void> createAndSweepTAddressesForAccount(final int cId) async {
358 if (!_isWalletSynced(cId)) {
359 printV("rotation sweep waiting for wallet sync (account $cId)");
360 return;
361 }
362
363 printV("createAndSweepTAddresses for account $cId");
364
365 final rotationAccount = await getRotationAccountForMainAccount(cId);
366 if (rotationAccount == null) {
367 printV("rotation account is null, bailing out");
368 return;
369 }
370
371 await _ensureLookahead(rotationAccount);
372 await _ensurePoolScanned(rotationAccount);
373 await _refreshWalletAddresses(cId);
374
375 BigInt transparentBal = BigInt.zero;
376 try {
377 transparentBal = await _rotationTransparentBalance(rotationAccount);
378 printV("rotation account $rotationAccount transparent balance: $transparentBal");
379 } catch (e) {
380 printV("getTBalance: $e");
381 return;
382 }
383
384 if (transparentBal < BigInt.from(_sweepThreshold)) {
385 await updateCache(mainAccountId: cId);
386 await _refreshWalletAfterRotationChange(cId);
387 return;
388 }
389
390 if (!_canBroadcastSweep(cId)) {
391 printV("rotation sweep rate-limited for account $cId");
392 await updateCache(mainAccountId: cId);
393 await _refreshWalletAfterRotationChange(cId);
394 return;
395 }
396
397 final toAddress = await ZcashWalletBase.runWithCoin(
398 accountId: cId,
399 func: (final coin) async {
400 final addrs = await zkool_account.getAddresses(c: coin, uaPools: 7);
401 final orchard = addrs.oaddr;
402 if (orchard == null || orchard.isEmpty) {
403 throw Exception('Orchard address unavailable for rotation sweep');
404 }
405 return orchard;
406 },
407 );
408 final result = await ZcashWalletBase.runWithCoin(
409 accountId: rotationAccount,
410 func: (final coin) async {
411 final height = await zkool_network.getCurrentHeight(c: coin);
412 final notes = await zkool_account.listNotes(c: coin);
413 var amount = BigInt.zero;
414 for (final note in notes) {
415 if (note.pool != NotePool.transparent.index || note.locked) {
416 continue;
417 }
418 if (note.value < BigInt.from(minSpendableNote)) {
419 continue;
420 }
421 if (note.height > height) {
422 continue;
423 }
424 amount += note.value;
425 }
426 if (amount < BigInt.from(_sweepThreshold)) {
427 throw Exception('rotation sweep: insufficient spendable transparent notes');
428 }
429 final ironwood = await zkool_network.isIronwoodActive(c: coin);
430 final tx = await zkool_pay.prepare(
431 recipients: [
432 zkool_paydart.Recipient(
433 assetBase: zecBase,
434 address: toAddress,
435 amount: amount,
436 pools: ironwood ? ironwoodPoolMask : null,
437 ),
438 ],
439 options: zkool_pay.PaymentOptions(
440 srcPools: 1,
441 recipientPaysFee: true,
442 smartTransparent: false,
443 mode: 0,
444 ),
445 c: coin,
446 );
447 final signTx = await zkool_pay.signTransaction(pczt: tx, c: coin);
448 final txBytes = await zkool_pay.extractTransaction(package: signTx);
449 return zkool_pay.broadcastTransaction(height: height, txBytes: txBytes, c: coin);
450 },
451 );
452 printV("rotation sweep broadcast: $result");
453 if (result.isEmpty) {
454 throw Exception("rotation sweep broadcast failed");
455 }
456
457 await ZcashWalletService.addShieldedTx(result);
458 _lastSweepBroadcastAt[cId] = DateTime.now();
459 await updateCache(mainAccountId: cId);
460 await _refreshWalletAfterRotationChange(cId);
461 }
462
463 static Future<void> _refreshWalletAfterRotationChange(final int mainAccountId) async {
464 final wallet = ZcashWalletBase.walletsByAccountId[mainAccountId];
465 if (wallet == null) {
466 return;
467 }
468 unawaited(wallet.updateTransactions());
469 unawaited(wallet.updateBalance());
470 }
471
472 static Future<void> updateCache({required final int mainAccountId}) async {
473 final rotationAccount = await getRotationAccountForMainAccount(mainAccountId);
474 if (rotationAccount == null) {
475 printV("rotationAccount is null");
476 return;
477 }
478 await _ensureLookahead(rotationAccount);
479 await _ensurePoolScanned(rotationAccount);
480 final txs = await _loadTxHistory(rotationAccount);
481 shieldedAccountsTx[mainAccountId] = txs;
482 if (mainAccountId != rotationAccount && shieldedAccountsTx.containsKey(rotationAccount)) {
483 shieldedAccountsTx.remove(rotationAccount);
484 }
485
486 for (final tx in shieldedAccountsTx[mainAccountId]!) {
487 if (tx.direction == TransactionDirection.outgoing) {
488 await ZcashWalletService.addShieldedTx(tx.txHash);
489 }
490 }
491 await _refreshWalletAddresses(mainAccountId);
492 await _refreshWalletAfterRotationChange(mainAccountId);
493 }
494
495 static Future<void> _refreshWalletAddresses(final int mainAccountId) async {
496 final wallet = ZcashWalletBase.walletsByAccountId[mainAccountId];
497 if (wallet == null) {
498 return;
499 }
500 try {
501 await wallet.walletAddresses.refreshRotationAddresses();
502 } catch (e) {
503 printV("rotation address refresh: $e");
504 }
505 }
506
507 static List<ZkoolTx> rotationTxsForMainAccount(final int mainAccountId) {
508 return shieldedAccountsTx[mainAccountId] ?? const [];
509 }
510
511 static Future<zkool_account.Account?> accountForSeed(final zkool_account.Seed seed) async {
512 final accounts = await zkool_account.listAccounts(c: c);
513 if (accounts.isEmpty) return null;
514 for (int i = 0; i < accounts.length; i++) {
515 final acc = accounts[i];
516 final backup = await zkool_account.getAccountSeed(c: c, account: acc.id);
517 if (backup?.mnemonic == seed.mnemonic && backup?.phrase == seed.phrase) {
518 return acc;
519 }
520 }
521 return null;
522 }
523
524 static Future<void> _jobRunner() async {
525 for (;;) {
526 try {
527 await Future.delayed(const Duration(seconds: 5));
528 await createAndSweepTAddresses();
529 } catch (e, s) {
530 printV("createAndSweepTAddresses error: $e");
531 s.toString().split("\n").forEach(printV);
532 } finally {
533 await Future.delayed(const Duration(seconds: 30));
534 }
535 }
536 }
537
538 // Current disposable address = first unused slot (lowest dindex).
539 static Future<String?> addressForAccount(final int accountId) async {
540 final rotationAccount = await getRotationAccountForMainAccount(accountId);
541 if (rotationAccount == null) {
542 return null;
543 }
544 final slots = await _transparentSlots(rotationAccount);
545 final unused = slots.where((final e) => e.txCount == 0);
546 if (unused.isNotEmpty) {
547 return unused.first.address;
548 }
549 return slots.isNotEmpty ? slots.last.address : null;
550 }
551
552 static Future<List<String>> usedAddressesForAccount(final int accountId) async {
553 final rotationAccount = await getRotationAccountForMainAccount(accountId);
554 if (rotationAccount == null) {
555 return const [];
556 }
557 final slots = await _transparentSlots(rotationAccount);
558 return [
559 for (final entry in slots)
560 if (entry.txCount > 0) entry.address,
561 ];
562 }
563
564 // The full disposable pool: used slots plus the unused look-ahead window.
565 static Future<List<String>> allAddressesForAccount(final int accountId) async {
566 final rotationAccount = await getRotationAccountForMainAccount(accountId);
567 if (rotationAccount == null) {
568 return const [];
569 }
570 final slots = await _transparentSlots(rotationAccount);
571 return slots.map((final e) => e.address).toList();
572 }
573
574 static Future<int> newAccount({
575 required final String name,
576 required final String seed,
577 required final String passphrase,
578 required final int height,
579 required final int aindex,
580 }) async {
581 final id = await zkool_account.newAccount(
582 na: zkool_account.NewAccount(
583 name: name,
584 restore: true,
585 passphrase: passphrase,
586 key: seed,
587 aindex: aindex,
588 birth: height,
589 folder: '',
590 pools: 1,
591 useInternal: true,
592 internal: false,
593 ledger: false,
594 ),
595 c: c,
596 );
597 return id;
598 }
599 }