From 52e778f2757f380aef710f83fc8bd14b4bedb37a Mon Sep 17 00:00:00 2001 From: cyan Date: Fri, 12 Sep 2025 08:47:19 -0400 Subject: [PATCH] feat: monero cache debugging (#2436) * feat: monero cache debugging feat: better JSON explorer for network traffic * frozen coins debugging --- cw_monero/lib/api/get_all_unspent.dart | 30 ++ cw_monero/lib/api/wallet.dart | 20 +- cw_monero/lib/monero_unspent.dart | 31 +- cw_monero/lib/monero_wallet.dart | 13 +- cw_monero/pubspec.lock | 4 +- cw_monero/pubspec.yaml | 2 +- cw_wownero/pubspec.lock | 4 +- cw_wownero/pubspec.yaml | 2 +- cw_zano/pubspec.lock | 4 +- cw_zano/pubspec.yaml | 2 +- lib/di.dart | 3 + lib/monero/cw_monero.dart | 5 + lib/router.dart | 6 + lib/routes.dart | 1 + lib/src/screens/dev/moneroc_cache_debug.dart | 443 ++++++++++++++++++ lib/src/screens/dev/network_requests.dart | 27 ++ .../screens/settings/other_settings_page.dart | 6 + scripts/prepare_moneroc.sh | 2 +- tool/configure.dart | 1 + 19 files changed, 584 insertions(+), 22 deletions(-) create mode 100644 cw_monero/lib/api/get_all_unspent.dart create mode 100644 lib/src/screens/dev/moneroc_cache_debug.dart diff --git a/cw_monero/lib/api/get_all_unspent.dart b/cw_monero/lib/api/get_all_unspent.dart new file mode 100644 index 000000000..a18b57c98 --- /dev/null +++ b/cw_monero/lib/api/get_all_unspent.dart @@ -0,0 +1,30 @@ +import 'package:cw_monero/api/account_list.dart'; +import 'package:cw_monero/monero_unspent.dart'; + +Map> getAllUnspent() { + final coins = currentWallet!.coins(); + coins.refresh(); + final coinCount = coins.count(); + + final ret = >{}; + ret["_count"] = {coinCount.toString(): coinCount}; + for (var i = 0; i < coinCount; i++) { + final coin = coins.coin(i); + final subaddr = coin.subaddrAccount(); + if (ret[subaddr.toString()] == null) { + ret[subaddr.toString()] = {}; + } + + final unspent = MoneroUnspent.fromUnspent( + address: coin.address(), + hash: coin.hash(), + keyImage: coin.keyImage(), + value: coin.amount(), + isFrozen: coin.frozen(), + isUnlocked: coin.unlocked(), + isSpent: coin.spent(), + ); + ret[subaddr.toString()]!["0x${coin.ffiAddress().toRadixString(16)}"] = unspent.toJson(); + } + return ret; +} diff --git a/cw_monero/lib/api/wallet.dart b/cw_monero/lib/api/wallet.dart index 61cb271a8..ff7e4f7d6 100644 --- a/cw_monero/lib/api/wallet.dart +++ b/cw_monero/lib/api/wallet.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:ffi'; import 'dart:isolate'; import 'dart:math'; @@ -6,6 +7,7 @@ import 'dart:math'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/api/exceptions/setup_wallet_exception.dart'; +import 'package:cw_monero/api/get_all_unspent.dart'; import 'package:monero/monero.dart' as monero; import 'package:mutex/mutex.dart'; import 'package:polyseed/polyseed.dart'; @@ -144,8 +146,9 @@ String getAddress({int accountIndex = 0, int addressIndex = 0}) { return addressCache[currentWallet!.ffiAddress()]![accountIndex]![addressIndex]!; } -int getFullBalance({int accountIndex = 0}) => - currentWallet?.balance(accountIndex: accountIndex) ?? 0; +int getFullBalance({int accountIndex = 0}) { + return currentWallet?.balance(accountIndex: accountIndex) ?? 0; +} int getUnlockedBalance({int accountIndex = 0}) => currentWallet?.unlockedBalance(accountIndex: accountIndex) ?? 0; @@ -422,3 +425,16 @@ bool verifyMessage(String message, String address, String signature) { } Map> debugCallLength() => monero.debugCallLength; + +Map getWalletCacheDebug() { + try { + final jsonString = monero.MONERO_Wallet_serializeCacheToJson(Pointer.fromAddress(currentWallet!.ffiAddress())); + final blob = json.decode(jsonString); + blob['cake:unspent'] = getAllUnspent(); + return blob; + } catch (e) { + return { + "error": e.toString(), + }; + } +} \ No newline at end of file diff --git a/cw_monero/lib/monero_unspent.dart b/cw_monero/lib/monero_unspent.dart index a88b98323..f81292e1b 100644 --- a/cw_monero/lib/monero_unspent.dart +++ b/cw_monero/lib/monero_unspent.dart @@ -1,17 +1,25 @@ import 'package:cw_core/unspent_transaction_output.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_monero/api/coins_info.dart'; -import 'package:monero/src/monero.dart'; class MoneroUnspent extends Unspent { - static Future fromUnspent(String address, String hash, String keyImage, int value, bool isFrozen, bool isUnlocked) async { + static MoneroUnspent fromUnspent({ + required String address, + required String hash, + required String keyImage, + required int value, + required bool isFrozen, + required bool isUnlocked, + required bool isSpent, + }) { return MoneroUnspent( address: address, hash: hash, keyImage: keyImage, value: value, isFrozen: isFrozen, - isUnlocked: isUnlocked); + isUnlocked: isUnlocked, + isSpent: isSpent); } MoneroUnspent( @@ -20,7 +28,8 @@ class MoneroUnspent extends Unspent { required String keyImage, required int value, required bool isFrozen, - required this.isUnlocked}) + required this.isUnlocked, + required this.isSpent}) : super(address, hash, value, 0, keyImage) { _frozen = isFrozen; } @@ -47,4 +56,18 @@ class MoneroUnspent extends Unspent { bool get isFrozen => _frozen; final bool isUnlocked; + final bool isSpent; + + Map toJson() { + return { + 'address': address, + 'hash': hash, + 'keyImage': keyImage, + 'value': value, + 'isFrozen': isFrozen, + 'isUnlocked': isUnlocked, + 'isChange': isChange, + 'isSpent': isSpent, + }; + } } diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index 91096593c..57cc9a3fc 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -643,12 +643,13 @@ abstract class MoneroWalletBase extends WalletBase setup({ getIt.registerFactory(() => DevMoneroCallProfilerPage()); + getIt.registerFactory(() => DevMoneroWalletCacheDebugPage()); + getIt.registerFactory(() => DevSharedPreferencesPage(getIt.get())); getIt.registerFactory(() => DevSecurePreferencesPage(getIt.get())); diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index 9c51749e8..8de8f9078 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -442,4 +442,9 @@ class CWMonero extends Monero { Map> debugCallLength() { return monero_wallet_api.debugCallLength(); } + + @override + Map getWalletCacheDebug() { + return monero_wallet_api.getWalletCacheDebug(); + } } diff --git a/lib/router.dart b/lib/router.dart index 6ed7e6040..1577abedc 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -36,6 +36,7 @@ import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart'; import 'package:cake_wallet/src/screens/dashboard/sign_page.dart'; import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart'; +import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart'; import 'package:cake_wallet/src/screens/dev/network_requests.dart'; import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart'; @@ -929,6 +930,11 @@ Route createRoute(RouteSettings settings) { builder: (_) => getIt.get(), ); + case Routes.devMoneroWalletCacheDebug: + return MaterialPageRoute( + builder: (_) => getIt.get(), + ); + case Routes.devSecurePreferences: return MaterialPageRoute( builder: (_) => getIt.get(), diff --git a/lib/routes.dart b/lib/routes.dart index 33196d98c..3f9050de2 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -116,6 +116,7 @@ class Routes { static const devMoneroBackgroundSync = '/dev/monero_background_sync'; static const devMoneroCallProfiler = '/dev/monero_call_profiler'; + static const devMoneroWalletCacheDebug = '/dev/monero_wallet_cache_debug'; static const devSharedPreferences = '/dev/shared_preferences'; static const devSecurePreferences = '/dev/secure_preferences'; diff --git a/lib/src/screens/dev/moneroc_cache_debug.dart b/lib/src/screens/dev/moneroc_cache_debug.dart new file mode 100644 index 000000000..6c0b20827 --- /dev/null +++ b/lib/src/screens/dev/moneroc_cache_debug.dart @@ -0,0 +1,443 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/monero/monero.dart'; +import 'package:cake_wallet/utils/share_util.dart'; +import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import 'package:cw_core/root_dir.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as path; + +class DevMoneroWalletCacheDebugPage extends StatelessWidget { + DevMoneroWalletCacheDebugPage(); + + @override + Widget build(BuildContext context) { + return MoneroCacheDebug(); + } +} + +class MoneroCacheDebug extends StatefulWidget { + const MoneroCacheDebug({super.key}); + + @override + State createState() => _MoneroCacheDebugState(); +} + +enum DebuggableWallets { + monero, +} + +class _MoneroCacheDebugState extends State { + final dashboardViewModel = getIt.get(); + + late DebuggableWallets wallet = switch (dashboardViewModel.wallet.type) { + WalletType.monero => DebuggableWallets.monero, + _ => throw Exception("Unknown wallet type"), + }; + + late Map walletCache = switch (wallet) { + DebuggableWallets.monero => monero!.getWalletCacheDebug(), + }; + + @override + Widget build(BuildContext context) { + return JsonExplorerPage( + data: walletCache, + title: 'Wallet Cache', + ); + } +} + +class JsonExplorerPage extends StatelessWidget { + final dynamic data; + final String title; + + const JsonExplorerPage({ + super.key, + required this.data, + required this.title, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + actions: [ + IconButton( + onPressed: () => _copyToClipboard(context), + icon: const Icon(Icons.copy), + tooltip: 'Copy all to clipboard', + ), + ], + ), + body: JsonExplorer( + data: data, + title: title, + ), + ); + } + + Future _copyToClipboard(BuildContext context) async { + final jsonString = JsonEncoder.withIndent(' ').convert(data); + try { + await Clipboard.setData(ClipboardData(text: jsonString)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Data copied to clipboard')), + ); + return; + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to copy to clipboard: $e')), + ); + } + try { + final appDir = await getAppDir(); + final filePath = appDir.path + '/.json_dump_temp.json'; + await File(filePath).writeAsString(jsonString); + await ShareUtil.shareFile(filePath: filePath, fileName: path.basename(filePath), context: context); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to share file: $e')), + ); + } + } +} + +class JsonExplorer extends StatefulWidget { + final dynamic data; + final String title; + + const JsonExplorer({ + super.key, + required this.data, + required this.title, + }); + + @override + State createState() => _JsonExplorerState(); +} + +class _JsonExplorerState extends State { + final TextEditingController _searchController = TextEditingController(); + String _searchQuery = ''; + List _filteredItems = []; + final List _allItems = []; + + @override + void initState() { + super.initState(); + _buildItemList(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _buildItemList() { + _allItems.clear(); + + if (widget.data is Map) { + final map = widget.data as Map; + final sortedKeys = map.keys.toList()..sort(); + + for (final key in sortedKeys) { + _allItems.add(CacheItem( + key: key, + value: map[key], + displayKey: key, + )); + } + } else if (widget.data is List) { + final list = widget.data as List; + for (int i = 0; i < list.length; i++) { + _allItems.add(CacheItem( + key: i.toString(), + value: list[i], + displayKey: '[$i]', + )); + } + } + + _applyFilter(); + } + + void _applyFilter() { + if (_searchQuery.isEmpty) { + _filteredItems = _allItems; + } else { + _filteredItems = _allItems.where((item) { + return item.displayKey.toLowerCase().contains(_searchQuery.toLowerCase()) || + _valueContainsSearch(item.value, _searchQuery.toLowerCase()); + }).toList(); + } + } + + bool _valueContainsSearch(dynamic value, String search) { + if (value == null) return false; + return value.toString().toLowerCase().contains(search); + } + + void _copyItemToClipboard(CacheItem item) { + final jsonString = JsonEncoder.withIndent(' ').convert(item.value); + Clipboard.setData(ClipboardData(text: jsonString)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${item.displayKey} copied to clipboard')), + ); + } + + void _navigateToItem(CacheItem item) { + if (item.value is Map || item.value is List) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => JsonExplorerPage( + data: item.value, + title: item.displayKey, + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final totalItems = widget.data is Map + ? (widget.data as Map).length + : widget.data is List + ? (widget.data as List).length + : 0; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search in ${totalItems} items...', + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + isDense: true, + suffixText: _searchQuery.isNotEmpty + ? '${_filteredItems.length} found' + : null, + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + _applyFilter(); + }); + }, + ), + ), + ], + ), + ), + + Expanded( + child: _filteredItems.isEmpty && _searchQuery.isNotEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.search_off, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text( + 'No items found for "${_searchQuery}"', + style: theme.textTheme.titleMedium?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ) + : ListView.builder( + itemCount: _filteredItems.length, + itemBuilder: (context, index) { + final item = _filteredItems[index]; + return CacheItemTile( + item: item, + onTap: () => _navigateToItem(item), + onCopy: () => _copyItemToClipboard(item), + searchQuery: _searchQuery, + ); + }, + ), + ), + ], + ); + } +} + +class CacheItem { + final String key; + final dynamic value; + final String displayKey; + + CacheItem({ + required this.key, + required this.value, + required this.displayKey, + }); +} + +class CacheItemTile extends StatelessWidget { + final CacheItem item; + final VoidCallback onTap; + final VoidCallback onCopy; + final String searchQuery; + + const CacheItemTile({ + super.key, + required this.item, + required this.onTap, + required this.onCopy, + this.searchQuery = '', + }); + + Color _getTypeColor(BuildContext context, dynamic value) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + + if (value == null) { + return Colors.grey; + } + + switch (value.runtimeType) { + case String: + return Colors.green; + case int: + case double: + return Colors.blue; + case bool: + return Colors.orange; + // ignore: strict_raw_type + case Map: + return Colors.purple; + // ignore: strict_raw_type + case List: + return Colors.cyan; + default: + return Colors.pink; + } + } + + String _getValuePreview(dynamic value) { + if (value == null) return 'null'; + + if (value is Map) { + return '{${value.length} items}'; + } + + if (value is List) { + return '[${value.length} items]'; + } + + if (value is String) { + if (value.length > 100) { + return '"${value.substring(0, 97)}..."'; + } + return '"$value"'; + } + + final str = value.toString(); + if (str.length > 100) { + return '${str.substring(0, 97)}...'; + } + return str; + } + + Widget _buildHighlightedText(String text, String query, TextStyle style) { + if (query.isEmpty) { + return Text(text, style: style); + } + + final lowerText = text.toLowerCase(); + final lowerQuery = query.toLowerCase(); + final matches = []; + int start = 0; + + while (true) { + final index = lowerText.indexOf(lowerQuery, start); + if (index == -1) { + if (start < text.length) { + matches.add(TextSpan(text: text.substring(start), style: style)); + } + break; + } + + if (index > start) { + matches.add(TextSpan(text: text.substring(start, index), style: style)); + } + + matches.add(TextSpan( + text: text.substring(index, index + query.length), + style: style.copyWith( + backgroundColor: Colors.yellow.withOpacity(0.3), + fontWeight: FontWeight.bold, + ), + )); + + start = index + query.length; + } + + return RichText(text: TextSpan(children: matches)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final canNavigate = item.value is Map || item.value is List; + final valuePreview = _getValuePreview(item.value); + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: ListTile( + leading: Icon( + canNavigate + ? (item.value is Map ? Icons.folder : Icons.list) + : Icons.description, + color: _getTypeColor(context, item.value), + ), + title: _buildHighlightedText( + item.displayKey, + searchQuery, + theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold), + ), + subtitle: _buildHighlightedText( + valuePreview, + searchQuery, + theme.textTheme.bodySmall!.copyWith( + color: _getTypeColor(context, item.value), + fontFamily: 'monospace', + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: onCopy, + icon: const Icon(Icons.copy, size: 18), + tooltip: 'Copy ${item.displayKey}', + ), + if (canNavigate) + const Icon(Icons.chevron_right), + ], + ), + onTap: canNavigate ? onTap : null, + enabled: canNavigate, + ), + ); + } +} \ No newline at end of file diff --git a/lib/src/screens/dev/network_requests.dart b/lib/src/screens/dev/network_requests.dart index 1aec76bd4..6be850378 100644 --- a/lib/src/screens/dev/network_requests.dart +++ b/lib/src/screens/dev/network_requests.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/view_model/dev/network_requests_view_model.dart'; import 'package:cw_core/utils/proxy_logger/abstract.dart'; import 'package:cw_core/utils/proxy_logger/memory_proxy_logger.dart'; @@ -133,9 +135,11 @@ class DevRequestDetails extends BasePage { _sectionTitle("Body (as UTF-8)"), SelectableText(_tryDecodeBody(req.body)), + _buildJsonExplorer(context, _tryDecodeBody(req.body)), _sectionTitle("Response"), SelectableText(req.response?.body ?? "null"), + _buildJsonExplorer(context, req.response?.body ?? "{}"), _sectionTitle("Error"), SelectableText(req.error ?? "No error"), @@ -146,6 +150,29 @@ class DevRequestDetails extends BasePage { ); } + Widget _buildJsonExplorer(BuildContext context, String body) { + try { + final jsonData = json.decode(body); + return PrimaryButton( + text: "View JSON", + color: Colors.blue, + textColor: Colors.white, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return JsonExplorerPage(data: jsonData, title: "body"); + }, + ), + ); + }, + ); + + } catch (e) { + return SelectableText("Invalid JSON: $e"); + } + } + Widget _sectionTitle(String title) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/src/screens/settings/other_settings_page.dart b/lib/src/screens/settings/other_settings_page.dart index 1991ee289..62f4d2ca5 100644 --- a/lib/src/screens/settings/other_settings_page.dart +++ b/lib/src/screens/settings/other_settings_page.dart @@ -75,6 +75,12 @@ class OtherSettingsPage extends BasePage { handler: (BuildContext context) => Navigator.of(context).pushNamed(Routes.devMoneroCallProfiler), ), + if (FeatureFlag.hasDevOptions && [WalletType.monero].contains(_otherSettingsViewModel.walletType)) + SettingsCellWithArrow( + title: '[dev] xmr wallet cache debug', + handler: (BuildContext context) => + Navigator.of(context).pushNamed(Routes.devMoneroWalletCacheDebug), + ), if (FeatureFlag.hasDevOptions) SettingsCellWithArrow( title: '[dev] shared preferences', diff --git a/scripts/prepare_moneroc.sh b/scripts/prepare_moneroc.sh index cd1136de0..a8c6e2cae 100755 --- a/scripts/prepare_moneroc.sh +++ b/scripts/prepare_moneroc.sh @@ -9,7 +9,7 @@ then rm -rf monero_c git clone https://github.com/mrcyjanek/monero_c --branch master monero_c cd monero_c - git checkout b576312e4d466569cd03482b61c597b39a9f4dc3 + git checkout 5225c47e5033d46bfd60e612e87b24c562f67efa git reset --hard git submodule update --init --force --recursive ./apply_patches.sh monero diff --git a/tool/configure.dart b/tool/configure.dart index 540768cf3..747d51bfd 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -447,6 +447,7 @@ WalletCredentials createMoneroNewWalletCredentials({required String name, requir void setGlobalLedgerConnection(ledger.LedgerConnection connection); String? getLastLedgerCommand(); Map> debugCallLength(); + Map getWalletCacheDebug(); } abstract class MoneroSubaddressList {