From 28c2cc152ef310c0d2e51fadd378521cf925051e Mon Sep 17 00:00:00 2001 From: cyan Date: Fri, 17 Jul 2026 09:01:31 -0400 Subject: [PATCH 1/5] feat: zkool2 (#3165) * feat: zkool2 * feat: rescan on zkool fix: sending to extracted addresses fix: labeling shielding txs fix: autoshield endless loop fix: autoshield t addr not firing fix: autoshield destinations chore: remove old routes for rescan with zkool other minor fixes/cleanups * fix: zcash hardfork * fix: update sync progress more reliably * fix: pending transaction loading fix: autoshield txs fix: taddress fix: fee estimation fix: tx send errors * chore: bump zkool2 to latest version fix: reduce amount of taddrs generated fix: derivation path for internal wallets fix: display address in correct hidden/usable spots fix: get wallet db height before it syncs for the first time fix: taddr rotation with passphrase fix: sync progress ui refresh fix: taddress generation not getting capped fix: hidden addresses not being hidden fix: pending transactions not being shown fix: t address cache misses fix: t address rotation not getting refreshed fix: t address high index not being used * fix: refresh t addresses * fix: patch rust side to make t addr rotation work * fix: ui issue in recipient address fix: zcash multiaddress send to show in the ui fix: potential race condition * minor fixes * format * fix: flashing on receive screen * remove unused 'autoShield' * review --- .github/workflows/pr_test_build_linux.yml | 5 - .gitignore | 3 +- assets/zcash_node_list.yml | 4 +- cw_core/lib/get_height_by_date_zec.dart | 416 ++++ cw_zcash/lib/cw_zcash.dart | 1 - cw_zcash/lib/src/legacy/zkool_sweep.dart | 196 -- cw_zcash/lib/src/migrate.dart | 96 + .../lib/src/pending_zcash_transaction.dart | 80 +- cw_zcash/lib/src/util/hex.dart | 5 + cw_zcash/lib/src/zcash_mempool.dart | 118 ++ ...zcash_mnemonic_is_incorrect_exception.dart | 5 - cw_zcash/lib/src/zcash_taddress_rotation.dart | 831 ++++---- cw_zcash/lib/src/zcash_transaction_info.dart | 64 +- cw_zcash/lib/src/zcash_wallet.dart | 1846 ++++++++++------- cw_zcash/lib/src/zcash_wallet_addresses.dart | 213 +- cw_zcash/lib/src/zcash_wallet_service.dart | 53 +- cw_zcash/lib/src/zkool_compat.dart | 3 + cw_zcash/lib/src/zkooltx.dart | 312 +++ cw_zcash/pubspec.yaml | 8 +- ios/Podfile.lock | 86 +- ios/Runner.xcodeproj/project.pbxproj | 4 - lib/core/background_sync.dart | 6 +- lib/entities/default_settings_migration.dart | 2 +- .../transaction_details_modal.dart | 33 +- .../pages/balance/crypto_balance_widget.dart | 60 - .../dashboard/pages/transactions_page.dart | 2 +- .../settings/display_settings_page.dart | 9 - .../dashboard/transaction_list_item.dart | 9 +- lib/view_model/send/output.dart | 6 +- .../transaction_details_view_model.dart | 4 +- lib/zcash/cw_zcash.dart | 10 +- pubspec_base.yaml | 2 - res/values/strings_en.arb | 2 + 33 files changed, 2843 insertions(+), 1651 deletions(-) create mode 100644 cw_core/lib/get_height_by_date_zec.dart delete mode 100644 cw_zcash/lib/src/legacy/zkool_sweep.dart create mode 100644 cw_zcash/lib/src/migrate.dart create mode 100644 cw_zcash/lib/src/util/hex.dart create mode 100644 cw_zcash/lib/src/zcash_mempool.dart delete mode 100644 cw_zcash/lib/src/zcash_mnemonic_is_incorrect_exception.dart create mode 100644 cw_zcash/lib/src/zkool_compat.dart create mode 100644 cw_zcash/lib/src/zkooltx.dart 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", From 03edd658a525912e1980a617daf06d6ee00c33b2 Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Fri, 17 Jul 2026 16:49:17 +0200 Subject: [PATCH 2/5] Cw 1539 lightning enhancements (#3400) * feat: add Lightning transaction URL support in transaction details view model * fix: update currency selection logic in WalletAddressListViewModel and ReceivePage * feat: integrate computed transaction amount and fee getters in TransactionDetailsViewModel, refactor CopyWrapper logic in TransactionDetailsModal * refactor: apply lint rules * refactor: apply lint rules and improve LNURL handling with new methods for withdrawal requests and error checks * auto-reformat * fix: receive_page regression * fix: Withdraw lightning to other btc wallet gives LN address * chore: update .lock files [skip ci] --------- Co-authored-by: Robert Malikowski Co-authored-by: Omar Hatem --- cw_bitcoin/pubspec.lock | 10 +- cw_core/lib/lnurl.dart | 177 ++++-- cw_core/pubspec.lock | 10 +- cw_decred/pubspec.lock | 8 +- cw_monero/pubspec.lock | 10 +- cw_nano/pubspec.lock | 8 +- cw_wownero/pubspec.lock | 8 +- cw_zano/pubspec.lock | 8 +- .../widgets/rounded_overlay_cards_widget.dart | 16 +- lib/core/lightning_invoice_service.dart | 36 +- lib/new-ui/pages/receive_page.dart | 236 ++++---- lib/new-ui/pages/send_page.dart | 4 +- .../assets_history_section.dart | 9 +- .../transaction_details_modal.dart | 360 ++++++------ .../send_page/l2_action_wallet_selector.dart | 113 ++-- .../transaction_details/rbf_details_page.dart | 304 +++++----- .../transaction_details_view_model.dart | 526 +++++++++--------- .../wallet_address_list_view_model.dart | 353 ++++++------ 18 files changed, 1193 insertions(+), 1003 deletions(-) diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index 6c15a9ad1..b35b2bf09 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -634,10 +634,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1100,10 +1100,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" torch_dart: dependency: transitive description: @@ -1322,5 +1322,5 @@ packages: source: hosted version: "2.2.2" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.9.0 <4.0.0" flutter: ">=3.29.0" diff --git a/cw_core/lib/lnurl.dart b/cw_core/lib/lnurl.dart index 8ea33b99d..e0d17685a 100644 --- a/cw_core/lib/lnurl.dart +++ b/cw_core/lib/lnurl.dart @@ -1,19 +1,20 @@ -import 'dart:convert'; +import "dart:convert"; -import 'package:bech32/bech32.dart'; -import 'package:cw_core/amount/money.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/utils/proxy_wrapper.dart'; +import "package:bech32/bech32.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/utils/proxy_wrapper.dart"; const _BOLT_PREFIXES = ["lnbcrt", "lntbs", "lnbc", "lntb"]; -const _LUD17_PREFIXES = ['lnurlw', 'lnurlc', 'lnurlp', 'keyauth']; +const _LUD17_PREFIXES = ["lnurlw", "lnurlc", "lnurlp", "keyauth"]; bool isBolt11ZeroInvoice(String invoice) { try { - final request = Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length); + final request = + const Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length); final prefix = - _BOLT_PREFIXES.firstWhere((prefix) => request.hrp.startsWith(prefix), orElse: () => ""); + _BOLT_PREFIXES.firstWhere(request.hrp.startsWith, orElse: () => ""); return request.hrp.length == prefix.length; } catch (e) { @@ -23,48 +24,56 @@ bool isBolt11ZeroInvoice(String invoice) { /// Get the amount of a Bolt 11 Invoice in int? _getAmountBolt11Msat(String invoice) { - final request = Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length); + final request = + const Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length); final prefix = _BOLT_PREFIXES.firstWhere( - (prefix) => request.hrp.startsWith(prefix), - orElse: () => throw FormatException('Invalid BOLT11 invoice: unknown HRP prefix.'), + request.hrp.startsWith, + orElse: () => throw const FormatException("Invalid BOLT11 invoice: unknown HRP prefix."), ); final amountPart = request.hrp.substring(prefix.length); - if (amountPart.isEmpty) return null; // zero-amount invoice + if (amountPart.isEmpty) { + return null; // zero-amount invoice + } - final hasMultiplier = RegExp(r'[munp]$').hasMatch(amountPart); - final multiplier = hasMultiplier ? amountPart[amountPart.length - 1] : ''; + final hasMultiplier = RegExp(r"[munp]$").hasMatch(amountPart); + final multiplier = hasMultiplier ? amountPart[amountPart.length - 1] : ""; final numberStr = hasMultiplier ? amountPart.substring(0, amountPart.length - 1) : amountPart; - if (numberStr.isEmpty || !RegExp(r'^\d+$').hasMatch(numberStr)) { - throw FormatException('Invalid BOLT11 invoice: invalid amount number in HRP.'); + if (numberStr.isEmpty || !RegExp(r"^\d+$").hasMatch(numberStr)) { + throw const FormatException("Invalid BOLT11 invoice: invalid amount number in HRP."); } final amount = int.parse(numberStr); switch (multiplier) { - case '': // bitcoin + case "": // bitcoin return amount * 100000000000; - case 'm': // milli-bitcoin + case "m": // milli-bitcoin return amount * 100000000; - case 'u': // micro-bitcoin + case "u": // micro-bitcoin return amount * 100000; - case 'n': // nano-bitcoin + case "n": // nano-bitcoin return amount * 100; - case 'p': + case "p": if (amount % 10 != 0) { - throw FormatException('Invalid BOLT11 invoice: amount not representable in whole msat.'); + throw const FormatException( + "Invalid BOLT11 invoice: amount not representable in whole msat.", + ); } return amount ~/ 10; default: - throw FormatException('Invalid BOLT11 invoice: unknown amount multiplier.'); + throw const FormatException("Invalid BOLT11 invoice: unknown amount multiplier."); } } Money? getBolt11Amount(String invoice) { final msat = _getAmountBolt11Msat(invoice); - if (msat == null || msat % 1000 != 0) return null; + if (msat == null || msat % 1000 != 0) { + return null; + } + return Money.fromInt(msat ~/ 1000, CryptoCurrency.btcln); } @@ -73,30 +82,99 @@ class LNURL { final url = decode(lnurl); final response = await ProxyWrapper().get(clearnetUri: url); - if (response.statusCode != 200) return null; + if (response.statusCode != 200) { + return null; + } try { final body = jsonDecode(response.body) as Map; final tag = body["tag"] as String?; - if (tag != "payRequest") return null; + if (tag != "payRequest") { + return null; + } final msat = body["minSendable"] as int?; final maxSendable = body["maxSendable"] as int?; // if minSendable and maxSendable are the same we assume a specific payment request - if (msat != maxSendable) return null; + if (msat != maxSendable) { + return null; + } - if (msat == null || msat % 1000 != 0) return null; + if (msat == null || msat % 1000 != 0) { + return null; + } return Money.fromInt(msat ~/ 1000, CryptoCurrency.btcln); } catch (_) { return null; } } + static Future getWithdrawRequest(String lnurl) async { + final url = decode(lnurl); + final response = await ProxyWrapper().get(clearnetUri: url); + + if (response.statusCode != 200) { + return null; + } + + try { + final body = jsonDecode(response.body) as Map; + final tag = body["tag"] as String?; + + if (tag != "withdrawRequest") { + return null; + } + + final minWithdrawable = body["minWithdrawable"] as int?; + final maxWithdrawable = body["maxWithdrawable"] as int?; + + if ((minWithdrawable == null || minWithdrawable % 1000 != 0) || + (maxWithdrawable == null || maxWithdrawable % 1000 != 0)) { + return null; + } + + return LNURLWithdrawRequest( + callback: body["callback"] as String, + k1: body["k1"] as String, + description: body["defaultDescription"] as String, + minAmount: Money.fromInt(minWithdrawable ~/ 1000, CryptoCurrency.btcln), + maxAmount: Money.fromInt(maxWithdrawable ~/ 1000, CryptoCurrency.btcln), + ); + } catch (_) { + return null; + } + } + + Future commitWithdrawRequest(LNURLWithdrawRequest request, String invoice) async { + try { + final url = Uri.parse(request.callback); + url.queryParameters["k1"] = request.k1; + url.queryParameters["pr"] = invoice; + + final response = await ProxyWrapper().get(clearnetUri: url); + + if (response.statusCode != 200) { + return false; + } + + final body = jsonDecode(response.body) as Map; + final status = body["status"] as String?; + + if (status == "OK") { + return true; + } + + return false; + } catch (_) { + return false; + } + } + static String encode(String url) { final raw = _convert(utf8.encode(url), 8, 5, true); - return const Bech32Codec().encode(Bech32('lnurl', raw), 999); + return const Bech32Codec().encode(Bech32("lnurl", raw), 999); } static Uri decode(String encodedUrl) { @@ -114,9 +192,10 @@ class LNURL { if (_LUD17_PREFIXES.contains(decodedUri.scheme)) { /// If the non-bech32 LNURL is a Tor address, the port has to be http instead of https for the clearnet LNURL so check if the host ends with '.onion' or '.onion.' decodedUri = decodedUri.replace( - scheme: decodedUri.host.endsWith('onion') || decodedUri.host.endsWith('onion.') - ? 'http' - : 'https'); + scheme: decodedUri.host.endsWith("onion") || decodedUri.host.endsWith("onion.") + ? "http" + : "https", + ); } else { /// Try to parse the input as a lnUrl. Will throw an error if it fails. final lnUrl = _findLnUrl(encodedUrl); @@ -129,12 +208,30 @@ class LNURL { } } +class LNURLWithdrawRequest { + const LNURLWithdrawRequest({ + required this.callback, + required this.k1, + required this.description, + required this.minAmount, + required this.maxAmount, + }); + + final String callback; + final String k1; + final String description; + final Money minAmount; + final Money maxAmount; +} + /// Parse and return a given lnurl string if it's valid. Will remove /// `lightning:` from the beginning of it if present. String _findLnUrl(String input) { - final res = RegExp(r',*?((lnurl)([0-9]+[a-z0-9]+))').allMatches(input.toLowerCase()); + final res = RegExp(r",*?((lnurl)([0-9]+[a-z0-9]+))").allMatches(input.toLowerCase()); - if (res.length != 1) throw ArgumentError('Not a valid lnurl string'); + if (res.length != 1) { + throw ArgumentError("Not a valid lnurl string"); + } return res.first.group(0)!; } @@ -156,10 +253,16 @@ List _convert(List data, int inBits, int outBits, bool pad) { } if (pad) { - if (bits > 0) result.add((value << (outBits - bits)) & maxV); + if (bits > 0) { + result.add((value << (outBits - bits)) & maxV); + } } else { - if (bits >= inBits) throw Exception('[BECH32] Excess padding'); - if ((value << (outBits - bits)) & maxV > 0) throw Exception('[BECH32] Non-zero padding'); + if (bits >= inBits) { + throw Exception("[BECH32] Excess padding"); + } + if ((value << (outBits - bits)) & maxV > 0) { + throw Exception("[BECH32] Non-zero padding"); + } } return result; diff --git a/cw_core/pubspec.lock b/cw_core/pubspec.lock index 908829bcb..8eff1d338 100644 --- a/cw_core/pubspec.lock +++ b/cw_core/pubspec.lock @@ -394,10 +394,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -738,10 +738,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" torch_dart: dependency: "direct main" description: @@ -838,5 +838,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.9.0 <4.0.0" flutter: ">=3.27.0" diff --git a/cw_decred/pubspec.lock b/cw_decred/pubspec.lock index 0b460e9f9..9a568daa7 100644 --- a/cw_decred/pubspec.lock +++ b/cw_decred/pubspec.lock @@ -417,10 +417,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -769,10 +769,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" torch_dart: dependency: transitive description: diff --git a/cw_monero/pubspec.lock b/cw_monero/pubspec.lock index b32f56c5b..79da36944 100644 --- a/cw_monero/pubspec.lock +++ b/cw_monero/pubspec.lock @@ -505,10 +505,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -906,10 +906,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" torch_dart: dependency: transitive description: @@ -1048,5 +1048,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0-0 <4.0.0" + dart: ">=3.9.0 <4.0.0" flutter: ">=3.24.0" diff --git a/cw_nano/pubspec.lock b/cw_nano/pubspec.lock index 2dd436985..509d74498 100644 --- a/cw_nano/pubspec.lock +++ b/cw_nano/pubspec.lock @@ -462,10 +462,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -887,10 +887,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" torch_dart: dependency: transitive description: diff --git a/cw_wownero/pubspec.lock b/cw_wownero/pubspec.lock index 7fd7eac2d..4b622b350 100644 --- a/cw_wownero/pubspec.lock +++ b/cw_wownero/pubspec.lock @@ -417,10 +417,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -786,10 +786,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" torch_dart: dependency: transitive description: diff --git a/cw_zano/pubspec.lock b/cw_zano/pubspec.lock index a4a6c5381..c4602f532 100644 --- a/cw_zano/pubspec.lock +++ b/cw_zano/pubspec.lock @@ -422,10 +422,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -783,10 +783,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" torch_dart: dependency: transitive description: diff --git a/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart b/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart index 9df2848f8..484792bf5 100644 --- a/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart +++ b/lib/cake_pay/src/widgets/rounded_overlay_cards_widget.dart @@ -15,14 +15,14 @@ class RoundedOverlayCards extends StatelessWidget { return ClipRRect( borderRadius: - BorderRadius.only(bottomLeft: Radius.circular(25.0), bottomRight: Radius.circular(25.0)), + BorderRadius.only(bottomLeft: Radius.circular(25), bottomRight: Radius.circular(25)), child: Container( decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(24), - bottomRight: Radius.circular(24), - ), - color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + color: Theme.of(context).colorScheme.surfaceContainer, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -30,8 +30,8 @@ class RoundedOverlayCards extends StatelessWidget { Flexible( child: ClipRRect( borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(25.0), - bottomRight: Radius.circular(25.0), + bottomLeft: Radius.circular(25), + bottomRight: Radius.circular(25), ), child: Container( decoration: BoxDecoration( diff --git a/lib/core/lightning_invoice_service.dart b/lib/core/lightning_invoice_service.dart index 995c62d6b..8b29f6c5d 100644 --- a/lib/core/lightning_invoice_service.dart +++ b/lib/core/lightning_invoice_service.dart @@ -1,6 +1,6 @@ -import 'dart:convert'; +import "dart:convert"; -import 'package:cw_core/utils/proxy_wrapper.dart'; +import "package:cw_core/utils/proxy_wrapper.dart"; Future getBolt11FromLightingAddress(String lightningAddress, {int amount = 0}) async { try { @@ -31,28 +31,34 @@ Uri getURlOfLightningAddress(String lightningAddress) { } class _LNURLPResponseDTO { + const _LNURLPResponseDTO( + this.callback, + this.maxSendable, + this.minSendable, + this.tag, + this.metadata, + ); + + factory _LNURLPResponseDTO.fromJson(Map map) => _LNURLPResponseDTO( + map["callback"] as String, + map["maxSendable"] as int, + map["minSendable"] as int, + map["tag"] as String, + map["metadata"] as String, + ); + final String callback; final int maxSendable; final int minSendable; final String tag; final String metadata; - - const _LNURLPResponseDTO( - this.callback, this.maxSendable, this.minSendable, this.tag, this.metadata); - - static _LNURLPResponseDTO fromJson(Map map) => _LNURLPResponseDTO( - map["callback"] as String, - map["maxSendable"] as int, - map["minSendable"] as int, - map["tag"] as String, - map["metadata"] as String); } class _LNURLPCallbackResponseDTO { - final String pr; - const _LNURLPCallbackResponseDTO(this.pr); - static _LNURLPCallbackResponseDTO fromJson(Map map) => + factory _LNURLPCallbackResponseDTO.fromJson(Map map) => _LNURLPCallbackResponseDTO(map["pr"] as String); + + final String pr; } diff --git a/lib/new-ui/pages/receive_page.dart b/lib/new-ui/pages/receive_page.dart index e1c6afd01..4b7846c03 100644 --- a/lib/new-ui/pages/receive_page.dart +++ b/lib/new-ui/pages/receive_page.dart @@ -1,54 +1,54 @@ -import 'package:cake_wallet/core/utilities.dart'; -import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/new-ui/widgets/modern_button.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/payjoin_copy_modal.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_address_type.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_address_widget.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_amount_display.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_amount_modal.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_bottom_buttons.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_info_box.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_label_modal.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_label_widget.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_large_amount_preview.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_qr_code.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_token_display.dart'; -import 'package:cake_wallet/utils/share_util.dart'; -import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cake_wallet/view_model/dashboard/receive_option_view_model.dart'; -import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; -import 'package:cake_wallet/zcash/zcash.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/payment_uris.dart'; -import 'package:cw_core/receive_page_option.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:mobx/mobx.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; -import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:cake_wallet/bitcoin/bitcoin.dart'; -import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/anonpay/anonpay_donation_link_info.dart'; -import 'package:cake_wallet/entities/preferences_key.dart'; -import 'package:cake_wallet/src/screens/receive/anonpay_receive_page.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; +import "package:cake_wallet/anonpay/anonpay_donation_link_info.dart"; +import "package:cake_wallet/bitcoin/bitcoin.dart"; +import "package:cake_wallet/core/utilities.dart"; +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/entities/auto_generate_subaddress_status.dart"; +import "package:cake_wallet/entities/preferences_key.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/modern_button.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/payjoin_copy_modal.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_address_type.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_address_widget.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_amount_display.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_amount_modal.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_bottom_buttons.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_info_box.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_label_modal.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_label_widget.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_large_amount_preview.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_qr_code.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_token_display.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/screens/receive/anonpay_receive_page.dart"; +import "package:cake_wallet/utils/share_util.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cake_wallet/view_model/dashboard/receive_option_view_model.dart"; +import "package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart"; +import "package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart"; +import "package:cake_wallet/zcash/zcash.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/payment_uris.dart"; +import "package:cw_core/receive_page_option.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; +import "package:mobx/mobx.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; +import "package:shared_preferences/shared_preferences.dart"; class NewReceivePage extends StatefulWidget { - NewReceivePage( - {super.key, - required this.addressListViewModel, - required this.receiveOptionViewModel, - required this.dashboardViewModel, - required this.lightningMode, - CryptoCurrency? initialCurrency}) { + NewReceivePage({ + required this.addressListViewModel, + required this.receiveOptionViewModel, + required this.dashboardViewModel, + required this.lightningMode, + super.key, + CryptoCurrency? initialCurrency, + }) { if (initialCurrency != null && initialCurrency != addressListViewModel.selectedCurrency) { addressListViewModel.setTokenCurrency(initialCurrency); } @@ -78,31 +78,33 @@ class _NewReceivePageState extends State { .firstWhereOrNull((item) => item.value.contains("Lightning")) ?? ReceivePageOption.mainnet, ); - widget.addressListViewModel.setTokenCurrency(CryptoCurrency.btcln); + widget.addressListViewModel.selectedCurrency = CryptoCurrency.btcln; } else if (widget.addressListViewModel.wallet.type == WalletType.bitcoin) { widget.receiveOptionViewModel.selectReceiveOption( widget.receiveOptionViewModel.options .firstWhereOrNull((item) => item.value.contains("Standard")) ?? ReceivePageOption.mainnet, ); + widget.addressListViewModel.selectedCurrency = CryptoCurrency.btc; } }); - reaction((_) => widget.addressListViewModel.uri, (newAddress) { - _reloadAddressWithLabel(newAddress); - }); + reaction((_) => widget.addressListViewModel.uri, _reloadAddressWithLabel); - _addressItemWithLabel = - widget.addressListViewModel.forceRecomputeItems.firstWhereOrNull((item) { - return (item is WalletAddressListItem && - item.address == widget.addressListViewModel.uri.address); - }) as WalletAddressListItem?; + _addressItemWithLabel = widget.addressListViewModel.forceRecomputeItems.firstWhereOrNull( + (item) => + item is WalletAddressListItem && item.address == widget.addressListViewModel.uri.address, + ) as WalletAddressListItem?; - reaction((_) => widget.receiveOptionViewModel.selectedReceiveOption, - (ReceivePageOption option) { + reaction((_) => widget.receiveOptionViewModel.selectedReceiveOption, (option) { if (widget.dashboardViewModel.type == WalletType.bitcoin && bitcoin!.isBitcoinReceivePageOption(option)) { widget.addressListViewModel.setAddressType(bitcoin!.getOptionToType(option)); + if (option.value.contains("Lightning")) { + widget.addressListViewModel.selectedCurrency = CryptoCurrency.btcln; + } else { + widget.addressListViewModel.selectedCurrency = CryptoCurrency.btc; + } return; } if (widget.dashboardViewModel.type == WalletType.zcash) { @@ -166,16 +168,18 @@ class _NewReceivePageState extends State { final hasAddressTypeSelector = widget.receiveOptionViewModel.options.length > 1; final hasLabel = _addressItemWithLabel?.name != null && _addressItemWithLabel!.name!.isNotEmpty; final infoboxDismissed = widget.addressListViewModel.wallet.walletInfo.receiveInfoboxDismissed; - final infobox = ReceiveInfoBox.forWalletType(widget.addressListViewModel.type, - supportedCurrencies: widget.addressListViewModel.tokenCurrencies - .whereType() - .toList(), onDismissed: () { - widget.addressListViewModel.dismissInfobox(); - setState(() {}); - }, - autoGenerateSubaddressStatus: widget.lightningMode - ? AutoGenerateSubaddressStatus.disabled - : widget.dashboardViewModel.settingsStore.autoGenerateSubaddressStatus); + final infobox = ReceiveInfoBox.forWalletType( + widget.addressListViewModel.type, + supportedCurrencies: + widget.addressListViewModel.tokenCurrencies.whereType().toList(), + onDismissed: () { + widget.addressListViewModel.dismissInfobox(); + setState(() {}); + }, + autoGenerateSubaddressStatus: widget.lightningMode + ? AutoGenerateSubaddressStatus.disabled + : widget.dashboardViewModel.settingsStore.autoGenerateSubaddressStatus, + ); return Container( decoration: BoxDecoration( @@ -187,9 +191,7 @@ class _NewReceivePageState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, ), - borderRadius: BorderRadius.vertical( - top: Radius.circular(24), - ), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), ), child: SafeArea( child: Column( @@ -198,13 +200,13 @@ class _NewReceivePageState extends State { children: [ ModalTopBar( title: _largeQrMode ? "" : S.of(context).receive, - leadingIcon: Icon(Icons.close), + leadingIcon: const Icon(Icons.close), onLeadingPressed: () { Navigator.of(context, rootNavigator: true).pop(); }, trailingWidget: Observer( builder: (_) => AnimatedSwitcher( - duration: Duration(milliseconds: 300), + duration: const Duration(milliseconds: 300), child: _largeQrMode || widget.addressListViewModel.hasAddressRotation /* TODO rotating is broken on mweb, disabling for now, fix after mvp*/ @@ -217,10 +219,10 @@ class _NewReceivePageState extends State { key: ValueKey(_largeQrMode), size: 36, icon: _largeQrMode - ? Icon(Icons.share) + ? const Icon(Icons.share) : widget.addressListViewModel.isRotatingAddress - ? CupertinoActivityIndicator() - : Icon(Icons.refresh), + ? const CupertinoActivityIndicator() + : const Icon(Icons.refresh), onPressed: () { if (_largeQrMode) { ShareUtil.share( @@ -232,8 +234,9 @@ class _NewReceivePageState extends State { widget.addressListViewModel.rotateAddress(); } } - }) - : SizedBox.shrink(), + }, + ) + : const SizedBox.shrink(), ), ), ), @@ -269,11 +272,12 @@ class _NewReceivePageState extends State { addressListViewModel: widget.addressListViewModel, ), GestureDetector( - onTap: _showLabelModal, - child: ReceiveLabelWidget( - name: _addressItemWithLabel?.name ?? "", - largeQrMode: _largeQrMode, - )), + onTap: _showLabelModal, + child: ReceiveLabelWidget( + name: _addressItemWithLabel?.name ?? "", + largeQrMode: _largeQrMode, + ), + ), Observer( builder: (_) => ReceiveBottomButtons( key: const ValueKey(0), @@ -285,14 +289,16 @@ class _NewReceivePageState extends State { : ClipboardData( text: widget.addressListViewModel.displayAmount.isEmpty ? widget.addressListViewModel.uri.address - : widget.addressListViewModel.uri.toString()), + : widget.addressListViewModel.uri.toString(), + ), onCopyButtonPressed: () { if (widget.addressListViewModel.hasPayjoin) { showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) => - PayjoinCopyModal(uri: widget.addressListViewModel.uri)); + isScrollControlled: true, + context: context, + builder: (context) => + PayjoinCopyModal(uri: widget.addressListViewModel.uri), + ); } }, onAmountButtonPressed: () { @@ -300,12 +306,10 @@ class _NewReceivePageState extends State { context: context, backgroundColor: Colors.transparent, barrierColor: Colors.black.withAlpha(80), - builder: (context) { - return ReceiveAmountModal( - walletAddressListViewModel: widget.addressListViewModel, - onSubmitted: (amount) {}, - ); - }, + builder: (context) => ReceiveAmountModal( + walletAddressListViewModel: widget.addressListViewModel, + onSubmitted: (amount) {}, + ), ); }, onLabelButtonPressed: _showLabelModal, @@ -318,22 +322,25 @@ class _NewReceivePageState extends State { ), ), ReceiveLargeAmountPreview( - amount: widget.addressListViewModel.displayAmount, - currency: widget.addressListViewModel.cryptoCurrencySymbol, - largeQrMode: _largeQrMode), + amount: widget.addressListViewModel.displayAmount, + currency: widget.addressListViewModel.cryptoCurrencySymbol, + largeQrMode: _largeQrMode, + ), if (infobox != null && !widget.addressListViewModel.isLightning) ClipRect( - child: AnimatedAlign( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - heightFactor: infoboxDismissed ? 0 : 1, - alignment: Alignment.center, - child: AnimatedOpacity( + child: AnimatedAlign( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + heightFactor: infoboxDismissed ? 0 : 1, + alignment: Alignment.center, + child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: infoboxDismissed ? 0 : 1, curve: Curves.easeOutCubic, - child: infobox), - )) + child: infobox, + ), + ), + ), ], ), ), @@ -345,12 +352,11 @@ class _NewReceivePageState extends State { void _showLabelModal() { showMaterialModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - barrierColor: Colors.black.withAlpha(80), - builder: (context) { - return getIt.get(param1: _addressItemWithLabel); - }).then((value) { + context: context, + backgroundColor: Colors.transparent, + barrierColor: Colors.black.withAlpha(80), + builder: (_) => getIt.get(param1: _addressItemWithLabel), + ).then((value) { _reloadAddressWithLabel(widget.addressListViewModel.uri); }); } @@ -359,8 +365,8 @@ class _NewReceivePageState extends State { // FIXME: viewmodel doesn't want to load address name here, so we make it. investigate why later setState(() { _addressItemWithLabel = widget.addressListViewModel.forceRecomputeItems.firstWhereOrNull( - (item) => (item is WalletAddressListItem && item.address == newAddress.address)) - as WalletAddressListItem?; + (item) => item is WalletAddressListItem && item.address == newAddress.address, + ) as WalletAddressListItem?; }); } } diff --git a/lib/new-ui/pages/send_page.dart b/lib/new-ui/pages/send_page.dart index 994e13456..6ee37d2ee 100644 --- a/lib/new-ui/pages/send_page.dart +++ b/lib/new-ui/pages/send_page.dart @@ -594,7 +594,7 @@ class _NewSendPageState extends State { builder: (context) => Material( child: L2ActionWalletSelector( showOtherWallets: false, - action: l2actions.deposit, + action: L2Actions.deposit, sendViewModel: widget.sendViewModel, contactListViewModel: widget.contactListViewModel, @@ -609,7 +609,7 @@ class _NewSendPageState extends State { builder: (context) => Material( child: L2ActionWalletSelector( showOtherWallets: false, - action: l2actions.withdraw, + action: L2Actions.withdraw, sendViewModel: widget.sendViewModel, contactListViewModel: widget.contactListViewModel, diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart index d36d67bf3..08eec7498 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_history_section.dart @@ -59,14 +59,13 @@ class _AssetsHistorySectionState extends State { AssetsSection( dashboardViewModel: widget.dashboardViewModel, ), - hasAssetsButton ? - AssetsHistorySectionActionButton(S.current.tokens, "assets/new-ui/options_slider.svg", - () { - Navigator.of(context).pushNamed( + hasAssetsButton ? AssetsHistorySectionActionButton(S.current.tokens, "assets/new-ui/options_slider.svg", + + () {Navigator.of(context).pushNamed( Routes.homeSettings, arguments: widget.dashboardViewModel.balanceViewModel, ); - }) : null), + }): null), AssetsHistorySectionTab( S.current.history, HistorySection( 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 ff2248035..fb705ace6 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 @@ -1,24 +1,28 @@ -import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item.dart'; -import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart'; -import 'package:cake_wallet/new-ui/widgets/copy_wrapper.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/transaction_details/confirmations_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart'; -import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; -import 'package:cake_wallet/utils/address_formatter.dart'; -import 'package:cake_wallet/view_model/transaction_details_view_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import "package:cake_wallet/entities/new_ui_entities/list_item/list_item.dart"; +import "package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart"; +import "package:cake_wallet/new-ui/widgets/copy_wrapper.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/screens/transaction_details/address_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/confirmations_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart"; +import "package:cake_wallet/src/widgets/new_list_row/new_list_section.dart"; +import "package:cake_wallet/utils/address_formatter.dart"; +import "package:cake_wallet/view_model/transaction_details_view_model.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; class TransactionDetailsModal extends StatefulWidget { const TransactionDetailsModal( - {super.key, required this.transactionDetailsViewModel, this.highlightNoteField = false}); + { + required this.transactionDetailsViewModel, + this.highlightNoteField = false, + super.key, + }); final TransactionDetailsViewModel transactionDetailsViewModel; final bool highlightNoteField; @@ -48,177 +52,195 @@ class _TransactionDetailsModalState extends State { } @override - Widget build(BuildContext context) { - final transactionInfoAmount = widget.transactionDetailsViewModel.transactionInfo.amount; - - return SafeArea( - bottom: false, - child: Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: GestureDetector( - onTap: FocusScope.of(context).unfocus, - child: Container( - decoration: BoxDecoration( + Widget build(BuildContext context) => SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: GestureDetector( + onTap: FocusScope.of(context).unfocus, + child: Container( + decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(25))), - child: Column( - children: [ - ModalTopBar( - title: S.of(context).transaction, - leadingIcon: Icon(Icons.close), - onLeadingPressed: Navigator.of(context).pop, - ), - Expanded( - child: SingleChildScrollView( - controller: ModalScrollController.of(context), - child: Column( - children: [ - TokenImageWidget( - imageUrl: - widget.transactionDetailsViewModel.transactionAsset.iconPath ?? "", - size: 64, - ), - SizedBox(height: 10), - Text( - widget.transactionDetailsViewModel.formattedTitle + - widget.transactionDetailsViewModel.formattedStatus, - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500), - ), - CopyWrapper( - requireLongPress: true, - data: ClipboardData(text: transactionInfoAmount.toString()), - builder: (context, copied) => AnimatedSwitcher( - duration: Duration(milliseconds: 300), - child: Text( - key: ValueKey(copied), - copied - ? S.of(context).copied - : transactionInfoAmount.toStringWithSymbol(), - style: TextStyle( - fontSize: 28, - color: copied - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface), + borderRadius: const BorderRadius.vertical(top: Radius.circular(25)), + ), + child: Column( + children: [ + ModalTopBar( + title: S.of(context).transaction, + leadingIcon: const Icon(Icons.close), + onLeadingPressed: Navigator.of(context).pop, + ), + Expanded( + child: SingleChildScrollView( + controller: ModalScrollController.of(context), + child: Column( + children: [ + TokenImageWidget( + imageUrl: + widget.transactionDetailsViewModel.transactionAsset.iconPath ?? "", + size: 64, + ), + const SizedBox(height: 10), + Text( + widget.transactionDetailsViewModel.formattedTitle + + widget.transactionDetailsViewModel.formattedStatus, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w500), + ), + Observer( + builder: (_) => CopyWrapper( + requireLongPress: true, + data: ClipboardData( + text: widget.transactionDetailsViewModel.transactionCopyAmount, + ), + builder: (context, copied) => AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: Text( + key: ValueKey(copied), + copied + ? S.of(context).copied + : widget.transactionDetailsViewModel.transactionAmount, + style: TextStyle( + fontSize: 28, + color: copied + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, + ), + ), + ), ), ), - ), - Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - spacing: 12, - children: [ - NewListSections(sections: { - "": widget.transactionDetailsViewModel.items - .map((item) { - if (item.value.isEmpty) return null; + Padding( + padding: const EdgeInsets.all(16), + child: Column( + spacing: 12, + children: [ + NewListSections( + sections: { + "": widget.transactionDetailsViewModel.items + .map((item) { + if (item.value.isEmpty) { + return null; + } - final shouldBuildBottomWidget = item.value.length > 25; + final shouldBuildBottomWidget = item.value.length > 25; - return ListItemRegularRow( - copyableText: item.value, - showArrow: false, - keyValue: ((item.key as ValueKey?)?.value as String?) ?? - item.title, - label: item.title, - trailingWidget: shouldBuildBottomWidget - ? null - : _buildTrailingWIdget(item), - bottomWidget: shouldBuildBottomWidget - ? _buildBottomWidget(item) - : null); - }) - .whereType() - .toList(), - }), - Container( - decoration: BoxDecoration( + return ListItemRegularRow( + copyableText: item.value, + showArrow: false, + keyValue: ((item.key as ValueKey?)?.value as String?) ?? + item.title, + label: item.title, + trailingWidget: shouldBuildBottomWidget + ? null + : _buildTrailingWidget(item), + bottomWidget: shouldBuildBottomWidget + ? _buildBottomWidget(item) + : null, + ); + }) + .whereType() + .toList(), + }, + ), + Container( + decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), - color: Theme.of(context).colorScheme.surfaceContainer), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).note), - TextField( - focusNode: noteFocusNode, - controller: noteController, - decoration: InputDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(S.of(context).note), + TextField( + focusNode: noteFocusNode, + controller: noteController, + decoration: InputDecoration( hintText: S.of(context).add_a_note, border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, contentPadding: EdgeInsets.zero, - isDense: true), - ) - ], + isDense: true, + ), + ), + ], + ), ), ), - ), - Observer( - builder: (_) => NewListSections(sections: { - "view tx": [ - ListItemRegularRow( - keyValue: "view tx on", - label: - widget.transactionDetailsViewModel.explorerDescription, - onTap: widget.transactionDetailsViewModel.launchExplorer, - foregroundColor: Theme.of(context).colorScheme.primary, - trailingIconPath: "assets/new-ui/link_arrow.svg", - trailingIconSize: 8) - ], - if (widget.transactionDetailsViewModel.canReplaceByFee) - "rbf": [ - ListItemRegularRow( - keyValue: "replace by fee", - label: S.of(context).bump_fee, - onTap: () { - Navigator.of(context) - .pushNamed(Routes.bumpFeePage, arguments: [ - widget.transactionDetailsViewModel.transactionInfo, - widget.transactionDetailsViewModel.rawTransaction - ]); - }) - ] - }), - ) - ], + Observer( + builder: (_) => NewListSections( + sections: { + "view tx": [ + ListItemRegularRow( + keyValue: "view tx on", + label: widget + .transactionDetailsViewModel.explorerDescription, + onTap: widget.transactionDetailsViewModel.launchExplorer, + foregroundColor: Theme.of(context).colorScheme.primary, + trailingIconPath: "assets/new-ui/link_arrow.svg", + trailingIconSize: 8, + ), + ], + if (widget.transactionDetailsViewModel.canReplaceByFee) + "rbf": [ + ListItemRegularRow( + keyValue: "replace by fee", + label: S.of(context).bump_fee, + onTap: () { + Navigator.of(context).pushNamed( + Routes.bumpFeePage, + arguments: [ + widget + .transactionDetailsViewModel.transactionInfo, + widget.transactionDetailsViewModel.rawTransaction, + ], + ); + }, + ), + ], + }, + ), + ), + ], + ), ), - ), - SizedBox(height: MediaQuery.of(context).viewPadding.bottom) - ], + SizedBox(height: MediaQuery.of(context).viewPadding.bottom), + ], + ), ), ), - ) - ], + ], + ), ), ), ), - ), - ); - } + ); - Widget _buildTrailingWIdget(TransactionDetailsListItem item) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: switch (item.runtimeType) { - ConfirmationsListItem => Row( - children: [ - Text((item as ConfirmationsListItem).current.toString(), - style: TextStyle(color: Theme.of(context).colorScheme.primary)), - if (item.needed > 0) - Text("/${item.needed}", - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)) - ], - ), - _ => Text( - item.value, - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), - ) - }, - ); - } + Widget _buildTrailingWidget(TransactionDetailsListItem item) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: switch (item.runtimeType) { + ConfirmationsListItem => Row( + children: [ + Text( + (item as ConfirmationsListItem).current.toString(), + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + if (item.needed > 0) + Text( + "/${item.needed}", + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + _ => Text( + item.value, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ) + }, + ); Widget _buildBottomWidget(TransactionDetailsListItem item) { return switch (item.runtimeType) { diff --git a/lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart b/lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart index cea40e197..835631e9a 100644 --- a/lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart +++ b/lib/new-ui/widgets/send_page/l2_action_wallet_selector.dart @@ -1,41 +1,41 @@ -import 'dart:async'; +import "dart:async"; -import 'package:cake_wallet/bitcoin/bitcoin.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/main.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart'; -import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; -import 'package:cake_wallet/new-ui/widgets/send_page/l2_send_external_modal.dart'; -import 'package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart'; -import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; -import 'package:cake_wallet/src/widgets/new_list_row/new_simple_checkbox.dart'; -import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; -import 'package:cake_wallet/view_model/send/send_view_model.dart'; -import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart'; -import 'package:cw_core/currency_for_wallet_type.dart'; -import 'package:cw_core/wallet_info.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import "package:cake_wallet/bitcoin/bitcoin.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/main.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart"; +import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/new-ui/widgets/send_page/l2_send_external_modal.dart"; +import "package:cake_wallet/new-ui/widgets/send_page/send_address_input.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/src/widgets/new_list_row/new_simple_checkbox.dart"; +import "package:cake_wallet/view_model/contact_list/contact_list_view_model.dart"; +import "package:cake_wallet/view_model/send/send_view_model.dart"; +import "package:cake_wallet/view_model/wallet_switcher_view_model.dart"; +import "package:cw_core/currency_for_wallet_type.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; -enum l2actions { deposit, withdraw } +enum L2Actions { deposit, withdraw } class L2ActionWalletSelector extends StatefulWidget { const L2ActionWalletSelector({ - super.key, required this.showOtherWallets, required this.sendViewModel, required this.action, required this.onSendInitiated, required this.contactListViewModel, required this.walletSwitcherViewModel, + super.key, }); final bool showOtherWallets; final SendViewModel sendViewModel; - final l2actions action; + final L2Actions action; final VoidCallback onSendInitiated; final ContactListViewModel contactListViewModel; final WalletSwitcherViewModel walletSwitcherViewModel; @@ -56,12 +56,18 @@ class _L2ActionWalletSelectorState extends State { super.initState(); if (widget.showOtherWallets) { () async { - items.addAll((await WalletInfo.getAll()).where((item) => - item.type == widget.sendViewModel.walletType && item.hardwareWalletType == null)); + items.addAll( + (await WalletInfo.getAll()).where( + (item) => + item.type == widget.sendViewModel.walletType && item.hardwareWalletType == null, + ), + ); items.sort((a, b) { - if (a.name == widget.sendViewModel.wallet.name) + if (a.name == widget.sendViewModel.wallet.name) { return -1; - else if (b.name == widget.sendViewModel.wallet.name) return 1; + } else if (b.name == widget.sendViewModel.wallet.name) { + return 1; + } return 0; }); setState(() {}); @@ -80,16 +86,16 @@ class _L2ActionWalletSelectorState extends State { mainAxisSize: MainAxisSize.min, children: [ ModalTopBar( - title: widget.action == l2actions.deposit + title: widget.action == L2Actions.deposit ? "${S.of(context).send_from}..." : "${S.of(context).receive_to}...", - leadingIcon: Icon(Icons.arrow_back_ios_new), + leadingIcon: const Icon(Icons.arrow_back_ios_new), onLeadingPressed: Navigator.of(context).pop, ), Flexible( child: SafeArea( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric(horizontal: 16), child: Column( spacing: 12, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -123,29 +129,17 @@ class _L2ActionWalletSelectorState extends State { itemBuilder: (context, index) { final item = items[index]; return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), + padding: const EdgeInsets.symmetric(vertical: 4), child: WalletRow( currencyIconPath: getCryptoCurrencyIconForWalletListItem(item.type), walletName: item.name, isCurrent: item.name == widget.sendViewModel.wallet.name, isSelected: _selectedWalletIndex == index && !textEntered, - onTap: () async { + onTap: () { setState(() { addressController.text = ""; _selectedWalletIndex = index; }); - // if (widget.action == l2actions.withdraw) { - // widget.sendViewModel.outputs.first.address = item.address; - // widget.onSendInitiated(); - // } else if (widget.action == l2actions.deposit) { - // setState(() { - // loadingWalletName = item.name; - // }); - // await _handleChangeWallet(item); - // widget.onSendInitiated(); - // setState(() { - // loadingWalletName = null; - // }); }, ), ); @@ -198,7 +192,7 @@ class _L2ActionWalletSelectorState extends State { // ), // ), // ), - if (widget.action == l2actions.withdraw) + if (widget.action == L2Actions.withdraw) Row( children: [ Flexible( @@ -215,12 +209,12 @@ class _L2ActionWalletSelectorState extends State { Column( spacing: 8, children: [ - if (widget.action == l2actions.deposit && widget.showOtherWallets) ...[ + if (widget.action == L2Actions.deposit && widget.showOtherWallets) ...[ Container( height: 1, color: Theme.of(context).colorScheme.onSurfaceVariant.withAlpha(64), ), - SizedBox(), + const SizedBox(), Container( height: 52, decoration: BoxDecoration( @@ -232,7 +226,7 @@ class _L2ActionWalletSelectorState extends State { color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(16), - onTap: () async { + onTap: () { Navigator.of(context, rootNavigator: true).pop(); showCupertinoModalBottomSheet( context: navigatorKey.currentContext ?? context, @@ -246,7 +240,7 @@ class _L2ActionWalletSelectorState extends State { ); }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( mainAxisAlignment: MainAxisAlignment.center, spacing: 10, @@ -275,19 +269,22 @@ class _L2ActionWalletSelectorState extends State { onPressed: () async { if (widget.sendViewModel.wallet.type == WalletType.bitcoin || widget.sendViewModel.wallet.type == WalletType.litecoin) { - if (widget.action == l2actions.withdraw) { + if (widget.action == L2Actions.withdraw) { widget.sendViewModel.outputs.first.address = bitcoin!.getUnusedSegwitAddress(widget.sendViewModel.wallet)!; } if (widget.showOtherWallets) { - if (widget.action == l2actions.deposit) { + if (widget.action == L2Actions.deposit) { await _handleChangeWallet(items[_selectedWalletIndex]); } else { - widget.sendViewModel.outputs.first.address = - addressController.text.isNotEmpty - ? addressController.text - : items[_selectedWalletIndex].address; + if (items[_selectedWalletIndex].name != + widget.sendViewModel.wallet.name) { + widget.sendViewModel.outputs.first.address = + addressController.text.isNotEmpty + ? addressController.text + : items[_selectedWalletIndex].address; + } } } widget.onSendInitiated(); @@ -298,7 +295,7 @@ class _L2ActionWalletSelectorState extends State { textColor: Theme.of(context).colorScheme.onPrimary, isLoading: _isLoading, ), - SizedBox() + const SizedBox() ], ), ], @@ -339,13 +336,13 @@ class _L2ActionWalletSelectorState extends State { class WalletRow extends StatelessWidget { const WalletRow({ - super.key, required this.currencyIconPath, required this.walletName, required this.onTap, this.isLoading = false, this.isCurrent = false, this.isSelected, + super.key, }); final String currencyIconPath; @@ -370,7 +367,7 @@ class WalletRow extends StatelessWidget { borderRadius: BorderRadius.circular(16), onTap: onTap, child: Padding( - padding: EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/src/screens/transaction_details/rbf_details_page.dart b/lib/src/screens/transaction_details/rbf_details_page.dart index 0ec4d5acc..78a5321de 100644 --- a/lib/src/screens/transaction_details/rbf_details_page.dart +++ b/lib/src/screens/transaction_details/rbf_details_page.dart @@ -1,34 +1,36 @@ -import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/textfield_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/transaction_expandable_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/widgets/textfield_list_row.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; -import 'package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart'; -import 'package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart'; -import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart'; -import 'package:cake_wallet/src/widgets/list_row.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/src/widgets/standard_expandable_list.dart'; -import 'package:cake_wallet/src/widgets/standard_list.dart'; -import 'package:cake_wallet/src/widgets/standard_picker_list.dart'; -import 'package:cake_wallet/utils/show_bar.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; -import 'package:cake_wallet/view_model/transaction_details_view_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:mobx/mobx.dart'; +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/base_page.dart"; +import "package:cake_wallet/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/standart_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/textfield_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/transaction_expandable_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/widgets/textfield_list_row.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; +import "package:cake_wallet/src/widgets/alert_with_two_actions.dart"; +import "package:cake_wallet/src/widgets/bottom_sheet/base_bottom_sheet_widget.dart"; +import "package:cake_wallet/src/widgets/bottom_sheet/confirm_sending_bottom_sheet_widget.dart"; +import "package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.dart"; +import "package:cake_wallet/src/widgets/list_row.dart"; +import "package:cake_wallet/src/widgets/primary_button.dart"; +import "package:cake_wallet/src/widgets/standard_expandable_list.dart"; +import "package:cake_wallet/src/widgets/standard_list.dart"; +import "package:cake_wallet/src/widgets/standard_picker_list.dart"; +import "package:cake_wallet/utils/show_bar.dart"; +import "package:cake_wallet/utils/show_pop_up.dart"; +import "package:cake_wallet/view_model/send/send_view_model_state.dart"; +import "package:cake_wallet/view_model/transaction_details_view_model.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; +import "package:mobx/mobx.dart"; class RBFDetailsPage extends BasePage { RBFDetailsPage({required this.transactionDetailsViewModel, required this.rawTransaction}) { transactionDetailsViewModel.addBumpFeesListItems( - transactionDetailsViewModel.transactionInfo, rawTransaction); + transactionDetailsViewModel.transactionInfo, + rawTransaction, + ); } @override @@ -46,70 +48,70 @@ class RBFDetailsPage extends BasePage { children: [ Expanded( child: SectionStandardList( - sectionCount: 1, - itemCounter: (int _) => transactionDetailsViewModel.RBFListItems.length, - itemBuilder: (__, index) { - final item = transactionDetailsViewModel.RBFListItems[index]; + sectionCount: 1, + itemCounter: (_) => transactionDetailsViewModel.rbfListItems.length, + itemBuilder: (__, index) { + final item = transactionDetailsViewModel.rbfListItems[index]; - if (item is StandartListItem) { - return GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: item.value)); - showBar(context, S.of(context).transaction_details_copied(item.title)); - }, - child: ListRow(title: '${item.title}:', value: item.value), - ); - } + if (item is StandartListItem) { + return GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: item.value)); + showBar(context, S.of(context).transaction_details_copied(item.title)); + }, + child: ListRow(title: "${item.title}:", value: item.value), + ); + } - if (item is StandardExpandableListItem) { - return StandardExpandableList( - title: '${item.title}: ${item.expandableItems.length}', - expandableItems: item.expandableItems, - ); - } + if (item is StandardExpandableListItem) { + return StandardExpandableList( + title: "${item.title}: ${item.expandableItems.length}", + expandableItems: item.expandableItems, + ); + } - if (item is StandardPickerListItem) { - return StandardPickerList( - title: item.title, - value: item.value, - items: item.items, - displayItem: item.displayItem, - onSliderChanged: item.onSliderChanged, - onItemSelected: item.onItemSelected, - selectedIdx: item.selectedIdx, - customItemIndex: item.customItemIndex, - customValue: item.customValue, - maxValue: item.maxValue, - ); - } + if (item is StandardPickerListItem) { + return StandardPickerList( + title: item.title, + value: item.value, + items: item.items, + displayItem: item.displayItem, + onSliderChanged: item.onSliderChanged, + onItemSelected: item.onItemSelected, + selectedIdx: item.selectedIdx, + customItemIndex: item.customItemIndex, + customValue: item.customValue, + maxValue: item.maxValue, + ); + } - if (item is TextFieldListItem) { - return TextFieldListRow( - title: item.title, - value: item.value, - onSubmitted: item.onSubmitted, - ); - } + if (item is TextFieldListItem) { + return TextFieldListRow( + title: item.title, + value: item.value, + onSubmitted: item.onSubmitted, + ); + } - return Container(); - }), + return Container(); + }, + ), ), Padding( - padding: const EdgeInsets.all(24), - child: Observer( - builder: (_) => LoadingPrimaryButton( - onPressed: () async { - transactionDetailsViewModel - .replaceByFee(transactionDetailsViewModel.newFee.toString()); - }, - text: S.of(context).send, - isLoading: - transactionDetailsViewModel.sendViewModel.state is IsExecutingState, - isDisabled: transactionDetailsViewModel.sendViewModel.state - is ExecutedSuccessfullyState, - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - ))), + padding: const EdgeInsets.all(24), + child: Observer( + builder: (_) => LoadingPrimaryButton( + onPressed: () => transactionDetailsViewModel + .replaceByFee(transactionDetailsViewModel.newFee.toString()), + text: S.of(context).send, + isLoading: transactionDetailsViewModel.sendViewModel.state is IsExecutingState, + isDisabled: + transactionDetailsViewModel.sendViewModel.state is ExecutedSuccessfullyState, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + ), ], ); } @@ -121,7 +123,7 @@ class RBFDetailsPage extends BasePage { return; } - reaction((_) => transactionDetailsViewModel.sendViewModel.state, (ExecutionState state) { + reaction((_) => transactionDetailsViewModel.sendViewModel.state, (state) { if (state is! IsExecutingState && loadingBottomSheetContext != null && loadingBottomSheetContext!.mounted) { @@ -131,35 +133,35 @@ class RBFDetailsPage extends BasePage { if (state is FailureState) { WidgetsBinding.instance.addPostFrameCallback((_) { showPopUp( - context: context, - builder: (BuildContext popupContext) { - return AlertWithOneAction( - alertTitle: S.of(popupContext).error, - alertContent: state.error, - buttonText: S.of(popupContext).ok, - buttonAction: () => Navigator.of(popupContext).pop()); - }); + context: context, + builder: (popupContext) => AlertWithOneAction( + alertTitle: S.of(popupContext).error, + alertContent: state.error, + buttonText: S.of(popupContext).ok, + buttonAction: () => Navigator.of(popupContext).pop(), + ), + ); }); } if (state is AwaitingConfirmationState) { WidgetsBinding.instance.addPostFrameCallback((_) { showPopUp( - context: context, - builder: (BuildContext popupContext) { - return AlertWithTwoActions( - alertTitle: state.title ?? '', - alertContent: state.message ?? '', - rightButtonText: S.of(context).ok, - leftButtonText: S.of(context).cancel, - actionRightButton: () { - state.onConfirm?.call(); - Navigator.of(popupContext).pop(); - }, - actionLeftButton: () { - state.onCancel?.call(); - Navigator.of(popupContext).pop(); - }); - }); + context: context, + builder: (popupContext) => AlertWithTwoActions( + alertTitle: state.title ?? "", + alertContent: state.message ?? "", + rightButtonText: S.of(context).ok, + leftButtonText: S.of(context).cancel, + actionRightButton: () { + state.onConfirm?.call(); + Navigator.of(popupContext).pop(); + }, + actionLeftButton: () { + state.onCancel?.call(); + Navigator.of(popupContext).pop(); + }, + ), + ); }); } @@ -169,7 +171,7 @@ class RBFDetailsPage extends BasePage { showModalBottomSheet( context: context, isDismissible: false, - builder: (BuildContext context) { + builder: (context) { loadingBottomSheetContext = context; return LoadingBottomSheet( titleText: S.of(context).generating_transaction, @@ -187,42 +189,42 @@ class RBFDetailsPage extends BasePage { context: context, isDismissible: false, isScrollControlled: true, - builder: (BuildContext bottomSheetContext) { - return ConfirmSendingBottomSheet( - key: ValueKey('rbf_confirm_sending_bottom_sheet'), - titleText: S.of(bottomSheetContext).confirm_transaction, - isSlideActionEnabled: transactionDetailsViewModel.sendViewModel.isReadyForSend, - walletType: transactionDetailsViewModel.sendViewModel.walletType, - titleIconPath: - transactionDetailsViewModel.sendViewModel.selectedCryptoCurrency.iconPath, - currency: transactionDetailsViewModel.sendViewModel.selectedCryptoCurrency, - amount: S.of(bottomSheetContext).send_amount, - amountValue: - transactionDetailsViewModel.sendViewModel.pendingTransaction!.amountFormatted, - fiatAmountValue: transactionDetailsViewModel - .sendViewModel.pendingTransactionFiatAmountFormatted, - fee: S.of(bottomSheetContext).send_fee, - feeValue: - transactionDetailsViewModel.sendViewModel.pendingTransaction!.feeFormatted, - feeFiatAmount: transactionDetailsViewModel - .sendViewModel.pendingTransactionFeeFiatAmountFormatted, - outputs: transactionDetailsViewModel.sendViewModel.outputs, - footerType: FooterType.slideActionButton, - accessibleNavigationModeSlideActionButtonText: S.of(context).send, - onSlideActionComplete: () async { - Navigator.of(bottomSheetContext).pop(); - await transactionDetailsViewModel.sendViewModel.commitTransaction(context); - try { + builder: (bottomSheetContext) => ConfirmSendingBottomSheet( + key: const ValueKey("rbf_confirm_sending_bottom_sheet"), + titleText: S.of(bottomSheetContext).confirm_transaction, + isSlideActionEnabled: transactionDetailsViewModel.sendViewModel.isReadyForSend, + walletType: transactionDetailsViewModel.sendViewModel.walletType, + titleIconPath: + transactionDetailsViewModel.sendViewModel.selectedCryptoCurrency.iconPath, + currency: transactionDetailsViewModel.sendViewModel.selectedCryptoCurrency, + amount: S.of(bottomSheetContext).send_amount, + amountValue: + transactionDetailsViewModel.sendViewModel.pendingTransaction!.amountFormatted, + fiatAmountValue: + transactionDetailsViewModel.sendViewModel.pendingTransactionFiatAmountFormatted, + fee: S.of(bottomSheetContext).send_fee, + feeValue: + transactionDetailsViewModel.sendViewModel.pendingTransaction!.feeFormatted, + feeFiatAmount: transactionDetailsViewModel + .sendViewModel.pendingTransactionFeeFiatAmountFormatted, + outputs: transactionDetailsViewModel.sendViewModel.outputs, + footerType: FooterType.slideActionButton, + accessibleNavigationModeSlideActionButtonText: S.of(context).send, + onSlideActionComplete: () async { + Navigator.of(bottomSheetContext).pop(); + await transactionDetailsViewModel.sendViewModel.commitTransaction(context); + try { + if (bottomSheetContext.mounted) { Navigator.of(bottomSheetContext).pop(); - } catch (_) {} - }, - change: transactionDetailsViewModel.sendViewModel.pendingTransaction!.change, - amountParsingProxy: transactionDetailsViewModel.sendViewModel.amountParsingProxy, - ); - }, + } + } catch (_) {} + }, + change: transactionDetailsViewModel.sendViewModel.pendingTransaction!.change, + amountParsingProxy: transactionDetailsViewModel.sendViewModel.amountParsingProxy, + ), ); if (result == null) { - transactionDetailsViewModel.sendViewModel.dismissTransaction(); + await transactionDetailsViewModel.sendViewModel.dismissTransaction(); } } }); @@ -232,14 +234,14 @@ class RBFDetailsPage extends BasePage { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { showPopUp( - context: context, - builder: (BuildContext popupContext) { - return AlertWithOneAction( - alertTitle: S.of(popupContext).sending, - alertContent: S.of(popupContext).transaction_sent, - buttonText: S.of(popupContext).ok, - buttonAction: () => Navigator.of(popupContext).pop()); - }); + context: context, + builder: (popupContext) => AlertWithOneAction( + alertTitle: S.of(popupContext).sending, + alertContent: S.of(popupContext).transaction_sent, + buttonText: S.of(popupContext).ok, + buttonAction: () => Navigator.of(popupContext).pop(), + ), + ); } }); } diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index 16f12ac0b..e83feb90c 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -1,50 +1,50 @@ -import 'package:cake_wallet/reactions/wallet_connect.dart'; -import 'package:cake_wallet/solana/solana.dart'; -import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/confirmations_list_item.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cake_wallet/core/address_validator.dart'; -import 'package:cake_wallet/tron/tron.dart'; -import 'package:cake_wallet/zano/zano.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/wallet_base.dart'; -import 'package:cw_core/transaction_info.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:cake_wallet/evm/evm.dart'; -import 'package:cake_wallet/bitcoin/bitcoin.dart'; -import 'package:cake_wallet/entities/priority_for_wallet_type.dart'; -import 'package:cake_wallet/entities/transaction_description.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/monero/monero.dart'; -import 'package:cake_wallet/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; -import 'package:cake_wallet/src/screens/transaction_details/transaction_expandable_list_item.dart'; -import 'package:cake_wallet/utils/date_formatter.dart'; -import 'package:cake_wallet/view_model/send/send_view_model.dart'; -import 'package:collection/collection.dart'; -import 'package:cw_core/transaction_direction.dart'; -import 'package:cw_core/transaction_priority.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hive/hive.dart'; -import 'package:intl/intl.dart'; -import 'package:mobx/mobx.dart'; -import 'package:url_launcher/url_launcher.dart'; +import "package:cake_wallet/bitcoin/bitcoin.dart"; +import "package:cake_wallet/core/address_validator.dart"; +import "package:cake_wallet/entities/priority_for_wallet_type.dart"; +import "package:cake_wallet/entities/transaction_description.dart"; +import "package:cake_wallet/evm/evm.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/monero/monero.dart"; +import "package:cake_wallet/reactions/wallet_connect.dart"; +import "package:cake_wallet/solana/solana.dart"; +import "package:cake_wallet/src/screens/transaction_details/address_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/confirmations_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/standart_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart"; +import "package:cake_wallet/src/screens/transaction_details/transaction_expandable_list_item.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cake_wallet/tron/tron.dart"; +import "package:cake_wallet/view_model/send/send_view_model.dart"; +import "package:cake_wallet/zano/zano.dart"; +import "package:collection/collection.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency_for_wallet_type.dart"; +import "package:cw_core/transaction_direction.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/transaction_priority.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/foundation.dart"; +import "package:hive/hive.dart"; +import "package:intl/intl.dart"; +import "package:mobx/mobx.dart"; +import "package:url_launcher/url_launcher.dart"; -part 'transaction_details_view_model.g.dart'; +part "transaction_details_view_model.g.dart"; bool _trueFunc(_) => true; /// We're adding a regex here so we can remove any already saved address that has the account in it. /// In the refactor, we will make another separate variable for accounts and the UI would handle it as needed. String _moneroRecipientAddressForDisplay(String raw, WalletType walletType) { - if (walletType != WalletType.monero || raw.isEmpty) return raw; + if (walletType != WalletType.monero || raw.isEmpty) { + return raw; + } - final compact = raw.replaceAll(RegExp(r'\s'), ''); + final compact = raw.replaceAll(RegExp(r"\s"), ""); final match = RegExp( - r'4[0-9a-zA-Z]{94}|8[0-9a-zA-Z]{94}|[0-9a-zA-Z]{106}', + r"4[0-9a-zA-Z]{94}|8[0-9a-zA-Z]{94}|[0-9a-zA-Z]{106}", caseSensitive: false, ).firstMatch(compact); return match?.group(0) ?? raw.trim(); @@ -55,6 +55,14 @@ bool isLightning(TransactionInfo tx) => (tx.additionalInfo["isLightning"] as boo bool hasLightningPreimage(TransactionInfo tx) => (tx.additionalInfo["preimage"] as String?) != null; class TxDetailRowDefinition { + TxDetailRowDefinition({ + required this.keyString, + required this.title, + required this.valueGetter, + this.applicable = _trueFunc, + this.listItemBuilder = StandartListItem.new, + }); + final String keyString; final String title; final String Function(TransactionDetailsViewModelBase) valueGetter; @@ -65,20 +73,13 @@ class TxDetailRowDefinition { required Key key, }) listItemBuilder; - TxDetailRowDefinition( - {required this.keyString, - required this.title, - required this.valueGetter, - this.applicable = _trueFunc, - this.listItemBuilder = StandartListItem.new}); - static final List defs = [ TxDetailRowDefinition( - keyString: "standard_list_item_transaction_details_date_key", - title: S.current.transaction_details_date, - valueGetter: (vm) => - DateFormat("d MMMM yyyy, HH:mm", vm._appStore.settingsStore.languageCode) - .format(vm.transactionInfo.date)), + keyString: "standard_list_item_transaction_details_date_key", + title: S.current.transaction_details_date, + valueGetter: (vm) => DateFormat("d MMMM yyyy, HH:mm", vm._appStore.settingsStore.languageCode) + .format(vm.transactionInfo.date), + ), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_height_key", title: S.current.transaction_details_height, @@ -88,125 +89,137 @@ class TxDetailRowDefinition { !isLightning(vm.transactionInfo), ), TxDetailRowDefinition( - keyString: "standard_list_item_transaction_details_fee_key", - title: S.current.transaction_details_fee, - valueGetter: (vm) => vm.transactionInfo.fee!.toStringWithSymbol(), - applicable: (vm) => - vm.wallet.type != WalletType.nano && - (vm.transactionInfo.fee?.toStringWithSymbol() ?? "").isNotEmpty), + keyString: "standard_list_item_transaction_details_fee_key", + title: S.current.transaction_details_fee, + valueGetter: (vm) => vm.feeAmount, + applicable: (vm) => + vm.wallet.type != WalletType.nano && + (vm.transactionInfo.fee?.toStringWithSymbol() ?? "").isNotEmpty, + ), TxDetailRowDefinition( - keyString: "standard_list_item_transaction_confirmations_key", - title: S.current.confirmations, - valueGetter: (vm) => "${vm.transactionInfo.confirmations}/${vm.neededConfirmations}", - applicable: (vm) => - [...electrumWalletTypes, ...evmWalletTypes, WalletType.zcash, WalletType.monero] - .contains(vm.wallet.type) && - !isLightning(vm.transactionInfo), - listItemBuilder: ConfirmationsListItem.new), + keyString: "standard_list_item_transaction_confirmations_key", + title: S.current.confirmations, + valueGetter: (vm) => "${vm.transactionInfo.confirmations}/${vm.neededConfirmations}", + applicable: (vm) => + [...electrumWalletTypes, ...evmWalletTypes, WalletType.zcash, WalletType.monero] + .contains(vm.wallet.type) && + !isLightning(vm.transactionInfo), + listItemBuilder: ConfirmationsListItem.new, + ), TxDetailRowDefinition( - keyString: "standard_list_item_transaction_details_recipient_address_key", - title: S.current.transaction_details_recipient_address, - valueGetter: (vm) { - String? ret = null; + keyString: "standard_list_item_transaction_details_recipient_address_key", + title: S.current.transaction_details_recipient_address, + valueGetter: (vm) { + String? ret; - switch (vm.wallet.type) { - case WalletType.monero: - if (vm.transactionInfo.direction == TransactionDirection.incoming) { - ret = monero!.getTransactionAddress( - vm.wallet, - vm.transactionInfo.additionalInfo['accountIndex'] as int, - vm.transactionInfo.additionalInfo['addressIndex'] as int); - } - case WalletType.bitcoin: - ret = (bitcoin!.getTransactionAddresses(vm.wallet, vm.transactionInfo) ?? []) - .firstOrNull ?? - ""; - case WalletType.tron: - if (vm.transactionInfo.to != null) - ret = tron!.getTronBase58Address(vm.transactionInfo.to!, vm.wallet); - default: - break; - } - if (ret == null) { - ret = vm.transactionInfo.to ?? ""; - } - final resolvedAddress = _moneroRecipientAddressForDisplay(ret, vm.wallet.type); - vm.isRecipientAddressShown = resolvedAddress.isNotEmpty; - return resolvedAddress; - }, - applicable: (vm) => - vm.showRecipientAddress && - (vm.transactionInfo.to != null || - [WalletType.monero, WalletType.tron].contains(vm.wallet.type) || - vm.wallet.type == WalletType.bitcoin && - vm.transactionInfo.direction == TransactionDirection.incoming), - listItemBuilder: AddressListItem.new), - TxDetailRowDefinition( - keyString: "standard_list_item_transaction_details_source_address_key", - title: S.current.transaction_details_source_address, - valueGetter: (vm) { - switch (vm.wallet.type) { - case WalletType.tron: - return tron!.getTronBase58Address(vm.transactionInfo.from!, vm.wallet); - default: - return vm.transactionInfo.from!; - } - }, - applicable: (vm) => vm.transactionInfo.from != null, - listItemBuilder: AddressListItem.new), - TxDetailRowDefinition( - keyString: "standard_list_item_address_label_key", - title: S.current.address_label, - valueGetter: (vm) => monero!.getSubaddressLabel( - vm.wallet, - vm.transactionInfo.additionalInfo['accountIndex'] as int, - vm.transactionInfo.additionalInfo['addressIndex'] as int), - applicable: (vm) => vm.wallet.type == WalletType.monero), - TxDetailRowDefinition( - keyString: "standard_list_item_transaction_key", - title: S.current.transaction_key, - valueGetter: (vm) { - final descriptionKey = - '${vm.transactionInfo.txHash}_${vm.wallet.walletAddresses.primaryAddress}'; + switch (vm.wallet.type) { + case WalletType.monero: + if (vm.transactionInfo.direction == TransactionDirection.incoming) { + ret = monero!.getTransactionAddress( + vm.wallet, + vm.transactionInfo.additionalInfo["accountIndex"] as int, + vm.transactionInfo.additionalInfo["addressIndex"] as int, + ); + } + case WalletType.bitcoin: + ret = (bitcoin!.getTransactionAddresses(vm.wallet, vm.transactionInfo) ?? []) + .firstOrNull ?? + ""; + case WalletType.tron: + if (vm.transactionInfo.to != null) { + ret = tron!.getTronBase58Address(vm.transactionInfo.to!, vm.wallet); + } + default: + break; + } + ret ??= vm.transactionInfo.to ?? ""; - final description = vm.transactionDescriptionBox.values.firstWhere( - (val) => val.id == descriptionKey || val.id == vm.transactionInfo.txHash, - orElse: () => TransactionDescription(id: descriptionKey)); - return vm.transactionInfo.additionalInfo['key'] as String? ?? - description.transactionKey ?? - ""; - }, - applicable: (vm) => vm.wallet.type == WalletType.monero), + final resolvedAddress = _moneroRecipientAddressForDisplay(ret, vm.wallet.type); + vm.isRecipientAddressShown = resolvedAddress.isNotEmpty; + return resolvedAddress; + }, + applicable: (vm) => + vm.showRecipientAddress && + (vm.transactionInfo.to != null || + [WalletType.monero, WalletType.tron].contains(vm.wallet.type) || + vm.wallet.type == WalletType.bitcoin && + vm.transactionInfo.direction == TransactionDirection.incoming), + listItemBuilder: AddressListItem.new, + ), + TxDetailRowDefinition( + keyString: "standard_list_item_transaction_details_source_address_key", + title: S.current.transaction_details_source_address, + valueGetter: (vm) { + switch (vm.wallet.type) { + case WalletType.tron: + return tron!.getTronBase58Address(vm.transactionInfo.from!, vm.wallet); + default: + return vm.transactionInfo.from!; + } + }, + applicable: (vm) => vm.transactionInfo.from != null, + listItemBuilder: AddressListItem.new, + ), + TxDetailRowDefinition( + keyString: "standard_list_item_address_label_key", + title: S.current.address_label, + valueGetter: (vm) => monero!.getSubaddressLabel( + vm.wallet, + vm.transactionInfo.additionalInfo["accountIndex"] as int, + vm.transactionInfo.additionalInfo["addressIndex"] as int, + ), + applicable: (vm) => vm.wallet.type == WalletType.monero, + ), + TxDetailRowDefinition( + keyString: "standard_list_item_transaction_key", + title: S.current.transaction_key, + valueGetter: (vm) { + final descriptionKey = + "${vm.transactionInfo.txHash}_${vm.wallet.walletAddresses.primaryAddress}"; + + final description = vm.transactionDescriptionBox.values.firstWhere( + (val) => val.id == descriptionKey || val.id == vm.transactionInfo.txHash, + orElse: () => TransactionDescription(id: descriptionKey), + ); + return vm.transactionInfo.additionalInfo["key"] as String? ?? + description.transactionKey ?? + ""; + }, + applicable: (vm) => vm.wallet.type == WalletType.monero, + ), TxDetailRowDefinition( keyString: "standard_list_item_lightning_preimage", title: S.current.transaction_preimage, - valueGetter: (vm) => vm.transactionInfo.additionalInfo['preimage'] as String? ?? "", + valueGetter: (vm) => vm.transactionInfo.additionalInfo["preimage"] as String? ?? "", applicable: (vm) => hasLightningPreimage(vm.transactionInfo) && isLightning(vm.transactionInfo), ), TxDetailRowDefinition( - keyString: "standard_list_item_transaction_confirmed_key", - title: S.current.confirmed_tx, - valueGetter: (vm) => (vm.transactionInfo.confirmations > 0).toString(), - applicable: (vm) => vm.wallet.type == WalletType.nano), + keyString: "standard_list_item_transaction_confirmed_key", + title: S.current.confirmed_tx, + valueGetter: (vm) => (vm.transactionInfo.confirmations > 0).toString(), + applicable: (vm) => vm.wallet.type == WalletType.nano, + ), TxDetailRowDefinition( - keyString: "standard_list_item_transaction_details_memo_key", - title: S.current.memo, - valueGetter: (vm) => vm.transactionInfo.additionalInfo['memo'] as String, - applicable: (vm) => - vm.wallet.type == WalletType.zcash && - vm.transactionInfo.additionalInfo["memo"] != null), + keyString: "standard_list_item_transaction_details_memo_key", + title: S.current.memo, + valueGetter: (vm) => vm.transactionInfo.additionalInfo["memo"] as String, + applicable: (vm) => + vm.wallet.type == WalletType.zcash && vm.transactionInfo.additionalInfo["memo"] != null, + ), TxDetailRowDefinition( - keyString: "standard_list_item_transaction_details_asset_id_key", - title: "Asset ID", - valueGetter: (vm) => - vm.transactionInfo.additionalInfo["assetId"] as String? ?? "Unknown asset id", - applicable: (vm) => vm.wallet.type == WalletType.zano), + keyString: "standard_list_item_transaction_details_asset_id_key", + title: "Asset ID", + valueGetter: (vm) => + vm.transactionInfo.additionalInfo["assetId"] as String? ?? "Unknown asset id", + applicable: (vm) => vm.wallet.type == WalletType.zano, + ), TxDetailRowDefinition( - keyString: "standard_list_item_transaction_details_comment_key", - title: S.current.transaction_details_title, - valueGetter: (vm) => vm.transactionInfo.additionalInfo['comment'] as String? ?? "", - applicable: (vm) => vm.wallet.type == WalletType.zano), + keyString: "standard_list_item_transaction_details_comment_key", + title: S.current.transaction_details_title, + valueGetter: (vm) => vm.transactionInfo.additionalInfo["comment"] as String? ?? "", + applicable: (vm) => vm.wallet.type == WalletType.zano, + ), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_id_key", title: S.current.transaction_details_transaction_id, @@ -227,7 +240,7 @@ abstract class TransactionDetailsViewModelBase with Store { required this.sendViewModel, this.canReplaceByFee = false, }) : items = [], - RBFListItems = [], + rbfListItems = [], newFee = 0, isRecipientAddressShown = false, _appStore = appStore, @@ -236,19 +249,23 @@ abstract class TransactionDetailsViewModelBase with Store { for (final def in TxDetailRowDefinition.defs) { if (def.applicable(this)) { - items.add(def.listItemBuilder( + items.add( + def.listItemBuilder( title: def.title, value: def.valueGetter(this), - key: ValueKey(def.keyString)) as TransactionDetailsListItem); + key: ValueKey(def.keyString), + ) as TransactionDetailsListItem, + ); } } _checkForRBF(tx); - final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; + final descriptionKey = "${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}"; final description = transactionDescriptionBox.values.firstWhere( - (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, - orElse: () => TransactionDescription(id: descriptionKey)); + (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, + orElse: () => TransactionDescription(id: descriptionKey), + ); if (showRecipientAddress && !isRecipientAddressShown) { final recipientAddress = description.recipientAddress; @@ -260,7 +277,7 @@ abstract class TransactionDetailsViewModelBase with Store { AddressListItem( title: S.current.transaction_details_recipient_address, value: recipientAddressForDisplay, - key: ValueKey('standard_list_item_${recipientAddressForDisplay}_key'), + key: ValueKey("standard_list_item_${recipientAddressForDisplay}_key"), ), ); } @@ -268,10 +285,11 @@ abstract class TransactionDetailsViewModelBase with Store { } void updateNote(String note) { - final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; + final descriptionKey = "${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}"; final description = transactionDescriptionBox.values.firstWhere( - (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, - orElse: () => TransactionDescription(id: descriptionKey)); + (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, + orElse: () => TransactionDescription(id: descriptionKey), + ); description.transactionNote = note; @@ -283,9 +301,10 @@ abstract class TransactionDetailsViewModelBase with Store { } String get note { - final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; + final descriptionKey = "${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}"; final description = transactionDescriptionBox.values - .firstWhereOrNull((val) => val.id == descriptionKey || val.id == transactionInfo.txHash); + .firstWhereOrNull((val) => val.id == descriptionKey || val.id == transactionInfo.txHash, + ); return description?.transactionNote ?? ""; } @@ -296,7 +315,7 @@ abstract class TransactionDetailsViewModelBase with Store { final AppStore _appStore; final List items; - final List RBFListItems; + final List rbfListItems; bool showRecipientAddress; bool isRecipientAddressShown; int newFee; @@ -304,9 +323,13 @@ abstract class TransactionDetailsViewModelBase with Store { TransactionPriority? transactionPriority; CryptoCurrency get transactionAsset { - if (isEVMCompatibleChain(wallet.type)) return evm!.assetOfTransaction(wallet, transactionInfo); + if (isEVMCompatibleChain(wallet.type)) { + return evm!.assetOfTransaction(wallet, transactionInfo); + } - if (isLightning(transactionInfo)) return CryptoCurrency.btcln; + if (isLightning(transactionInfo)) { + return CryptoCurrency.btcln; + } return switch (wallet.type) { WalletType.solana => solana!.assetOfTransaction(wallet, transactionInfo), @@ -316,26 +339,38 @@ abstract class TransactionDetailsViewModelBase with Store { }; } - // TODO integrate these getters with the TransactionInfo object + @computed + String get transactionAmount => + _appStore.amountParsingProxy.asDisplayStringWithSymbol(transactionInfo.amount); + + @computed + String get feeAmount => + _appStore.amountParsingProxy.asDisplayStringWithSymbol(transactionInfo.fee!); + + @computed + String get transactionCopyAmount => + _appStore.amountParsingProxy.asDisplayString(transactionInfo.amount); + + // TODO(malik1004x): integrate these getters with the TransactionInfo object String get formattedPendingStatus { switch (wallet.type) { case WalletType.monero: case WalletType.haven: case WalletType.zano: if (transactionInfo.confirmations >= 0 && transactionInfo.confirmations < 10) { - return ' (${transactionInfo.confirmations}/10)'; + return " (${transactionInfo.confirmations}/10)"; } break; case WalletType.wownero: if (transactionInfo.confirmations >= 0 && transactionInfo.confirmations < 3) { - return ' (${transactionInfo.confirmations}/3)'; + return " (${transactionInfo.confirmations}/3)"; } break; case WalletType.litecoin: - bool isPegIn = (transactionInfo.additionalInfo["isPegIn"] as bool?) ?? false; - bool isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false; - bool fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false; - String str = ''; + final isPegIn = (transactionInfo.additionalInfo["isPegIn"] as bool?) ?? false; + final isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false; + final fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false; + String str = ""; if (transactionInfo.confirmations <= 0) { str = S.current.pending; } @@ -352,10 +387,10 @@ abstract class TransactionDetailsViewModelBase with Store { } return str; default: - return ''; + return ""; } - return ''; + return ""; } String get formattedStatus { @@ -369,7 +404,7 @@ abstract class TransactionDetailsViewModelBase with Store { return formattedPendingStatus; } - return transactionInfo.isPending ? S.current.pending : ''; + return transactionInfo.isPending ? S.current.pending : ""; } int get neededConfirmations { @@ -381,9 +416,11 @@ abstract class TransactionDetailsViewModelBase with Store { case WalletType.wownero: return 3; case WalletType.litecoin: - bool isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false; - bool fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false; - if (isPegOut || fromPegOut) return 6; + final isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false; + final fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false; + if (isPegOut || fromPegOut) { + return 6; + } default: return 0; } @@ -408,52 +445,56 @@ abstract class TransactionDetailsViewModelBase with Store { final txId = transactionInfo.txHash; if (wallet.chainId != null) { final explorerUrl = evm!.getExplorerUrlForChainId(wallet.chainId!); - if (explorerUrl != null) return '$explorerUrl/tx/${txId}'; + if (explorerUrl != null) { + return "$explorerUrl/tx/${txId}"; + } } switch (wallet.type) { case WalletType.monero: - return 'https://monero.com/tx/${txId}'; + return "https://monero.com/tx/${txId}"; case WalletType.bitcoin: - return 'https://mempool.cakewallet.com/${wallet.isTestnet ? "testnet/" : ""}tx/${txId}'; + return isLightning(transactionInfo) + ? "https://sparkscan.io/tx/${txId}" + : 'https://mempool.cakewallet.com/${wallet.isTestnet ? "testnet/" : ""}tx/${txId}'; case WalletType.litecoin: return bitcoin!.txIsMweb(transactionInfo) ? "https://www.mwebexplorer.com/blocks/block/${transactionInfo.height}" - : 'https://blockchair.com/litecoin/transaction/${txId}'; + : "https://blockchair.com/litecoin/transaction/${txId}"; case WalletType.bitcoinCash: - return 'https://blockchair.com/bitcoin-cash/transaction/${txId}'; + return "https://blockchair.com/bitcoin-cash/transaction/${txId}"; case WalletType.haven: - return 'https://explorer.havenprotocol.org/search?value=${txId}'; + return "https://explorer.havenprotocol.org/search?value=${txId}"; case WalletType.ethereum: - return 'https://etherscan.io/tx/${txId}'; + return "https://etherscan.io/tx/${txId}"; case WalletType.base: - return 'https://basescan.org/tx/${txId}'; + return "https://basescan.org/tx/${txId}"; case WalletType.arbitrum: - return 'https://arbiscan.io/tx/${txId}'; + return "https://arbiscan.io/tx/${txId}"; case WalletType.bsc: - return 'https://bscscan.com/tx/${txId}'; + return "https://bscscan.com/tx/${txId}"; case WalletType.polygon: - return 'https://polygonscan.com/tx/${txId}'; + return "https://polygonscan.com/tx/${txId}"; case WalletType.nano: - return 'https://nanexplorer.com/nano/block/${txId}'; + return "https://nanexplorer.com/nano/block/${txId}"; case WalletType.banano: - return 'https://nanexplorer.com/banano/block/${txId}'; + return "https://nanexplorer.com/banano/block/${txId}"; case WalletType.solana: - return 'https://solscan.io/tx/${txId}'; + return "https://solscan.io/tx/${txId}"; case WalletType.tron: - return 'https://tronscan.org/#/transaction/${txId}'; + return "https://tronscan.org/#/transaction/${txId}"; case WalletType.wownero: - return 'https://explore.wownero.com/tx/${txId}'; + return "https://explore.wownero.com/tx/${txId}"; case WalletType.zano: - return 'https://explorer.zano.org/transaction/${txId}'; + return "https://explorer.zano.org/transaction/${txId}"; case WalletType.decred: return 'https://${wallet.isTestnet ? "testnet" : "dcrdata"}.decred.org/tx/${txId.split(':')[0]}'; case WalletType.dogecoin: - return 'https://blockchair.com/dogecoin/transaction/${txId}'; + return "https://blockchair.com/dogecoin/transaction/${txId}"; case WalletType.zcash: - return 'https://blockchair.com/zcash/transaction/${txId}'; + return "https://blockchair.com/zcash/transaction/${txId}"; case WalletType.none: - return ''; + return ""; } } @@ -473,13 +514,17 @@ abstract class TransactionDetailsViewModelBase with Store { : transactionInfo.outputAddresses!.length; newFee = bitcoin!.getFeeAmountForPriority( - wallet, bitcoin!.getBitcoinTransactionPriorityMedium(), inputsCount, outputsCount); + wallet, + bitcoin!.getBitcoinTransactionPriorityMedium(), + inputsCount, + outputsCount, + ); - RBFListItems.add( + rbfListItems.add( StandartListItem( title: S.current.old_fee, - value: tx.fee?.toStringWithSymbol() ?? '0.0', - key: ValueKey('standard_list_item_rbf_old_fee_key'), + value: tx.fee?.toStringWithSymbol() ?? "0.0", + key: const ValueKey("standard_list_item_rbf_old_fee_key"), ), ); @@ -488,32 +533,33 @@ abstract class TransactionDetailsViewModelBase with Store { final recommendedRate = (transactionInfo.fee! / BigInt.from(size)) + transactionInfo.fee!.copyWith(amount: BigInt.one); - RBFListItems.add( - StandartListItem(title: 'New recommended fee rate', value: '$recommendedRate sat/byte')); + rbfListItems.add( + StandartListItem(title: "New recommended fee rate", value: "$recommendedRate sat/byte"), + ); } final priorities = priorityForWalletType(wallet.type); final selectedItem = priorities.indexOf(sendViewModel.feesViewModel.transactionPriority); final customItem = priorities.firstWhereOrNull( - (element) => element == sendViewModel.feesViewModel.bitcoinTransactionPriorityCustom); + (element) => element == sendViewModel.feesViewModel.bitcoinTransactionPriorityCustom, + ); final customItemIndex = customItem != null ? priorities.indexOf(customItem) : null; final maxCustomFeeRate = sendViewModel.feesViewModel.maxCustomFeeRate?.toDouble(); - RBFListItems.add( + rbfListItems.add( StandardPickerListItem( - key: ValueKey('standard_picker_list_item_transaction_priorities_key'), + key: const ValueKey("standard_picker_list_item_transaction_priorities_key"), title: S.current.estimated_new_fee, - value: bitcoin!.formatterBitcoinAmountToString(amount: newFee) + ' ${wallet.currency}', + value: "${bitcoin!.formatterBitcoinAmountToString(amount: newFee)} ${wallet.currency}", items: priorityForWalletType(wallet.type), customValue: _appStore.settingsStore.customBitcoinFeeRate.toDouble(), maxValue: maxCustomFeeRate, selectedIdx: selectedItem, customItemIndex: customItemIndex ?? 0, - displayItem: (dynamic priority, double sliderValue) => + displayItem: (dynamic priority, sliderValue) => sendViewModel.feesViewModel.displayFeeRate(priority, sliderValue.round()), - onSliderChanged: (double newValue) => - setNewFee(value: newValue, priority: transactionPriority!), - onItemSelected: (dynamic item, double sliderValue) { + onSliderChanged: (newValue) => setNewFee(value: newValue, priority: transactionPriority!), + onItemSelected: (dynamic item, sliderValue) { transactionPriority = item as TransactionPriority; return setNewFee(value: sliderValue, priority: transactionPriority!); }, @@ -521,9 +567,9 @@ abstract class TransactionDetailsViewModelBase with Store { ); if (transactionInfo.inputAddresses != null && transactionInfo.inputAddresses!.isNotEmpty) { - RBFListItems.add( + rbfListItems.add( StandardExpandableListItem( - key: ValueKey('standard_expandable_list_item_transaction_input_addresses_key'), + key: const ValueKey("standard_expandable_list_item_transaction_input_addresses_key"), title: S.current.inputs, expandableItems: transactionInfo.inputAddresses!, ), @@ -532,17 +578,17 @@ abstract class TransactionDetailsViewModelBase with Store { if (transactionInfo.outputAddresses != null && transactionInfo.outputAddresses!.isNotEmpty) { final outputAddresses = transactionInfo.outputAddresses!.map((element) { - if (element.contains('OP_RETURN:') && element.length > 40) { - return element.substring(0, 40) + '...'; + if (element.contains("OP_RETURN:") && element.length > 40) { + return "${element.substring(0, 40)}..."; } return element; }).toList(); - RBFListItems.add( + rbfListItems.add( StandardExpandableListItem( title: S.current.outputs, expandableItems: outputAddresses, - key: ValueKey('standard_expandable_list_item_transaction_output_addresses_key'), + key: const ValueKey("standard_expandable_list_item_transaction_output_addresses_key"), ), ); } @@ -552,7 +598,7 @@ abstract class TransactionDetailsViewModelBase with Store { Future _checkForRBF(TransactionInfo tx) async { if (wallet.type == WalletType.bitcoin && transactionInfo.direction == TransactionDirection.outgoing) { - final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; + final descriptionKey = "${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}"; final description = transactionDescriptionBox.values .firstWhereOrNull((val) => val.id == descriptionKey || val.id == transactionInfo.txHash); @@ -569,45 +615,23 @@ abstract class TransactionDetailsViewModelBase with Store { } } - String setNewFee({double? value, required TransactionPriority priority}) { + String setNewFee({required TransactionPriority priority, double? value}) { newFee = priority == bitcoin!.getBitcoinTransactionPriorityCustom() && value != null ? bitcoin!.feeAmountWithFeeRate( wallet, value.round(), transactionInfo.inputAddresses?.length ?? 1, - transactionInfo.outputAddresses?.length ?? 1) + transactionInfo.outputAddresses?.length ?? 1, + ) : bitcoin!.getFeeAmountForPriority( wallet, priority, transactionInfo.inputAddresses?.length ?? 1, - transactionInfo.outputAddresses?.length ?? 1); + transactionInfo.outputAddresses?.length ?? 1, + ); return bitcoin!.formatterBitcoinAmountToString(amount: newFee); } - String get formattedCryptoAmount { - if (wallet.type == WalletType.bitcoin) { - final crypto = isLightning(transactionInfo) ? CryptoCurrency.btcln : CryptoCurrency.btc; - final amount = _appStore.amountParsingProxy - .asDisplayString(transactionInfo.amount) - .withMaxDecimals(8) - .withLocalSeperator(_appStore.settingsStore.languageCode); - - return '$amount ${_appStore.amountParsingProxy.getCryptoSymbol(crypto)}'; - } - - return transactionInfo.amount.toStringWithSymbol(); - } - void replaceByFee(String newFee) => sendViewModel.replaceByFee(transactionInfo, newFee); - - @computed - String get pendingTransactionFiatAmountValueFormatted => sendViewModel.isFiatDisabled - ? '' - : sendViewModel.pendingTransactionFiatAmount + ' ' + sendViewModel.fiat.title; - - @computed - String get pendingTransactionFeeFiatAmountFormatted => sendViewModel.isFiatDisabled - ? '' - : sendViewModel.pendingTransactionFeeFiatAmount + ' ' + sendViewModel.fiat.title; } diff --git a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart index 65ee7aef8..0610381cb 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart @@ -1,59 +1,58 @@ -import 'dart:core'; -import 'dart:developer' as dev; +import "dart:core"; +import "dart:developer" as dev; -import 'package:cake_wallet/bitcoin/bitcoin.dart'; -import 'package:cake_wallet/core/address_resolver/yat/yat_store.dart'; -import 'package:cake_wallet/core/amount_parsing_proxy.dart'; -import 'package:cake_wallet/core/fiat_conversion_service.dart'; -import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; -import 'package:cake_wallet/decred/decred.dart'; -import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; -import 'package:cake_wallet/entities/fiat_api_mode.dart'; -import 'package:cake_wallet/entities/fiat_currency.dart'; -import 'package:cake_wallet/evm/evm.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/monero/monero.dart'; -import 'package:cake_wallet/reactions/wallet_connect.dart'; -import 'package:cake_wallet/reactions/wallet_utils.dart'; -import 'package:cake_wallet/solana/solana.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; -import 'package:cake_wallet/tron/tron.dart'; -import 'package:cake_wallet/utils/list_item.dart'; -import 'package:cake_wallet/utils/qr_util.dart'; -import 'package:cake_wallet/view_model/wallet_address_list/wallet_account_list_header.dart'; -import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_hidden_list_header.dart'; -import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_header.dart'; -import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; -import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_util.dart'; -import 'package:cake_wallet/wownero/wownero.dart'; -import 'package:cake_wallet/zcash/zcash.dart'; -import 'package:cake_wallet/zano/zano.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/currency.dart'; -import 'package:cw_core/currency_for_wallet_type.dart'; -import 'package:cw_core/erc20_token.dart'; -import 'package:cw_core/payment_uris.dart'; -import 'package:cw_core/spl_token.dart'; -import 'package:cw_core/tron_token.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:mobx/mobx.dart'; +import "package:cake_wallet/bitcoin/bitcoin.dart"; +import "package:cake_wallet/core/address_resolver/yat/yat_store.dart"; +import "package:cake_wallet/core/amount_parsing_proxy.dart"; +import "package:cake_wallet/core/fiat_conversion_service.dart"; +import "package:cake_wallet/core/wallet_change_listener_view_model.dart"; +import "package:cake_wallet/decred/decred.dart"; +import "package:cake_wallet/entities/auto_generate_subaddress_status.dart"; +import "package:cake_wallet/entities/fiat_api_mode.dart"; +import "package:cake_wallet/entities/fiat_currency.dart"; +import "package:cake_wallet/evm/evm.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/monero/monero.dart"; +import "package:cake_wallet/reactions/wallet_connect.dart"; +import "package:cake_wallet/reactions/wallet_utils.dart"; +import "package:cake_wallet/solana/solana.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cake_wallet/store/dashboard/fiat_conversion_store.dart"; +import "package:cake_wallet/tron/tron.dart"; +import "package:cake_wallet/utils/list_item.dart"; +import "package:cake_wallet/utils/qr_util.dart"; +import "package:cake_wallet/view_model/wallet_address_list/wallet_account_list_header.dart"; +import "package:cake_wallet/view_model/wallet_address_list/wallet_address_hidden_list_header.dart"; +import "package:cake_wallet/view_model/wallet_address_list/wallet_address_list_header.dart"; +import "package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart"; +import "package:cake_wallet/view_model/wallet_address_list/wallet_address_util.dart"; +import "package:cake_wallet/wownero/wownero.dart"; +import "package:cake_wallet/zano/zano.dart"; +import "package:cake_wallet/zcash/zcash.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/currency.dart"; +import "package:cw_core/currency_for_wallet_type.dart"; +import "package:cw_core/erc20_token.dart"; +import "package:cw_core/payment_uris.dart"; +import "package:cw_core/spl_token.dart"; +import "package:cw_core/tron_token.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:mobx/mobx.dart"; -part 'wallet_address_list_view_model.g.dart'; +part "wallet_address_list_view_model.g.dart"; class WalletAddressListViewModel = WalletAddressListViewModelBase with _$WalletAddressListViewModel; abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewModel with Store { WalletAddressListViewModelBase({ - required AppStore appStore, + required super.appStore, required this.yatStore, required this.fiatConversionStore, }) : _baseItems = [], selectedCurrency = appStore.wallet!.currency, hasAccounts = [WalletType.monero, WalletType.wownero].contains(appStore.wallet!.type), _appStore = appStore, - receivePageOption = appStore.wallet!.walletAddresses.walletInfo.addressPageType ?? '', - super(appStore: appStore) { + receivePageOption = appStore.wallet!.walletAddresses.walletInfo.addressPageType ?? "" { _init(); } @@ -81,8 +80,12 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo CryptoCurrency? tokenCurrency; @computed - String get cryptoCurrencySymbol => - _appStore.amountParsingProxy.getCryptoSymbol(tokenCurrency ?? wallet.currency); + String get cryptoCurrencySymbol => _appStore.amountParsingProxy.getCryptoSymbol( + tokenCurrency ?? + (selectedCurrency is CryptoCurrency + ? selectedCurrency as CryptoCurrency + : wallet.currency), + ); void setTokenCurrency(Currency curr) { if (curr == wallet.currency || curr == CryptoCurrency.btcln) { @@ -122,7 +125,7 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo _appStore.amountParsingProxy.useSatoshi(selectedCurrency as CryptoCurrency); @observable - String searchText = ''; + String searchText = ""; @computed int get selectedCurrencyIndex => currencies.indexOf(selectedCurrency); @@ -131,30 +134,45 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo int get tokenCurrencyIndex => tokenCurrency == null ? 0 : tokenCurrencies.indexOf(tokenCurrency!); @observable - String _amount = ''; + String _amount = ""; @computed - String get displayAmount => _appStore.amountParsingProxy - .getDisplayCryptoAmount(_amount, tokenCurrency ?? wallet.currency); + String get displayAmount => _appStore.amountParsingProxy.getDisplayCryptoAmount( + _amount, + tokenCurrency ?? + (selectedCurrency is CryptoCurrency + ? selectedCurrency as CryptoCurrency + : wallet.currency), + ); // NOT PRECISE! just for display purposes. @computed String get fiatAmount { - if (_amount.isEmpty) return ""; + if (_amount.isEmpty) { + return ""; + } + var cryptoCurrency = tokenCurrency ?? wallet.currency; - if (cryptoCurrency == CryptoCurrency.btcln) cryptoCurrency = CryptoCurrency.btc; + if (cryptoCurrency == CryptoCurrency.btcln) { + cryptoCurrency = CryptoCurrency.btc; + } + if (selectedCurrency is FiatCurrency && _fiatRate != null) { return selectedCurrencyFiatAmount; } - if (!fiatConversionStore.prices.containsKey(cryptoCurrency)) return ""; + if (!fiatConversionStore.prices.containsKey(cryptoCurrency)) { + return ""; + } final amount = double.tryParse(_amount) ?? 0; return (amount * fiatConversionStore.prices[cryptoCurrency]!).toStringAsFixed(2); } @computed String get selectedCurrencyFiatAmount { - if (_fiatRate == null) return ""; + if (_fiatRate == null) { + return ""; + } final amount = double.tryParse(_amount) ?? 0; return (amount * _fiatRate!).toStringAsFixed(2); } @@ -201,26 +219,29 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo PaymentURI get uri { if (tokenCurrency != null && isEVMCompatibleChain(wallet.type)) { return ERC681URI( - chainId: wallet.chainId ?? 1, - address: wallet.walletAddresses.address, - amount: _amount, - contractAddress: (tokenCurrency as Erc20Token).contractAddress); + chainId: wallet.chainId ?? 1, + address: wallet.walletAddresses.address, + amount: _amount, + contractAddress: (tokenCurrency! as Erc20Token).contractAddress, + ); } if (tokenCurrency is TronToken && wallet.type == WalletType.tron) { return TronURI( amount: _amount, address: wallet.walletAddresses.address, - contractAddress: (tokenCurrency as TronToken).contractAddress, + contractAddress: (tokenCurrency! as TronToken).contractAddress, ); } if (tokenCurrency is SPLToken && wallet.type == WalletType.solana) { return SolanaURI( amount: _amount, address: wallet.walletAddresses.address, - contractAddress: (tokenCurrency as SPLToken).mintAddress, + contractAddress: (tokenCurrency! as SPLToken).mintAddress, ); } - if (isLightning && _lnPaymentRequest != null) return _lnPaymentRequest!; + if (isLightning && _lnPaymentRequest != null) { + return _lnPaymentRequest!; + } return wallet.walletAddresses.getPaymentUri(_amount); } @@ -257,10 +278,11 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo final isPrimary = subaddress == primaryAddress; return WalletAddressListItem( - id: subaddress.id, - isPrimary: isPrimary, - name: subaddress.label, - address: subaddress.address); + id: subaddress.id, + isPrimary: isPrimary, + name: subaddress.label, + address: subaddress.address, + ); }); addressList.addAll(addressItems); } @@ -285,37 +307,37 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo addressList.addAll(addressItems); addressList.add(WalletAddressListHeader(title: S.current.received)); - final receivedAddressItems = - bitcoin!.getSilentPaymentReceivedAddresses(wallet).map((address) { - return WalletAddressListItem( - id: address.id, - isPrimary: false, - name: address.name, - address: address.address, - txCount: address.txCount, - balance: _appStore.amountParsingProxy - .getDisplayCryptoString(address.balance, walletTypeToCryptoCurrency(type)), - isChange: address.isChange, - isOneTimeReceiveAddress: true, - derivationPath: address.derivationPath, - ); - }); + final receivedAddressItems = bitcoin!.getSilentPaymentReceivedAddresses(wallet).map( + (address) => WalletAddressListItem( + id: address.id, + isPrimary: false, + name: address.name, + address: address.address, + txCount: address.txCount, + balance: _appStore.amountParsingProxy + .getDisplayCryptoString(address.balance, walletTypeToCryptoCurrency(type)), + isChange: address.isChange, + isOneTimeReceiveAddress: true, + derivationPath: address.derivationPath, + ), + ); addressList.addAll(receivedAddressItems); } else { var addressItems = bitcoin!.getSubAddresses(wallet).map((subaddress) { final isPrimary = subaddress.id == 0; return WalletAddressListItem( - id: subaddress.id, - isPrimary: isPrimary, - name: subaddress.name, - address: subaddress.address, - txCount: subaddress.txCount, - balance: _appStore.amountParsingProxy - .getDisplayCryptoString(subaddress.balance, walletTypeToCryptoCurrency(type)), - isChange: subaddress.isChange, - isLegacyDerivation: subaddress.isLegacyDerivation, - derivationPath: subaddress.derivationPath); + id: subaddress.id, + isPrimary: isPrimary, + name: subaddress.name, + address: subaddress.address, + txCount: subaddress.txCount, + balance: _appStore.amountParsingProxy + .getDisplayCryptoString(subaddress.balance, walletTypeToCryptoCurrency(type)), + isChange: subaddress.isChange, + isLegacyDerivation: subaddress.isLegacyDerivation, + derivationPath: subaddress.derivationPath, + ); }); // don't show all 1000+ mweb addresses: @@ -346,11 +368,13 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo } if (wallet.type == WalletType.nano) { - addressList.add(WalletAddressListItem( - isPrimary: true, - name: null, - address: wallet.walletAddresses.address, - )); + addressList.add( + WalletAddressListItem( + isPrimary: true, + name: null, + address: wallet.walletAddresses.address, + ), + ); } if (wallet.type == WalletType.tron) { @@ -361,29 +385,35 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo if (wallet.type == WalletType.decred) { final addrInfos = decred!.getAddressInfos(wallet); - addrInfos.forEach((info) { + for (final info in addrInfos) { addressList.add( - new WalletAddressListItem(isPrimary: false, address: info.address, name: info.label)); - }); + WalletAddressListItem(isPrimary: false, address: info.address, name: info.label), + ); + } } if (wallet.type == WalletType.zcash) { final addrInfos = zcash!.getAddressInfos(wallet); - addrInfos.forEach((info) { + for (final info in addrInfos) { addressList.add( - new WalletAddressListItem(isPrimary: false, address: info.address, name: info.label)); - }); + WalletAddressListItem(isPrimary: false, address: info.address, name: info.label), + ); + } } for (var i = 0; i < addressList.length; i++) { - if (!(addressList[i] is WalletAddressListItem)) continue; + if (addressList[i] is! WalletAddressListItem) { + continue; + } final item = addressList[i] as WalletAddressListItem; item.isHidden = wallet.walletAddresses.hiddenAddresses.contains(item.address) || (isElectrumWallet && item.isLegacyDerivation); } for (var i = 0; i < addressList.length; i++) { - if (!(addressList[i] is WalletAddressListItem)) continue; + if (addressList[i] is! WalletAddressListItem) { + continue; + } (addressList[i] as WalletAddressListItem).isManual = wallet.walletAddresses.manualAddresses .contains((addressList[i] as WalletAddressListItem).address); } @@ -395,25 +425,25 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo } if (searchText.isNotEmpty) { - return ObservableList.of(addressList.where((item) { - if (item is WalletAddressListItem) { - return item.address.toLowerCase().contains(searchText.toLowerCase()); - } - return false; - })); + return ObservableList.of( + addressList.where((item) { + if (item is WalletAddressListItem) { + return item.address.toLowerCase().contains(searchText.toLowerCase()); + } + return false; + }), + ); } return addressList; } @computed - ObservableList get addressList { - return _computeAddressList(); - } + ObservableList get addressList => _computeAddressList(); List get forceRecomputeItems { // necessary because the addressList contains non-observable items - List recomputed = []; + final recomputed = []; recomputed.addAll(_baseItems); recomputed.addAll(_computeAddressList()); return recomputed; @@ -451,9 +481,9 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo case WalletType.wownero: wownero!.getCurrentAccount(wallet).label; default: - return ''; + return ""; } - return ''; + return ""; } @computed @@ -473,7 +503,7 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo WalletType.litecoin, WalletType.decred, WalletType.dogecoin, - WalletType.zcash + WalletType.zcash, ].contains(wallet.type) && !isLightning && isZCashTransparent; @@ -486,7 +516,7 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, - WalletType.dogecoin + WalletType.dogecoin, ].contains(wallet.type); List getWalletImages(int? chainId) { @@ -494,44 +524,44 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo switch (chainId) { case 1: return [ - 'assets/new-ui/crypto_full_icons/ethereum.svg', - 'assets/images/usdc_icon.svg', - 'assets/images/usdt_wallet_icon.svg', - 'assets/images/deuro_icon.svg', - 'assets/images/more_tokens.svg', + "assets/new-ui/crypto_full_icons/ethereum.svg", + "assets/images/usdc_icon.svg", + "assets/images/usdt_wallet_icon.svg", + "assets/images/deuro_icon.svg", + "assets/images/more_tokens.svg", ]; case 137: return [ - 'assets/new-ui/crypto_full_icons/polygon.svg', - 'assets/images/eth_pol_icon.svg', - 'assets/images/usdc_icon.svg', - 'assets/images/usdt_wallet_icon.svg', - 'assets/images/more_tokens.svg', + "assets/new-ui/crypto_full_icons/polygon.svg", + "assets/images/eth_pol_icon.svg", + "assets/images/usdc_icon.svg", + "assets/images/usdt_wallet_icon.svg", + "assets/images/more_tokens.svg", ]; case 8453: return [ - 'assets/new-ui/crypto_full_icons/ethereum.svg', - 'assets/images/usdc_icon.svg', - 'assets/images/more_tokens.svg', + "assets/new-ui/crypto_full_icons/ethereum.svg", + "assets/images/usdc_icon.svg", + "assets/images/more_tokens.svg", ]; case 42161: return [ - 'assets/new-ui/crypto_full_icons/arbitrum.svg', - 'assets/images/usdc_icon.svg', - 'assets/images/more_tokens.svg', + "assets/new-ui/crypto_full_icons/arbitrum.svg", + "assets/images/usdc_icon.svg", + "assets/images/more_tokens.svg", ]; case 56: return [ - 'assets/new-ui/crypto_full_icons/bnb.svg', - 'assets/images/usdc_icon.svg', - 'assets/images/usdt_wallet_icon.svg', - 'assets/images/more_tokens.svg', + "assets/new-ui/crypto_full_icons/bnb.svg", + "assets/images/usdc_icon.svg", + "assets/images/usdt_wallet_icon.svg", + "assets/images/more_tokens.svg", ]; default: return [ - 'assets/new-ui/crypto_full_icons/ethereum.svg', - 'assets/images/usdc_icon.svg', - 'assets/images/usdt_wallet_icon.svg', + "assets/new-ui/crypto_full_icons/ethereum.svg", + "assets/images/usdc_icon.svg", + "assets/images/usdt_wallet_icon.svg", ]; } } @@ -539,22 +569,22 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo switch (wallet.type) { case WalletType.solana: return [ - 'assets/images/sol_icon.svg', - 'assets/images/usdc_icon.svg', - 'assets/images/usdt_wallet_icon.svg', - 'assets/images/more_tokens.svg', + "assets/images/sol_icon.svg", + "assets/images/usdc_icon.svg", + "assets/images/usdt_wallet_icon.svg", + "assets/images/more_tokens.svg", ]; case WalletType.tron: return [ - 'assets/images/trx_icon.svg', - 'assets/images/usdc_icon.svg', - 'assets/images/usdt_wallet_icon.svg', - 'assets/images/more_tokens.svg', + "assets/images/trx_icon.svg", + "assets/images/usdc_icon.svg", + "assets/images/usdt_wallet_icon.svg", + "assets/images/more_tokens.svg", ]; case WalletType.zano: return [ - 'assets/images/zano_icon.svg', - 'assets/images/more_tokens.svg', + "assets/images/zano_icon.svg", + "assets/images/more_tokens.svg", ]; default: return []; @@ -563,7 +593,9 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo @computed String get qrImage { - if (isLightning) return 'assets/images/btc_chain_qr_lightning.svg'; + if (isLightning) { + return "assets/images/btc_chain_qr_lightning.svg"; + } return getQrImage(type); } @@ -582,8 +614,7 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo @computed bool get isLightning => - wallet.type == WalletType.bitcoin && - (wallet.walletAddresses.getPaymentUri(_amount) is LightningPaymentRequest); + wallet.type == WalletType.bitcoin && selectedCurrency == CryptoCurrency.btcln; @computed bool get isZCashTransparent { @@ -698,16 +729,14 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo @action void changeAmount(String amount) { if (selectedCurrency is FiatCurrency) { - this._amount = amount; + _amount = amount; _convertAmountToCrypto(); } else if (selectedCurrency is CryptoCurrency) { - this._amount = _appStore.amountParsingProxy + _amount = _appStore.amountParsingProxy .getCanonicalCryptoAmount(amount, selectedCurrency as CryptoCurrency); } if (isLightning) { - wallet.walletAddresses - .getPaymentRequestUri(this._amount) - .then((uri) => _lnPaymentRequest = uri); + wallet.walletAddresses.getPaymentRequestUri(_amount).then((uri) => _lnPaymentRequest = uri); } } @@ -719,22 +748,24 @@ abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewMo @action void _convertAmountToCrypto() { var cryptoCurrency = tokenCurrency ?? wallet.currency; - if (cryptoCurrency == CryptoCurrency.btcln) cryptoCurrency = CryptoCurrency.btc; + if (cryptoCurrency == CryptoCurrency.btcln) { + cryptoCurrency = CryptoCurrency.btc; + } final fiatRate = _fiatRate ?? (fiatConversionStore.prices[cryptoCurrency] ?? 0.0); if (fiatRate <= 0.0) { dev.log("Invalid Fiat Rate $fiatRate"); - _amount = ''; + _amount = ""; return; } try { - final crypto = (double.parse(_amount.replaceAll(',', '.')) / fiatRate).toStringAsFixed(8); + final crypto = (double.parse(_amount.replaceAll(",", ".")) / fiatRate).toStringAsFixed(8); if (_amount != crypto) { _amount = crypto; } } catch (e) { - _amount = ''; + _amount = ""; } } From 9d685d4645ffb349694cc0d4625183c581b0b23c Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Fri, 17 Jul 2026 16:52:52 +0200 Subject: [PATCH 3/5] trezor-monero-passphrase-modal (#3337) * trezor monero passphrase modal * feat: add Trezor passphrase support for wallet restoration process * revert: send page changes * chore: format code * chore: format code * chore: reorganize imports and improve code formatting in send_page.dart [skip ci] * auto-reformat * fix: trezor usb device refresh race condition * fix: ui bugs in relation to trezor * fix: only show passphrase option for trezor devices --------- Co-authored-by: Robert Malikowski Co-authored-by: Omar Hatem --- cw_monero/lib/api/wallet.dart | 4 + cw_monero/lib/api/wallet_manager.dart | 5 + cw_monero/lib/monero_wallet.dart | 18 +- cw_monero/lib/monero_wallet_service.dart | 25 +- cw_monero/lib/trezor.dart | 9 + cw_monero/pubspec.lock | 8 +- cw_monero/pubspec.yaml | 2 +- lib/monero/cw_monero.dart | 2 + lib/new-ui/widgets/digit_input.dart | 88 +++---- .../connect_device/connect_device_page.dart | 18 +- .../monero_hardware_wallet_options_page.dart | 241 ++++++++++-------- ...nero_hardware_wallet_passphrase_input.dart | 54 ++++ .../screens/restore/wallet_restore_page.dart | 2 +- .../wallet_hardware_restore_view_model.dart | 5 +- lib/view_model/wallet_restore_view_model.dart | 4 + res/values/strings_en.arb | 4 + tool/configure.dart | 2 +- 17 files changed, 319 insertions(+), 172 deletions(-) create mode 100644 lib/src/screens/connect_device/monero_hardware_wallet_passphrase_input.dart diff --git a/cw_monero/lib/api/wallet.dart b/cw_monero/lib/api/wallet.dart index 80924bb0d..787933217 100644 --- a/cw_monero/lib/api/wallet.dart +++ b/cw_monero/lib/api/wallet.dart @@ -257,6 +257,10 @@ void setRecoveringFromSeed({required bool isRecovery}) { final storeMutex = Mutex(); +void refresh() { + currentWallet!.refresh(); +} + int lastStorePointer = 0; int lastStoreHeight = 0; void storeSync({bool force = false}) async { diff --git a/cw_monero/lib/api/wallet_manager.dart b/cw_monero/lib/api/wallet_manager.dart index 2bdcf0bac..85bc77291 100644 --- a/cw_monero/lib/api/wallet_manager.dart +++ b/cw_monero/lib/api/wallet_manager.dart @@ -150,6 +150,11 @@ void restoreWalletFromSeedSync( _lastOpenedWallet = path; } +void storePassphrase({required String path, required String passphrase}) { + currentWallet!.setCacheAttribute(key: "cakewallet.passphrase", value: passphrase); + currentWallet!.store(path: path); +} + void restoreWalletFromKeys( {required String path, required String password, diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index 16ab89ad6..d0e653907 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -382,22 +382,30 @@ abstract class MoneroWalletBase } MoneroTrezorService? trezorService; + + Future _getTrezor() async { + if (trezorService == null) throw Exception("Trezor not connected"); + + final trezor = Trezor(trezorService!); + await trezor.newPassphraseSession(passphrase); + return trezor; + } + Future syncTrezor() async { if (trezorService == null) throw Exception("Trezor not connected"); final ptr = Pointer.fromAddress(currentWallet!.ffiAddress()); final tdis = monero.Wallet_exportTrezorTdis(ptr); - - final response = await Trezor(trezorService!).keyImageSync(tdis); - + final trezor = await _getTrezor(); + final response = await trezor.keyImageSync(tdis); final success = monero.Wallet_importTrezorEncryptedKeyImagesJson(ptr, response); if (!success) throw Exception(monero.Wallet_errorString(ptr)); } Future signTrezorTransaction(String json) async { - if (trezorService == null) throw Exception("Trezor not connected"); - return Trezor(trezorService!).signTransaction(json); + final trezor = await _getTrezor(); + return trezor.signTransaction(json); } @override diff --git a/cw_monero/lib/monero_wallet_service.dart b/cw_monero/lib/monero_wallet_service.dart index cb549a861..e774b47ba 100644 --- a/cw_monero/lib/monero_wallet_service.dart +++ b/cw_monero/lib/monero_wallet_service.dart @@ -44,10 +44,15 @@ class MoneroNewWalletCredentials extends WalletCredentials { } class MoneroRestoreWalletFromHardwareCredentials extends WalletCredentials { - MoneroRestoreWalletFromHardwareCredentials( - {required String name, required this.hardwareWalletService, int height = 0, String? password}) - : super(name: name, password: password, height: height); - HardwareWalletService hardwareWalletService; + MoneroRestoreWalletFromHardwareCredentials({ + required String name, + required this.hardwareWalletService, + int height = 0, + String? password, + String? passphrase, + }) : super(name: name, password: password, height: height, passphrase: passphrase); + + final HardwareWalletService hardwareWalletService; } class MoneroRestoreWalletFromSeedCredentials extends WalletCredentials { @@ -306,7 +311,13 @@ class MoneroWalletService extends WalletService< deviceName: 'Ledger', ); } else if (credentials.hardwareWalletService case MoneroTrezorService service) { - final watchCredentials = await Trezor(service).getWatchCredentials(); + final trezor = Trezor(service); + + if (credentials.passphrase != null) { + await trezor.newPassphraseSession(credentials.passphrase); + } + + final watchCredentials = await trezor.getWatchCredentials(); monero_wallet_manager.restoreWalletFromKeys( path: path, @@ -317,6 +328,10 @@ class MoneroWalletService extends WalletService< viewKey: watchCredentials.watchKey, spendKey: "", ); + + if (credentials.passphrase != null) { + monero_wallet_manager.storePassphrase(path: path, passphrase: credentials.passphrase!); + } } final wallet = MoneroWallet( diff --git a/cw_monero/lib/trezor.dart b/cw_monero/lib/trezor.dart index eff953d1b..a9a387912 100644 --- a/cw_monero/lib/trezor.dart +++ b/cw_monero/lib/trezor.dart @@ -21,6 +21,15 @@ class Trezor { Trezor(this.service); + String? _sessionPassphrase; + Future newPassphraseSession(String? passphrase) async { + if (passphrase == null) return; + + if (_sessionPassphrase == passphrase) return; + _sessionPassphrase = passphrase; + return service.client.createChannel(passphrase: _sessionPassphrase); + } + Future getWatchCredentials() async { final credentials = await TrezorMonero(service.client).getWatchCredentials(); diff --git a/cw_monero/pubspec.lock b/cw_monero/pubspec.lock index 79da36944..275e7d4aa 100644 --- a/cw_monero/pubspec.lock +++ b/cw_monero/pubspec.lock @@ -921,8 +921,8 @@ packages: dependency: "direct main" description: path: trezor-flutter - ref: d1289dfa4247a93ee57342dfd249957b339915ef - resolved-ref: d1289dfa4247a93ee57342dfd249957b339915ef + ref: "944080d950a62dd48a199b9736237cbe580351de" + resolved-ref: "944080d950a62dd48a199b9736237cbe580351de" url: "https://github.com/cake-tech/trezor-flutter.git" source: git version: "0.1.0" @@ -930,8 +930,8 @@ packages: dependency: transitive description: path: trezor_usb_transport - ref: d1289dfa4247a93ee57342dfd249957b339915ef - resolved-ref: d1289dfa4247a93ee57342dfd249957b339915ef + ref: "944080d950a62dd48a199b9736237cbe580351de" + resolved-ref: "944080d950a62dd48a199b9736237cbe580351de" url: "https://github.com/cake-tech/trezor-flutter.git" source: git version: "0.0.1" diff --git a/cw_monero/pubspec.yaml b/cw_monero/pubspec.yaml index c84f08b04..692103eba 100644 --- a/cw_monero/pubspec.yaml +++ b/cw_monero/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: trezor_flutter: git: url: https://github.com/cake-tech/trezor-flutter.git - ref: d1289dfa4247a93ee57342dfd249957b339915ef + ref: 944080d950a62dd48a199b9736237cbe580351de path: trezor-flutter dev_dependencies: diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index 1551b95de..7d2b8ec26 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -236,12 +236,14 @@ class CWMonero extends Monero { required String password, required int height, required HardwareWalletService hardwareWalletService, + required String? passphrase, }) => MoneroRestoreWalletFromHardwareCredentials( name: name, password: password, height: height, hardwareWalletService: hardwareWalletService, + passphrase: passphrase, ); @override diff --git a/lib/new-ui/widgets/digit_input.dart b/lib/new-ui/widgets/digit_input.dart index d6889db67..2994a75e9 100644 --- a/lib/new-ui/widgets/digit_input.dart +++ b/lib/new-ui/widgets/digit_input.dart @@ -1,5 +1,7 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import "dart:io"; + +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; class DigitInputController { String _text = ""; @@ -19,38 +21,37 @@ class DigitInputController { } class DigitInputPill extends StatelessWidget { - const DigitInputPill({super.key, this.digit, required this.highlighted}); + const DigitInputPill({required this.highlighted, super.key, this.digit}); final String? digit; final bool highlighted; @override - Widget build(BuildContext context) { - return Container( - width: 51, - height: 84, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - border: - highlighted ? Border.all(color: Theme.of(context).colorScheme.primary, width: 2) : null, - ), - child: Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Text(digit ?? " ", style: TextStyle(fontSize: 40)), + Widget build(BuildContext context) => Container( + width: 51, + height: 84, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: highlighted + ? Border.all(color: Theme.of(context).colorScheme.primary, width: 2) + : null, ), - ), - ); - } + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text(digit ?? " ", style: const TextStyle(fontSize: 40)), + ), + ), + ); } class DigitInput extends StatefulWidget { const DigitInput({ - super.key, required this.controller, required this.desiredLength, this.breakAt = 3, + super.key, }); final DigitInputController controller; @@ -74,12 +75,9 @@ class _DigitInputState extends State implements TextInputClient { if (_textInputConnection == null || !_textInputConnection!.attached) { _textInputConnection = TextInput.attach( this, - const TextInputConfiguration( - inputType: TextInputType.numberWithOptions( - signed: false, - decimal: false, - ), - inputAction: TextInputAction.none, + TextInputConfiguration( + inputType: TextInputType.number, + inputAction: Platform.isIOS ? TextInputAction.done : TextInputAction.none, ), ); } @@ -102,30 +100,28 @@ class _DigitInputState extends State implements TextInputClient { } @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: _openVirtualKeyboard, - child: SizedBox( - height: 84, - child: ListView.separated( - scrollDirection: Axis.horizontal, - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => DigitInputPill( - digit: widget.controller.text.length > index ? widget.controller.text[index] : null, - highlighted: widget.controller.text.length == index, + Widget build(BuildContext context) => GestureDetector( + onTap: _openVirtualKeyboard, + child: SizedBox( + height: 84, + child: ListView.separated( + scrollDirection: Axis.horizontal, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => DigitInputPill( + digit: widget.controller.text.length > index ? widget.controller.text[index] : null, + highlighted: widget.controller.text.length == index, + ), + separatorBuilder: (context, index) => + SizedBox(width: (index + 1) % widget.breakAt == 0 ? 16 : 4), + itemCount: widget.desiredLength, ), - separatorBuilder: (context, index) => - SizedBox(width: (index + 1) % widget.breakAt == 0 ? 16 : 4), - itemCount: widget.desiredLength, ), - ), - ); - } + ); @override void updateEditingValue(TextEditingValue value) { - final RegExp numberRegExp = RegExp(r'^\d{0,6}$'); + final RegExp numberRegExp = RegExp(r"^\d{0,6}$"); if (numberRegExp.hasMatch(value.text)) { setState(() => widget.controller.text = value.text); diff --git a/lib/src/screens/connect_device/connect_device_page.dart b/lib/src/screens/connect_device/connect_device_page.dart index 21cbd0292..ed1ae3154 100644 --- a/lib/src/screens/connect_device/connect_device_page.dart +++ b/lib/src/screens/connect_device/connect_device_page.dart @@ -142,12 +142,22 @@ class ConnectDevicePageBodyState extends State { super.dispose(); } + var _isRefreshingUsb = false; Future _refreshUsbDevices() async { - final dev = await widget.hardwareWalletVM.getAllUsbDevices(); - - if (usbDevices.length != dev.length) { - setState(() => usbDevices = dev); + if (_isRefreshingUsb) { + return; } + _isRefreshingUsb = true; + try { + final dev = await widget.hardwareWalletVM.getAllUsbDevices(); + + if (usbDevices.length != dev.length) { + setState(() => usbDevices = dev); + } + } catch(e) { + printV(e); + } + _isRefreshingUsb = false; } Future _refreshBleDevices() async { diff --git a/lib/src/screens/connect_device/monero_hardware_wallet_options_page.dart b/lib/src/screens/connect_device/monero_hardware_wallet_options_page.dart index 7e77d4350..e6286a80d 100644 --- a/lib/src/screens/connect_device/monero_hardware_wallet_options_page.dart +++ b/lib/src/screens/connect_device/monero_hardware_wallet_options_page.dart @@ -1,19 +1,22 @@ -import 'package:cake_wallet/core/wallet_name_validator.dart'; -import 'package:cw_core/generate_name.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; -import 'package:cake_wallet/src/widgets/blockchain_height_widget.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/src/widgets/scrollable_with_bottom_section.dart'; -import 'package:cake_wallet/utils/responsive_layout_util.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cake_wallet/view_model/wallet_hardware_restore_view_model.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:mobx/mobx.dart'; +import "dart:async"; + +import "package:cake_wallet/core/wallet_name_validator.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/base_page.dart"; +import "package:cake_wallet/src/screens/connect_device/monero_hardware_wallet_passphrase_input.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; +import "package:cake_wallet/src/widgets/base_text_form_field.dart"; +import "package:cake_wallet/src/widgets/blockchain_height_widget.dart"; +import "package:cake_wallet/src/widgets/primary_button.dart"; +import "package:cake_wallet/src/widgets/scrollable_with_bottom_section.dart"; +import "package:cake_wallet/utils/responsive_layout_util.dart"; +import "package:cake_wallet/utils/show_pop_up.dart"; +import "package:cake_wallet/view_model/wallet_hardware_restore_view_model.dart"; +import "package:cw_core/generate_name.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; +import "package:mobx/mobx.dart"; class MoneroHardwareWalletOptionsPage extends BasePage { MoneroHardwareWalletOptionsPage(this._walletHardwareRestoreVM); @@ -42,13 +45,15 @@ class _MoneroHardwareWalletOptionsFormState extends State<_MoneroHardwareWalletO : _formKey = GlobalKey(), _blockchainHeightKey = GlobalKey(), _blockHeightFocusNode = FocusNode(), - _controller = TextEditingController(); + _walletNameController = TextEditingController(), + _passphraseController = TextEditingController(); final GlobalKey _formKey; final GlobalKey _blockchainHeightKey; final FocusNode _blockHeightFocusNode; final WalletHardwareRestoreViewModel _walletHardwareRestoreVM; - final TextEditingController _controller; + final TextEditingController _walletNameController; + final TextEditingController _passphraseController; @override void initState() { @@ -57,131 +62,159 @@ class _MoneroHardwareWalletOptionsFormState extends State<_MoneroHardwareWalletO } @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(top: 24), - child: ScrollableWithBottomSection( - contentPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24), - content: Center( - child: ConstrainedBox( - constraints: - BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.only(top: 0), - child: Form( - key: _formKey, - child: Stack( - alignment: Alignment.centerRight, - children: [ - BaseTextFormField( - onChanged: (value) => _walletHardwareRestoreVM.name = value, - controller: _controller, - textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 20.0, - fontWeight: FontWeight.w600, - ), - placeholderTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( - fontSize: 18.0, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - hintText: S.of(context).wallet_name, - suffixIcon: Semantics( - label: S.of(context).generate_name, - child: IconButton( - onPressed: _onGenerateName, - icon: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6.0), - color: Theme.of(context).colorScheme.surfaceContainerHighest, + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 24), + child: ScrollableWithBottomSection( + contentPadding: const EdgeInsets.only(left: 24, right: 24, bottom: 24), + content: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.zero, + child: Form( + key: _formKey, + child: Stack( + alignment: Alignment.centerRight, + children: [ + BaseTextFormField( + onChanged: (value) => _walletHardwareRestoreVM.name = value, + controller: _walletNameController, + textStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 20, + fontWeight: FontWeight.w600, ), - width: 34, - height: 34, - child: Image.asset( - 'assets/images/refresh_icon.png', - color: Theme.of(context).colorScheme.primary, + placeholderTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + hintText: S.of(context).wallet_name, + suffixIcon: Semantics( + label: S.of(context).generate_name, + child: IconButton( + onPressed: _onGenerateName, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: Theme.of(context).colorScheme.surfaceContainerHighest, + ), + width: 34, + height: 34, + child: Image.asset( + "assets/images/refresh_icon.png", + color: Theme.of(context).colorScheme.primary, + ), ), ), ), + validator: WalletNameValidator(), ), - validator: WalletNameValidator(), - ), - ], + ], + ), ), ), - ), - Padding( - padding: EdgeInsets.only(top: 20), - child: BlockchainHeightWidget( - focusNode: _blockHeightFocusNode, - key: _blockchainHeightKey, - hasDatePicker: true, - walletType: WalletType.monero, + Padding( + padding: const EdgeInsets.only(top: 20), + child: BlockchainHeightWidget( + focusNode: _blockHeightFocusNode, + key: _blockchainHeightKey, + hasDatePicker: true, + walletType: WalletType.monero, + ), ), + ], + ), + ), + ), + bottomSectionPadding: const EdgeInsets.all(24), + bottomSection: Observer( + builder: (_) => Column( + children: [ + if (_walletHardwareRestoreVM.passphraseAvailable) ...[ + TextButton( + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => MoneroHardwareWalletPassphraseInputModal( + controller: _passphraseController, + ), + ); + }, + child: Text(S.of(context).add_a_passphrase), + ), + ], + LoadingPrimaryButton( + onPressed: _confirmForm, + text: S.of(context).seed_language_next, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isDisabled: _walletHardwareRestoreVM.name.isEmpty, ), ], ), ), ), - bottomSectionPadding: EdgeInsets.all(24), - bottomSection: Observer( - builder: (context) => LoadingPrimaryButton( - onPressed: _confirmForm, - text: S.of(context).seed_language_next, - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - isDisabled: _walletHardwareRestoreVM.name.isEmpty, - ), - ), - ), - ); - } + ); Future _onGenerateName() async { final rName = await generateName(); FocusManager.instance.primaryFocus?.unfocus(); setState(() { - _controller.text = rName; + _walletNameController.text = rName; _walletHardwareRestoreVM.name = rName; - _controller.selection = - TextSelection.fromPosition(TextPosition(offset: _controller.text.length)); + _walletNameController.selection = + TextSelection.fromPosition(TextPosition(offset: _walletNameController.text.length)); }); } Future _confirmForm() async { - showPopUp( - context: context, - builder: (BuildContext context) => AlertWithOneAction( - alertTitle: S.of(context).proceed_on_device, - alertContent: S.of(context).proceed_on_device_description, - buttonText: S.of(context).cancel, - alertBarrierDismissible: false, - buttonAction: () => Navigator.of(context).pop(), + unawaited( + showPopUp( + context: context, + builder: (context) => AlertWithOneAction( + alertTitle: S.of(context).proceed_on_device, + alertContent: S.of(context).proceed_on_device_description, + buttonText: S.of(context).cancel, + alertBarrierDismissible: false, + buttonAction: () => Navigator.of(context).pop(), + ), ), ); - final options = {'height': _blockchainHeightKey.currentState?.height ?? -1}; + final options = {"height": _blockchainHeightKey.currentState?.height ?? -1}; + + if (_walletHardwareRestoreVM.passphraseAvailable && _passphraseController.text.isNotEmpty) { + options["passphrase"] = _passphraseController.text; + } await _walletHardwareRestoreVM.create(options: options); } bool _effectsInstalled = false; void _setEffects(BuildContext context) { - if (_effectsInstalled) return; + if (_effectsInstalled) { + return; + } - reaction((_) => _walletHardwareRestoreVM.error, (String? error) { + reaction((_) => _walletHardwareRestoreVM.error, (error) { if (error != null) { - if (error == S.current.ledger_connection_error) Navigator.of(context).pop(); + if (error == S.current.ledger_connection_error) { + Navigator.of(context).pop(); + } WidgetsBinding.instance.addPostFrameCallback((_) { showPopUp( context: context, - builder: (BuildContext context) => AlertWithOneAction( + builder: (context) => AlertWithOneAction( alertTitle: S.of(context).error, alertContent: error, buttonText: S.of(context).ok, diff --git a/lib/src/screens/connect_device/monero_hardware_wallet_passphrase_input.dart b/lib/src/screens/connect_device/monero_hardware_wallet_passphrase_input.dart new file mode 100644 index 000000000..6dd277e97 --- /dev/null +++ b/lib/src/screens/connect_device/monero_hardware_wallet_passphrase_input.dart @@ -0,0 +1,54 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/src/widgets/base_text_form_field.dart"; +import "package:flutter/material.dart"; + +class MoneroHardwareWalletPassphraseInputModal extends StatelessWidget { + const MoneroHardwareWalletPassphraseInputModal({required this.controller, super.key}); + + final TextEditingController controller; + + @override + Widget build(BuildContext context) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(top: Radius.circular(18)), + color: Theme.of(context).colorScheme.surface, + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ModalTopBar( + title: S.of(context).passphrase_entry, + leadingIcon: const Icon(Icons.close), + onLeadingPressed: Navigator.of(context).pop, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Column( + spacing: 12, + children: [ + BaseTextFormField( + controller: controller, + hintText: S.of(context).passphrase_raw, + ), + const SizedBox(), + NewPrimaryButton( + onPressed: Navigator.of(context).pop, + text: S.of(context).restore_next, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + const SizedBox(), + ], + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/src/screens/restore/wallet_restore_page.dart b/lib/src/screens/restore/wallet_restore_page.dart index 2588fd35c..c8f795c5f 100644 --- a/lib/src/screens/restore/wallet_restore_page.dart +++ b/lib/src/screens/restore/wallet_restore_page.dart @@ -99,7 +99,7 @@ class WalletRestorePage extends BasePage { children: [ Observer( builder: (context) { - return walletRestoreViewModel.mode == WalletRestoreMode.seed + return walletRestoreViewModel.passphraseAvailable ? StandardCheckbox( captionColor: Theme.of(context).colorScheme.onSecondaryContainer, value: walletRestoreViewModel.hasPassphrase, diff --git a/lib/view_model/wallet_hardware_restore_view_model.dart b/lib/view_model/wallet_hardware_restore_view_model.dart index 37f6467d3..35de089c8 100644 --- a/lib/view_model/wallet_hardware_restore_view_model.dart +++ b/lib/view_model/wallet_hardware_restore_view_model.dart @@ -6,13 +6,13 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart'; -import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; import 'package:cake_wallet/view_model/seed_settings_view_model.dart'; import 'package:cake_wallet/view_model/wallet_creation_vm.dart'; import 'package:cw_core/hardware/hardware_account_data.dart'; import 'package:cw_core/utils/print_verbose.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:mobx/mobx.dart'; @@ -43,6 +43,8 @@ abstract class WalletHardwareRestoreViewModelBase extends WalletCreationVM with @observable String? error = null; + bool get passphraseAvailable => hardwareWalletVM.hardwareWalletType == HardwareWalletType.trezor; + // @observable ObservableList availableAccounts = ObservableList(); @@ -87,6 +89,7 @@ abstract class WalletHardwareRestoreViewModelBase extends WalletCreationVM with hardwareWalletService: hardwareWalletVM.getHardwareWalletService(type), password: password, height: _options['height'] as int? ?? 0, + passphrase: _options['passphrase'] as String?, ); default: throw Exception('Unexpected type: ${type.toString()}'); diff --git a/lib/view_model/wallet_restore_view_model.dart b/lib/view_model/wallet_restore_view_model.dart index 0bc32341b..1f9557170 100644 --- a/lib/view_model/wallet_restore_view_model.dart +++ b/lib/view_model/wallet_restore_view_model.dart @@ -108,6 +108,10 @@ abstract class WalletRestoreViewModelBase extends WalletCreationVM with Store { @observable WalletRestoreMode mode; + @computed + bool get passphraseAvailable => + mode == WalletRestoreMode.seed || hardwareWalletType == HardwareWalletType.trezor; + @observable bool hasPassphrase; diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index e52f35696..e5b669cb6 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -14,6 +14,7 @@ "activeConnectionsPrompt": "Active connections will appear here", "add": "Add", "add_a_note": "Add a note", + "add_a_passphrase": "Add a passphrase", "add_account": "Add Account", "add_contact": "Add contact", "add_contact_to_address_book": "Would you like to add this contact to your address book?", @@ -402,6 +403,7 @@ "enter_code": "Enter code", "enter_greater_than": "Please enter an amount greater than", "enter_less_than": "Please enter an amount less than", + "enter_on_device_instead": "Enter on device instead", "enter_recipient_address": "Please enter recipient address", "enter_seed_phrase": "Enter your seed phrase", "enter_totp_code": "Please enter the TOTP Code.", @@ -745,6 +747,8 @@ "pairing_error": "Pairing Error", "pairingInvalidEvent": "Pairing Invalid Event", "passphrase": "Passphrase (Optional)", + "passphrase_raw": "Passphrase", + "passphrase_entry": "Passphrase entry", "passphrase_view_keys": "Passphrase", "passphrases_doesnt_match": "Passphrases do not match, please try again", "password": "Password", diff --git a/tool/configure.dart b/tool/configure.dart index d6b76d756..1628abbcb 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -455,7 +455,7 @@ abstract class Monero { HardwareWalletType? hardwareWalletType, required int height}); WalletCredentials createMoneroRestoreWalletFromSeedCredentials({required String name, required String password, required String passphrase, required int height, required String mnemonic}); - WalletCredentials createMoneroRestoreWalletFromHardwareCredentials({required String name, required String password, required int height, required HardwareWalletService hardwareWalletService}); + WalletCredentials createMoneroRestoreWalletFromHardwareCredentials({required String name, required String password, required int height, required HardwareWalletService hardwareWalletService, required String? passphrase}); WalletCredentials createMoneroNewWalletCredentials({required String name, required String language, required int seedType, required String? passphrase, String? password, String? mnemonic}); Map getKeys(Object wallet); int? getRestoreHeight(Object wallet); From 5dfae0e13a8d18b0df2a428221bec3b2f217b00b Mon Sep 17 00:00:00 2001 From: malik1004x Date: Sat, 18 Jul 2026 17:31:06 +0200 Subject: [PATCH 4/5] fix height of details modal when adding note from send confirmation (#3413) --- lib/new-ui/widgets/send_page/send_confirm_sheet.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart index 165823a1a..b6df11d7b 100644 --- a/lib/new-ui/widgets/send_page/send_confirm_sheet.dart +++ b/lib/new-ui/widgets/send_page/send_confirm_sheet.dart @@ -513,7 +513,8 @@ class _TransactionCommitedScreenState extends State { showModalBottomSheet( isScrollControlled: true, context: context, - builder: (context) => page); + builder: (context) => + FractionallySizedBox(heightFactor: 0.9, child: page)); }), ], ), From 7dc84e604572db0b0b691fafd35dbbc10de07bea Mon Sep 17 00:00:00 2001 From: rottenwheel <92872541+rottenwheel@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:52:07 -0500 Subject: [PATCH 5/5] readme: remove wownero from supported cryptocurrency list (#3412) Removed Wownero (WOW) from the list of cryptocurrencies. Co-authored-by: malik1004x --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 0852d8552..fa98091c6 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ Cake Wallet includes support for several cryptocurrencies, including: * Nano (XNO) * Zano (ZANO) * Decred (DCR) -* Wownero (WOW) ## Features