diff --git a/.github/workflows/pr_test_build_linux.yml b/.github/workflows/pr_test_build_linux.yml index 879ea247f..467743048 100644 --- a/.github/workflows/pr_test_build_linux.yml +++ b/.github/workflows/pr_test_build_linux.yml @@ -159,11 +159,6 @@ jobs: - name: Generate localization run: | dart run tool/generate_localization.dart - - name: Workaround zcash - run: | - mkdir -p scripts/zcash_lib/assets/ - touch scripts/zcash_lib/assets/sapling-spend.params - touch scripts/zcash_lib/assets/sapling-output.params - name: Build linux run: | flutter build linux --dart-define=hasDevOptions=true --release diff --git a/.gitignore b/.gitignore index 268167bc4..cd064fc15 100644 --- a/.gitignore +++ b/.gitignore @@ -226,4 +226,5 @@ scripts/android/yttrium scripts/bitbox_flutter scripts/reown_flutter -*.salive \ No newline at end of file +*.salive +android/.kotlin diff --git a/assets/zcash_node_list.yml b/assets/zcash_node_list.yml index afdd49599..148e785ed 100644 --- a/assets/zcash_node_list.yml +++ b/assets/zcash_node_list.yml @@ -5,8 +5,8 @@ useSSL: true isEnabledForAutoSwitching: true label: "Cake Wallet" -- - uri: zec.rocks:443 +- uri: zec.rocks:443 + is_default: true useSSL: true isEnabledForAutoSwitching: true label: "Zec.rocks" diff --git a/cw_core/lib/get_height_by_date_zec.dart b/cw_core/lib/get_height_by_date_zec.dart new file mode 100644 index 000000000..2176e8bfc --- /dev/null +++ b/cw_core/lib/get_height_by_date_zec.dart @@ -0,0 +1,416 @@ +class ZcashHeight { + // for i in $(seq 1 331); do echo "_Checkpoint($(date -j -f "%Y-%m-%d %H:%M:%S" "$(curl -s "https://mainnet.zcashexplorer.app/blocks/${i}0000" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}')" "+%s"), ${i}0000),"; done > points.txt + static final List<_Checkpoint> _points = [ + _Checkpoint(1479114667, 10000), + _Checkpoint(1480618460, 20000), + _Checkpoint(1482129143, 30000), + _Checkpoint(1483635610, 40000), + _Checkpoint(1485145514, 50000), + _Checkpoint(1486655742, 60000), + _Checkpoint(1488163642, 70000), + _Checkpoint(1489668244, 80000), + _Checkpoint(1491175107, 90000), + _Checkpoint(1492681094, 100000), + _Checkpoint(1494185626, 110000), + _Checkpoint(1495691260, 120000), + _Checkpoint(1497196465, 130000), + _Checkpoint(1498702428, 140000), + _Checkpoint(1500209116, 150000), + _Checkpoint(1501716823, 160000), + _Checkpoint(1503222893, 170000), + _Checkpoint(1504730866, 180000), + _Checkpoint(1506236963, 190000), + _Checkpoint(1507744233, 200000), + _Checkpoint(1509254578, 210000), + _Checkpoint(1510768449, 220000), + _Checkpoint(1512275502, 230000), + _Checkpoint(1513783951, 240000), + _Checkpoint(1515292290, 250000), + _Checkpoint(1516799219, 260000), + _Checkpoint(1518306180, 270000), + _Checkpoint(1519813817, 280000), + _Checkpoint(1521319150, 290000), + _Checkpoint(1522828107, 300000), + _Checkpoint(1524336223, 310000), + _Checkpoint(1525844954, 320000), + _Checkpoint(1527354342, 330000), + _Checkpoint(1528860301, 340000), + _Checkpoint(1530365206, 350000), + _Checkpoint(1531873693, 360000), + _Checkpoint(1533380392, 370000), + _Checkpoint(1534888873, 380000), + _Checkpoint(1536393381, 390000), + _Checkpoint(1537899117, 400000), + _Checkpoint(1539406330, 410000), + _Checkpoint(1540912214, 420000), + _Checkpoint(1542422891, 430000), + _Checkpoint(1543931111, 440000), + _Checkpoint(1545437758, 450000), + _Checkpoint(1546945529, 460000), + _Checkpoint(1548450107, 470000), + _Checkpoint(1549957767, 480000), + _Checkpoint(1551466960, 490000), + _Checkpoint(1552969806, 500000), + _Checkpoint(1554477648, 510000), + _Checkpoint(1555981451, 520000), + _Checkpoint(1557489241, 530000), + _Checkpoint(1558997282, 540000), + _Checkpoint(1560503840, 550000), + _Checkpoint(1562011717, 560000), + _Checkpoint(1563518091, 570000), + _Checkpoint(1565027136, 580000), + _Checkpoint(1566532760, 590000), + _Checkpoint(1568039395, 600000), + _Checkpoint(1569547911, 610000), + _Checkpoint(1571051853, 620000), + _Checkpoint(1572559831, 630000), + _Checkpoint(1574069743, 640000), + _Checkpoint(1575576895, 650000), + _Checkpoint(1576603014, 660000), + _Checkpoint(1577356346, 670000), + _Checkpoint(1578109375, 680000), + _Checkpoint(1578863180, 690000), + _Checkpoint(1579616443, 700000), + _Checkpoint(1580369806, 710000), + _Checkpoint(1581122786, 720000), + _Checkpoint(1581876634, 730000), + _Checkpoint(1582630471, 740000), + _Checkpoint(1583383678, 750000), + _Checkpoint(1584133944, 760000), + _Checkpoint(1584887940, 770000), + _Checkpoint(1585641057, 780000), + _Checkpoint(1586395543, 790000), + _Checkpoint(1587148500, 800000), + _Checkpoint(1587901493, 810000), + _Checkpoint(1588655183, 820000), + _Checkpoint(1589408476, 830000), + _Checkpoint(1590163464, 840000), + _Checkpoint(1590916379, 850000), + _Checkpoint(1591669786, 860000), + _Checkpoint(1592423887, 870000), + _Checkpoint(1593176771, 880000), + _Checkpoint(1593930930, 890000), + _Checkpoint(1594684380, 900000), + _Checkpoint(1595439209, 910000), + _Checkpoint(1596192463, 920000), + _Checkpoint(1596946009, 930000), + _Checkpoint(1597700256, 940000), + _Checkpoint(1598453538, 950000), + _Checkpoint(1599207483, 960000), + _Checkpoint(1599960598, 970000), + _Checkpoint(1600714563, 980000), + _Checkpoint(1601467884, 990000), + _Checkpoint(1602220941, 1000000), + _Checkpoint(1602974722, 1010000), + _Checkpoint(1603728848, 1020000), + _Checkpoint(1604485637, 1030000), + _Checkpoint(1605238381, 1040000), + _Checkpoint(1605992807, 1050000), + _Checkpoint(1606747750, 1060000), + _Checkpoint(1607500606, 1070000), + _Checkpoint(1608255278, 1080000), + _Checkpoint(1609008684, 1090000), + _Checkpoint(1609763782, 1100000), + _Checkpoint(1610517871, 1110000), + _Checkpoint(1611271575, 1120000), + _Checkpoint(1612025546, 1130000), + _Checkpoint(1612779940, 1140000), + _Checkpoint(1613533993, 1150000), + _Checkpoint(1614287669, 1160000), + _Checkpoint(1615042297, 1170000), + _Checkpoint(1615792993, 1180000), + _Checkpoint(1616546145, 1190000), + _Checkpoint(1617299867, 1200000), + _Checkpoint(1618054038, 1210000), + _Checkpoint(1618808513, 1220000), + _Checkpoint(1619563243, 1230000), + _Checkpoint(1620318159, 1240000), + _Checkpoint(1621071298, 1250000), + _Checkpoint(1621826156, 1260000), + _Checkpoint(1622580237, 1270000), + _Checkpoint(1623335553, 1280000), + _Checkpoint(1624090141, 1290000), + _Checkpoint(1624844712, 1300000), + _Checkpoint(1625597720, 1310000), + _Checkpoint(1626351652, 1320000), + _Checkpoint(1627106054, 1330000), + _Checkpoint(1627860648, 1340000), + _Checkpoint(1628614015, 1350000), + _Checkpoint(1629368398, 1360000), + _Checkpoint(1630122679, 1370000), + _Checkpoint(1630877190, 1380000), + _Checkpoint(1631631884, 1390000), + _Checkpoint(1632385851, 1400000), + _Checkpoint(1633141006, 1410000), + _Checkpoint(1633895592, 1420000), + _Checkpoint(1634649550, 1430000), + _Checkpoint(1635403121, 1440000), + _Checkpoint(1636157669, 1450000), + _Checkpoint(1636915133, 1460000), + _Checkpoint(1637670036, 1470000), + _Checkpoint(1638424121, 1480000), + _Checkpoint(1639177413, 1490000), + _Checkpoint(1639931234, 1500000), + _Checkpoint(1640684476, 1510000), + _Checkpoint(1641438827, 1520000), + _Checkpoint(1642192402, 1530000), + _Checkpoint(1642946383, 1540000), + _Checkpoint(1643700901, 1550000), + _Checkpoint(1644455522, 1560000), + _Checkpoint(1645208504, 1570000), + _Checkpoint(1645962081, 1580000), + _Checkpoint(1646715150, 1590000), + _Checkpoint(1647465727, 1600000), + _Checkpoint(1648219045, 1610000), + _Checkpoint(1648973131, 1620000), + _Checkpoint(1649727422, 1630000), + _Checkpoint(1650481408, 1640000), + _Checkpoint(1651234287, 1650000), + _Checkpoint(1651989865, 1660000), + _Checkpoint(1652743327, 1670000), + _Checkpoint(1653497455, 1680000), + _Checkpoint(1654252094, 1690000), + _Checkpoint(1655005944, 1700000), + _Checkpoint(1655759708, 1710000), + _Checkpoint(1656513765, 1720000), + _Checkpoint(1657268358, 1730000), + _Checkpoint(1658022453, 1740000), + _Checkpoint(1658777019, 1750000), + _Checkpoint(1659531133, 1760000), + _Checkpoint(1660283907, 1770000), + _Checkpoint(1661037645, 1780000), + _Checkpoint(1661791977, 1790000), + _Checkpoint(1662544394, 1800000), + _Checkpoint(1663298018, 1810000), + _Checkpoint(1664051450, 1820000), + _Checkpoint(1664805547, 1830000), + _Checkpoint(1665558994, 1840000), + _Checkpoint(1666312456, 1850000), + _Checkpoint(1667066473, 1860000), + _Checkpoint(1667823393, 1870000), + _Checkpoint(1668576623, 1880000), + _Checkpoint(1669330657, 1890000), + _Checkpoint(1670084156, 1900000), + _Checkpoint(1670837831, 1910000), + _Checkpoint(1671591094, 1920000), + _Checkpoint(1672343975, 1930000), + _Checkpoint(1673097703, 1940000), + _Checkpoint(1673852016, 1950000), + _Checkpoint(1674605810, 1960000), + _Checkpoint(1675360050, 1970000), + _Checkpoint(1676113710, 1980000), + _Checkpoint(1676867174, 1990000), + _Checkpoint(1677620242, 2000000), + _Checkpoint(1678374852, 2010000), + _Checkpoint(1679125063, 2020000), + _Checkpoint(1679878121, 2030000), + _Checkpoint(1680632254, 2040000), + _Checkpoint(1681386518, 2050000), + _Checkpoint(1682140381, 2060000), + _Checkpoint(1682894564, 2070000), + _Checkpoint(1683648178, 2080000), + _Checkpoint(1684401932, 2090000), + _Checkpoint(1685156464, 2100000), + _Checkpoint(1685910040, 2110000), + _Checkpoint(1686663406, 2120000), + _Checkpoint(1687417875, 2130000), + _Checkpoint(1688172069, 2140000), + _Checkpoint(1688925610, 2150000), + _Checkpoint(1689679500, 2160000), + _Checkpoint(1690432691, 2170000), + _Checkpoint(1691186349, 2180000), + _Checkpoint(1691940901, 2190000), + _Checkpoint(1692694780, 2200000), + _Checkpoint(1693449743, 2210000), + _Checkpoint(1694203665, 2220000), + _Checkpoint(1694957643, 2230000), + _Checkpoint(1695710712, 2240000), + _Checkpoint(1696465787, 2250000), + _Checkpoint(1697218701, 2260000), + _Checkpoint(1697973031, 2270000), + _Checkpoint(1698726864, 2280000), + _Checkpoint(1699483348, 2290000), + _Checkpoint(1700237650, 2300000), + _Checkpoint(1700991210, 2310000), + _Checkpoint(1701745742, 2320000), + _Checkpoint(1702500723, 2330000), + _Checkpoint(1703253121, 2340000), + _Checkpoint(1704007238, 2350000), + _Checkpoint(1704760935, 2360000), + _Checkpoint(1705514669, 2370000), + _Checkpoint(1706268087, 2380000), + _Checkpoint(1707021423, 2390000), + _Checkpoint(1707776257, 2400000), + _Checkpoint(1708529542, 2410000), + _Checkpoint(1709283065, 2420000), + _Checkpoint(1710037165, 2430000), + _Checkpoint(1710787780, 2440000), + _Checkpoint(1711541488, 2450000), + _Checkpoint(1712294996, 2460000), + _Checkpoint(1713048843, 2470000), + _Checkpoint(1713803486, 2480000), + _Checkpoint(1714557127, 2490000), + _Checkpoint(1715311181, 2500000), + _Checkpoint(1716064271, 2510000), + _Checkpoint(1716818792, 2520000), + _Checkpoint(1717572765, 2530000), + _Checkpoint(1718326333, 2540000), + _Checkpoint(1719080275, 2550000), + _Checkpoint(1719834297, 2560000), + _Checkpoint(1720588042, 2570000), + _Checkpoint(1721341832, 2580000), + _Checkpoint(1722095122, 2590000), + _Checkpoint(1722848604, 2600000), + _Checkpoint(1723602357, 2610000), + _Checkpoint(1724355843, 2620000), + _Checkpoint(1725110085, 2630000), + _Checkpoint(1725863681, 2640000), + _Checkpoint(1726616892, 2650000), + _Checkpoint(1727371443, 2660000), + _Checkpoint(1728124893, 2670000), + _Checkpoint(1728878141, 2680000), + _Checkpoint(1729631572, 2690000), + _Checkpoint(1730385888, 2700000), + _Checkpoint(1731142437, 2710000), + _Checkpoint(1731896266, 2720000), + _Checkpoint(1732650193, 2730000), + _Checkpoint(1733403626, 2740000), + _Checkpoint(1734157390, 2750000), + _Checkpoint(1734911442, 2760000), + _Checkpoint(1735665720, 2770000), + _Checkpoint(1736419845, 2780000), + _Checkpoint(1737173511, 2790000), + _Checkpoint(1737926795, 2800000), + _Checkpoint(1738681511, 2810000), + _Checkpoint(1739434236, 2820000), + _Checkpoint(1740188214, 2830000), + _Checkpoint(1740941352, 2840000), + _Checkpoint(1741692338, 2850000), + _Checkpoint(1742445247, 2860000), + _Checkpoint(1743198927, 2870000), + _Checkpoint(1743952947, 2880000), + _Checkpoint(1744706370, 2890000), + _Checkpoint(1745461377, 2900000), + _Checkpoint(1746214735, 2910000), + _Checkpoint(1746967908, 2920000), + _Checkpoint(1747722271, 2930000), + _Checkpoint(1748474582, 2940000), + _Checkpoint(1749228582, 2950000), + _Checkpoint(1749983372, 2960000), + _Checkpoint(1750737278, 2970000), + _Checkpoint(1751490762, 2980000), + _Checkpoint(1752243931, 2990000), + _Checkpoint(1752997873, 3000000), + _Checkpoint(1753751756, 3010000), + _Checkpoint(1754504935, 3020000), + _Checkpoint(1755259449, 3030000), + _Checkpoint(1756012904, 3040000), + _Checkpoint(1756765335, 3050000), + _Checkpoint(1757519206, 3060000), + _Checkpoint(1758272484, 3070000), + _Checkpoint(1759025597, 3080000), + _Checkpoint(1759778957, 3090000), + _Checkpoint(1760532780, 3100000), + _Checkpoint(1761286210, 3110000), + _Checkpoint(1762039783, 3120000), + _Checkpoint(1762796627, 3130000), + _Checkpoint(1763550532, 3140000), + _Checkpoint(1764303860, 3150000), + _Checkpoint(1765057381, 3160000), + _Checkpoint(1765811222, 3170000), + _Checkpoint(1766564356, 3180000), + _Checkpoint(1767318326, 3190000), + _Checkpoint(1768071956, 3200000), + _Checkpoint(1768824957, 3210000), + _Checkpoint(1769578781, 3220000), + _Checkpoint(1770332206, 3230000), + _Checkpoint(1771086265, 3240000), + _Checkpoint(1771840180, 3250000), + _Checkpoint(1772592783, 3260000), + _Checkpoint(1773343947, 3270000), + _Checkpoint(1774097085, 3280000), + _Checkpoint(1774850161, 3290000), + _Checkpoint(1775603525, 3300000), + _Checkpoint(1776358117, 3310000), + ]; + + static int getBlockHeightByTime(DateTime time) { + final t = time.toUtc().millisecondsSinceEpoch; + + if (_points.isEmpty) return 0; + + if (t <= _points.first.timeMs) { + return 1; + } + + for (int i = 0; i < _points.length - 1; i++) { + final p0 = _points[i]; + final p1 = _points[i + 1]; + + if (t >= p0.timeMs && t < p1.timeMs) { + return _interpolate( + p0.height, + p1.height, + p0.timeMs, + p1.timeMs, + t, + ); + } + } + + final last = _points[_points.length - 1]; + final prev = _points[_points.length - 2]; + + final avgBlockTimeMs = (last.timeMs - prev.timeMs) / (last.height - prev.height); + + final blocksSinceLast = ((t - last.timeMs) / avgBlockTimeMs).floor(); + + return last.height + blocksSinceLast; + } + + static DateTime getTimeByBlockHeight(int height) { + if (_points.isEmpty) return DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + if (height <= 1) { + return DateTime.fromMillisecondsSinceEpoch(_points.first.timeMs, isUtc: true); + } + + for (int i = 0; i < _points.length - 1; i++) { + final p0 = _points[i]; + final p1 = _points[i + 1]; + if (height >= p0.height && height < p1.height) { + final t = _interpolate( + p0.timeMs, + p1.timeMs, + p0.height, + p1.height, + height, + ); + return DateTime.fromMillisecondsSinceEpoch(t, isUtc: true); + } + } + + final last = _points[_points.length - 1]; + final prev = _points[_points.length - 2]; + final avgBlockTimeMs = (last.timeMs - prev.timeMs) / (last.height - prev.height); + final msSinceLast = ((height - last.height) * avgBlockTimeMs).floor(); + return DateTime.fromMillisecondsSinceEpoch(last.timeMs + msSinceLast, isUtc: true); + } + + static int _interpolate( + int h0, + int h1, + int t0, + int t1, + int t, + ) { + final ratio = (t - t0) / (t1 - t0); + return h0 + ((h1 - h0) * ratio).floor(); + } +} + +class _Checkpoint { + final int timeMs; + final int height; + + _Checkpoint(int timeSec, this.height) : timeMs = timeSec * 1000; +} diff --git a/cw_zcash/lib/cw_zcash.dart b/cw_zcash/lib/cw_zcash.dart index c312ddb82..92af28582 100644 --- a/cw_zcash/lib/cw_zcash.dart +++ b/cw_zcash/lib/cw_zcash.dart @@ -4,7 +4,6 @@ library; export 'src/zcash_balance.dart'; -export 'src/zcash_mnemonic_is_incorrect_exception.dart'; export 'src/zcash_transaction_info.dart'; export 'src/zcash_transactions.dart'; export 'src/zcash_wallet.dart'; diff --git a/cw_zcash/lib/src/legacy/zkool_sweep.dart b/cw_zcash/lib/src/legacy/zkool_sweep.dart deleted file mode 100644 index 20f04daa3..000000000 --- a/cw_zcash/lib/src/legacy/zkool_sweep.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'dart:io'; - -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:path/path.dart' as p; -import 'package:zkool/src/rust/api/account.dart' as zkool_account; -import 'package:zkool/src/rust/api/coin.dart' as zkool_coin; -import 'package:zkool/src/rust/api/sync.dart' as zkool_sync; -import 'package:zkool/src/rust/api/pay.dart' as zkool_pay; -import 'package:zkool/src/rust/api/key.dart' as zkool_key; -import 'package:zkool/src/rust/api/network.dart' as zkool_network; -import 'package:zkool/src/rust/pay.dart' as zkool_paydart; -import 'package:zkool/src/rust/frb_generated.dart' as zkool_frb; - -class ZkoolSweep { - ZkoolSweep({ - required this.currentBalance, - required this.cacheDir, - required this.seed, - required this.passphrase, - required this.address, - required this.url, - required this.height, - }); - final int currentBalance; - final String seed; - final String cacheDir; - final String passphrase; - final String address; - final String url; - final int height; - static SweepUpdate msg = SweepUpdate(message: 'Idling', currentHeight: 0, networkHeight: 0); - Future start() async { - try { - await _init(); - if (!zkool_key.isValidAddress(address: address)) { - msg.message = "$address is not valid"; - return; - } - - msg = SweepUpdate(message: 'Starting import', currentHeight: 0, networkHeight: 0); - await _newAccount(); - msg.message = "Starting sync"; - await warpSync(); - msg.message = "Waiting"; - while (msg.blocksLeft != 0 && msg.networkHeight != 0) { - await Future.delayed(Duration(seconds: 1)); - } - await Future.delayed(Duration(seconds: 5)); - final balance = await getBalance(); - if (balance.toInt() == balance) { - msg.message = "No need to sweep, balance is the same"; - return; - } - msg.message = "Sweeping"; - await sweep(balance); - } catch (e) { - msg.message = e.toString(); - printV("zkool_sweep: $e"); - rethrow; - } - } - - Future getBalance() async { - final b = await zkool_sync.balance(c: c); - return b.field0.reduce((final a, final b) => a + b); - } - - Future sweep(final BigInt amount) async { - msg.message = "prepare"; - final tx = await zkool_pay.prepare( - recipients: [zkool_paydart.Recipient(address: address, amount: amount)], - options: zkool_pay.PaymentOptions( - srcPools: 7, - recipientPaysFee: true, - smartTransparent: false, - ), - c: c, - ); - msg.message = "getLatestHeight"; - final height = await getLatestHeight(); - msg.message = "signTransaction"; - final signTx = await zkool_pay.signTransaction(pczt: tx, c: c); - msg.message = "extractTransaction"; - final txBytes = await zkool_pay.extractTransaction(package: signTx); - msg.message = "broadcastTransaction"; - final result = await zkool_pay.broadcastTransaction(height: height, txBytes: txBytes, c: c); - msg.message = "result"; - if (result.isEmpty) { - msg.message = "Unknown error"; - throw Exception(result); - } - } - - static var c = zkool_coin.Coin(); - - static bool _isInit = false; - - File get dbFile => File(p.join(cacheDir, "zkool-tmp-mirror.db")); - - Future warpSync() async { - final currentHeight = await getLatestHeight(); - msg.networkHeight = currentHeight; - final sync = zkool_sync.synchronize( - accounts: [currentAccount], - currentHeight: currentHeight, - actionsPerSync: 10000, - transparentLimit: 100, - checkpointAge: 200, - c: c, - ); - bool success = false; - await sync.listen( - (final syncProgress) { - msg.currentHeight = syncProgress.height; - msg.message = "Sync ${syncProgress.height} / ${syncProgress.time}"; - }, - onError: (final e) { - msg.message = e.toString(); - }, - onDone: () { - msg.message = "Sync done!"; - success = true; - }, - ); - - if (msg.currentHeight != currentHeight && msg.currentHeight != 0) { - msg.message = "Canceling sync"; - await zkool_sync.cancelSync(); - await Future.delayed(Duration(seconds: 1)); - return warpSync(); - } - return 0; - } - - Future getLatestHeight() async { - try { - return await zkool_network.getCurrentHeight(c: c); - } catch (e) { - printV("getLatestHeight: $e"); - } - return 0; - } - - Future _newAccount() async { - final id = await zkool_account.newAccount( - na: zkool_account.NewAccount( - name: 'restoretmp' + DateTime.now().microsecondsSinceEpoch.toString(), - restore: true, - passphrase: "", - key: seed, - aindex: 0, - birth: height, - folder: '', - useInternal: true, - internal: false, - ledger: false, - ), - c: c, - ); - currentAccount = id; - c = await c.setAccount(account: id); - } - - int currentAccount = 0; - - Future _init() async { - if (_isInit) return; - _isInit = true; - printV(".init()"); - await zkool_frb.RustLib.init(); - await zkool_network.initDatadir(directory: dbFile.parent.path); - c = await c.openDatabase(dbFilepath: dbFile.path, password: 'cw_zcash_migration'); - c = c.setLwd(url: url, serverType: 0); - printV(".init(): Done"); - } -} - -class SweepUpdate { - SweepUpdate({required this.message, required this.currentHeight, required this.networkHeight}); - String message; - int currentHeight; - int networkHeight; - double get progress { - if (networkHeight == 0) return 0.01; - return currentHeight / networkHeight; - } - - int get blocksLeft => networkHeight - currentHeight; - - Map toJson() => { - "message": message, - "currentHeight": currentHeight, - "networkHeight": networkHeight, - "progress": progress, - }; -} diff --git a/cw_zcash/lib/src/migrate.dart b/cw_zcash/lib/src/migrate.dart new file mode 100644 index 000000000..e76e4d9b7 --- /dev/null +++ b/cw_zcash/lib/src/migrate.dart @@ -0,0 +1,96 @@ +import 'dart:math'; + +import 'package:cw_core/get_height_by_date_zec.dart'; +import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_zcash/cw_zcash.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:path/path.dart' as p; + +class ZkoolSeed { + ZkoolSeed({required this.seed, required this.passphrase}); + + factory ZkoolSeed.fromYwalletSeed({required final String ywalletSeed}) { + final splSeed = ywalletSeed.split(" "); + String seed = ""; + ; + String passphrase = ""; + if ([13, 25].contains(splSeed.length)) { + seed = splSeed.take(splSeed.length - 1).join(" "); + passphrase = ywalletSeed.replaceFirst("${seed} ", ""); + } else { + seed = ywalletSeed; + } + return ZkoolSeed(seed: seed, passphrase: passphrase); + } + + String seed; + String passphrase; +} + +class YwalletAccountInfo { + YwalletAccountInfo({ + required this.idAccount, + required this.name, + required this.seed, + required this.aindex, + required this.sk, + required this.ivk, + required this.address, + }); + final int idAccount; + final String name; + final String seed; + late final zkoolSeed = ZkoolSeed.fromYwalletSeed(ywalletSeed: seed); + final int aindex; + final String sk; + final String ivk; + final String address; + static YwalletAccountInfo fromJson(final Map j) => YwalletAccountInfo( + idAccount: j['id_account'], + name: j['name'], + seed: j['seed'], + aindex: j['aindex'], + sk: j['sk'], + ivk: j['ivk'], + address: j['address'], + ); +} + +Future migrateOldSqliteToZkool2({required final String walletName}) async { + final dbFile = p.join(await pathForWalletTypeDir(type: WalletType.zcash), "zec.db"); + final migrateDb = await openDatabase(dbFile); + final outputRaw = await migrateDb.rawQuery( + "SELECT `id_account`, `name`, `seed`, `aindex`, `sk`, `ivk`, `address` FROM `accounts`", + ); + final output = outputRaw.map(YwalletAccountInfo.fromJson); + + final walletId = await ZcashWalletBase.getLegacyZcashAccountIdForName(walletName); + for (final ya in output) { + if (ya.idAccount != walletId) { + continue; + } + printV("migrating account: ${ya.name}"); + int birthHeight = await ZcashHeight.getBlockHeightByTime(DateTime(2026, 1, 1)); + + final accTxs = await migrateDb.rawQuery( + "SELECT height FROM transactions WHERE account = ${ya.idAccount}", + ); + for (final tx in accTxs) { + final txHeight = int.tryParse(tx['height'].toString()) ?? birthHeight; + birthHeight = min(birthHeight, txHeight); + } + + final accountId = await ZcashWalletBase.restoreZcashWalletFromSeed( + name: ya.name, + seed: ya.zkoolSeed.seed, + passphrase: ya.zkoolSeed.passphrase, + birthHeight: birthHeight, + ); + await ZcashWalletBase.saveAccountId(ya.name, accountId); + return; + } + + throw Exception("migration not finished (wallet name: $walletName not found)"); +} diff --git a/cw_zcash/lib/src/pending_zcash_transaction.dart b/cw_zcash/lib/src/pending_zcash_transaction.dart index db4f59f7e..3f6762d3c 100644 --- a/cw_zcash/lib/src/pending_zcash_transaction.dart +++ b/cw_zcash/lib/src/pending_zcash_transaction.dart @@ -1,14 +1,10 @@ -import 'dart:convert'; -import 'dart:math'; - import 'package:cw_core/amount/money.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/output_info.dart'; +import 'package:cw_core/exceptions.dart'; import 'package:cw_core/pending_transaction.dart'; +import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_zcash/cw_zcash.dart'; -import 'package:cw_zcash/src/zcash_taddress_rotation.dart'; -import 'package:warp_api/data_fb_generated.dart'; -import 'package:warp_api/warp_api.dart'; +import 'package:zkool/src/rust/api/pay.dart' as zkool_pay; +import 'package:zkool/src/rust/api/network.dart' as zkool_network; class PendingZcashTransaction with PendingTransaction { PendingZcashTransaction({ @@ -21,7 +17,7 @@ class PendingZcashTransaction with PendingTransaction { final ZcashWallet zcashWallet; final ZcashTransactionCredentials credentials; - final String txPlan; + final zkool_pay.PcztPackage txPlan; String? _txId; final Money availableBalance; @@ -32,56 +28,42 @@ class PendingZcashTransaction with PendingTransaction { String get hex => ''; @override - Money get amount => Money.fromInt(_totalAmount, CryptoCurrency.zec); + Money get amount { + final isAll = credentials.outputs.fold(false, (final a, final b) => a || (b.sendAll)); + if (isAll) { + return availableBalance - fee; + } + return credentials.outputs + .map((final output) => output.cryptoAmount) + .reduce((final a, final b) => a + b); + } @override String get amountFormatted => amount.toString(); - int get _totalAmount { - final isAll = credentials.outputs.fold(false, (final a, final b) => a || (b.sendAll)); - if (isAll) { - return availableBalance.amount.toInt(); - } - return credentials.outputs.fold( - 0, - (final a, final b) => a + b.cryptoAmount.amount.toInt(), - ); - } - @override final Money fee; @override Future commit() async { - _txId = await ZcashWalletService.runInDbMutex( - () => WarpApi.signAndBroadcast(ZcashWalletBase.coin, zcashWallet.accountId, txPlan), - ); - ZcashWalletBase.temporarySentTx[zcashWallet.accountId] ??= []; - ZcashWalletBase.temporarySentTx[zcashWallet.accountId]?.add( - ShieldedTx( - base64.decode( - ZcashTaddressRotation.flatBuffersPack( - ShieldedTxT( - id: Random().nextInt(pow(2, 32).toInt()), - txId: _txId, - height: 0, - timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000, - value: -_totalAmount, - ).pack, - ), - ), - ), - ); - await ZcashTransactionInfo.addCachedDestinationAddress( - _txId ?? '', - credentials.outputs.reduce((final o1, final o2) { - return OutputInfo( - cryptoAmount: Money.zero(CryptoCurrency.zec), - address: "${o1.address},${o2.address}", - sendAll: false, - isParsedAddress: false, + await ZcashWalletBase.runWithCoin( + accountId: zcashWallet.accountId, + func: (coin) async { + final signTx = await zkool_pay.signTransaction(pczt: txPlan, c: coin); + final txBytes = await zkool_pay.extractTransaction(package: signTx); + final currentHeight = await zkool_network.getCurrentHeight(c: coin); + final result = await zkool_pay.broadcastTransaction( + height: currentHeight, + txBytes: txBytes, + c: coin, ); - }).address, + printV("result: $result"); + final txId = ZcashWalletService.normalizeTxId(result); + if (txId.length != 64) { + throw TransactionCommitFailed(errorMessage: result); + } + _txId = txId; + }, ); await zcashWallet.updateTransactions(); await zcashWallet.updateBalance(); diff --git a/cw_zcash/lib/src/util/hex.dart b/cw_zcash/lib/src/util/hex.dart new file mode 100644 index 000000000..1cd6a1253 --- /dev/null +++ b/cw_zcash/lib/src/util/hex.dart @@ -0,0 +1,5 @@ +import 'package:flutter/services.dart'; + +String uint8ListToHex(final Uint8List bytes) { + return bytes.map((final b) => b.toRadixString(16).padLeft(2, '0')).join(); +} diff --git a/cw_zcash/lib/src/zcash_mempool.dart b/cw_zcash/lib/src/zcash_mempool.dart new file mode 100644 index 000000000..33ba40fd7 --- /dev/null +++ b/cw_zcash/lib/src/zcash_mempool.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_zcash/src/zcash_wallet_service.dart'; +import 'package:zkool/src/rust/api/coin.dart' as zkool_coin; +import 'package:zkool/src/rust/api/mempool.dart' as zkool_mempool; + +typedef ZcashMempoolRefreshCallback = void Function(Set accountIds); + +class ZcashMempoolService { + ZcashMempoolService._(); + + static final ZcashMempoolService instance = ZcashMempoolService._(); + + zkool_mempool.Mempool? _mempool; + final Map _txsById = {}; + + zkool_mempool.Mempool get _mempoolInstance => _mempool ??= zkool_mempool.Mempool(); + + ZcashMempoolRefreshCallback? onAccountsUpdated; + + bool _loopRunning = false; + String? _nodeUrl; + StreamSubscription? _subscription; + + List txsForAccount(final int accountId) => _txsById.values + .where( + (final tx) => + tx.notes.any((final n) => n.account == accountId) || + tx.amounts.any((final a) => a.account == accountId), + ) + .toList(); + + Future ensureRunning(final zkool_coin.Coin c) async { + if (c.url.isEmpty) { + return; + } + if (_loopRunning && _nodeUrl == c.url) { + return; + } + await stop(); + _nodeUrl = c.url; + _loopRunning = true; + unawaited(_runLoop(c)); + } + + Future stop() async { + _loopRunning = false; + await _subscription?.cancel(); + _subscription = null; + _txsById.clear(); + try { + await _mempool?.cancel(); + } catch (e) { + printV("mempool cancel: $e"); + } + _mempool = null; + } + + Future _runLoop(final zkool_coin.Coin c) async { + while (_loopRunning && _nodeUrl == c.url) { + try { + final completer = Completer(); + _subscription = _mempoolInstance + .run(c: c) + .listen( + (final msg) { + if (!_loopRunning) { + return; + } + switch (msg) { + case zkool_mempool.MempoolMsg_BlockHeight(): + _onBlockMined(); + case zkool_mempool.MempoolMsg_TxId(:final field0): + _onMempoolTx(field0); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.complete(); + } + }, + onError: (final e) { + printV("mempool stream error: $e"); + if (!completer.isCompleted) { + completer.complete(); + } + }, + ); + await completer.future; + } catch (e) { + printV("mempool loop error: $e"); + } + if (_loopRunning) { + await Future.delayed(const Duration(seconds: 5)); + } + } + } + + void _onBlockMined() { + final affected = _allAffectedAccounts(); + _txsById.clear(); + onAccountsUpdated?.call(affected); + } + + void _onMempoolTx(final zkool_mempool.MempoolTx tx) { + final normalized = ZcashWalletService.normalizeTxId(tx.txid); + _txsById[normalized] = tx; + onAccountsUpdated?.call(_affectedAccountsForTx(tx)); + } + + Set _allAffectedAccounts() => _txsById.values.expand(_affectedAccountsForTx).toSet(); + + Set _affectedAccountsForTx(final zkool_mempool.MempoolTx tx) => { + ...tx.notes.map((final n) => n.account), + ...tx.amounts.map((final a) => a.account), + }; +} diff --git a/cw_zcash/lib/src/zcash_mnemonic_is_incorrect_exception.dart b/cw_zcash/lib/src/zcash_mnemonic_is_incorrect_exception.dart deleted file mode 100644 index 4986837c5..000000000 --- a/cw_zcash/lib/src/zcash_mnemonic_is_incorrect_exception.dart +++ /dev/null @@ -1,5 +0,0 @@ -class ZcashMnemonicIsIncorrectException implements Exception { - @override - String toString() => - 'Zcash mnemonic has incorrect format. Mnemonic should contain 12 or 24 words separated by space.'; -} diff --git a/cw_zcash/lib/src/zcash_taddress_rotation.dart b/cw_zcash/lib/src/zcash_taddress_rotation.dart index 1c2ef1f20..8e8940395 100644 --- a/cw_zcash/lib/src/zcash_taddress_rotation.dart +++ b/cw_zcash/lib/src/zcash_taddress_rotation.dart @@ -12,368 +12,484 @@ // turned off and replaced with proper solution. import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; +import 'dart:math' show max; -import 'package:cw_core/pathForWallet.dart'; +import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/wallet_info.dart'; -import 'package:cw_core/wallet_type.dart'; import 'package:cw_zcash/src/util/crc32.dart'; import 'package:cw_zcash/src/zcash_wallet.dart'; import 'package:cw_zcash/src/zcash_wallet_service.dart'; -import 'package:flutter/foundation.dart'; -import 'package:warp_api/data_fb_generated.dart'; -import 'package:warp_api/warp_api.dart'; -import 'package:flat_buffers/flat_buffers.dart' as fb; -import 'package:path/path.dart' as p; +import 'package:cw_zcash/src/zkool_compat.dart'; +import 'package:cw_zcash/src/zkooltx.dart'; +import 'package:zkool/src/rust/api/account.dart' as zkool_account; +import 'package:zkool/src/rust/api/coin.dart' as zkool_coin; +import 'package:zkool/src/rust/api/sync.dart' as zkool_sync; +import 'package:zkool/src/rust/api/pay.dart' as zkool_pay; +import 'package:zkool/src/rust/api/network.dart' as zkool_network; +import 'package:zkool/src/rust/api/sweep.dart' as zkool_sweep; +import 'package:zkool/src/rust/pay.dart' as zkool_paydart; class ZcashTaddressRotation { - ZcashTaddressRotation(this.accountIndex); - final int accountIndex; - static bool _isStarted = false; + static zkool_coin.Coin get c => ZcashWalletBase.c; + static const int _sweepThreshold = 30000; + static const int _lookahead = 5; + // Matches transparentLimit in ZcashWalletBase._oneshotSync. + static const int _transparentSyncLimit = 100; - static const coin = ZcashWalletBase.coin; - static String seedForOffset(final String seed) { - final seedWords = seed.split(" "); + static zkool_account.Seed seedForOffset(final zkool_account.Seed seed) { + final seedStr = "${seed.mnemonic} ${seed.phrase.replaceAll(" ", "_")}".trim(); + final seedWords = seedStr.split(" "); if ([12, 24].contains(seedWords.length)) { seedWords.add(""); } final lastI = seedWords.length - 1; - final crc = CRC32.compute(seed); - seedWords[lastI] += ":tgen:${crc}"; - return seedWords.join(" "); + final crc = CRC32.compute(seedStr); + seedWords[lastI] += ":tgen:$crc"; + final phrase = seedWords.removeLast(); + return zkool_account.Seed(mnemonic: seedWords.join(" "), phrase: phrase, aindex: 0); } - static bool isSeedForWallet(final String? mainWallet, final String? subWallet) { + static bool zkoolAccountSeedIsEqual(final zkool_account.Seed a, final zkool_account.Seed b) { + return a.mnemonic == b.mnemonic && a.phrase == b.phrase; + } + + static bool isSeedForWallet( + final zkool_account.Seed? mainWallet, + final zkool_account.Seed? subWallet, + ) { if (mainWallet == null || subWallet == null) return false; - return seedForOffset(mainWallet.trim()) == subWallet; + return zkoolAccountSeedIsEqual(seedForOffset(mainWallet), subWallet); + } + + static Map> shieldedAccountsTx = {}; + static final Map _rotationAccountByMain = {}; + static final Set _resyncedRotationAccounts = {}; + static final Set _poolRescanAttempted = {}; + static bool _sweepJobRunning = false; + static final Map _lastSweepBroadcastAt = {}; + + static bool _poolNeedsRescan(final List slots) { + if (slots.isEmpty) { + return false; + } + final unused = slots.where((final e) => e.txCount == 0); + if (unused.isEmpty) { + return false; + } + final head = unused.first; + final tail = slots.last.dindex; + if (slots.any((final e) => e.dindex > head.dindex && e.txCount > 0)) { + return true; + } + return head.dindex + _transparentSyncLimit <= tail; + } + + static Future _scanTransparentChain(final int rotationAccountId) async { + await ZcashWalletBase.runWithCoin( + accountId: rotationAccountId, + func: (final coin) async { + final all = await zkool_account.fetchAddressTxCount( + c: coin, + aggregate: false, + poolFilter: 1, + ); + final external = all.where((final e) => e.scope == 0).toList(); + if (external.isEmpty) { + return; + } + final maxD = external.map((final e) => e.dindex).reduce(max); + final scanner = await zkool_sweep.TransparentScanner.newInstance(); + final height = await zkool_network.getCurrentHeight(c: coin); + final completer = Completer(); + final sub = scanner + .run(endHeight: height, gapLimit: maxD + _lookahead + 1, c: coin) + .listen( + (_) {}, + onError: (final e) { + printV("transparent chain scan: $e"); + if (!completer.isCompleted) { + completer.complete(); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.complete(); + } + }, + ); + await Future.any([ + completer.future, + Future.delayed(Duration(seconds: (maxD + 2).clamp(8, 45))), + ]); + await sub.cancel(); + }, + ); + } + + static Future _resyncRotationPool( + final int rotationAccountId, + final int transparentLimit, + ) async { + await ZcashWalletBase.runWithCoin( + accountId: rotationAccountId, + func: (final coin) async { + await zkool_sync.cancelSync(); + await zkool_account.resetSync(id: rotationAccountId, c: coin); + final height = await zkool_network.getCurrentHeight(c: coin); + final sync = zkool_sync.synchronize( + accounts: [rotationAccountId], + currentHeight: height, + actionsPerSync: 10000, + transparentLimit: transparentLimit, + checkpointAge: 200, + c: coin, + fast: false, + ); + final completer = Completer(); + sync.listen( + (_) {}, + onError: (final e) { + printV("rotation pool resync error: $e"); + if (!completer.isCompleted) { + completer.complete(); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.complete(); + } + }, + ); + await completer.future; + }, + ); + } + + static Future _ensurePoolScanned(final int rotationAccountId) async { + var slots = await _transparentSlots(rotationAccountId); + final firstSession = _resyncedRotationAccounts.add(rotationAccountId); + if (!firstSession && !_poolNeedsRescan(slots)) { + return; + } + if (!firstSession) { + final head = slots.where((final e) => e.txCount == 0).firstOrNull?.dindex; + final key = '$rotationAccountId:$head'; + if (_poolRescanAttempted.contains(key)) { + return; + } + _poolRescanAttempted.add(key); + } + await _ensureLookahead(rotationAccountId); + await _scanTransparentChain(rotationAccountId); + slots = await _transparentSlots(rotationAccountId); + final limit = slots.isEmpty ? _transparentSyncLimit : slots.last.dindex + 1; + printV("rotation pool resync account $rotationAccountId limit $limit"); + await _resyncRotationPool(rotationAccountId, limit); } - static Map> rotationAccounts = {}; - static Map> rotationAccountsUsable = {}; - static Map> shieldedAccountsTx = {}; static Future init() async { - printV("Deserializing previous state"); if (_isStarted) { return; } _isStarted = true; - final pfw = await pathForWalletTypeDir(type: WalletType.zcash); - try { - final f = File(p.join(pfw, "zec-taddr.json")); - if (!f.existsSync()) { - f.writeAsBytesSync(serialize()); - } - deserialize(f.readAsBytesSync()); - } catch (e) { - printV("Failed to deserialize T address DB for zcash, it doesn't really matter: $e"); - } - unawaited( (() async { - await Future.delayed(Duration(seconds: 5)); + await Future.delayed(const Duration(seconds: 2)); return _jobRunner(); })(), ); } - static Future serializeToFile() async { - final pfw = await pathForWalletTypeDir(type: WalletType.zcash); - final f = File(p.join(pfw, "zec-taddr.json")); - f.writeAsBytesSync(serialize()); - } - - static Uint8List serialize() { - final data = { - "rotationAccounts": rotationAccounts.map( - (final k, final v) => MapEntry( - k.toRadixString(16), - v.map((final a) => flatBuffersPack(a.unpack().pack)).toList(), - ), - ), - if (kDebugMode) - "_rotationAccounts": rotationAccounts.map( - (final k, final v) => - MapEntry(k.toRadixString(16), v.map((final a) => a.toString()).toList()), - ), - "rotationAccountsUsable": rotationAccountsUsable.map( - (final k, final v) => MapEntry( - k.toRadixString(16), - v.map((final a) => flatBuffersPack(a.unpack().pack)).toList(), - ), - ), - if (kDebugMode) - "_rotationAccountsUsable": rotationAccountsUsable.map( - (final k, final v) => - MapEntry(k.toRadixString(16), v.map((final a) => a.toString()).toList()), - ), - "shieldedAccountsTx": shieldedAccountsTx.map( - (final k, final v) => MapEntry( - k.toRadixString(16), - v.map((final a) => flatBuffersPack(a.unpack().pack)).toList(), - ), - ), - if (kDebugMode) - "_shieldedAccountsTx": shieldedAccountsTx.map( - (final k, final v) => - MapEntry(k.toRadixString(16), v.map((final a) => a.toString()).toList()), - ), - }; - - if (kDebugMode) { - return utf8.encode(JsonEncoder.withIndent(" ").convert(data)); + static Future getRotationAccountForMainAccount(final int mainAccountId) async { + final cached = _rotationAccountByMain[mainAccountId]; + if (cached != null) { + return cached; } - return utf8.encode(jsonEncode(data)); - } - - static String flatBuffersPack(final int Function(fb.Builder fbBuilder) pack) { - final fbBuilder = fb.Builder(); - final offset = pack(fbBuilder); - fbBuilder.finish(offset); - return base64.encode(fbBuilder.buffer); - } - - static void deserialize(final Uint8List bytes) { - // todo: replace keys with sums and reverse in serialize - // final List seeds = []; - // for (int i = 0; i < accounts.length; i++) { - // final acc = accounts[i]; - // final backup = WarpApi.getBackup(coin, acc.id); - // await WarpApi.transparentSync(coin, acc.id, syncHeight); - // seeds.add(backup.seed!); - // } try { - final Map data = jsonDecode(utf8.decode(bytes)); - rotationAccounts = (data["rotationAccounts"] as Map).map( - (final k, final v) => MapEntry( - int.parse(k, radix: 16), - (v as List).map((final a) => Account(atob(a))).toList(), - ), + await ZcashWalletBase.runWithCoinMutex.acquire(); + final wSeed = await zkool_account.getAccountSeed(c: c, account: mainAccountId); + if (wSeed == null) { + printV("Not running Taddr rotation - seed not found"); + return null; + } + final seed = seedForOffset(wSeed); + final accs = await zkool_account.listAccounts(c: c); + for (int i = 0; i < accs.length; i++) { + final accSeed = await zkool_account.getAccountSeed(c: c, account: accs[i].id); + if (accSeed?.mnemonic == seed.mnemonic && + accSeed?.phrase == seed.phrase && + accSeed?.aindex == seed.aindex) { + _rotationAccountByMain[mainAccountId] = accs[i].id; + return accs[i].id; + } + } + + zkool_account.Account? acc; + for (final a in accs) { + if (a.id == mainAccountId) { + acc = a; + break; + } + } + if (acc == null) { + printV("Main account $mainAccountId not found for Taddr rotation"); + return null; + } + + final name = CRC32.compute(wSeed.mnemonic).toString(); + final id = await newAccount( + name: name, + seed: seed.mnemonic, + passphrase: seed.phrase, + height: acc.birth, + aindex: seed.aindex, ); - rotationAccountsUsable = (data["rotationAccountsUsable"] as Map).map( - (final k, final v) => MapEntry( - int.parse(k, radix: 16), - (v as List).map((final a) => Account(atob(a))).toList(), - ), - ); - shieldedAccountsTx = (data["shieldedAccountsTx"] as Map).map( - (final k, final v) => MapEntry( - int.parse(k, radix: 16), - (v as List).map((final a) => ShieldedTx(atob(a))).toList(), - ), - ); - } catch (e) { - rethrow; + _rotationAccountByMain[mainAccountId] = id; + return id; + } finally { + ZcashWalletBase.runWithCoinMutex.release(); } } - static Uint8List atob(final String value) => - Uint8List.fromList(List.from(base64.decode(value))); + // External (scope 0) transparent slots for the rotation account, ordered by + // dindex. zkool derives every slot in 0..=dindex, so unused ones (txCount==0) + // form the disposable look-ahead pool. + static Future> _transparentSlots( + final int rotationAccountId, + ) async { + final all = await ZcashWalletBase.runWithCoin( + accountId: rotationAccountId, + func: (final coin) => + zkool_account.fetchAddressTxCount(c: coin, aggregate: false, poolFilter: 1), + ); + return all.where((final e) => e.scope == 0).toList() + ..sort((final a, final b) => a.dindex.compareTo(b.dindex)); + } + + // Keep _lookahead unused disposable addresses ready, generating more whenever + // the pool runs low. Newly generated addresses are scanned by ongoing sync + // (the latest addresses are always in the scan window), so no resync needed. + static Future _ensureLookahead(final int rotationAccountId) async { + final slots = await _transparentSlots(rotationAccountId); + final unused = slots.where((final e) => e.txCount == 0).length; + final toGenerate = _lookahead - unused; + for (var i = 0; i < toGenerate; i++) { + await ZcashWalletBase.runWithCoin( + accountId: rotationAccountId, + func: (final coin) => zkool_account.generateNextDindex(c: coin), + ); + } + if (toGenerate > 0) { + printV("topped up disposable taddr pool by $toGenerate (had $unused)"); + } + } + + static bool _isWalletSynced(final int mainAccountId) { + final wallet = ZcashWalletBase.walletsByAccountId[mainAccountId]; + return wallet != null && wallet.syncStatus is SyncedSyncStatus; + } + + static Future _rotationTransparentBalance(final int rotationAccount) async { + return ZcashWalletBase.runWithCoin( + accountId: rotationAccount, + func: (final coin) async { + final b = await zkool_sync.balance(c: coin); + return b.field0.elementAt(0); + }, + ); + } + + static Future> _loadTxHistory(final int accountId) async { + return ZcashWalletBase.runWithCoin( + accountId: accountId, + func: (final coin) async { + final txsI = await zkool_account.listTxHistory(c: coin); + final txs = []; + for (int i = 0; i < txsI.length; i++) { + final tx = txsI[i]; + try { + final details = await zkool_account.getTxDetails(idTx: tx.id, c: coin); + txs.add(ZkoolTx(tx, details)); + } catch (e) { + printV("getTxDetails skipped for tx ${tx.id}: $e"); + } + } + return txs; + }, + ); + } + + static bool _canBroadcastSweep(final int mainAccountId) { + final lastSweep = _lastSweepBroadcastAt[mainAccountId]; + if (lastSweep == null) { + return true; + } + return !lastSweep.isAfter(DateTime.now().subtract(const Duration(seconds: 120))); + } static Future createAndSweepTAddresses() async { - int chainHeight = 0; - try { - chainHeight = await WarpApi.getLatestHeight(coin); - } catch (e) { - printV("Error getting latest height: $e"); - } - final dbHeight = WarpApi.getDbHeight(coin); - final height = dbHeight.unpack(); - final syncHeight = height.height; - if (chainHeight != syncHeight) { - printV( - "Waiting for sync to finish: chainHeight(${chainHeight}) != syncHeight(${syncHeight})", - ); + if (_sweepJobRunning) { return; } - final Map> newShieldedAccountsTx = {}; - final accounts = WarpApi.getAccountList(coin); - if (accounts.isEmpty) return; - final Map seeds = {}; - for (int i = 0; i < accounts.length; i++) { - final acc = accounts[i]; - final backup = WarpApi.getBackup(coin, acc.id); - await WarpApi.transparentSync(coin, acc.id, syncHeight); - if (backup.seed == null) continue; - seeds[acc.id] = backup.seed!; - } - for (int i = 0; i < accounts.length; i++) { - final seed = seeds[accounts[i].id]!; - if ([12, 13, 24, 25].contains(seed.split(" ").length)) { - if (seed.split(" ").last.contains(":tgen:")) continue; - rotationAccounts[accounts[i].id] = []; + _sweepJobRunning = true; + try { + final mainAccountIds = ZcashWalletBase.walletsByAccountId.keys.toList(); + if (mainAccountIds.isEmpty) { + await createAndSweepTAddressesForAccount(c.account); + return; } - } - final raKeys = rotationAccounts.keys.toList(); - seeds.removeWhere((_, final val) => raKeys.contains(val)); - printV("raKeys: ${raKeys.length}"); - for (int i = 0; i < raKeys.length; i++) { - final accs = accounts.where((final a) { - final seed = WarpApi.getBackup(coin, a.id).seed; - return isSeedForWallet(seeds[raKeys[i]], seed); - }).toList(); - rotationAccounts[raKeys[i]]!.addAll(accs); - final acc = accountForSeed(seeds[raKeys[i]]!)!; - final txs = WarpApi.getTxsSync(coin, acc.id); - newShieldedAccountsTx[acc.id] ??= []; - newShieldedAccountsTx[acc.id]!.addAll(txs); - } - rotationAccountsUsable = rotationAccounts.map((final k, final v) => MapEntry(k, v.toList())); - printV("rotationAccounts: ${rotationAccounts.length}"); - - for (int i = 0; i < raKeys.length; i++) { - final acc = accountForSeed(seeds[raKeys[i]]!)!; - newShieldedAccountsTx[acc.id] = []; - for (int j = 0; j < (rotationAccounts[raKeys[i]]?.length ?? 0); j++) { - final txs = WarpApi.getTxsSync(coin, rotationAccounts[raKeys[i]]![j].id); - rotationAccounts[raKeys[i]]![j]; - newShieldedAccountsTx[acc.id]!.addAll(txs); - } - rotationAccountsUsable[raKeys[i]]!.removeWhere((final a) { - final txs = WarpApi.getTxsSync(coin, a.id); - return txs.isNotEmpty; - }); - - // remove hidden addresses - final wis = await WalletInfo.selectList('type = ?', [WalletType.zcash.index]); - - final List hiddenAddresses_ = []; - for (int k = 0; k < wis.length; k++) { - final addrs = await wis[k].getHiddenAddresses(); - final addr2 = await wis[k].getUsedAddresses(); - hiddenAddresses_.addAll(addrs); - hiddenAddresses_.addAll(addr2); - } - final Set hiddenAddresses = hiddenAddresses_.toSet(); - rotationAccountsUsable[raKeys[i]]!.removeWhere((final a) { - return hiddenAddresses.contains(WarpApi.getTAddr(coin, a.id)); - }); - } - printV("rotationAccountsUsable: ${rotationAccountsUsable.length}"); - - bool didAddNewAccount = false; - for (int i = 0; i < raKeys.length; i++) { - if (rotationAccountsUsable[raKeys[i]]!.length < 5) { - final seed = seedForOffset(seeds[raKeys[i]]!); - final name = CRC32.compute(raKeys[i].toString()).toString(); - final id = await ZcashWalletService.runInDbMutex( - () => WarpApi.newAccount(coin, name, seed, rotationAccounts[raKeys[i]]!.length), - ); - printV("new id: $id / $seed"); - printV("${rotationAccounts[raKeys[i]]}"); - printV(raKeys[i]); - rotationAccounts[raKeys[i]]!.forEach((final a) { - final b = WarpApi.getBackup(coin, a.id); - printV("${a.id}"); - }); - didAddNewAccount = true; - } - } - printV("didAddNewAccount: ${didAddNewAccount}"); - if (didAddNewAccount) { - return createAndSweepTAddresses(); - } - - for (var i = 0; i < raKeys.length; i++) { - final accs = rotationAccounts[raKeys[i]]!; - inner: - for (var j = 0; j < accs.length; j++) { - int bal = 0; + for (final mainAccountId in mainAccountIds) { try { - bal += await WarpApi.getTBalance(coin, accs[j].id); - } catch (e) { - printV("getTBalance: $e"); - return; + await createAndSweepTAddressesForAccount(mainAccountId); + } catch (e, s) { + printV("createAndSweepTAddresses for $mainAccountId error: $e"); + s.toString().split("\n").forEach(printV); } - if (bal < 30000) continue inner; - final to = accountForSeed(seeds[raKeys[i]]!)!; - // final toAddress = WarpApi.getTAddr(coin, to.id); - // final fromSeed = WarpApi.getBackup(coin, to.id); - // final txId = await ZcashWalletService.runInDbMutex( - // () => WarpApi.sweepTransparentSeed( - // to.coin, - // to.id, - // chainHeight, - // fromSeed.seed!, - // 7, - // toAddress, - // 0, - // 1, - // FeeT(fee: 10000, minFee: 0, maxFee: 0, scheme: 0), - // ), - // ); - final recipientBuilder = RecipientObjectBuilder( - address: WarpApi.getAddress(coin, to.id, 4), - pools: 4, - feeIncluded: true, - amount: bal, - ); + } + } finally { + _sweepJobRunning = false; + } + } - final recipient = Recipient(recipientBuilder.toBytes()); - final fee = FeeT(fee: 10000, minFee: 0, maxFee: 0, scheme: 0); - await ZcashWalletBase.loadProver(); - final txPlan = await ZcashWalletService.runInDbMutex( - () => WarpApi.prepareTx( - coin, - accs[j].id, - [recipient], - 3, // pools: (Transparent + Sapling) - 1, - 0, // anchorOffset - fee, + static Future createAndSweepTAddressesForAccount(final int cId) async { + if (!_isWalletSynced(cId)) { + printV("rotation sweep waiting for wallet sync (account $cId)"); + return; + } + + printV("createAndSweepTAddresses for account $cId"); + + final rotationAccount = await getRotationAccountForMainAccount(cId); + if (rotationAccount == null) { + printV("rotation account is null, bailing out"); + return; + } + + await _ensureLookahead(rotationAccount); + await _ensurePoolScanned(rotationAccount); + await _refreshWalletAddresses(cId); + + BigInt transparentBal = BigInt.zero; + try { + transparentBal = await _rotationTransparentBalance(rotationAccount); + printV("rotation account $rotationAccount transparent balance: $transparentBal"); + } catch (e) { + printV("getTBalance: $e"); + return; + } + + if (transparentBal < BigInt.from(_sweepThreshold)) { + await updateCache(mainAccountId: cId); + await _refreshWalletAfterRotationChange(cId); + return; + } + + if (!_canBroadcastSweep(cId)) { + printV("rotation sweep rate-limited for account $cId"); + await updateCache(mainAccountId: cId); + await _refreshWalletAfterRotationChange(cId); + return; + } + + final toAddress = await ZcashWalletBase.runWithCoin( + accountId: cId, + func: (final coin) async { + final addrs = await zkool_account.getAddresses(c: coin, uaPools: 7); + final orchard = addrs.oaddr; + if (orchard == null || orchard.isEmpty) { + throw Exception('Orchard address unavailable for rotation sweep'); + } + return orchard; + }, + ); + final result = await ZcashWalletBase.runWithCoin( + accountId: rotationAccount, + func: (final coin) async { + final tx = await zkool_pay.prepare( + recipients: [ + zkool_paydart.Recipient(assetBase: zecBase, address: toAddress, amount: transparentBal), + ], + options: zkool_pay.PaymentOptions( + srcPools: 1, + recipientPaysFee: true, + smartTransparent: false, ), + c: coin, ); - final _txId = await ZcashWalletService.runInDbMutex( - () => WarpApi.signAndBroadcast(ZcashWalletBase.coin, accs[j].id, txPlan), - ); - await ZcashWalletService.addShieldedTx(_txId); - printV("shielded: $_txId"); + final height = await zkool_network.getCurrentHeight(c: coin); + final signTx = await zkool_pay.signTransaction(pczt: tx, c: coin); + final txBytes = await zkool_pay.extractTransaction(package: signTx); + return zkool_pay.broadcastTransaction(height: height, txBytes: txBytes, c: coin); + }, + ); + printV("rotation sweep broadcast: $result"); + if (result.isEmpty) { + throw Exception("rotation sweep broadcast failed"); + } - await Future.delayed(Duration(seconds: 120)); // let it be - return createAndSweepTAddresses(); - } - } - final nsatKeys = newShieldedAccountsTx.keys.toList(); - for (int i = 0; i < nsatKeys.length; i++) { - inner: - for (int j = 0; j < newShieldedAccountsTx[nsatKeys[i]]!.length; j++) { - final tx = newShieldedAccountsTx[nsatKeys[i]]?[j]; - if (tx == null) { - continue inner; - } - final _txId = tx.txId; - if (_txId == null) { - continue inner; - } - if (tx.value < 0) { - await ZcashWalletService.addShieldedTx(_txId); - // continue inner; - } - if (ZcashWalletService.autoshieldTx.contains(_txId)) { - continue inner; - } - } - } - shieldedAccountsTx = newShieldedAccountsTx.map((final k, final v) => MapEntry(k, v.toList())); - await serializeToFile(); - return; + await ZcashWalletService.addShieldedTx(result); + _lastSweepBroadcastAt[cId] = DateTime.now(); + await updateCache(mainAccountId: cId); + await _refreshWalletAfterRotationChange(cId); } - static ShieldedTx reverseAmount(final ShieldedTx t) { - final tt = t.unpack(); - tt.value = -tt.value; - return ShieldedTx(base64.decode(flatBuffersPack(tt.pack))); + static Future _refreshWalletAfterRotationChange(final int mainAccountId) async { + final wallet = ZcashWalletBase.walletsByAccountId[mainAccountId]; + if (wallet == null) { + return; + } + unawaited(wallet.updateTransactions()); + unawaited(wallet.updateBalance()); } - static Account? accountForSeed(final String seed) { - final accounts = WarpApi.getAccountList(coin); + static Future updateCache({required final int mainAccountId}) async { + final rotationAccount = await getRotationAccountForMainAccount(mainAccountId); + if (rotationAccount == null) { + printV("rotationAccount is null"); + return; + } + await _ensureLookahead(rotationAccount); + await _ensurePoolScanned(rotationAccount); + final txs = await _loadTxHistory(rotationAccount); + shieldedAccountsTx[mainAccountId] = txs; + if (mainAccountId != rotationAccount && shieldedAccountsTx.containsKey(rotationAccount)) { + shieldedAccountsTx.remove(rotationAccount); + } + + for (final tx in shieldedAccountsTx[mainAccountId]!) { + if (tx.direction == TransactionDirection.outgoing) { + await ZcashWalletService.addShieldedTx(tx.txHash); + } + } + await _refreshWalletAddresses(mainAccountId); + await _refreshWalletAfterRotationChange(mainAccountId); + } + + static Future _refreshWalletAddresses(final int mainAccountId) async { + final wallet = ZcashWalletBase.walletsByAccountId[mainAccountId]; + if (wallet == null) { + return; + } + try { + await wallet.walletAddresses.refreshRotationAddresses(); + } catch (e) { + printV("rotation address refresh: $e"); + } + } + + static List rotationTxsForMainAccount(final int mainAccountId) { + return shieldedAccountsTx[mainAccountId] ?? const []; + } + + static Future accountForSeed(final zkool_account.Seed seed) async { + final accounts = await zkool_account.listAccounts(c: c); if (accounts.isEmpty) return null; for (int i = 0; i < accounts.length; i++) { final acc = accounts[i]; - final backup = WarpApi.getBackup(coin, acc.id); - if (backup.seed == seed) { + final backup = await zkool_account.getAccountSeed(c: c, account: acc.id); + if (backup?.mnemonic == seed.mnemonic && backup?.phrase == seed.phrase) { return acc; } } @@ -383,63 +499,76 @@ class ZcashTaddressRotation { static Future _jobRunner() async { for (;;) { try { - await Future.delayed(Duration(seconds: 5)); + await Future.delayed(const Duration(seconds: 5)); await createAndSweepTAddresses(); } catch (e, s) { - printV(e); + printV("createAndSweepTAddresses error: $e"); s.toString().split("\n").forEach(printV); - rethrow; } finally { - await Future.delayed(Duration(seconds: 30)); + await Future.delayed(const Duration(seconds: 30)); } } } - static String? addressForAccount(final int accountId, final Set? hiddenAddresses) { - final accs = rotationAccountsUsable[accountId] ?? []; - for (int i = 0; i < accs.length; i++) { - final addr = WarpApi.getTAddr(coin, accs[i].id); - if (hiddenAddresses?.contains(addr) == true) { - continue; - } - return addr; - } - return null; - } - - static List? allAddressesForAccount(final int accountId) { - final acc = rotationAccounts[accountId]; - if (acc == null) { - printV("Nothing found"); + // Current disposable address = first unused slot (lowest dindex). + static Future addressForAccount(final int accountId) async { + final rotationAccount = await getRotationAccountForMainAccount(accountId); + if (rotationAccount == null) { return null; } - for (int i = 0; i < acc.length; i++) { - final b = WarpApi.getBackup(coin, acc[i].id); - printV("$i. ${b.seed?.split(" ").last}, ${b.index}, ${WarpApi.getTAddr(coin, acc[i].id)}"); + final slots = await _transparentSlots(rotationAccount); + final unused = slots.where((final e) => e.txCount == 0); + if (unused.isNotEmpty) { + return unused.first.address; } - return acc.map((final a) => WarpApi.getTAddr(coin, a.id)).toList(); + return slots.isNotEmpty ? slots.last.address : null; } - static List? allUsedAddressesForAccount(final int accountId) { - final seed = WarpApi.getBackup(coin, accountId).seed; - if (seed == null) return []; - final acc = rotationAccounts[seed]?.toList(); - if (acc == null) { - printV("Nothing found"); - return null; + static Future> usedAddressesForAccount(final int accountId) async { + final rotationAccount = await getRotationAccountForMainAccount(accountId); + if (rotationAccount == null) { + return const []; } - acc.removeWhere((final a1) { - for (int i = 0; i < (rotationAccountsUsable[seed]?.length ?? 0); i++) { - if (rotationAccountsUsable[seed]?[i].id == a1.id) { - return true; - } - } - return false; - }); - for (int i = 0; i < acc.length; i++) { - final b = WarpApi.getBackup(coin, acc[i].id); - printV("$i. ${b.seed?.split(" ").last}, ${b.index}, ${WarpApi.getTAddr(coin, acc[i].id)}"); + final slots = await _transparentSlots(rotationAccount); + return [ + for (final entry in slots) + if (entry.txCount > 0) entry.address, + ]; + } + + // The full disposable pool: used slots plus the unused look-ahead window. + static Future> allAddressesForAccount(final int accountId) async { + final rotationAccount = await getRotationAccountForMainAccount(accountId); + if (rotationAccount == null) { + return const []; } - return acc.map((final a) => WarpApi.getTAddr(coin, a.id)).toList(); + final slots = await _transparentSlots(rotationAccount); + return slots.map((final e) => e.address).toList(); + } + + static Future newAccount({ + required final String name, + required final String seed, + required final String passphrase, + required final int height, + required final int aindex, + }) async { + final id = await zkool_account.newAccount( + na: zkool_account.NewAccount( + name: name, + restore: true, + passphrase: passphrase, + key: seed, + aindex: aindex, + birth: height, + folder: '', + pools: 1, + useInternal: true, + internal: false, + ledger: false, + ), + c: c, + ); + return id; } } diff --git a/cw_zcash/lib/src/zcash_transaction_info.dart b/cw_zcash/lib/src/zcash_transaction_info.dart index d307cafa7..9dd09bdda 100644 --- a/cw_zcash/lib/src/zcash_transaction_info.dart +++ b/cw_zcash/lib/src/zcash_transaction_info.dart @@ -1,15 +1,9 @@ -import 'dart:convert'; -import 'dart:io'; - import 'package:cw_core/amount/money.dart'; -import 'package:cw_core/pathForWallet.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/format_amount.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/wallet_type.dart'; import 'package:cw_zcash/cw_zcash.dart'; -import 'package:path/path.dart' as p; +import 'package:cw_zcash/src/zkooltx.dart'; class ZcashTransactionInfo extends TransactionInfo { ZcashTransactionInfo({ @@ -23,6 +17,9 @@ class ZcashTransactionInfo extends TransactionInfo { required final int confirmations, required final String to, final String? memo, + final TxType? txType, + final bool isRotationReceive = false, + final bool isShieldAction = false, }) { this.id = id; this.amount = amount; @@ -32,23 +29,21 @@ class ZcashTransactionInfo extends TransactionInfo { this.date = date; this.isPending = isPending; this.confirmations = confirmations; - this.to = getCachedDestinationAddress(id); + this.to = to; if (memo != null && memo.isNotEmpty) { additionalInfo['memo'] = memo; } - additionalInfo['autoShield'] = false; - // note: this won't work yet, fee is not in zcash_lib metadata, - // leaving here so it can start working automagically in future - if (amount == fee && to.startsWith("u")) { - additionalInfo['autoShield'] = true; + if (txType != null) { + additionalInfo['txType'] = txType.name; } - // remove below when above starts working - additionalInfo['autoShield'] = ZcashWalletService.autoshieldTx.contains(txHash); - // --- == === cut here === == --- - if (additionalInfo['autoShield'] == true) { + additionalInfo['isRotationReceive'] = isRotationReceive; + additionalInfo['isAutoShield'] = isShieldAction || ZcashWalletService.isAutoshieldTx(txHash); + + if (additionalInfo['isAutoShield'] == true) { additionalInfo['memo'] ??= ''; - additionalInfo['memo'] += '\This is an auto-shielding transaction. Enjoy default privacy!'; - additionalInfo['memo'] = additionalInfo['memo'].trim(); + additionalInfo['memo'] = + '${additionalInfo['memo']}\nThis is an auto-shielding transaction. Enjoy default privacy!.' + .trim(); } } @@ -61,35 +56,4 @@ class ZcashTransactionInfo extends TransactionInfo { void changeFiatAmount(final String amount) => _fiatAmount = formatAmount(amount); String? get memo => additionalInfo['memo'] as String?; - - static final Map _destinationAddressMap = {}; - - static String? getCachedDestinationAddress(final String txId) { - return _destinationAddressMap[txId] ?? - _destinationAddressMap['"$txId"'] ?? - _destinationAddressMap[txId.replaceAll('"', '')]; - } - - static Future addCachedDestinationAddress(final String txId, final String address) async { - _destinationAddressMap[txId] = address; - final pfwt = await pathForWalletTypeDir(type: WalletType.zcash); - final f = File(p.join(pfwt, "sent-tx-map.json")); - f.writeAsStringSync(json.encode(_destinationAddressMap)); - } - - static Future init() async { - try { - final pfwt = await pathForWalletTypeDir(type: WalletType.zcash); - final f = File(p.join(pfwt, "sent-tx-map.json")); - if (!f.existsSync()) { - f.writeAsStringSync('{}'); - } - final tmpMap = json.decode(f.readAsStringSync()); - tmpMap.forEach((final k, final v) { - _destinationAddressMap[k.toString()] = v.toString(); - }); - } catch (e, s) { - printV("failed to deserialize: $e, $s"); - } - } } diff --git a/cw_zcash/lib/src/zcash_wallet.dart b/cw_zcash/lib/src/zcash_wallet.dart index d255ae7fb..e1258e5e3 100644 --- a/cw_zcash/lib/src/zcash_wallet.dart +++ b/cw_zcash/lib/src/zcash_wallet.dart @@ -1,9 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/get_height_by_date_zec.dart'; import 'package:cw_core/monero_transaction_priority.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/pathForWallet.dart'; @@ -12,26 +13,28 @@ import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/utils/print_verbose.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; -import 'package:cw_core/wallet_addresses.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_credentials.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:bip39/bip39.dart' as bip39; import 'package:cw_zcash/cw_zcash.dart'; -import 'package:cw_zcash/src/legacy/zkool_sweep.dart'; import 'package:cw_zcash/src/util/crc32.dart'; +import 'package:cw_zcash/src/zcash_mempool.dart'; import 'package:cw_zcash/src/zcash_taddress_rotation.dart'; import 'package:cw_zcash/src/zcash_wallet_addresses.dart'; -import 'package:flutter/foundation.dart'; +import 'package:cw_zcash/src/zkool_compat.dart'; +import 'package:cw_zcash/src/zkooltx.dart'; import 'package:mobx/mobx.dart'; -import 'package:warp_api/warp_api.dart'; -import 'package:warp_api/data_fb_generated.dart'; -import 'package:flutter/services.dart'; import 'package:mutex/mutex.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; +import 'package:zkool/src/rust/api/account.dart' as zkool_account; +import 'package:zkool/src/rust/api/coin.dart' as zkool_coin; +import 'package:zkool/src/rust/api/mempool.dart' as zkool_mempool; +import 'package:zkool/src/rust/api/sync.dart' as zkool_sync; +import 'package:zkool/src/rust/api/pay.dart' as zkool_pay; +import 'package:zkool/src/rust/api/network.dart' as zkool_network; +import 'package:zkool/src/rust/pay.dart' as zkool_paydart; +import 'package:zkool/src/rust/frb_generated.dart' as zkool_frb; part 'zcash_wallet.g.dart'; @@ -42,24 +45,51 @@ abstract class ZcashWalletBase with Store { ZcashWalletBase(super.walletInfo, super.derivationInfo, {required this.accountId}) { transactionHistory = ZcashTransactionHistory(); + walletsByAccountId[accountId] = this; } - final int accountId; - static const int coin = 0; // Zcash mainnet coin ID in warp_api + static final Map walletsByAccountId = {}; + + static Future refreshWalletForAccount(final int accountId) async { + final wallet = walletsByAccountId[accountId]; + if (wallet == null) { + return; + } + await wallet.updateTransactions(); + await wallet.updateBalance(); + } + + int accountId; + @override @observable SyncStatus syncStatus = NotConnectedSyncStatus(); - Timer? _syncStatusTimer; - Timer? _periodicSyncTimer; - int _initialSyncHeight = 0; - int _lastKnownBlockHeight = 0; - @override ObservableMap balance = ObservableMap.of({ CryptoCurrency.zec: ZcashBalance.zero(), }); + static const int _autoShieldMinSweep = 30000; + + Money _feeFromTxPlan( + final zkool_pay.PcztPackage txPlan, + final TransactionPriority priority, + final int tryReduceFeeAmount, { + final zkool_coin.Coin? coin, + }) { + try { + return Money(zkool_pay.toPlan(package: txPlan, c: coin ?? c).fee, currency); + } catch (_) { + return Money.fromInt( + tryReduceFeeAmount != 0 + ? tryReduceFeeAmount + : internalCalculateEstimatedFee(priority, null), + currency, + ); + } + } + static int internalCalculateEstimatedFee(final TransactionPriority priority, final int? amount) { const baseFee = 10000; switch (priority) { @@ -96,9 +126,8 @@ abstract class ZcashWalletBase @override Future close({final bool shouldCleanup = false}) async { - _stopSyncStatusUpdates(); - _stopPeriodicSync(); - await ZcashWalletService.runInDbMutex(() async => WarpApi.cancelSync()); + _syncLoopRunning = false; + walletsByAccountId.remove(accountId); } Node? lastNode; @@ -115,14 +144,12 @@ abstract class ZcashWalletBase lwdUrl = '$protocol$lwdUrl'; } printV("Setting LWD URL to: $lwdUrl"); - WarpApi.updateLWD(coin, lwdUrl); + c = c.setLwd(url: lwdUrl, serverType: 0); syncStatus = ConnectedSyncStatus(); - try { - await updateBalance(); - await updateTransactions(); - } catch (e) { - printV("Error updating balance/transactions after connect: $e"); - } + unawaited(ZcashMempoolService.instance.ensureRunning(c)); + _ensureSyncLoopRunning(); + unawaited(_refreshSyncStatus()); + unawaited(_oneshotSync()); } catch (e) { printV("Connection error: $e"); syncStatus = FailedSyncStatus(error: e.toString()); @@ -130,261 +157,844 @@ abstract class ZcashWalletBase } } + bool _syncLoopRunning = false; + + void _ensureSyncLoopRunning() { + if (_syncLoopRunning) { + return; + } + _syncLoopRunning = true; + unawaited(_runSyncLoop()); + } + + static const _syncedPollInterval = Duration(seconds: 5); + static const _activePollInterval = Duration(seconds: 1); + + Future _runSyncLoop() async { + var pollInterval = _activePollInterval; + while (_syncLoopRunning) { + await Future.delayed(pollInterval); + try { + final alreadySynced = await _oneshotSync(); + pollInterval = alreadySynced ? _syncedPollInterval : _activePollInterval; + } catch (e) { + printV("zcash sync failed: $e"); + pollInterval = _activePollInterval; + } + } + } + + int _syncCheckpointHeight = 0; + + bool get isSyncing { + return _isSyncing; + } + + set isSyncing(final bool value) { + _isSyncing = value; + } + + bool _isSyncing = false; + + static int oneshotSyncCount = 0; + + Future _getLowestSyncHeight() async { + final accounts = await zkool_account.listAccounts(c: c); + var lowest = await _getWalletDbHeight(); + for (final acc in accounts) { + if (acc.id == accountId) continue; + c = await c.setAccount(account: acc.id); + lowest = min(lowest, await _getWalletDbHeight()); + } + c = await c.setAccount(account: accountId); + return lowest; + } + + Future _anyAccountNeedsSync(final int currentHeight) => + withSharedCoinLock(() => _anyAccountNeedsSyncUnlocked(currentHeight)); + + Future _anyAccountNeedsSyncUnlocked(final int currentHeight) async { + final accounts = await zkool_account.listAccounts(c: c); + for (final acc in accounts) { + c = await c.setAccount(account: acc.id); + if (currentHeight > await _getWalletDbHeight()) { + c = await c.setAccount(account: accountId); + return true; + } + } + c = await c.setAccount(account: accountId); + return false; + } + + @action + void _applySyncProgress(final int currentHeight, final int walletHeight) { + final blocksLeft = (currentHeight - walletHeight).clamp(0, currentHeight); + _syncCheckpointHeight = walletHeight; + if (blocksLeft <= 0) { + syncStatus = _isSyncing ? SyncingSyncStatus(1, 0.999) : SyncedSyncStatus(); + return; + } + final ptc = currentHeight > 0 ? (walletHeight / currentHeight).clamp(0.0, 1.0) : 0.0; + syncStatus = SyncingSyncStatus(blocksLeft, ptc); + } + + void _broadcastSyncProgress(final int currentHeight, final int walletHeight) { + runInAction(() { + for (final wallet in walletsByAccountId.values) { + wallet._applySyncProgress(currentHeight, walletHeight); + } + }); + } + + Future _getWalletDbHeight() async { + try { + return (await zkool_sync.getDbHeight(c: c)).height; + } catch (_) { + final accounts = await zkool_account.listAccounts(c: c); + final account = accounts.where((final a) => a.id == accountId).firstOrNull; + if (account != null) { + return account.height; + } + rethrow; + } + } + + void _onSyncCheckpoint(final int currentHeight, final int checkpointHeight) { + final height = checkpointHeight > _syncCheckpointHeight + ? checkpointHeight + : _syncCheckpointHeight; + _broadcastSyncProgress(currentHeight, height); + } + + @action + Future _refreshSyncStatus() async { + try { + await withSharedCoinLock(() async { + c = await c.setAccount(account: accountId); + final currentHeight = await zkool_network.getCurrentHeight(c: c); + final walletDbHeight = await _getWalletDbHeight(); + _broadcastSyncProgress(currentHeight, walletDbHeight); + }); + } catch (e) { + printV("refresh sync status: $e"); + } + } + + @action + Future _oneshotSync() async { + try { + if (isSyncing) { + return syncStatus is SyncedSyncStatus; + } + isSyncing = true; + late final int currentHeight; + late final int walletDbHeight; + await withSharedCoinLock(() async { + c = await c.setAccount(account: accountId); + currentHeight = await zkool_network.getCurrentHeight(c: c); + walletDbHeight = await _getWalletDbHeight(); + }); + if (!await _anyAccountNeedsSync(currentHeight)) { + _syncCheckpointHeight = walletDbHeight; + if (syncStatus is! SyncedSyncStatus) { + syncStatus = SyncedSyncStatus(); + } + isSyncing = false; + return true; + } + await zkool_sync.cancelSync(); + late final List accountList; + late final int lagHeight; + await withSharedCoinLock(() async { + final accounts = await zkool_account.listAccounts(c: c); + accountList = accounts.map((final a) => a.id).toList() + ..removeWhere((final a) => a == c.account); + c = await c.setAccount(account: accountId); + lagHeight = await _getLowestSyncHeight(); + }); + _broadcastSyncProgress(currentHeight, lagHeight); + final sync = zkool_sync.synchronize( + accounts: [c.account, ...accountList], + currentHeight: currentHeight, + actionsPerSync: 10000, + transparentLimit: 100, + checkpointAge: 200, + c: c, + fast: false, + ); + await withSharedCoinLock(() async { + c = await c.setAccount(account: accountId); + }); + final randInt = CRC32.compute("${DateTime.now().microsecondsSinceEpoch}").toRadixString(16); + oneshotSyncCount++; + final completer = Completer(); + var lastLoggedHeight = walletDbHeight; + var chainTip = currentHeight; + late final StreamSubscription subscription; + subscription = sync.listen( + (final syncProgress) { + if (syncProgress.height > chainTip) { + chainTip = syncProgress.height; + } + if (syncProgress.height >= lastLoggedHeight + 5000) { + lastLoggedHeight = syncProgress.height; + printV( + "[${c.account} ($accountList)] [$oneshotSyncCount/$randInt] sync: ${syncProgress.height}", + ); + unawaited( + zkool_network.getCurrentHeight(c: c).then((final tip) { + if (tip > chainTip) { + chainTip = tip; + _onSyncCheckpoint(chainTip, syncProgress.height); + } + }), + ); + } + _onSyncCheckpoint(chainTip, syncProgress.height); + }, + onError: (final e) { + printV("[${c.account} ($accountList)] [$oneshotSyncCount/$randInt] error syncing: $e"); + runInAction(() { + syncStatus = FailedSyncStatus( + error: + e.toString().replaceAll("AnyhowException(", "").split("\n").firstOrNull ?? + "Unknown error", + ); + }); + isSyncing = false; + if (!completer.isCompleted) { + completer.complete(); + } + }, + onDone: () async { + printV("[${c.account} ($accountList)] [$oneshotSyncCount/$randInt] synchronized"); + oneshotSyncCount--; + isSyncing = false; + try { + await withSharedCoinLock(() async { + c = await c.setAccount(account: accountId); + if (await _anyAccountNeedsSyncUnlocked(currentHeight)) { + final lagHeight = await _getLowestSyncHeight(); + _broadcastSyncProgress(currentHeight, lagHeight); + return; + } + runInAction(() { + for (final wallet in walletsByAccountId.values) { + wallet.syncStatus = SyncedSyncStatus(); + } + }); + for (final wallet in walletsByAccountId.values) { + unawaited(wallet.updateBalance()); + unawaited(wallet.updateTransactions()); + unawaited( + ZcashTaddressRotation.updateCache(mainAccountId: wallet.accountId) + .catchError((final e) { + printV("rotation cache refresh: $e"); + }), + ); + } + }); + } catch (e) { + printV("sync done height refresh: $e"); + } + if (!completer.isCompleted) { + completer.complete(); + } + }, + ); + await completer.future; + await subscription.cancel(); + return syncStatus is SyncedSyncStatus; + } catch (e) { + syncStatus = FailedSyncStatus(error: e.toString()); + isSyncing = false; + printV("error syncing: $e"); + return false; + } + } + @override - Future createTransaction(final Object credentials) async { + Future createTransaction(final Object credentials) => + _createTransaction(credentials); + + Future _createTransaction( + final Object credentials, { + final int tryReduceFeeAmount = 0, + }) async { final creds = credentials as ZcashTransactionCredentials; await updateBalance(); final zcashBalance = balance[CryptoCurrency.zec]; final availableBalance = zcashBalance?.available ?? Money.zero(currency); - final recipients = []; - var totalAmount = Money.zero(currency); + final recipients = []; + + bool receipientPaysFee = false; for (final output in creds.outputs) { - Money amount; + receipientPaysFee = receipientPaysFee || output.sendAll; + var amount = output.cryptoAmount; if (output.sendAll) { - amount = availableBalance; - } else { - amount = output.cryptoAmount; - - if (amount <= Money.zero(currency)) { - throw Exception( - 'Invalid amount for output. Amount: ${output.cryptoAmount}, Formatted: ${output.cryptoAmount}', - ); - } + amount = availableBalance - Money.fromInt(tryReduceFeeAmount, currency); } - - totalAmount += amount; - - var address = (output.isParsedAddress ? output.extractedAddress! : output.address).trim(); - - if (address.isEmpty) { - throw Exception('Empty address for output'); - } - - final paymentUri = WarpApi.decodePaymentURI(coin, address); - String memo = output.memo ?? ''; - // String memo = ''; - if (paymentUri != null && paymentUri.address != null) { - address = paymentUri.address!; - if (memo.isEmpty && paymentUri.memo != null) { - memo = paymentUri.memo!; - } - } - - if (!WarpApi.validAddress(coin, address)) { - throw Exception('Invalid Zcash address: $address'); - } - - int recipientPools = 7; - - if (address.startsWith('t1') || address.startsWith('t3')) { - recipientPools = 1; // Transparent only - } else if (address.startsWith('zs')) { - recipientPools = 2; // Sapling only - } - // For unified addresses (u1...) and other types, use 7 (all pools) - - final builder = RecipientObjectBuilder( - address: address, - pools: recipientPools, - amount: amount.amount.toInt(), - feeIncluded: output.sendAll, - replyTo: false, - memo: memo.isNotEmpty ? memo : null, + final recipientAddress = output.isParsedAddress ? output.extractedAddress! : output.address; + recipients.add( + zkool_paydart.Recipient( + assetBase: zecBase, + address: recipientAddress, + amount: amount.amount, + userMemo: output.memo, + ), ); - - recipients.add(Recipient(builder.toBytes())); } - if (totalAmount > availableBalance) { - throw Exception('Insufficient balance'); - } + final sendAmount = creds.outputs + .map((final out) => out.cryptoAmount) + .reduce((final a, final b) => a + b); - final fee = FeeT( - fee: internalCalculateEstimatedFee(creds.priority, null), - minFee: 0, - maxFee: 0, - scheme: 0, // Fixed fee scheme - ); + if (availableBalance == sendAmount) { + receipientPaysFee = true; + } // pools parameter: bitmask for which pools to use for sending // 1=Transparent, 2=Sapling, 4=Orchard, 7=All pools - await ZcashWalletBase.loadProver(); // Using 7 (all pools) allows spending from any pool type - final txPlan = await ZcashWalletService.runInDbMutex( - () => WarpApi.prepareTx( - coin, - accountId, - recipients, - 7, // pools: All pools (Transparent + Sapling + Orchard) - allows spending from any pool - 1, // senderUAType: 0 = unified address - 0, // anchorOffset - fee, - ), - ); - - return PendingZcashTransaction( - zcashWallet: this as ZcashWallet, - credentials: creds, - txPlan: txPlan, - fee: Money.fromInt(internalCalculateEstimatedFee(creds.priority, null), currency), - availableBalance: availableBalance, - ); + try { + return await runWithCoin( + accountId: accountId, + func: (coin) async { + final txPlan = await zkool_pay.prepare( + recipients: recipients, + options: zkool_pay.PaymentOptions( + srcPools: 7, + recipientPaysFee: receipientPaysFee, + smartTransparent: false, + ), + c: coin, + ); + final txFee = _feeFromTxPlan(txPlan, creds.priority, tryReduceFeeAmount, coin: coin); + return PendingZcashTransaction( + zcashWallet: this as ZcashWallet, + credentials: creds, + txPlan: txPlan, + fee: txFee, + availableBalance: availableBalance, + ); + }, + ); + } catch (e) { + if (tryReduceFeeAmount != 0) rethrow; + final estr = e.toString(); + const prefix = "Not enough funds, "; + const suffix = " more ZEC required"; + if (estr.contains(prefix) && estr.contains(suffix)) { + final start = estr.indexOf(prefix) + prefix.length; + final end = estr.indexOf(suffix, start); + final amtStr = estr.substring(start, end); + final amt = double.tryParse(amtStr); + if (amt == null) rethrow; + final feeInt = (amt * 100000000).ceil(); + return _createTransaction(credentials, tryReduceFeeAmount: feeInt); + } + rethrow; + } } static const _dispPhrase = "Received to disposable address"; - Future> getShieldTxForUi() async { - final tx = (ZcashTaddressRotation.shieldedAccountsTx[accountId] ?? []) - .map((final v) { - final unpacked = v.unpack(); - unpacked.memo ??= ""; - unpacked.memo = "${unpacked.memo}\n$_dispPhrase".trim(); - final List buff = base64.decode( - ZcashTaddressRotation.flatBuffersPack(unpacked.pack), - ); - return ShieldedTx(buff); - }) - .where((final t) => t.value > 0); - return tx.toList(); + ZcashTransactionInfo _zcashInfoFromMempoolTx( + final zkool_mempool.MempoolTx tx, + final int accountId, + ) { + final accountNotes = tx.notes.where((final n) => n.account == accountId); + final netValue = accountNotes.fold( + BigInt.zero, + (final sum, final note) => sum + BigInt.from(note.value), + ); + final direction = netValue >= BigInt.zero + ? TransactionDirection.incoming + : TransactionDirection.outgoing; + final memo = accountNotes + .map((final n) => n.memo) + .whereType() + .where((final m) => m.isNotEmpty) + .firstOrNull; + final recipientAddresses = _recipientAddresses( + accountNotes.map((final n) => n.address).whereType(), + direction, + ); + + final info = ZcashTransactionInfo( + id: ZcashWalletService.normalizeTxId(tx.txid), + amount: Money(netValue.abs(), currency), + fee: Money.zero(currency), + direction: direction, + isPending: true, + date: DateTime.now(), + height: 0, + confirmations: 0, + to: recipientAddresses.isEmpty ? '' : recipientAddresses.first, + memo: memo, + ); + if (recipientAddresses.isNotEmpty) { + info.outputAddresses = recipientAddresses; + } + return info; } - static Map> temporarySentTx = {}; + ZcashTransactionInfo _zcashInfoFromZkoolTx( + final ZkoolTx tx, + final int currentHeight, { + final String? extraMemo, + final bool isRotationReceive = false, + final bool isShieldAction = false, + final TransactionDirection? directionOverride, + final BigInt? amountOverride, + }) { + final confirmations = tx.height > 0 && currentHeight >= tx.height + ? currentHeight - tx.height + 1 + : 0; + final memo = extraMemo != null ? "${tx.memo ?? ''}\n$extraMemo".trim() : tx.memo; + final direction = directionOverride ?? tx.direction; + final recipientAddresses = _recipientAddresses(_paymentOutputAddresses(tx), direction); + final info = ZcashTransactionInfo( + id: tx.txHash, + amount: Money(amountOverride ?? tx.value, currency), + fee: Money.zero(currency), + direction: direction, + isPending: tx.height == 0, + date: tx.time, + height: tx.height, + confirmations: confirmations, + to: recipientAddresses.isEmpty ? '' : recipientAddresses.first, + memo: memo?.isNotEmpty == true ? memo : null, + txType: tx.type, + isRotationReceive: isRotationReceive, + isShieldAction: isShieldAction, + ); + if (recipientAddresses.isNotEmpty) { + info.outputAddresses = recipientAddresses; + } + return info; + } - static String txChecksumKey(final ShieldedTx tx) { - final direction = tx.value > 0 ? TransactionDirection.incoming : TransactionDirection.outgoing; - return 'tx${direction}_${tx.id}_${tx.timestamp}_${CRC32.compute(tx.toString())}'; + bool _isShieldActionTx(final ZkoolTx tx, {required final Set rotationSweepHashes}) { + if (ZcashWalletService.isAutoshieldTx(tx.txHash)) { + return true; + } + if (rotationSweepHashes.contains(tx.txHash)) { + return true; + } + if (_isPayToSelfAutoshield(tx)) { + return true; + } + if (tx.direction == TransactionDirection.outgoing && + (tx.type == TxType.shield || tx.type == TxType.transparentSelfTransfer)) { + return true; + } + return false; + } + + bool _isPayToSelfAutoshield(final ZkoolTx tx) { + if (tx.type != TxType.shield && tx.type != TxType.transparentSelfTransfer) { + return false; + } + if (tx.transparentOrSaplingSpent <= BigInt.zero) { + return false; + } + if (tx.orchardReceived <= BigInt.zero) { + return false; + } + for (final dest in tx.outputAddresses) { + if (_addressBelongsToWallet(dest)) { + return true; + } + } + return tx.orchardReceived > BigInt.zero; + } + + bool _shouldSplitAutoshieldTx(final ZkoolTx tx, {required final bool isShield}) { + if (!isShield) { + return false; + } + if (ZcashWalletService.isAutoshieldTx(tx.txHash) || _isPayToSelfAutoshield(tx)) { + return tx.transparentOrSaplingSpent > BigInt.zero && tx.orchardReceived > BigInt.zero; + } + return false; + } + + static String _txResultKey(final String txHash, {final String suffix = ''}) => + 'tx_$txHash$suffix'; + + static int _txDisplayPriority(final ZcashTransactionInfo info) { + if (info.additionalInfo['isAutoShield'] == true) { + return 3; + } + if (info.additionalInfo['isRotationReceive'] == true) { + return 2; + } + return 1; + } + + void _offerTx(final Map byHash, final ZcashTransactionInfo info) { + final hash = info.txHash; + final existing = byHash[hash]; + if (existing == null) { + byHash[hash] = info; + return; + } + final infoPriority = _txDisplayPriority(info); + final existingPriority = _txDisplayPriority(existing); + if (infoPriority > existingPriority) { + byHash[hash] = info; + return; + } + if (infoPriority == existingPriority && + info.additionalInfo['isAutoShield'] == true && + info.direction == TransactionDirection.outgoing && + existing.direction == TransactionDirection.incoming) { + byHash[hash] = info; + } + } + + bool _addressBelongsToWallet(final String addr) { + final addrs = walletAddresses; + if (addrs.containsAddress(addr) || + addrs.hiddenAddresses.contains(addr) || + addrs.usedAddresses.contains(addr)) { + return true; + } + for (final infos in addrs.addressInfos.values) { + for (final info in infos) { + if (info.address == addr) { + return true; + } + } + } + for (final own in [ + addrs.orchardAddress, + addrs.unifiedAddress, + addrs.saplingAddress, + addrs.transparentAddress, + addrs.address, + ]) { + if (own == null || own.isEmpty || own.startsWith('unknown ')) { + continue; + } + if (addr == own || addr.startsWith(own) || own.startsWith(addr)) { + return true; + } + } + return false; + } + + List _paymentOutputAddresses(final ZkoolTx tx) { + final all = tx.outputsWithAddress.toList(); + final external = all.where((final o) => !_addressBelongsToWallet(o.address)).toList(); + final outputs = external.isNotEmpty ? external : all; + final transparent = outputs.where((final o) => o.pool == NotePool.transparent.index).toList(); + if (transparent.length >= 2) { + return transparent.map((final o) => o.address).toList(); + } + return outputs.map((final o) => o.address).toList(); + } + + List _recipientAddresses( + final Iterable raw, + final TransactionDirection direction, + ) { + final seen = {}; + final addresses = [ + for (final address in raw) + if (address.trim().isNotEmpty && seen.add(address.trim())) address.trim(), + ]; + if (addresses.isEmpty) { + return []; + } + + final external = direction == TransactionDirection.incoming + ? addresses + : [ + for (final address in addresses) + if (!_addressBelongsToWallet(address)) address, + ]; + final list = external.isNotEmpty ? external : addresses; + if (list.isEmpty) { + return []; + } + + final embedded = { + for (final address in list) + if (address.startsWith('u')) ..._uaReceivers(address), + }; + var result = embedded.isEmpty + ? list + : [ + for (final address in list) + if (address.startsWith('u') || !embedded.contains(address)) address, + ]; + if (result.isEmpty) { + result = list; + } + + final standalone = [ + for (final address in result) + if (!address.startsWith('u') && !embedded.contains(address)) address, + ]; + if (standalone.length >= 2) { + result = standalone; + } + + if (direction == TransactionDirection.incoming) { + return [ + result.firstWhere( + (final a) => a.startsWith('u'), + orElse: () => + result.firstWhere((final a) => a.startsWith('z'), orElse: () => result.first), + ), + ]; + } + return result; + } + + Set _uaReceivers(final String ua) { + try { + final receivers = zkool_account.receiversFromUa(ua: ua, c: ZcashWalletBase.c); + return { + for (final address in [receivers.taddr, receivers.saddr, receivers.oaddr]) + if (address != null && address.isNotEmpty) address, + }; + } catch (_) { + return {}; + } } @override Future> fetchTransactions() async { await ZcashWalletService.loadShieldTxs(); - final txs = (await ZcashWalletService.runInDbMutex( - () => WarpApi.getTxs(coin, accountId), - )).toList(); - // ShieldedTx{id: 26, txId: 4d1be06ce2c2debec8d98ce4e9434c8aac27c980488b459017d423fdcab37f93, height: 3195705, shortTxId: 4d1be06c, timestamp: 1767730944, name: null, value: 1000000, address: null, memo: , messages: MemoVec{memos: null}} - - final shieldTx = await getShieldTxForUi(); - - txs.addAll(shieldTx); - final txIds = txs.map((final tx) => tx.txId!.replaceAll('"', '')).toSet(); - temporarySentTx[accountId]?.removeWhere( - (final ttx) => txIds.contains(ttx.txId!.replaceAll('"', '')), + final (txs, currentHeight) = await runWithCoin( + accountId: accountId, + func: (coin) async { + final txsI = await zkool_account.listTxHistory(c: coin); + final txsA = await Future.wait( + txsI.map((final tx) => zkool_account.getTxDetails(idTx: tx.id, c: coin)), + ); + final txs = []; + for (int i = 0; i < txsI.length; i++) { + txs.add(ZkoolTx(txsI[i], txsA[i])); + } + txs.sort((final a, final b) => a.height.compareTo(b.height)); + var currentHeight = 1; + try { + currentHeight = await zkool_network.getCurrentHeight(c: coin); + } catch (e) { + printV("failed to get height: $e"); + } + return (txs, currentHeight); + }, ); - txs.addAll(temporarySentTx[accountId] ?? []); + final Map byHash = {}; + final rotationTxs = ZcashTaddressRotation.rotationTxsForMainAccount(accountId); + final rotationSweepHashes = { + for (final tx in rotationTxs) + if (tx.direction == TransactionDirection.outgoing) tx.txHash, + }; - txs.sort((final a, final b) => a.height.compareTo(b.height)); - final Map result = {}; - int currentHeight = 0; - try { - currentHeight = await WarpApi.getLatestHeight(coin); - } catch (e) { - printV("Error getting latest height: $e"); + for (final tx in rotationTxs) { + if (tx.direction == TransactionDirection.incoming) { + _offerTx( + byHash, + _zcashInfoFromZkoolTx(tx, currentHeight, extraMemo: _dispPhrase, isRotationReceive: true), + ); + continue; + } + _offerTx(byHash, _zcashInfoFromZkoolTx(tx, currentHeight, isShieldAction: true)); } + final Map splitEntries = {}; for (final tx in txs) { - final direction = tx.value > 0 - ? TransactionDirection.incoming - : TransactionDirection.outgoing; - - final confirmations = tx.height > 0 && currentHeight > 0 ? currentHeight - tx.height + 1 : 0; - - final txChecksum = txChecksumKey(tx); - final txId = tx.txId ?? tx.shortTxId ?? txChecksum; - - final txInfo = ZcashTransactionInfo( - id: txId.trim().replaceAll('"', ''), - amount: Money.fromInt(tx.value.abs(), currency), - fee: Money.zero(currency), - direction: direction, - isPending: tx.height == 0, - date: DateTime.fromMillisecondsSinceEpoch(tx.timestamp * 1000), - height: tx.height, - confirmations: confirmations, - to: tx.address ?? '', - memo: tx.memo, - ); - // if (txInfo.additionalInfo['autoShield'] == true) { - // continue; - // } - result[txChecksum] = txInfo; + final isShield = _isShieldActionTx(tx, rotationSweepHashes: rotationSweepHashes); + if (_shouldSplitAutoshieldTx(tx, isShield: isShield)) { + byHash.remove(tx.txHash); + splitEntries[_txResultKey(tx.txHash, suffix: '_shield')] = _zcashInfoFromZkoolTx( + tx, + currentHeight, + isShieldAction: true, + directionOverride: TransactionDirection.outgoing, + amountOverride: tx.transparentOrSaplingSpent, + ); + splitEntries[_txResultKey(tx.txHash, suffix: '_recv')] = _zcashInfoFromZkoolTx( + tx, + currentHeight, + directionOverride: TransactionDirection.incoming, + amountOverride: tx.orchardReceived, + ); + continue; + } + _offerTx(byHash, _zcashInfoFromZkoolTx(tx, currentHeight, isShieldAction: isShield)); } - return result; + final knownHashes = {for (final tx in txs) tx.txHash, ...byHash.keys}; + for (final mempoolTx in ZcashMempoolService.instance.txsForAccount(accountId)) { + final hash = ZcashWalletService.normalizeTxId(mempoolTx.txid); + if (knownHashes.contains(hash)) { + continue; + } + final info = _zcashInfoFromMempoolTx(mempoolTx, accountId); + if (info.amount.isZero) { + continue; + } + _offerTx(byHash, info); + } + + return { + for (final entry in byHash.entries) _txResultKey(entry.key): entry.value, + ...splitEntries, + }; + } + + Future _initKeys() async { + try { + c = await c.setAccount(account: accountId); + final ufvk = await zkool_account.getAccountUfvk(account: accountId, c: c, pools: 7); + + keys = { + "privateViewKey": ufvk, + if (lastKnownRestoreHeight != null) "restoreHeight": lastKnownRestoreHeight.toString(), + }; + } catch (e) { + keys = {"privateViewKey": e.toString()}; + } + try { + c = await c.setAccount(account: accountId); + final s = (await zkool_account.getAccountSeed(account: accountId, c: c)); + + if (s == null) { + throw Exception("seed not found"); + } + final seedPhrase = s.mnemonic.split(" "); + if ([13, 25].contains(seedPhrase.length)) { + passphrase = seedPhrase.removeLast(); + } else { + passphrase = s.phrase; + } + seed = s.mnemonic.trim(); + } catch (e) { + seed = e.toString(); + } } @override - Object get keys { - final backup = WarpApi.getBackup(coin, accountId); - return { - // "seed": backup.seed, - // "index": backup.index, - "privateSpendKey": backup.sk, - "privateViewKey": backup.fvk, - "uvk": backup.uvk, - "tsk": backup.tsk, - if (lastKnownRestoreHeight != null) "restoreHeight": lastKnownRestoreHeight.toString(), - }; - } + Object keys = {}; @override String get password => _password!; @override Future renameWalletFiles(final String newWalletName) async { - final currentWalletPath = await pathForWallet(name: name, type: type); - final currentCacheFile = File(currentWalletPath); - final newWalletPath = await pathForWallet(name: newWalletName, type: type); - if (currentCacheFile.existsSync()) { - await currentCacheFile.copy(newWalletPath); + await renameWalletFilesForName(fromName: name, toName: newWalletName); + } + + static Future renameWalletFilesForName({ + required final String fromName, + required final String toName, + }) async { + if (fromName == toName) { + return; + } + final currentWalletDir = Directory(await pathForWalletDir(name: fromName, type: _type)); + if (!currentWalletDir.existsSync()) { + throw Exception('Wallet directory not found: $fromName'); + } + final newWalletDirPath = '${await pathForWalletTypeDir(type: _type)}/$toName'; + if (Directory(newWalletDirPath).existsSync()) { + throw Exception('Cannot rename wallet: "$toName" already exists'); + } + await currentWalletDir.rename(newWalletDirPath); + for (final suffix in const ['', '.v2']) { + final oldFile = File('$newWalletDirPath/$fromName$suffix'); + if (oldFile.existsSync()) { + await oldFile.rename('$newWalletDirPath/$toName$suffix'); + } } - Directory(currentWalletPath).deleteSync(recursive: true); } @override bool get hasRescan => true; - static Future storeZcashHeight(final int height) async { - lastKnownRestoreHeight = height; - final zcashDir = await pathForWalletTypeDir(type: WalletType.zcash); - final zcashInitialSync = File(p.join(zcashDir, ".initial-sync-marker")); - zcashInitialSync.writeAsBytesSync([0x00]); - zcashInitialSync.writeAsStringSync(height.toString(), mode: FileMode.writeOnlyAppend); - } - static int? lastKnownRestoreHeight = null; - static Future loadZcashHeight() async { - final zcashDir = await pathForWalletTypeDir(type: WalletType.zcash); - final zcashInitialSync = File(p.join(zcashDir, ".initial-sync-marker")); - if (!await zcashInitialSync.exists()) { - return null; - } - final bytes = await zcashInitialSync.readAsBytes(); - if (bytes.isEmpty) { - return null; - } - final heightString = String.fromCharCodes(bytes.skip(1)); - lastKnownRestoreHeight = int.tryParse(heightString); - return lastKnownRestoreHeight; - } static int zashiAnnouncedBlockHeight = 2419420; + Future _getAddressesForAccount(final int id) async { + return runWithCoin( + accountId: id, + func: (final coin) => zkool_account.getAddresses(c: coin, uaPools: 7), + ); + } + + bool _addressesMatch(final dynamic old, final dynamic new_) { + return old.ua == new_.ua && + old.oaddr == new_.oaddr && + old.saddr == new_.saddr && + old.taddr == new_.taddr; + } + + Future _switchToAccount(final int newAccountId, final int height) async { + walletsByAccountId.remove(accountId); + accountId = newAccountId; + walletsByAccountId[newAccountId] = this; + walletAddresses.accountId = newAccountId; + c = await c.setAccount(account: newAccountId); + lastKnownRestoreHeight = height; + await walletAddresses.init(); + await _initKeys(); + } + @override @action Future rescan({required final int height}) async { - try { - syncStatus = StartingScanSyncStatus(height); - printV("rescanning from: $height"); - await storeZcashHeight(height); - await ZcashWalletService.runInDbMutex(() async => WarpApi.rescanFrom(coin, height)); - await startSync(); - } catch (e) { - printV("Rescan error: $e"); - syncStatus = FailedSyncStatus(error: e.toString()); - } + await zkool_sync.rewindSync(height: height, account: accountId, c: c); + // try { + // syncStatus = StartingScanSyncStatus(height); + // printV("rescanning from: $height"); + // await zkool_sync.cancelSync(); + // isSyncing = false; + + // await runWithCoinMutex.acquire(); + // try { + // final oldAddresses = await _getAddressesForAccount(accountId); + + // c = await c.setAccount(account: accountId); + // final accountSeed = await zkool_account.getAccountSeed(account: accountId, c: c); + // if (accountSeed == null) { + // throw Exception('Cannot rescan: seed not available'); + // } + + // final newAccountId = await restoreZcashWalletFromSeed( + // name: name, + // seed: accountSeed.mnemonic, + // passphrase: accountSeed.phrase, + // birthHeight: height, + // ); + + // final newAddresses = await _getAddressesForAccount(newAccountId); + // if (!_addressesMatch(oldAddresses, newAddresses)) { + // throw Exception('Rescan address verification failed'); + // } + + // await saveAccountId(name, newAccountId); + // await _switchToAccount(newAccountId, height); + // } finally { + // runWithCoinMutex.release(); + // } + + // syncStatus = ConnectedSyncStatus(); + // } catch (e) { + // printV("Rescan error: $e"); + // syncStatus = FailedSyncStatus(error: e.toString()); + // rethrow; + // } } bool _isTransactionUpdating = false; @@ -417,56 +1027,31 @@ abstract class ZcashWalletBase } } - Future updateTransactionsHistory() => updateTransactions(); - @override Future save() async {} Future init() async { try { + await ZcashTaddressRotation.init(); await walletAddresses.init(); await updateBalance(); await updateTransactions(); + unawaited( + ZcashTaddressRotation.updateCache(mainAccountId: accountId) + .catchError((final e) => printV("rotation cache refresh: $e")), + ); + await _initKeys(); } catch (e) { printV("Wallet init error: $e"); } } @override - String? get seed { - try { - final backup = WarpApi.getBackup(coin, accountId); - final seed = backup.seed?.split(" "); - if (seed == null) { - return null; - } - if ([13, 25].contains(seed.length)) { - seed.removeLast(); - } - return seed.join(" ").trim(); - } catch (e) { - return null; - } - } + String? seed = ""; @override - String? get passphrase { - try { - final backup = WarpApi.getBackup(coin, accountId); - final seed = backup.seed?.split(" "); - if (seed == null) { - return null; - } - if ([13, 25].contains(seed.length)) { - final passphrase = seed.removeLast(); - return passphrase; - } - return null; - } catch (e) { - return null; - } - } + String? passphrase = ""; @override Future signMessage(final String message, {final String? address = null}) { @@ -476,328 +1061,129 @@ abstract class ZcashWalletBase @override @action Future startSync() async { + if (syncStatus is AttemptingSyncStatus || + syncStatus is SyncronizingSyncStatus || + syncStatus is SyncingSyncStatus) { + return; + } try { - syncStatus = AttemptingSyncStatus(); - - _initialSyncHeight = 0; - _lastKnownBlockHeight = 0; - - _startSyncStatusUpdates(); - - syncStatus = SyncronizingSyncStatus(); - - unawaited( - _runWarpSync().catchError((final e) { - isNodeWorking = false; - printV("WarpSync error in startSync: $e"); - syncStatus = FailedSyncStatus(error: e.toString()); - _stopSyncStatusUpdates(); - }), - ); + _ensureSyncLoopRunning(); + unawaited(_oneshotSync()); } catch (e) { isNodeWorking = false; printV("Sync error: $e"); syncStatus = FailedSyncStatus(error: e.toString()); - _stopSyncStatusUpdates(); rethrow; } } static Mutex warpSyncMutex = Mutex(); - static Future initialSyncCheck() async { - final zcashDir = await pathForWalletTypeDir(type: WalletType.zcash); - final zcashInitialSync = File(p.join(zcashDir, ".initial-sync-marker")); - if (!zcashInitialSync.existsSync()) { - int chainHeight = 3000000; // fallback if node is offline - try { - chainHeight = await WarpApi.getLatestHeight(coin); - } catch (e) { - printV("Error getting latest height: $e"); - } - await ZcashWalletService.runInDbMutex( - () async => await WarpApi.rescanFrom(coin, chainHeight - 150000), - ); - await storeZcashHeight(chainHeight); - } - } - - @action - Future _runWarpSync() async { - Timer? _t; - try { - await warpSyncMutex.acquire(); - await initialSyncCheck(); - isNodeWorking = true; - printV("Starting warpSync for coin $coin, account $accountId"); - int? initialQueue = null; - void _cancelSyncIfShould(final Timer t) { - initialQueue ??= ZcashWalletService.dbMutexQueue + 2; - if (ZcashWalletService.dbMutexQueue <= initialQueue!) { - initialQueue = ZcashWalletService.dbMutexQueue; - return; - } - printV( - "Canceling sync! (ZcashWalletService.dbMutexQueue: ${ZcashWalletService.dbMutexQueue} > initialQueue: ${initialQueue})", - ); - WarpApi.cancelSync(); - t.cancel(); - _t = null; - } - - unawaited( - Future.delayed(Duration(seconds: 2)).then((_) { - _t = Timer.periodic(Duration(milliseconds: 100), _cancelSyncIfShould); - }), - ); - final result = await ZcashWalletService.runInDbMutex( - () => WarpApi.warpSync(coin, accountId, true, 0, 1000000, 0), - ); - printV("warpSync completed with result: $result"); - - await _updateSyncStatus(); - } catch (e) { - syncStatus = FailedSyncStatus(error: e.toString()); - unawaited(Future.delayed(Duration(seconds: 1)).then((_) => unawaited(_runWarpSync()))); - _stopSyncStatusUpdates(); - } finally { - isNodeWorking = false; - warpSyncMutex.release(); - _t?.cancel(); - } - } - - void _startSyncStatusUpdates() { - _stopSyncStatusUpdates(); - _updateSyncStatus(); - _syncStatusTimer = Timer.periodic(const Duration(milliseconds: 5000), (_) { - _updateSyncStatus().catchError((final e) { - printV("Error in sync status update timer: $e"); - }); - }); - } - - void _stopSyncStatusUpdates() { - _syncStatusTimer?.cancel(); - _syncStatusTimer = null; - } - - void _startPeriodicSync() { - printV("_startPeriodicSync"); - _stopPeriodicSync(); - _periodicSyncTimer = Timer.periodic(const Duration(seconds: 5), (_) async { - try { - final chainHeight = await WarpApi.getLatestHeight(coin); - final dbHeight = WarpApi.getDbHeight(coin); - final height = dbHeight.unpack(); - final syncHeight = height.height; - - if (syncHeight < chainHeight) { - printV("Periodic sync: chainHeight=$chainHeight, syncHeight=$syncHeight, starting sync"); - await _runWarpSync(); - } else { - await updateBalance(); - await updateTransactions(); - } - } catch (e) { - printV("Periodic sync error: $e"); - } - }); - } - - void _stopPeriodicSync() { - _periodicSyncTimer?.cancel(); - _periodicSyncTimer = null; - } - - @action - Future _updateSyncStatus() async { - try { - final dbHeight = WarpApi.getDbHeight(coin); - final height = dbHeight.unpack(); - final syncHeight = height.height; - - final chainHeight = await WarpApi.getLatestHeight(coin); - - if (_initialSyncHeight <= 0 && syncHeight > 0) { - _initialSyncHeight = syncHeight; - printV("Initialized sync height to: $_initialSyncHeight"); - if (syncHeight - 10 > dbHeight.height) {} - } - - if (chainHeight <= 0) { - if (syncStatus is! ConnectedSyncStatus && syncStatus is! ConnectingSyncStatus) { - syncStatus = ConnectedSyncStatus(); - } - try { - await updateBalance(); - await updateTransactions(); - } catch (e) { - printV("Error updating balance/transactions: $e"); - } - return; - } - - if (syncHeight <= 0) { - if (syncStatus is! ConnectedSyncStatus && - syncStatus is! ConnectingSyncStatus && - syncStatus is! AttemptingSyncStatus) { - syncStatus = ConnectedSyncStatus(); - } - try { - await updateBalance(); - await updateTransactions(); - } catch (e) { - printV("Error updating balance/transactions: $e"); - } - return; - } - - if (syncHeight >= chainHeight && syncHeight > 0) { - syncStatus = SyncedSyncStatus(); - _stopSyncStatusUpdates(); - await updateBalance(); - await updateTransactions(); - _startPeriodicSync(); - return; - } - - if (_lastKnownBlockHeight != syncHeight) { - _lastKnownBlockHeight = syncHeight; - } - - if (syncHeight < 0 || chainHeight < syncHeight) { - return; - } - - final blocksLeft = chainHeight - syncHeight; - if (blocksLeft <= 0) { - syncStatus = SyncedSyncStatus(); - _stopSyncStatusUpdates(); - await updateBalance(); - await updateTransactions(); - _startPeriodicSync(); - return; - } - - double ptc = 0.0; - if (_initialSyncHeight > 0) { - final track = chainHeight - _initialSyncHeight; - final diff = track > 0 ? track - (chainHeight - syncHeight) : 0; - ptc = track > 0 && diff >= 0 ? diff / track : 0.0; - } else { - ptc = syncHeight / chainHeight; - } - - syncStatus = SyncingSyncStatus(blocksLeft, ptc.clamp(0.0, 1.0)); - - await updateBalance(); - await updateTransactions(); - } catch (e) { - printV("Sync status update error: $e"); - } - } - - String getDiversifiedAddress(final int uaType, {final DateTime? time}) { - try { - final timestamp = (time ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000; - return WarpApi.getDiversifiedAddress(coin, accountId, uaType, timestamp); - } catch (e) { - printV("Error getting diversified address: $e"); - return ""; - } - } - static final autoShieldMutex = Mutex(); + static DateTime? _lastAutoShieldAt; Future _autoShield() async { + if (_lastAutoShieldAt != null && + _lastAutoShieldAt!.isAfter(DateTime.now().subtract(const Duration(seconds: 75)))) { + return; + } try { await autoShieldMutex.acquire(); await _$autoShield(); - } catch (e) { + } catch (e, s) { printV("shielding failed: $e"); - await Future.delayed(Duration(seconds: 30)); + s.toString().split("\n").forEach(printV); } finally { autoShieldMutex.release(); } } Future _$autoShield() async { - final chainHeight = await WarpApi.getLatestHeight(coin); - final dbHeight = WarpApi.getDbHeight(coin); - final height = dbHeight.unpack(); - final syncHeight = height.height; - if (chainHeight != syncHeight) { - printV("Not autoshielding: chainHeight(${chainHeight}) != syncHeight(${syncHeight})"); + if (syncStatus is! SyncedSyncStatus) { return; } - final bpConfirmed = WarpApi.getPoolBalances(coin, accountId, 0, false); - if (bpConfirmed.transparent + bpConfirmed.sapling <= 20000) { + final txId = await runWithCoin( + accountId: accountId, + func: (coin) async { + final _notes = await zkool_account.listNotes(c: coin); + final List txNotes = []; + for (int i = 0; i < _notes.length; i++) { + final note = _notes[i]; + final noteType = NotePool.values[note.pool]; + if ([NotePool.sapling, NotePool.transparent].contains(noteType)) { + txNotes.add(note); + } + } + + final sweepable = txNotes.isEmpty + ? BigInt.from(0) + : txNotes.map((final txn) => txn.value).reduce((final a, final b) => a + b); + + if (sweepable <= BigInt.from(_autoShieldMinSweep)) { + return null; + } + + final txPlan = await zkool_pay.prepare( + recipients: [ + zkool_paydart.Recipient( + assetBase: zecBase, + address: walletAddresses.orchardAddress!, + amount: sweepable, + ), + ], + options: zkool_pay.PaymentOptions( + srcPools: 3, + recipientPaysFee: true, + smartTransparent: false, + ), + c: coin, + ); + + final signTx = await zkool_pay.signTransaction(pczt: txPlan, c: coin); + final txBytes = await zkool_pay.extractTransaction(package: signTx); + final currentHeight = await zkool_network.getCurrentHeight(c: coin); + return await zkool_pay.broadcastTransaction( + height: currentHeight, + txBytes: txBytes, + c: coin, + ); + }, + ); + if (txId == null) { return; } - final recipientBuilder = RecipientObjectBuilder( - address: (walletAddresses as ZcashWalletAddresses).orchardAddress, - pools: 4, - feeIncluded: true, - amount: bpConfirmed.transparent + bpConfirmed.sapling, - ); - - final recipient = Recipient(recipientBuilder.toBytes()); - final fee = FeeT(fee: 10000, minFee: 0, maxFee: 0, scheme: 0); - await ZcashWalletBase.loadProver(); - final txPlan = await ZcashWalletService.runInDbMutex( - () => WarpApi.prepareTx( - coin, - accountId, - [recipient], - 3, // pools: (Transparent + Sapling) - 1, - 0, // anchorOffset - fee, - ), - ); - final _txId = await ZcashWalletService.runInDbMutex( - () => WarpApi.signAndBroadcast(ZcashWalletBase.coin, accountId, txPlan), - ); - await ZcashWalletService.addShieldedTx(_txId); - printV("shielded: $_txId"); + await ZcashWalletService.addShieldedTx(txId); + _lastAutoShieldAt = DateTime.now(); + printV("shielded: $txId"); await updateTransactions(); - await updateBalance(); - await Future.delayed(Duration(seconds: 75 * 5)); // do not re-try doing that + await _refreshBalance(runAutoShield: false); } - @override - @action - Future updateBalance() async { + Future _refreshBalance({required final bool runAutoShield}) async { try { - final poolBalances = WarpApi.getPoolBalances(coin, accountId, 0, true); - final balances = poolBalances.unpack(); - // final notes = WarpApi.getNotesSync(coin, accountId); - // int frozenBalance = 0; - // for (final note in notes) { - // if (note.excluded) { - // frozenBalance += note.value; - // } - // } - final total = balances.orchard + balances.sapling + balances.transparent; - final spendable = total - balances.transparent; + final bal = await runWithCoin( + accountId: accountId, + func: (coin) => zkool_sync.balance(c: coin), + ); - final confirmedPoolBalances = WarpApi.getPoolBalances(coin, accountId, 3, true); - final confirmedBalances = confirmedPoolBalances.unpack(); - final confirmedTotal = - confirmedBalances.orchard + confirmedBalances.sapling + confirmedBalances.transparent; + // 0 - transparent + // 1 - sapling + // 2 - orchard + final confirmedTotal = bal.field0.reduce((final a, final b) => a + b); - int knownOutPending = 0; - ZcashWalletBase.temporarySentTx[accountId]?.forEach((final sTx) { - knownOutPending += sTx.value; // it's negative - }); - final confirmedSpendable = confirmedTotal - balances.transparent + knownOutPending; + // int knownOutPending = 0; + // ZcashWalletBase.temporarySentTx[accountId]?.forEach((final sTx) { + // knownOutPending += sTx.value; // it's negative + // }); + final confirmedSpendable = confirmedTotal - bal.field0[0]; - unawaited(_autoShield()); + if (runAutoShield) { + await _autoShield(); + } balance[CryptoCurrency.zec] = ZcashBalance( - Money.fromInt(confirmedSpendable, currency), - Money.fromInt(spendable - confirmedSpendable, currency), + Money(confirmedSpendable, currency), + Money(confirmedTotal - confirmedSpendable, currency), frozen: Money.zero(currency), ); } catch (e, stackTrace) { @@ -806,6 +1192,12 @@ abstract class ZcashWalletBase } } + @override + @action + Future updateBalance() async { + await _refreshBalance(runAutoShield: true); + } + @override Future verifyMessage( final String message, @@ -815,11 +1207,11 @@ abstract class ZcashWalletBase throw UnimplementedError(); } - @observable - late WalletAddresses walletAddresses = ZcashWalletAddresses(accountId, walletInfo); + @override + late ZcashWalletAddresses walletAddresses = ZcashWalletAddresses(accountId, walletInfo); static Future create(final WalletCredentials credentials) async { - await _init(); + await $init(); final newWalletCredentials = credentials as ZcashNewWalletCredentials; String mnemonic; @@ -830,57 +1222,50 @@ abstract class ZcashWalletBase mnemonic = bip39.generateMnemonic(strength: strength); } - final accountId = await _restoreZcashWalletFromSeed( + final birthHeight = await ZcashHeight.getBlockHeightByTime(DateTime.now()); + + final accountId = await restoreZcashWalletFromSeed( name: credentials.name, seed: mnemonic, passphrase: newWalletCredentials.passphrase, + birthHeight: birthHeight, ); - await _saveAccountId(credentials.name, accountId); + await saveAccountId(credentials.name, accountId); final wallet = await open( name: credentials.name, password: credentials.password!, walletInfo: credentials.walletInfo!, ); - await wallet.walletAddresses.saveAddressesInBox(); + await wallet.init(); return wallet; } static Future restore(final WalletCredentials credentials) async { - await _init(); + await $init(); final fromSeedCredentials = credentials as ZcashFromSeedWalletCredentials; final String? seed = fromSeedCredentials.seed; if (seed == null || seed.isEmpty) { throw Exception('Seed phrase is required for wallet restoration'); } - final accountId = await _restoreZcashWalletFromSeed( + final accountId = await restoreZcashWalletFromSeed( name: credentials.name, seed: seed, passphrase: fromSeedCredentials.passphrase, + birthHeight: credentials.height!, ); - await _saveAccountId(credentials.name, accountId); + await saveAccountId(credentials.name, accountId); final wallet = await open( name: credentials.name, password: credentials.password!, walletInfo: credentials.walletInfo!, ); - await wallet.walletAddresses.saveAddressesInBox(); - printV("height: ${credentials.height}"); - if (credentials.height != null) { - await storeZcashHeight(credentials.height!); - unawaited( - Future.delayed(Duration(seconds: 2)).then( - (_) => ZcashWalletService.runInDbMutex( - () async => await WarpApi.rescanFrom(coin, credentials.height ?? 0), - ), - ), - ); - } + await wallet.init(); return wallet; } static Future restoreKeys(final WalletCredentials credentials) async { - await _init(); + await $init(); final fromKeysCredentials = credentials as ZcashFromKeysWalletCredentials; final String? keys = fromKeysCredentials.privateKey; if (keys == null || keys.isEmpty) { @@ -892,29 +1277,20 @@ abstract class ZcashWalletBase throw Exception('Key is not in secret-extended-key-main1 format'); } - final accountId = await _restoreZcashWalletFromSeed( + final accountId = await restoreZcashWalletFromSeed( name: credentials.name, seed: keys, passphrase: fromKeysCredentials.passphrase, + birthHeight: credentials.height!, ); - await _saveAccountId(credentials.name, accountId); + await saveAccountId(credentials.name, accountId); final wallet = await open( name: credentials.name, password: credentials.password!, walletInfo: credentials.walletInfo!, ); - await wallet.walletAddresses.saveAddressesInBox(); + await wallet.init(); printV("height: ${credentials.height}"); - if (credentials.height != null) { - await storeZcashHeight(credentials.height!); - unawaited( - Future.delayed(Duration(seconds: 2)).then( - (_) => ZcashWalletService.runInDbMutex( - () async => await WarpApi.rescanFrom(coin, credentials.height ?? 0), - ), - ), - ); - } return wallet; } @@ -923,56 +1299,80 @@ abstract class ZcashWalletBase required final String password, required final WalletInfo walletInfo, }) async { - await _init(); - if (password.isNotEmpty) { - WarpApi.setDbPasswd(coin, password); - } + await $init(); + // if (password.isNotEmpty) { + // setDbPasswd(coin, password); + // } final accountId = await getZcashAccountIdForName(name); if (accountId == null) { - throw Exception("Wallet account not found for name: $name"); + throw Exception("accountId is null"); } + c = await c.setAccount(account: accountId); final wallet = ZcashWallet( walletInfo, await walletInfo.getDerivationInfo(), accountId: accountId, ); - await wallet.walletAddresses.init(); + await wallet._initKeys(); return wallet; } - static Future _restoreZcashWalletFromSeed({ + static Future restoreZcashWalletFromSeed({ required final String name, - required String seed, - required String? passphrase, + required final String seed, + required final String? passphrase, + required final int birthHeight, }) async { - if (passphrase?.isNotEmpty == true) { - passphrase = passphrase!.replaceAll(" ", "_"); - seed = "${seed} ${passphrase}"; - } - final accountId = await ZcashWalletService.runInDbMutex( - () => WarpApi.newAccount(coin, name, seed, 0), + // if (passphrase?.isNotEmpty == true) { + // passphrase = passphrase!.replaceAll(" ", "_"); + // seed = "${seed} ${passphrase}"; + // } + + final accountId = await newAccount( + name: name, + height: birthHeight, + seed: seed, + passphrase: passphrase ?? '', ); return accountId; } - static Future getZcashAccountIdForName(final String name) async { - final wPath = await pathForWallet(name: name, type: _type); + static Future getLegacyZcashAccountIdForName(final String name) async { + final wPath = (await pathForWallet(name: name, type: _type)); final f = File(wPath); if (!f.existsSync()) { - final accounts = WarpApi.getAccountList(coin); - for (final account in accounts) { - if (account.name == name) { - return account.id; + final accs = await zkool_account.listAccounts(c: c); + for (final acc in accs) { + if (acc.name == name) { + return acc.id; } } - return null; } final content = f.readAsStringSync(); return int.tryParse(content.trim()); } - static Future _saveAccountId(final String name, final int accountId) async { - final wPath = await pathForWallet(name: name, type: _type); + static Future getZcashAccountIdForName(final String name) async { + final wPath = (await pathForWallet(name: name, type: _type)) + ".v2"; + final f = File(wPath); + if (!f.existsSync()) { + final accs = await zkool_account.listAccounts(c: c); + for (final acc in accs) { + if (acc.name == name) { + return acc.id; + } + } + } + final content = f.readAsStringSync(); + return int.tryParse(content.trim()); + } + + static Future saveAccountId(final String name, final int accountId) async { + final wPath = (await pathForWallet(name: name, type: _type)) + ".v2"; + final dirName = Directory(wPath).parent.path; + if (!Directory(dirName).existsSync()) { + Directory(dirName).createSync(recursive: true); + } final f = File(wPath); f.writeAsStringSync(accountId.toString()); } @@ -980,6 +1380,15 @@ abstract class ZcashWalletBase static WalletType get _type => WalletType.zcash; static Future getDbDataPath() async { + final pathForWalletType = await pathForWalletTypeDir(type: _type); + final dbDataPath = "${pathForWalletType}/zec.v2.db"; + if (!Directory(pathForWalletType).existsSync()) { + Directory(pathForWalletType).createSync(recursive: true); + } + return dbDataPath; + } + + static Future getDbDataPathLegacyYwallet() async { final pathForWalletType = await pathForWalletTypeDir(type: _type); final dbDataPath = "${pathForWalletType}/zec.db"; if (!Directory(pathForWalletType).existsSync()) { @@ -988,195 +1397,104 @@ abstract class ZcashWalletBase return dbDataPath; } - static Future getTorDir() async { - final pathForWalletType = await pathForWalletTypeDir(type: _type); - final torPath = "${pathForWalletType}/tor"; - if (!Directory(torPath).existsSync()) { - Directory(torPath).createSync(recursive: true); - } - return torPath; - } - - static Future getFsBlockCacheDir() async { - final pathForWalletType = await pathForWalletTypeDir(type: _type); - final fsBlockCacheDir = "${pathForWalletType}/blockCache"; - if (!Directory(pathForWalletType).existsSync()) { - Directory(pathForWalletType).createSync(recursive: true); - } - return fsBlockCacheDir; - } - - static String? dbDataPath; static bool _initialized = false; static void unlockDatabase(final String password) { _password = password; } + static var c = zkool_coin.Coin(); + static String? _password; - static Future _init() async { + static Future $init() async { if (_initialized) return; - dbDataPath = await getDbDataPath(); - printV("WarpApi.initWallet"); + _initialized = true; + printV(r".$init()"); + await zkool_frb.RustLib.init(); + ZcashMempoolService.instance.onAccountsUpdated = (final accountIds) { + for (final accountId in accountIds) { + unawaited(refreshWalletForAccount(accountId)); + } + }; + final dbFile = File(await getDbDataPath()); + final ywalletDbFile = File(await getDbDataPathLegacyYwallet()); + await zkool_network.initDatadir(directory: dbFile.parent.path); + // c = await c.openDatabase(dbFilepath: dbFile.path, password: 'cw_zcash_migration'); + c = await c.openDatabase(dbFilepath: dbFile.path, password: null); + printV("initWallet"); if (_password == null) { throw Exception("Zcash wallet locked! Please contact support"); } - if (!File(dbDataPath!).existsSync()) { + if (!dbFile.existsSync()) { //TODO(mrcyjanek): copy-encrypt } - // coin+1 = ycash - WarpApi.setDbPasswd(coin, ''); - WarpApi.setDbPasswd(coin + 1, ''); - WarpApi.initWallet(coin, dbDataPath!); - WarpApi.initWallet(coin + 1, dbDataPath!); - try { - WarpApi.migrateData(coin); - WarpApi.migrateData(coin + 1); - } catch (e) { - printV("zec init failed: $e"); - } // do not fail on network exception - await loadZcashHeight(); + if (!ywalletDbFile.existsSync()) { + //TODO(mrcyjanek): migrate to zkool + } - unawaited(loadProver()); - - await ZcashTaddressRotation.init(); - await ZcashTransactionInfo.init(); _initialized = true; } - static bool isProverLoaded = false; - static Future loadProver() async { - Uint8List? spend; - Uint8List? output; - final cacheDir = await getApplicationCacheDirectory(); - try { - final spendBundle = await rootBundle.load('scripts/zcash_lib/assets/sapling-spend.params'); - final outputBundle = await rootBundle.load('scripts/zcash_lib/assets/sapling-output.params'); - spend = spendBundle.buffer.asUint8List(); - output = outputBundle.buffer.asUint8List(); - if (spend.length == 0 || output.length == 0) { - spend = await File(cacheDir.path + "/sapling-spend.params").readAsBytesSync(); - output = await File(cacheDir.path + "/sapling-output.params").readAsBytesSync(); - } - if (spend.length == 0 || output.length == 0) throw Exception("NUH UH"); - } catch (e) { - printV("$e. Fine, I'll download them."); - final spendResponse = await ProxyWrapper().get( - clearnetUri: Uri.parse("https://download.z.cash/downloads/sapling-spend.params"), - ); - final outputResponse = await ProxyWrapper().get( - clearnetUri: Uri.parse("https://download.z.cash/downloads/sapling-output.params"), - ); - spend = spendResponse.bodyBytes; - output = outputResponse.bodyBytes; - await File(cacheDir.path + "/sapling-spend.params").writeAsBytes(spend); - await File(cacheDir.path + "/sapling-output.params").writeAsBytes(output); - } - WarpApi.initProver(spend, output); - isProverLoaded = true; - } - - static Future getBlockHeightByTime(final DateTime time) async { - final genesisTime = DateTime.utc(2016, 10, 28); - const genesisHeight = 0; - - final firstHalvingTime = DateTime.utc(2020, 11, 18); - const firstHalvingHeight = 1046400; - - final secondHalvingTime = DateTime.utc(2024, 11, 23); - const secondHalvingHeight = 2726400; - - final t = time.toUtc().millisecondsSinceEpoch; - final t0 = genesisTime.millisecondsSinceEpoch; - final t1 = firstHalvingTime.millisecondsSinceEpoch; - final t2 = secondHalvingTime.millisecondsSinceEpoch; - - if (t <= t0) return genesisHeight; - - if (t < t1) { - return _interpolate(genesisHeight, firstHalvingHeight, t0, t1, t); - } - - if (t < t2) { - return _interpolate(firstHalvingHeight, secondHalvingHeight, t1, t2, t); - } - - final secondsSince2 = (t - t2) / 1000.0; - final blocksSince2 = (secondsSince2 / 76.0).floor(); - return secondHalvingHeight + blocksSince2; - } - - static int _interpolate( - final int hStart, - final int hEnd, - final int tStart, - final int tEnd, - final int t, - ) { - if (tEnd == tStart) return hStart; - final ratio = (t - tStart) / (tEnd - tStart); - return (hStart + (hEnd - hStart) * ratio).round(); - } - static Future getHeightByDate(final DateTime date) async { - int height = await getBlockHeightByTime(date); - try { - final h2 = await WarpApi.getBlockHeightByTime(coin, date); - height = h2; - } catch (e) { - printV("getHeightByDate: $e"); - } + final height = await ZcashHeight.getBlockHeightByTime(date); return height; } - bool couldBeZashiWallet() { - final backup = WarpApi.getBackup(coin, accountId); - final seed = backup.seed?.split(" "); - if (seed == null) return false; - if (zkoolSweep != null) return false; - if (!(syncStatus is SyncedSyncStatus)) return false; - return seed.length == 24; + static Future newAccount({ + required final String name, + required final int height, + required final String seed, + required final String passphrase, + }) async { + final id = await zkool_account.newAccount( + na: zkool_account.NewAccount( + name: name, + restore: true, + passphrase: passphrase, + key: seed, + aindex: 0, + birth: height, + folder: '', + useInternal: true, + internal: false, + ledger: false, + ), + c: c, + ); + return id; } - static ZkoolSweep? zkoolSweep; + static final runWithCoinMutex = Mutex(); + static int runWithCoinCount = 0; - static bool _didRunRescanInternalChange = false; - Future rescanInternalChange() async { - if (_didRunRescanInternalChange) { - return; + static Future withSharedCoinLock(final FutureOr Function() func) async { + await runWithCoinMutex.acquire(); + try { + return await func(); + } finally { + runWithCoinMutex.release(); + } + } + + static FutureOr runWithCoin({ + required final int accountId, + required final FutureOr Function(zkool_coin.Coin c) func, + }) async { + var newC = zkool_coin.Coin(); + newC = await newC.openDatabase(dbFilepath: c.dbFilepath); + newC = await newC.setAccount(account: accountId); + newC = await newC.setLwd(serverType: c.serverType, url: c.url); + newC = await newC.setUseTor(useTor: c.useTor); + + runWithCoinCount++; + printV("run with coin: $runWithCoinCount"); + await runWithCoinMutex.acquire(); + try { + newC = await newC.setAccount(account: accountId); + return await func(newC); + } finally { + runWithCoinMutex.release(); + runWithCoinCount--; } - _didRunRescanInternalChange = true; - final bal = - balance[CryptoCurrency.zec]!.available + - balance[CryptoCurrency.zec]!.unavailable + - (balance[CryptoCurrency.zec]!.frozen ?? Money.zero(CryptoCurrency.zec)); - final osCacheDir = await getApplicationCacheDirectory(); - final cacheDir = osCacheDir.createTempSync("zkool-import"); - zkoolSweep = ZkoolSweep( - currentBalance: bal.amount.toInt(), - cacheDir: cacheDir.path, - seed: seed ?? '', - passphrase: password, - address: (walletAddresses as ZcashWalletAddresses).orchardAddress, - url: (lastNode!.isSSL ? 'https://' : 'http://') + lastNode!.uriRaw, - height: (await loadZcashHeight()) ?? zashiAnnouncedBlockHeight, - ); - unawaited(zkoolSweep!.start()); - int count = 0; - Timer.periodic(Duration(milliseconds: 1000 ~/ 120), (final Timer t) { - final msg = ZkoolSweep.msg; - if (kDebugMode && (++count % (120 ~/ 10)) == 0) { - printV(msg.message); - } - if (msg.blocksLeft == 0 && msg.networkHeight != 0) { - zkoolSweep = null; - t.cancel(); - } - if (msg.blocksLeft == 0) { - syncStatus = ConnectedSyncStatus(); - return; - } - syncStatus = SyncingSyncStatus(msg.blocksLeft, msg.progress); - }); } } diff --git a/cw_zcash/lib/src/zcash_wallet_addresses.dart b/cw_zcash/lib/src/zcash_wallet_addresses.dart index a9fd207ae..31bdbbf9e 100644 --- a/cw_zcash/lib/src/zcash_wallet_addresses.dart +++ b/cw_zcash/lib/src/zcash_wallet_addresses.dart @@ -6,7 +6,7 @@ import 'package:cw_core/wallet_info.dart'; import 'package:cw_zcash/cw_zcash.dart'; import 'package:cw_zcash/src/zcash_taddress_rotation.dart'; import 'package:mobx/mobx.dart'; -import 'package:warp_api/warp_api.dart'; +import 'package:zkool/src/rust/api/account.dart' as zkool_account; part 'zcash_wallet_addresses.g.dart'; @@ -17,24 +17,34 @@ abstract class ZcashWalletAddressesBase extends WalletAddresses with Store { addressPageType = ZcashReceivePageOption.typeFromString(walletInfo.addressPageType ?? ""); } - final int accountId; + int accountId; static const int transparentType = 0; static const int shieldedSaplingType = 1; static const int shieldedOrchardType = 2; static const int unifiedType = 3; - String? _transparentAddress; - String? _saplingAddress; - String? _orchardAddress; - String? _unifiedAddress; + @observable + String? transparentAddress; + + @observable + String? _transparentObservableAddress; + + @observable + String? saplingAddress; + + @observable + String? orchardAddress; + + @observable + String? unifiedAddress; @override @computed String get latestAddress { switch (addressPageType) { case ZcashAddressType.transparent: - return transparentAddress; + return transparentAddress ?? "unknown transparentAddress"; case ZcashAddressType.transparentRotated: // just to display something, after the wallet is synced the address // will start rotating, and since we can't rotate address until the wallet syncs @@ -46,16 +56,15 @@ abstract class ZcashWalletAddressesBase extends WalletAddresses with Store { // 3) display first address from rotated pool which is also static until synced // 2 seems like an obvious winner simply because it offers little to no benefit over 3 // and is noticably less complex in implementation - return transparentAddressRotated ?? transparentAddress; + return _transparentObservableAddress ?? + transparentAddress ?? + "unknown transparentAddressRotated"; case ZcashAddressType.shieldedSapling: - return saplingAddress; + return saplingAddress ?? "unknown saplingAddress"; case ZcashAddressType.shieldedOrchard: - return orchardAddress; + return orchardAddress ?? "unknown orchardAddress"; case ZcashAddressType.unifiedType: - if (_unifiedAddress == null) { - _unifiedAddress = WarpApi.getAddress(ZcashWalletBase.coin, accountId, 6); - } - return _unifiedAddress ?? ""; + return unifiedAddress ?? "unknown unifiedAddress"; } } @@ -79,63 +88,21 @@ abstract class ZcashWalletAddressesBase extends WalletAddresses with Store { await walletInfo.save(); } - String get transparentAddress { - try { - if (_transparentAddress == null) { - _transparentAddress = WarpApi.getTAddr(ZcashWalletBase.coin, accountId); - } - return _transparentAddress ?? ""; - } catch (e) { - return ""; - } - } - + @override @computed - String? get transparentAddressRotated { - try { - return ZcashTaddressRotation.addressForAccount(accountId, hiddenAddresses); - } catch (e) { - return null; - } - } - - String get saplingAddress { - try { - if (_saplingAddress == null) { - _saplingAddress = WarpApi.getAddress(ZcashWalletBase.coin, accountId, 2); - } - return _saplingAddress ?? ""; - } catch (e) { - return ""; - } - } - - String get orchardAddress { - try { - if (_orchardAddress == null) { - _orchardAddress = WarpApi.getAddress(ZcashWalletBase.coin, accountId, 4); - } - return _orchardAddress ?? ""; - } catch (e) { - return ""; - } - } + String get addressForExchange => + _transparentObservableAddress ?? transparentAddress ?? "unknown addressForExchange"; @override @computed - String get addressForExchange => transparentAddressRotated ?? transparentAddress; - - @override - @computed - String get addressForBuy => transparentAddressRotated ?? transparentAddress; + String get addressForBuy => + _transparentObservableAddress ?? transparentAddress ?? "unknown addressForBuy"; @override bool containsAddress(final String address) { return this.address == address || addressesMap.values.contains(address); } - static int get coin => ZcashWalletBase.coin; - @override @observable Set hiddenAddresses = {}; @@ -149,37 +116,105 @@ abstract class ZcashWalletAddressesBase extends WalletAddresses with Store { address = latestAddress; } + Future _syncRotationHiddenAddresses() async { + final all = (await ZcashTaddressRotation.allAddressesForAccount(accountId)).toSet(); + final used = (await ZcashTaddressRotation.usedAddressesForAccount(accountId)).toSet(); + hiddenAddresses.removeWhere(all.contains); + hiddenAddresses.addAll(used); + } + @override Future init() async { + try { + await _init(); + } catch (e) { + printV("init failed, retrying in 2 seconds"); + printV(e); + await Future.delayed(Duration(seconds: 2)); + await init(); + } + } + + static bool isPlaceholderAddress(final String? address) => + address == null || address.isEmpty || address.startsWith('unknown '); + + String _resolvedWalletInfoAddress() { + for (final candidate in [unifiedAddress, orchardAddress, saplingAddress, transparentAddress]) { + if (!isPlaceholderAddress(candidate)) { + return candidate!; + } + } + return latestAddress; + } + + Future _init() async { await _initAddresses(); - await ZcashTaddressRotation.init(); - int accountIndex = 0; - addressInfos = { - 0: - ZcashTaddressRotation.allAddressesForAccount(accountId)?.map((final v) { - return WalletInfoAddressInfo( - walletInfoId: walletInfo.internalId, - mapKey: ++accountIndex, - accountIndex: 0, - address: v, - label: "", - ); - }).toList() ?? - [], - }; - hiddenAddresses.addAll( - ZcashTaddressRotation.allUsedAddressesForAccount(accountId)?.toSet() ?? {}, + final addr = await ZcashWalletBase.runWithCoin( + accountId: accountId, + func: (final coin) => zkool_account.getAddresses(c: coin, uaPools: 7), ); - // addressInfos[0]?.removeWhere((final test) => hiddenAddresses.contains(test.address)); + transparentAddress = addr.taddr ?? 'unknown addr.taddr'; + saplingAddress = addr.saddr ?? 'unknown addr.saddr'; + orchardAddress = addr.oaddr ?? 'unknown addr.oaddr'; + unifiedAddress = addr.ua ?? 'unknown addr.ua'; + _transparentObservableAddress = await ZcashTaddressRotation.addressForAccount(accountId); + final rotationAddrs = await ZcashTaddressRotation.allAddressesForAccount(accountId); + addressInfos = { + 0: [ + for (int i = 0; i < rotationAddrs.length; i++) + WalletInfoAddressInfo( + walletInfoId: walletInfo.internalId, + mapKey: i + 1, + accountIndex: 0, + address: rotationAddrs[i], + label: "", + ), + ], + }; + await _syncRotationHiddenAddresses(); + await _applyAddressForCurrentType(); + + if (isPlaceholderAddress(walletInfo.address)) { + final resolved = _resolvedWalletInfoAddress(); + if (!isPlaceholderAddress(resolved)) { + walletInfo.address = resolved; + await walletInfo.save(); + } + } + + await saveAddressesInBox(); + } + + Future refreshRotationAddresses() async { + _transparentObservableAddress = await ZcashTaddressRotation.addressForAccount(accountId); + final rotationAddrs = await ZcashTaddressRotation.allAddressesForAccount(accountId); + addressInfos = { + 0: [ + for (int i = 0; i < rotationAddrs.length; i++) + WalletInfoAddressInfo( + walletInfoId: walletInfo.internalId, + mapKey: i + 1, + accountIndex: 0, + address: rotationAddrs[i], + label: "", + ), + ], + }; + await _syncRotationHiddenAddresses(); + await _applyAddressForCurrentType(); + } + + Future _applyAddressForCurrentType() async { if (_addressPageType == ZcashAddressType.transparentRotated) { - final addr = ZcashTaddressRotation.addressForAccount(accountId, hiddenAddresses); + final addr = await ZcashTaddressRotation.addressForAccount(accountId); if (addr != null) { address = addr; } + return; } - await saveAddressesInBox(); + address = latestAddress; } @observable @@ -198,12 +233,17 @@ abstract class ZcashWalletAddressesBase extends WalletAddresses with Store { @override Future saveAddressesInBox() async { try { + if (!isPlaceholderAddress(address)) { + walletInfo.address = address; + } await walletInfo.setAddresses(addressesMap); await walletInfo.setAddressInfos(addressInfos); await walletInfo.setUsedAddresses(usedAddresses.toList()); await walletInfo.setHiddenAddresses(hiddenAddresses.toList()); await walletInfo.setManualAddresses(manualAddresses.toList()); + await walletInfo.save(); await _initAddresses(); + await _syncRotationHiddenAddresses(); } catch (e) { printV("Error saving addresses: $e"); } @@ -211,10 +251,7 @@ abstract class ZcashWalletAddressesBase extends WalletAddresses with Store { @override Future updateAddressesInBox() async { - _transparentAddress = null; - _saplingAddress = null; - _orchardAddress = null; - _unifiedAddress = null; + await _initAddresses(); } @override @@ -227,11 +264,7 @@ abstract class ZcashWalletAddressesBase extends WalletAddresses with Store { if (addressPageType != ZcashAddressType.transparentRotated) { return []; } - final List allInfos = []; - for (final entry in addressInfos.entries) { - allInfos.addAll(entry.value); - } - return allInfos; + return addressInfos[0] ?? []; } @override diff --git a/cw_zcash/lib/src/zcash_wallet_service.dart b/cw_zcash/lib/src/zcash_wallet_service.dart index 0d79e7065..4f4604722 100644 --- a/cw_zcash/lib/src/zcash_wallet_service.dart +++ b/cw_zcash/lib/src/zcash_wallet_service.dart @@ -9,6 +9,7 @@ import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_zcash/cw_zcash.dart'; +import 'package:cw_zcash/src/migrate.dart'; import 'package:mutex/mutex.dart'; import 'package:path/path.dart' as p; @@ -25,9 +26,9 @@ class ZcashWalletService static Mutex dbMutex = Mutex(); static int dbMutexQueue = 0; static Future runInDbMutex(final Future Function() call) async { + dbMutexQueue++; + await dbMutex.acquire(); try { - printV("dbMutexQueue: ${dbMutexQueue++}"); - await dbMutex.acquire(); return await call(); } finally { dbMutex.release(); @@ -37,10 +38,15 @@ class ZcashWalletService static Set autoshieldTx = {}; + static String normalizeTxId(final String txId) => + txId.replaceAll(RegExp(r'[^a-fA-F0-9]'), '').toLowerCase(); + + static bool isAutoshieldTx(final String txHash) => autoshieldTx.contains(normalizeTxId(txHash)); + static Future addShieldedTx(final String txId) async { final pathForWalletType = await pathForWalletTypeDir(type: type); - final shieldedListFile = p.join(pathForWalletType, "autoshield_list.txt"); - autoshieldTx.add(txId.replaceAll(RegExp(r'[^a-zA-Z0-9 ]'), '')); + final shieldedListFile = p.join(pathForWalletType, "autoshield_list.v2.txt"); + autoshieldTx.add(normalizeTxId(txId)); final shieldedList = File(shieldedListFile); shieldedList.writeAsStringSync(autoshieldTx.join("\n")); } @@ -48,12 +54,16 @@ class ZcashWalletService static Future loadShieldTxs() async { try { final pathForWalletType = await pathForWalletTypeDir(type: type); - final shieldedListFile = p.join(pathForWalletType, "autoshield_list.txt"); + final shieldedListFile = p.join(pathForWalletType, "autoshield_list.v2.txt"); final shieldedList = File(shieldedListFile); if (!shieldedList.existsSync()) { return; } - autoshieldTx = shieldedList.readAsLinesSync().toSet(); + autoshieldTx = shieldedList + .readAsLinesSync() + .map(normalizeTxId) + .where((final line) => line.isNotEmpty) + .toSet(); } catch (e) { printV("loadShieldTxs failed: $e"); } @@ -74,12 +84,21 @@ class ZcashWalletService @override Future isWalletExit(final String name) async { - final path = await pathForWallet(name: name, type: getType()); - return File(path).existsSync(); + final oldPath = (await pathForWallet(name: name, type: getType())); + final path = (await pathForWallet(name: name, type: getType())) + ".v2"; + return File(path).existsSync() || File(oldPath).existsSync(); } @override Future openWallet(final String name, final String password) async { + await ZcashWalletBase.$init(); + if (await isWalletExit(name)) { + final path = (await pathForWallet(name: name, type: getType())) + ".v2"; + if (!File(path).existsSync()) { + await migrateOldSqliteToZkool2(walletName: name); + } + } + await loadShieldTxs(); final walletInfo = await WalletInfo.get(name, getType()); if (walletInfo == null) { @@ -108,7 +127,7 @@ class ZcashWalletService @override Future remove(final String wallet) async { - final path = await pathForWalletDir(name: wallet, type: getType()); + final path = (await pathForWalletDir(name: wallet, type: getType())); final file = Directory(path); final isExist = file.existsSync(); @@ -129,17 +148,11 @@ class ZcashWalletService if (currentWalletInfo == null) { throw Exception('Wallet not found'); } - final accountId = await ZcashWalletBase.getZcashAccountIdForName(currentName); - if (accountId == null) { - throw Exception('Wallet account not found for name: $currentName'); + if (!await isWalletExit(currentName)) { + throw Exception('Wallet not found'); } - final currentWallet = ZcashWallet( - currentWalletInfo, - await currentWalletInfo.getDerivationInfo(), - accountId: accountId, - ); - await currentWallet.renameWalletFiles(newName); + await ZcashWalletBase.renameWalletFilesForName(fromName: currentName, toName: newName); final newWalletInfo = currentWalletInfo; newWalletInfo.id = WalletBase.idFor(newName, getType()); @@ -162,11 +175,11 @@ class ZcashWalletService final bool? isTestnet, }) async { if (credentials.seed == null || credentials.seed!.isEmpty) { - throw ZcashMnemonicIsIncorrectException(); + throw Exception("Seed is missing"); } if (!bip39.validateMnemonic(credentials.seed!)) { - throw ZcashMnemonicIsIncorrectException(); + throw Exception("Seed is not valid bip39"); } return ZcashWalletBase.restore(credentials); diff --git a/cw_zcash/lib/src/zkool_compat.dart b/cw_zcash/lib/src/zkool_compat.dart new file mode 100644 index 000000000..c13ff292b --- /dev/null +++ b/cw_zcash/lib/src/zkool_compat.dart @@ -0,0 +1,3 @@ +import 'dart:typed_data'; + +final zecBase = Uint8List(32); diff --git a/cw_zcash/lib/src/zkooltx.dart b/cw_zcash/lib/src/zkooltx.dart new file mode 100644 index 000000000..16fdc6fbf --- /dev/null +++ b/cw_zcash/lib/src/zkooltx.dart @@ -0,0 +1,312 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cw_core/get_height_by_date_zec.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:cw_zcash/src/util/hex.dart'; +import 'package:zkool/src/rust/api/account.dart' as zkool_account; + +enum TxType { + unknown, + selfTransfer, // 0 + receive, // 1 + sent, // 2 + unshield, // 4 + shield, // 8 + transparentSelfTransfer, // 12 +} + +enum NotePool { transparent, sapling, orchard, unknown } + +class ZkoolTx { + ZkoolTx(final zkool_account.Tx tx, final zkool_account.TxAccount txAccount) + : this._tx = tx, + this._txAccount = txAccount; + final zkool_account.Tx _tx; + final zkool_account.TxAccount _txAccount; + + int get height => max( + _tx.height, + _txAccount.height, + ); // sometimes returns 0 - higher number would be correct in this case + + DateTime get time { + final ts = max(_tx.time, _txAccount.time) * 1000; + if (ts == 0) { + return ZcashHeight.getTimeByBlockHeight(height); + } + return DateTime.fromMillisecondsSinceEpoch(ts); + } + + String? get to => _txAccount.outputs.firstOrNull?.address; + Iterable get outputAddresses => + _txAccount.outputs.map((final o) => o.address).whereType(); + Iterable<({String address, int pool, BigInt value})> get outputsWithAddress => _txAccount.outputs + .where((final o) => o.address.isNotEmpty) + .map((final o) => (address: o.address, pool: o.pool, value: o.value)); + Iterable get spendPools => _txAccount.spends.map((final s) => s.pool); + Iterable get notePools => _txAccount.notes.map((final n) => n.pool); + String? get memo => _txAccount.memos.firstOrNull?.memo; + + BigInt get transparentOrSaplingSpent { + if (_txAccount.spends.isEmpty) { + return BigInt.zero; + } + return _txAccount.spends + .where( + (final s) => s.pool == NotePool.transparent.index || s.pool == NotePool.sapling.index, + ) + .fold(BigInt.zero, (final a, final s) => a + s.value); + } + + BigInt get orchardReceived { + final fromNotes = _txAccount.notes + .where((final n) => n.pool == NotePool.orchard.index) + .fold(BigInt.zero, (final a, final n) => a + n.value); + if (fromNotes > BigInt.zero) { + return fromNotes; + } + return _txAccount.outputs + .where((final o) => o.pool == NotePool.orchard.index) + .fold(BigInt.zero, (final a, final o) => a + o.value); + } + + String get txHash { + final reversed = Uint8List.fromList(_txAccount.txid.reversed.toList()); + final txId = uint8ListToHex(reversed); + return txId; + } + + BigInt get value => _value.abs(); + + BigInt get fee => value - _calcValue; + + BigInt get _value { + if (_tx.value != 0) { + return BigInt.from(_tx.value); + } + + return _calcValue; + } + + /////////// calc is short for calculator (calculated in this case) + BigInt get _calcValue { + final noteSum = _txAccount.notes.isEmpty + ? BigInt.from(0) + : _txAccount.notes.map((final note) => note.value).reduce((final a, final b) => a + b); + final outputSum = _txAccount.outputs.isEmpty + ? BigInt.from(0) + : _txAccount.outputs + .map((final output) => output.value) + .reduce((final a, final b) => a + b); + final spentSum = _txAccount.spends.isEmpty + ? BigInt.from(0) + : _txAccount.spends.map((final spent) => spent.value).reduce((final a, final b) => a + b); + + return noteSum - outputSum - spentSum; + } + + TransactionDirection get direction { + switch (type) { + case TxType.selfTransfer: + case TxType.receive: + case TxType.unshield: + case TxType.shield: + case TxType.transparentSelfTransfer: + return TransactionDirection.incoming; + case TxType.sent: + return TransactionDirection.outgoing; + case TxType.unknown: + return _value > BigInt.from(0) + ? TransactionDirection.incoming + : TransactionDirection.outgoing; + } + } + + TxType get type { + switch (_tx.tpe) { + case 0: + return TxType.selfTransfer; + case 1: + return TxType.receive; + case 2: + return TxType.sent; + case 4: + return TxType.unshield; + case 8: + return TxType.shield; + case 12: + return TxType.transparentSelfTransfer; + default: + return TxType.unknown; + } + } + + Map toJson() => { + "tx": { + "id": _tx.id, + "txid": base64.encode(_tx.txid), + "height": _tx.height, + "time": _tx.time, + "value": value.toInt(), + "tpe": _tx.tpe, + "zsaValue": _tx.zsaValue, + "assetDisplay": _tx.assetDisplay, + }, + "txaccount": { + "id": _txAccount.id, + "account": _txAccount.account, + "txid": base64.encode(_txAccount.txid), + "height": _txAccount.height, + "time": _txAccount.time, + "notes": _txAccount.notes + .map( + (final note) => { + "id": note.id, + "pool": note.pool, + "height": note.height, + "tx": note.tx, + "scope": note.scope, + "value": note.value.toString(), + "locked": note.locked, + }, + ) + .toList(), + "spends": _txAccount.spends + .map( + (final spend) => { + "id": spend.id, + "pool": spend.pool, + "height": spend.height, + "value": spend.value.toString(), + "assetDisplay": spend.assetDisplay, + }, + ) + .toList(), + "outputs": _txAccount.outputs + .map( + (final output) => { + "id": output.id, + "pool": output.pool, + "height": output.height, + "value": output.value.toString(), + "address": output.address, + }, + ) + .toList(), + "memos": _txAccount.memos + .map( + (final memo) => { + "note": memo.note, + "pool": memo.pool, + "output": memo.output, + "memo": memo.memo, + }, + ) + .toList(), + }, + }; + + static int _asInt(final dynamic value, {final int fallback = 0}) { + if (value == null) { + return fallback; + } + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.parse(value.toString()); + } + + static Uint8List _txidFromJson(final dynamic raw) { + if (raw is Uint8List) { + return raw; + } + if (raw is List) { + return Uint8List.fromList(List.from(raw)); + } + if (raw is String) { + return base64.decode(raw); + } + throw FormatException("Invalid txid in ZkoolTx json: $raw"); + } + + static List _listFromJson(final dynamic raw, final T Function(Map) parse) { + if (raw is! List) { + return const []; + } + return raw.map((final entry) => parse(Map.from(entry as Map))).toList(); + } + + static ZkoolTx fromJson(final Map json) { + final txJson = Map.from((json["tx"] as Map?)?.cast() ?? json); + final txAccountJson = Map.from( + (json["txaccount"] as Map?)?.cast() ?? json, + ); + + return ZkoolTx( + zkool_account.Tx( + id: _asInt(txJson["id"]), + txid: _txidFromJson(txJson["txid"]), + height: _asInt(txJson["height"]), + time: _asInt(txJson["time"]), + value: _asInt(txJson["value"]), + tpe: txJson["tpe"] == null ? null : _asInt(txJson["tpe"]), + zsaValue: _asInt(txJson["zsaValue"]), + assetDisplay: txJson["assetDisplay"] as String? ?? "", + ), + zkool_account.TxAccount( + id: _asInt(txAccountJson["id"]), + account: _asInt(txAccountJson["account"]), + txid: _txidFromJson(txAccountJson["txid"]), + height: _asInt(txAccountJson["height"]), + time: _asInt(txAccountJson["time"]), + notes: _listFromJson( + txAccountJson["notes"], + (final a) => zkool_account.TxNote( + id: _asInt(a["id"]), + pool: _asInt(a["pool"]), + height: _asInt(a["height"]), + tx: _asInt(a["tx"]), + value: BigInt.parse(a["value"].toString()), + scope: _asInt(a["scope"]), + locked: a["locked"] as bool? ?? false, + assetDisplay: a["assetDisplay"] as String? ?? "", + ), + ), + spends: _listFromJson( + txAccountJson["spends"], + (final a) => zkool_account.TxSpend( + id: _asInt(a["id"]), + pool: _asInt(a["pool"]), + height: _asInt(a["height"]), + value: BigInt.parse(a["value"].toString()), + assetDisplay: a["assetDisplay"] as String? ?? "", + ), + ), + outputs: _listFromJson( + txAccountJson["outputs"], + (final a) => zkool_account.TxOutput( + id: _asInt(a["id"]), + pool: _asInt(a["pool"]), + height: _asInt(a["height"]), + value: BigInt.parse(a["value"].toString()), + address: a["address"] as String? ?? "", + ), + ), + memos: _listFromJson( + txAccountJson["memos"], + (final a) => zkool_account.TxMemo( + note: a["note"] == null ? null : _asInt(a["note"]), + pool: _asInt(a["pool"]), + output: a["output"] == null ? null : _asInt(a["output"]), + memo: a["memo"] as String?, + ), + ), + ), + ); + } +} diff --git a/cw_zcash/pubspec.yaml b/cw_zcash/pubspec.yaml index 72e6de2be..84e0d27e6 100644 --- a/cw_zcash/pubspec.yaml +++ b/cw_zcash/pubspec.yaml @@ -9,8 +9,6 @@ environment: dependencies: flutter: sdk: flutter - warp_api: - path: ../scripts/zcash_lib/packages/warp_api_ffi cw_core: path: ../cw_core mobx: ^2.6.0 @@ -20,11 +18,13 @@ dependencies: path: any flat_buffers: any zkool: + # path: ../../zkool2 git: - url: https://github.com/cake-tech/zkool2 - ref: b1642f260da4fa00dd2f8d4dd689073d0b56960a + url: https://github.com/cake-tech/zkool2.git + ref: dbd3dde8d8485428326a58e55660b7404666dd7c path_provider: any + sqflite_common_ffi: any dependency_overrides: decimal: ^2.3.3 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 29a2ad974..d46513dab 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,14 @@ PODS: + - AppAuth (1.7.6): + - AppAuth/Core (= 1.7.6) + - AppAuth/ExternalUserAgent (= 1.7.6) + - AppAuth/Core (1.7.6) + - AppAuth/ExternalUserAgent (1.7.6): + - AppAuth/Core + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) - bitbox_flutter (0.0.1): - Flutter - breez_sdk_spark_flutter (0.14.0): @@ -73,6 +83,34 @@ PODS: - Flutter - fluttertoast (0.0.2): - Flutter + - google_sign_in_ios (0.0.1): + - AppAuth (>= 1.7.4) + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 8.0) + - GTMSessionFetcher (>= 3.4.0) + - GoogleSignIn (8.0.0): + - AppAuth (< 2.0, >= 1.7.3) + - AppCheckCore (~> 11.0) + - GTMAppAuth (< 5.0, >= 4.1.1) + - GTMSessionFetcher/Core (~> 3.3) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMAppAuth (4.1.1): + - AppAuth/Core (~> 1.7) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher (3.5.0): + - GTMSessionFetcher/Full (= 3.5.0) + - GTMSessionFetcher/Core (3.5.0) + - GTMSessionFetcher/Full (3.5.0): + - GTMSessionFetcher/Core - image_picker_ios (0.0.1): - Flutter - in_app_review (2.0.0): @@ -88,6 +126,7 @@ PODS: - payjoin_flutter (0.23.0) - permission_handler_apple (9.4.8): - Flutter + - PromisesObjC (2.4.0) - quick_actions_ios (0.0.1): - Flutter - reown_yttrium (0.0.1): @@ -174,6 +213,7 @@ DEPENDENCIES: - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) + - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) @@ -200,10 +240,17 @@ DEPENDENCIES: SPEC REPOS: https://github.com/CocoaPods/Specs.git: + - AppAuth + - AppCheckCore - CryptoSwift - DKImagePickerController - DKPhotoGallery + - GoogleSignIn + - GoogleUtilities + - GTMAppAuth + - GTMSessionFetcher - OrderedSet + - PromisesObjC - SDWebImage - sqlite3 - SwiftyGif @@ -248,6 +295,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" + google_sign_in_ios: + :path: ".symlinks/plugins/google_sign_in_ios/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" in_app_review: @@ -296,6 +345,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/warp_api/ios" SPEC CHECKSUMS: + AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f bitbox_flutter: 9505732798041c413152669751beeaecc5fe400f breez_sdk_spark_flutter: ed3b6709b8ce9b40309d8728640eade5b19e8a0d connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd @@ -308,24 +359,30 @@ SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 dnssec_proof: d461cac7bd3301eb7447f87936745a0c1ae0a67e - fast_scanner: 44c00940355a51258cd6c2085734193cd23d95bc - file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 - Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 - flutter_local_authentication: 1172a4dd88f6306dadce067454e2c4caf07977bb - flutter_local_notifications: ff50f8405aaa0ccdc7dcfb9022ca192e8ad9688f - flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 - flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 - flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be - fluttertoast: 21eecd6935e7064cc1fcb733a4c5a428f3f24f0f - image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b - in_app_review: 436034b18594851a7328d7f1c2ed5ec235b79cfc - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 - local_auth_darwin: 63c73d6d28cc3e239be2b6aa460ea6e317cd5100 + fast_scanner: 2cb1ad3e69e645e9980fb4961396ce5804caa3e3 + file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517 + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99 + flutter_local_authentication: 989278c681612f1ee0e36019e149137f114b9d7f + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb + flutter_mailer: 3a8cd4f36c960fb04528d5471097270c19fec1c4 + flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + flutter_secure_storage: 2c2ff13db9e0a5647389bff88b0ecac56e3f3418 + fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 + google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 + GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 payjoin_flutter: d9d4c8aa16bd5dfedb9b21d0edc8199e0187d96e permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 quick_actions_ios: 4b07fb49d8d8f3518d7565fbb7a91014067a7d82 reown_yttrium: cee334ade64725b1d83f7b34c706a6aae2696d58 rive_common: dd421daaf9ae69f0125aa761dd96abd278399952 @@ -344,7 +401,6 @@ SPEC CHECKSUMS: universal_ble: 45519b2aeafe62761e2c6309f8927edb5288b914 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 - warp_api: 997d8fb3e777669bc6260d0c12e56cd40b9db1d1 YttriumWrapper: 31e937fe9fbe0f1314d2ca6be9ce9b379a059966 PODFILE CHECKSUM: 5296465b1c6d14d506230356756826012f65d97a diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 2c2584e8e..6b469cb1c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -26,7 +26,6 @@ A1B4A70C9CFA13AB71662216 /* LnurlPay.swift in Resources */ = {isa = PBXBuildFile; fileRef = 7D3364C03978A8A74B6D586E /* LnurlPay.swift */; settings = {ASSET_TAGS = (BreezSDK, ); }; }; A3D5E17CC53DF13FA740DEFA /* RedeemSwap.swift in Resources */ = {isa = PBXBuildFile; fileRef = 9D2F2C9F2555316C95EE7EA3 /* RedeemSwap.swift */; settings = {ASSET_TAGS = (BreezSDK, ); }; }; B6C6E59403ACDE44724C12F4 /* ServiceConfig.swift in Resources */ = {isa = PBXBuildFile; fileRef = B3D5E78267F5F18D882FDC3B /* ServiceConfig.swift */; settings = {ASSET_TAGS = (BreezSDK, ); }; }; - CE3D5B792F142305004D767D /* WarpApiFFI.xcframework in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE3D5B782F142305004D767D /* WarpApiFFI.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE918BF82D533ECE007F186E /* MoneroWallet.xcframework in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE918BF72D533ECE007F186E /* MoneroWallet.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE918BFA2D533ED4007F186E /* WowneroWallet.xcframework in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE918BF92D533ED4007F186E /* WowneroWallet.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; CE918BFC2D533ED8007F186E /* ZanoWallet.xcframework in CopyFiles */ = {isa = PBXBuildFile; fileRef = CE918BFB2D533ED8007F186E /* ZanoWallet.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -48,7 +47,6 @@ CE918BF82D533ECE007F186E /* MoneroWallet.xcframework in CopyFiles */, CE918BFA2D533ED4007F186E /* WowneroWallet.xcframework in CopyFiles */, CE918BFC2D533ED8007F186E /* ZanoWallet.xcframework in CopyFiles */, - CE3D5B792F142305004D767D /* WarpApiFFI.xcframework in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,7 +87,6 @@ B3D5E78267F5F18D882FDC3B /* ServiceConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ServiceConfig.swift; path = "../.symlinks/plugins/breez_sdk/ios/bindings-swift/Sources/BreezSDK/ServiceConfig.swift"; sourceTree = ""; }; B478EFCC3C0760ED97FFE561 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C58D93382C00FAC6004BCF69 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; - CE3D5B782F142305004D767D /* WarpApiFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = WarpApiFFI.xcframework; path = ../scripts/zcash_lib/WarpApiFFI.xcframework; sourceTree = ""; }; CE918BF72D533ECE007F186E /* MoneroWallet.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = MoneroWallet.xcframework; sourceTree = ""; }; CE918BF92D533ED4007F186E /* WowneroWallet.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = WowneroWallet.xcframework; sourceTree = ""; }; CE918BFB2D533ED8007F186E /* ZanoWallet.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = ZanoWallet.xcframework; sourceTree = ""; }; @@ -170,7 +167,6 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( - CE3D5B782F142305004D767D /* WarpApiFFI.xcframework */, 0C44A7182518EF4A00B570ED /* CakeWallet */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, diff --git a/lib/core/background_sync.dart b/lib/core/background_sync.dart index a63618d0c..c2e270575 100644 --- a/lib/core/background_sync.dart +++ b/lib/core/background_sync.dart @@ -210,7 +210,11 @@ class BackgroundSync { } await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name), tx.date.add(Duration(minutes: 1)).toIso8601String()); - final action = tx.direction == TransactionDirection.incoming ? "Received" : "Sent"; + final action = tx.additionalInfo['isAutoShield'] == true + ? "Autoshield" + : tx.direction == TransactionDirection.incoming + ? "Received" + : "Sent"; if (sharedPreferences.getBool(PreferencesKey.backgroundSyncNotificationsEnabled) ?? false) { await showNotification( "$action ${wallet.currency.fullName} in ${wallet.name}", "${tx.amount.toString()}"); diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index 5a673b58d..910ea069f 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -56,7 +56,7 @@ const dogecoinDefaultNodeUri = 'dogecoin.stackwallet.com:50022'; const baseDefaultNodeUri = 'base-rpc.publicnode.com'; const arbitrumDefaultNodeUri = 'arbitrum.nownodes.io'; const bscDefaultNodeUri = 'bsc-dataseed.bnbchain.org'; -const zcashDefaultNodeUri = 'zec-node.cakewallet.com:443'; +const zcashDefaultNodeUri = 'zec.rocks:443'; Future defaultSettingsMigration( {required int version, diff --git a/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart index 0ac7e724a..ff2248035 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart @@ -222,16 +222,37 @@ class _TransactionDetailsModalState extends State { Widget _buildBottomWidget(TransactionDetailsListItem item) { return switch (item.runtimeType) { - AddressListItem => AddressFormatter.buildSegmentedAddress( - address: item.value, - evenTextStyle: TextStyle( - fontSize: 12, - fontFamily: "IBM Plex Mono", - color: Theme.of(context).colorScheme.onSurface)), + AddressListItem => _segmentedAddressList(item.value), _ => Text( item.value, style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), ) }; } + + Widget _segmentedAddressList(String value) { + final style = TextStyle( + fontSize: 12, + fontFamily: "IBM Plex Mono", + color: Theme.of(context).colorScheme.onSurface, + ); + final lines = value + .split(RegExp(r'[,\n]')) + .map((final line) => line.trim()) + .where((final line) => line.isNotEmpty) + .toList(); + if (lines.length <= 1) { + return AddressFormatter.buildSegmentedAddress( + address: lines.isNotEmpty ? lines.first : value.trim(), + evenTextStyle: style, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in lines) + AddressFormatter.buildSegmentedAddress(address: line, evenTextStyle: style), + ], + ); + } } diff --git a/lib/src/screens/dashboard/pages/balance/crypto_balance_widget.dart b/lib/src/screens/dashboard/pages/balance/crypto_balance_widget.dart index 7c4a6ff46..932a220bd 100644 --- a/lib/src/screens/dashboard/pages/balance/crypto_balance_widget.dart +++ b/lib/src/screens/dashboard/pages/balance/crypto_balance_widget.dart @@ -334,37 +334,6 @@ class CryptoBalanceWidget extends StatelessWidget { ), ), ], - if (dashboardViewModel.showZcashMissingFundsCard) ...[ - SizedBox(height: 10), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: InfoCard( - title: S.of(context).zcash_card_missing_funds, - description: S.of(context).zcash_card_description, - leftButtonTitle: S.of(context).zcash_card_dismiss, - rightButtonTitle: S.of(context).zcash_card_scan, - image: 'assets/new-ui/crypto_full_icons/zcash.svg', - leftButtonAction: () => _dismissZcash(context), - rightButtonAction: () => _rescanInternalChangeZcash(context), - hintWidget: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => launchUrl( - Uri.parse( - "https://docs.cakewallet.com/tutorials/zashi/#creating-a-new-zcash-wallet-on-cake"), - mode: LaunchMode.externalApplication, - ), - child: Text( - S.of(context).learn_more, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - height: 1, - ), - softWrap: true, - ), - ), - ), - ), - ], if (dashboardViewModel.showDecredInfoCard) ...[ SizedBox(height: 10), Padding( @@ -494,21 +463,6 @@ class CryptoBalanceWidget extends StatelessWidget { dashboardViewModel.setMwebEnabled(); } - Future _rescanInternalChangeZcash(BuildContext context) async { - dashboardViewModel.rescanInternalChangeZcash(); - await showPopUp( - context: context, - builder: (BuildContext context) => AlertWithOneAction( - alertTitle: S.of(context).alert_notice, - alertContent: S.of(context).zcash_card_warning, - buttonText: S.of(context).understand, - buttonAction: () { - Navigator.of(context).pop(); - }, - ), - ); - } - Future _dismissMweb(BuildContext context) async { await showPopUp( context: context, @@ -522,18 +476,4 @@ class CryptoBalanceWidget extends StatelessWidget { )); dashboardViewModel.dismissMweb(); } - - Future _dismissZcash(BuildContext context) async { - await showPopUp( - context: context, - builder: (BuildContext context) => AlertWithOneAction( - alertTitle: S.of(context).alert_notice, - alertContent: S.of(context).zcash_card_enable_later, - buttonText: S.of(context).understand, - buttonAction: () { - Navigator.of(context).pop(); - }, - )); - dashboardViewModel.dismissZcash(); - } } diff --git a/lib/src/screens/dashboard/pages/transactions_page.dart b/lib/src/screens/dashboard/pages/transactions_page.dart index 9e623c044..6af5f75b0 100644 --- a/lib/src/screens/dashboard/pages/transactions_page.dart +++ b/lib/src/screens/dashboard/pages/transactions_page.dart @@ -111,7 +111,7 @@ class TransactionsPage extends StatelessWidget { key: item.key, onTap: () => Navigator.of(context) .pushNamed(Routes.transactionDetails, arguments: transaction), - isShield: transaction.additionalInfo['autoShield'] == true, + isShield: transaction.additionalInfo['isAutoShield'] == true, direction: transaction.direction, formattedDate: DateFormat('HH:mm').format(transaction.date), formattedAmount: item.formattedCryptoAmount, diff --git a/lib/src/screens/settings/display_settings_page.dart b/lib/src/screens/settings/display_settings_page.dart index 5a04b86e2..6a3fa6f69 100644 --- a/lib/src/screens/settings/display_settings_page.dart +++ b/lib/src/screens/settings/display_settings_page.dart @@ -115,21 +115,12 @@ class DisplaySettingsPage extends StatelessWidget { onChanged: (val) { _displaySettingsViewModel.setDisableTradeOption(val); }), - if (_displaySettingsViewModel.showZcashCardSetting) - ListItemToggle( - keyValue: "display_settings_show_zcashcard", - label: S.of(context).show_zcash_card, - value: _displaySettingsViewModel.showZcashCard, - onChanged: (val) { - _displaySettingsViewModel.setShowZcashCard(val); - }), ListItemSelector( keyValue: "display_settings_sync_status_display", label: S.of(context).sync_status_display_mode, options: [_displaySettingsViewModel.syncStatusDisplayMode.title], onTap: () async { final items = SyncStatusDisplayMode.values.toList(); - final selectedAtIndex = items.indexOf(_displaySettingsViewModel.syncStatusDisplayMode); diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index 4d6a0d527..e2554b225 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -60,8 +60,13 @@ class TransactionListItem extends ActionListItem with Keyable { return 'Transaction has missing data'; } - if (transaction.additionalInfo['autoShield'] == true) { - return "Autoshield"; + if (transaction.additionalInfo['isAutoShield'] == true) { + if (transaction.isPending) { + final status = formattedStatus; + final baseString = S.current.shielding; + return status.isNotEmpty ? "$baseString $status" : "$baseString..."; + } + return S.current.shielding; } if (transaction.isPending) { final status = formattedStatus; diff --git a/lib/view_model/send/output.dart b/lib/view_model/send/output.dart index 2933fbef8..ebd12a16d 100644 --- a/lib/view_model/send/output.dart +++ b/lib/view_model/send/output.dart @@ -21,6 +21,7 @@ import 'package:cw_core/balance.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency_for_wallet_type.dart'; +import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_history.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/utils/print_verbose.dart'; @@ -50,8 +51,9 @@ abstract class OutputBase with Store { parsedAddress = ParsedAddress(parsedAddressByCurrencyMap: {}) { autorun((_) { final status = _wallet.syncStatus; - printV("Sync status changed to $status. Recalculating fees"); - + if (status is! SyncedSyncStatus) { + return; + } calculateEstimatedFee(); }); } diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index a3b7d8ae8..16f12ac0b 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -391,8 +391,8 @@ abstract class TransactionDetailsViewModelBase with Store { } String get formattedTitle { - if (transactionInfo.additionalInfo['autoShield'] == true) { - return "Autoshield"; + if (transactionInfo.additionalInfo['isAutoShield'] == true) { + return S.current.shielding; } if (transactionInfo.direction == TransactionDirection.incoming) { return S.current.received; diff --git a/lib/zcash/cw_zcash.dart b/lib/zcash/cw_zcash.dart index ee6505fb1..1284aa335 100644 --- a/lib/zcash/cw_zcash.dart +++ b/lib/zcash/cw_zcash.dart @@ -209,16 +209,10 @@ class CWZcash extends Zcash { } @override - bool showMissingFundsCard(WalletBase wallet) { - final zcashWallet = wallet as ZcashWallet; - return zcashWallet.couldBeZashiWallet(); - } + bool showMissingFundsCard(WalletBase wallet) => false; @override - Future rescanInternalChange(WalletBase wallet) { - final zcashWallet = wallet as ZcashWallet; - return zcashWallet.rescanInternalChange(); - } + Future rescanInternalChange(WalletBase wallet) async {} } const wordList = [ diff --git a/pubspec_base.yaml b/pubspec_base.yaml index ee0c0f861..fb008138f 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -268,8 +268,6 @@ flutter: - assets/text/ - assets/faq/ - assets/animation/ - - scripts/zcash_lib/assets/sapling-spend.params - - scripts/zcash_lib/assets/sapling-output.params - assets/new-ui/ - assets/new-ui/icons/ - assets/new-ui/hero/ diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index efde482cf..e52f35696 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -1071,6 +1071,8 @@ "share_this_node": "Share this node", "shared_seed_wallet_groups": "Shared Seed Wallet Groups", "shared_seed_wallets": "Shared Seed Wallets", + "share_this_node": "Share this node", + "shielding": "Autoshield", "show": "Show", "show_address_book_popup": "Show Address Book popup", "show_balance": "Long Press to Show Balance",