From 0b48ba7eee7be9114f945a4b09115524d140272d Mon Sep 17 00:00:00 2001 From: kroplabeskidu <116884906+kroplabeskidu@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:06:57 +0100 Subject: [PATCH 1/7] Add Tor support for Ethereum web3dart was handed a plain http Client, so ETH and ERC-20 RPC could not use Tor and the coin was flagged as not Tor-ready. Give it an IOClient wrapping a SOCKS5-proxied HttpClient instead, the same way Solana does it, and do the same for the node connection test. Also add the usual node tor/clearnet mismatch check and set torSupport. --- lib/utilities/test_eth_node_connection.dart | 15 +- lib/utilities/test_node_connection.dart | 6 +- .../crypto_currency/coins/ethereum.dart | 3 + lib/wallets/wallet/impl/ethereum_wallet.dart | 54 ++++++- test/wallets/ethereum_tor_proxy_test.dart | 148 ++++++++++++++++++ 5 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 test/wallets/ethereum_tor_proxy_test.dart diff --git a/lib/utilities/test_eth_node_connection.dart b/lib/utilities/test_eth_node_connection.dart index be95a19ceb..e8c4473b99 100644 --- a/lib/utilities/test_eth_node_connection.dart +++ b/lib/utilities/test_eth_node_connection.dart @@ -1,12 +1,19 @@ -import 'package:http/http.dart'; -import 'package:web3dart/web3dart.dart' as web3; +import '../services/tor_service.dart'; +import '../wallets/wallet/impl/ethereum_wallet.dart'; +import 'prefs.dart'; -Future testEthNodeConnection(String host) async { - final web3.Web3Client client = web3.Web3Client(host, Client()); +Future testEthNodeConnection( + String host, { + required Prefs prefs, + required TorService torService, +}) async { + final client = EthereumWallet.createWeb3Client(host, prefs, torService); try { await client.getBlockNumber(); return true; } catch (_) { return false; + } finally { + await client.dispose(); } } diff --git a/lib/utilities/test_node_connection.dart b/lib/utilities/test_node_connection.dart index 0b3504ce21..80068fce59 100644 --- a/lib/utilities/test_node_connection.dart +++ b/lib/utilities/test_node_connection.dart @@ -235,7 +235,11 @@ Future testNodeConnection({ case Ethereum(): try { - testPassed = await testEthNodeConnection(formData.host!); + testPassed = await testEthNodeConnection( + formData.host!, + prefs: read(prefsChangeNotifierProvider), + torService: read(pTorService), + ); } catch (_) { testPassed = false; } diff --git a/lib/wallets/crypto_currency/coins/ethereum.dart b/lib/wallets/crypto_currency/coins/ethereum.dart index 448f4c9ff9..57b79a2be4 100644 --- a/lib/wallets/crypto_currency/coins/ethereum.dart +++ b/lib/wallets/crypto_currency/coins/ethereum.dart @@ -47,6 +47,9 @@ class Ethereum extends Bip39Currency { @override bool get hasTokenSupport => true; + @override + bool get torSupport => true; + @override NodeModel defaultNode({required bool isPrimary}) => NodeModel( host: "https://eth2.stackwallet.com", diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index f5444c7428..e2b0674509 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -1,16 +1,20 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:decimal/decimal.dart'; import 'package:ethereum_addresses/ethereum_addresses.dart'; import 'package:flutter/foundation.dart'; -import 'package:http/http.dart'; +import 'package:http/io_client.dart'; import 'package:isar_community/isar.dart'; +import 'package:socks5_proxy/socks.dart'; import 'package:wallet/wallet.dart' as eth_wallet; import 'package:web3dart/json_rpc.dart' show RPCError; import 'package:web3dart/web3dart.dart' as web3; +import '../../../app_config.dart'; import '../../../dto/ethereum/eth_tx_dto.dart'; +import '../../../exceptions/wallet/node_tor_mismatch_config_exception.dart'; import '../../../models/balance.dart'; import '../../../models/isar/models/blockchain_data/address.dart'; import '../../../models/isar/models/blockchain_data/transaction.dart'; @@ -21,9 +25,12 @@ import '../../../models/paymint/fee_object_model.dart'; import '../../../services/ethereum/ethereum_api.dart'; import '../../../services/event_bus/events/global/updated_in_background_event.dart'; import '../../../services/event_bus/global_event_bus.dart'; +import '../../../services/tor_service.dart'; import '../../../utilities/amount/amount.dart'; import '../../../utilities/eth_commons.dart'; import '../../../utilities/logger.dart'; +import '../../../utilities/prefs.dart'; +import '../../../utilities/tor_plain_net_option_enum.dart'; import '../../crypto_currency/crypto_currency.dart'; import '../../models/tx_data.dart'; import '../intermediate/bip39_wallet.dart'; @@ -85,8 +92,6 @@ Future> findReplacedPendingEthereumTransactions({ return replacedTransactions; } -// Eth can not use tor with web3dart - @visibleForTesting ({BigInt maxFeePerGas, BigInt maxPriorityFeePerGas}) resolveEip1559FeeCaps({ required BigInt baseFee, @@ -144,10 +149,45 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { web3.Web3Client getEthClient() { final node = getCurrentNode(); - // Eth can not use tor with web3dart as Client does not support proxies - final client = Client(); + final netOption = TorPlainNetworkOption.fromNodeData( + node.torEnabled, + node.clearnetEnabled, + ); - return web3.Web3Client(node.host, client); + if (prefs.useTor) { + if (netOption == TorPlainNetworkOption.clear) { + throw NodeTorMismatchConfigException( + message: "TOR enabled but node set to clearnet only", + ); + } + } else { + if (netOption == TorPlainNetworkOption.tor) { + throw NodeTorMismatchConfigException( + message: "TOR off but node set to TOR only", + ); + } + } + + return createWeb3Client(node.host, prefs, TorService.sharedInstance); + } + + // static helper function for building a web3 client, proxied over tor if + // enabled + static web3.Web3Client createWeb3Client( + String url, + Prefs prefs, + TorService torService, + ) { + final httpClient = HttpClient(); + + if (AppConfig.hasFeature(AppFeature.tor) && prefs.useTor) { + final proxyInfo = torService.getProxyInfo(); + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + + return web3.Web3Client(url, IOClient(httpClient)); } Amount estimateEthFee(BigInt feeRate, int gasLimit, int decimals) { @@ -292,8 +332,8 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { @override Future pingCheck() async { - final web3.Web3Client client = getEthClient(); try { + final client = getEthClient(); await client.getBlockNumber(); return true; } catch (_) { diff --git a/test/wallets/ethereum_tor_proxy_test.dart b/test/wallets/ethereum_tor_proxy_test.dart new file mode 100644 index 0000000000..a532408c8b --- /dev/null +++ b/test/wallets/ethereum_tor_proxy_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stackwallet/services/tor_service.dart'; +import 'package:stackwallet/utilities/prefs.dart'; +import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; + +class _MockPrefs extends Mock implements Prefs { + _MockPrefs(this._useTor); + + final bool _useTor; + + @override + bool get useTor => _useTor; +} + +class _FakeTorService extends Fake implements TorService { + _FakeTorService(this.proxyInfo); + + final ({InternetAddress host, int port}) proxyInfo; + int proxyInfoReads = 0; + + @override + ({InternetAddress host, int port}) getProxyInfo() { + proxyInfoReads++; + return proxyInfo; + } +} + +const _rpcBody = '{"jsonrpc":"2.0","id":0,"result":"0x2a"}'; + +String _rpcHttpResponse() => + 'HTTP/1.1 200 OK\r\n' + 'Content-Type: application/json\r\n' + 'Content-Length: ${_rpcBody.length}\r\n' + 'Connection: close\r\n\r\n' + '$_rpcBody'; + +// Minimal SOCKS5 server that records the CONNECT target and answers the +// tunnelled HTTP request with a canned JSON-RPC eth_blockNumber result. +class _FakeSocksServer { + late final ServerSocket _server; + int? addressType; + String? target; + int? port; + final request = StringBuffer(); + + int get listeningPort => _server.port; + + Future start() async { + _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + _server.listen((socket) { + var stage = 0; + socket.listen((Uint8List bytes) { + switch (stage) { + case 0: + socket.add([0x05, 0x00]); + stage = 1; + case 1: + addressType = bytes[3]; + if (addressType == 0x03) { + target = String.fromCharCodes(bytes.sublist(5, 5 + bytes[4])); + } else { + target = bytes.sublist(4, bytes.length - 2).join('.'); + } + port = (bytes[bytes.length - 2] << 8) | bytes[bytes.length - 1]; + socket.add([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]); + stage = 2; + case 2: + request.write(utf8.decode(bytes, allowMalformed: true)); + socket.write(_rpcHttpResponse()); + socket.close(); + stage = 3; + default: + request.write(utf8.decode(bytes, allowMalformed: true)); + } + }); + }); + } + + Future stop() => _server.close(); +} + +void main() { + late _FakeSocksServer socks; + + setUp(() async { + socks = _FakeSocksServer(); + await socks.start(); + }); + + tearDown(() => socks.stop()); + + test('web3 client sends RPC through the Tor SOCKS5 proxy', () async { + final torService = _FakeTorService(( + host: InternetAddress.loopbackIPv4, + port: socks.listeningPort, + )); + final client = EthereumWallet.createWeb3Client( + 'http://eth.example.invalid:8545', + _MockPrefs(true), + torService, + ); + + final height = await client.getBlockNumber(); + await client.dispose(); + + expect(height, 42); + expect(torService.proxyInfoReads, 1); + // Hostname goes to the proxy, so there is no local DNS lookup. + expect(socks.addressType, 0x03); + expect(socks.target, 'eth.example.invalid'); + expect(socks.port, 8545); + expect(socks.request.toString(), contains('eth_blockNumber')); + }); + + test('web3 client connects directly when Tor is off', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((req) async { + await utf8.decoder.bind(req).join(); + req.response + ..headers.contentType = ContentType.json + ..write(_rpcBody); + await req.response.close(); + }); + + final torService = _FakeTorService(( + host: InternetAddress.loopbackIPv4, + port: socks.listeningPort, + )); + final client = EthereumWallet.createWeb3Client( + 'http://127.0.0.1:${server.port}', + _MockPrefs(false), + torService, + ); + + final height = await client.getBlockNumber(); + await client.dispose(); + await server.close(force: true); + + expect(height, 42); + expect(torService.proxyInfoReads, 0); + expect(socks.target, isNull); + }); +} From 2b6b4f6d404e5c9a5ef85dca7e340c66765e7b7a Mon Sep 17 00:00:00 2001 From: kroplabeskidu <116884906+kroplabeskidu@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:06:57 +0100 Subject: [PATCH 2/7] Mark Nano, Banano and Fact0rn as Tor-ready, proxy Stellar node test Nano and Banano already send every request through Tor, and Fact0rn uses the regular ElectrumX client, so they only needed the torSupport flag. The Stellar node test proxied its first request but then created a StellarSDK with the default client, so the follow-up ledger query went out directly. --- lib/utilities/test_stellar_node_connection.dart | 12 +++++++++++- lib/wallets/crypto_currency/coins/fact0rn.dart | 4 ++-- .../crypto_currency/intermediate/nano_currency.dart | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/utilities/test_stellar_node_connection.dart b/lib/utilities/test_stellar_node_connection.dart index 3af380f633..b61f7a3c7c 100644 --- a/lib/utilities/test_stellar_node_connection.dart +++ b/lib/utilities/test_stellar_node_connection.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:socks5_proxy/socks.dart'; import 'package:stellar_flutter_sdk/stellar_flutter_sdk.dart'; import '../app_config.dart'; @@ -28,7 +30,15 @@ Future testStellarNodeConnection(String host, int port) async { if (response.code == 200) { //Get chain height for sdk - final StellarSDK stellarSdk = StellarSDK(host); + HttpClient? httpClient; + if (AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor) { + final proxyInfo = TorService.sharedInstance.getProxyInfo(); + httpClient = HttpClient(); + SocksTCPClient.assignToHttpClient(httpClient, [ + ProxySettings(proxyInfo.host, proxyInfo.port), + ]); + } + final StellarSDK stellarSdk = StellarSDK(host, httpClient: httpClient); final height = await stellarSdk.ledgers .order(RequestBuilderOrder.DESC) .limit(1) diff --git a/lib/wallets/crypto_currency/coins/fact0rn.dart b/lib/wallets/crypto_currency/coins/fact0rn.dart index 1ef113ac04..b6687c8ac8 100644 --- a/lib/wallets/crypto_currency/coins/fact0rn.dart +++ b/lib/wallets/crypto_currency/coins/fact0rn.dart @@ -48,7 +48,7 @@ class Fact0rn extends Bip39HDCurrency with ElectrumXCurrencyInterface { String get ticker => _ticker; @override - bool get torSupport => false; + bool get torSupport => true; @override List get supportedDerivationPathTypes => [ @@ -184,7 +184,7 @@ class Fact0rn extends Bip39HDCurrency with ElectrumXCurrencyInterface { coinName: identifier, isFailover: true, isDown: false, - torEnabled: false, + torEnabled: true, clearnetEnabled: true, isPrimary: isPrimary, ); diff --git a/lib/wallets/crypto_currency/intermediate/nano_currency.dart b/lib/wallets/crypto_currency/intermediate/nano_currency.dart index a553a6d6c9..573e91d7c0 100644 --- a/lib/wallets/crypto_currency/intermediate/nano_currency.dart +++ b/lib/wallets/crypto_currency/intermediate/nano_currency.dart @@ -18,6 +18,9 @@ abstract class NanoCurrency extends Bip39Currency { @override bool get hasBuySupport => false; + @override + bool get torSupport => true; + @override int get defaultSeedPhraseLength => 24; From 0f421bc06703296dc86bb438921c5590e2f0cee9 Mon Sep 17 00:00:00 2001 From: kroplabeskidu <116884906+kroplabeskidu@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:06:57 +0100 Subject: [PATCH 3/7] Load remote images over Tor when it is enabled Image.network and SvgPicture.network use their own HTTP clients and ignore the Tor setting, so token logos, exchange and coin logos, CakePay card images, ShopinBit attachments and theme previews were fetched directly even in Tor mode. Add TorAwareNetworkImage and TorAwareSvgNetworkLoader, which go through the app's HTTP wrapper, and use them at all call sites. --- lib/networking/tor_aware_network_image.dart | 123 ++++++++++ .../sub_widgets/add_token_list_element.dart | 5 +- .../sub_widgets/coin_select_item.dart | 9 +- .../cakepay/cakepay_card_detail_view.dart | 5 +- lib/pages/cakepay/cakepay_vendors_view.dart | 49 ++-- .../exchange_currency_selection_view.dart | 230 +++++++++--------- .../sub_widgets/exchange_provider_option.dart | 37 +-- .../sub_widgets/stack_theme_card.dart | 114 +++++---- .../shopinbit/shopinbit_ticket_detail.dart | 45 ++-- lib/widgets/icon_widgets/eth_token_icon.dart | 5 +- lib/widgets/icon_widgets/sol_token_icon.dart | 5 +- .../textfields/exchange_textfield.dart | 7 +- .../wallet_info_row_coin_icon.dart | 40 +-- 13 files changed, 401 insertions(+), 273 deletions(-) create mode 100644 lib/networking/tor_aware_network_image.dart diff --git a/lib/networking/tor_aware_network_image.dart b/lib/networking/tor_aware_network_image.dart new file mode 100644 index 0000000000..ef4cb03eaa --- /dev/null +++ b/lib/networking/tor_aware_network_image.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../app_config.dart'; +import '../services/tor_service.dart'; +import '../utilities/prefs.dart'; +import 'http.dart'; + +/// Fetches [url] through the app's [HTTP] client, routing over Tor when it is +/// enabled. Throws if Tor is enabled but not connected, so nothing falls back +/// to clearnet. +Future fetchTorAwareBytes(String url) async { + final response = await const HTTP().get( + url: Uri.parse(url), + proxyInfo: !AppConfig.hasFeature(AppFeature.tor) + ? null + : Prefs.instance.useTor + ? TorService.sharedInstance.getProxyInfo() + : null, + ); + + if (response.code != 200) { + throw Exception("Failed to load $url: status=${response.code}"); + } + + return Uint8List.fromList(response.bodyBytes); +} + +/// Drop-in replacement for [NetworkImage] that respects the Tor preference. +/// Use with [Image] instead of [Image.network], which always goes clearnet. +@immutable +class TorAwareNetworkImage extends ImageProvider { + const TorAwareNetworkImage(this.url); + + final String url; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + TorAwareNetworkImage key, + ImageDecoderCallback decode, + ) { + final chunkEvents = StreamController(); + return MultiFrameImageStreamCompleter( + codec: _load(key, decode, chunkEvents), + chunkEvents: chunkEvents.stream, + scale: 1.0, + debugLabel: key.url, + ); + } + + Future _load( + TorAwareNetworkImage key, + ImageDecoderCallback decode, + StreamController chunkEvents, + ) async { + try { + // Lets loadingBuilder show its indicator while the request runs. + chunkEvents.add( + const ImageChunkEvent( + cumulativeBytesLoaded: 0, + expectedTotalBytes: null, + ), + ); + final bytes = await fetchTorAwareBytes(key.url); + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + return await decode(buffer); + } catch (_) { + // Don't keep a failed load in the cache. + scheduleMicrotask(() { + PaintingBinding.instance.imageCache.evict(key); + }); + rethrow; + } finally { + unawaited(chunkEvents.close()); + } + } + + @override + bool operator ==(Object other) => + other is TorAwareNetworkImage && other.url == url; + + @override + int get hashCode => url.hashCode; + + @override + String toString() => + "${objectRuntimeType(this, "TorAwareNetworkImage")}" + "(\"$url\")"; +} + +/// Drop-in replacement for the loader behind [SvgPicture.network] that +/// respects the Tor preference. +/// Use as `SvgPicture(TorAwareSvgNetworkLoader(url))`. +@immutable +class TorAwareSvgNetworkLoader extends SvgLoader { + const TorAwareSvgNetworkLoader(this.url); + + final String url; + + @override + Future prepareMessage(BuildContext? context) => + fetchTorAwareBytes(url); + + @override + String provideSvg(Uint8List? message) => + utf8.decode(message!, allowMalformed: true); + + @override + bool operator ==(Object other) => + other is TorAwareSvgNetworkLoader && other.url == url; + + @override + int get hashCode => url.hashCode; +} diff --git a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart index 65088fbc94..a2b327b563 100644 --- a/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart +++ b/lib/pages/add_wallet_views/add_token_view/sub_widgets/add_token_list_element.dart @@ -16,6 +16,7 @@ import 'package:isar_community/isar.dart'; import '../../../../models/isar/exchange_cache/currency.dart'; import '../../../../models/isar/models/contract.dart'; import '../../../../models/isar/models/ethereum/eth_contract.dart'; +import '../../../../networking/tor_aware_network_image.dart'; import '../../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../../services/exchange/exchange_data_loading_service.dart'; import '../../../../themes/stack_colors.dart'; @@ -101,8 +102,8 @@ class _AddTokenListElementState extends ConsumerState { ) else currency != null - ? SvgPicture.network( - currency!.image, + ? SvgPicture( + TorAwareSvgNetworkLoader(currency!.image), width: iconSize, height: iconSize, placeholderBuilder: (_) => diff --git a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart index 95f3f4a790..2ac222b7c7 100644 --- a/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart +++ b/lib/pages/add_wallet_views/add_wallet_view/sub_widgets/coin_select_item.dart @@ -19,6 +19,7 @@ import '../../../../models/add_wallet_list_entity/add_wallet_list_entity.dart'; import '../../../../models/add_wallet_list_entity/sub_classes/eth_token_entity.dart'; import '../../../../models/add_wallet_list_entity/sub_classes/sol_token_entity.dart'; import '../../../../models/isar/exchange_cache/currency.dart'; +import '../../../../networking/tor_aware_network_image.dart'; import '../../../../providers/providers.dart'; import '../../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../../services/exchange/exchange_data_loading_service.dart'; @@ -146,15 +147,15 @@ class _CoinSelectItemState extends ConsumerState { else tokenImageUri != null ? tokenImageUri!.toLowerCase().endsWith(".svg") - ? SvgPicture.network( - tokenImageUri!, + ? SvgPicture( + TorAwareSvgNetworkLoader(tokenImageUri!), width: 26, height: 26, placeholderBuilder: (_) => const AppIcon(width: 26, height: 26), ) - : Image.network( - tokenImageUri!, + : Image( + image: TorAwareNetworkImage(tokenImageUri!), width: 26, height: 26, errorBuilder: (_, _, _) => SvgPicture.file( diff --git a/lib/pages/cakepay/cakepay_card_detail_view.dart b/lib/pages/cakepay/cakepay_card_detail_view.dart index c53633ef21..eec53dc0db 100644 --- a/lib/pages/cakepay/cakepay_card_detail_view.dart +++ b/lib/pages/cakepay/cakepay_card_detail_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../providers/global/locale_provider.dart'; import '../../services/cakepay/cakepay_service.dart'; import '../../services/cakepay/src/models/card.dart'; @@ -340,8 +341,8 @@ class _CardImage extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Image.network( - imageUrl, + child: Image( + image: TorAwareNetworkImage(imageUrl), width: isDesktop ? 300 : null, fit: isDesktop ? .contain : .fitWidth, loadingBuilder: (_, child, event) { diff --git a/lib/pages/cakepay/cakepay_vendors_view.dart b/lib/pages/cakepay/cakepay_vendors_view.dart index 59b145825b..c3c0dbc13a 100644 --- a/lib/pages/cakepay/cakepay_vendors_view.dart +++ b/lib/pages/cakepay/cakepay_vendors_view.dart @@ -2,6 +2,7 @@ import 'package:dropdown_button2/dropdown_button2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../services/cakepay/cakepay_service.dart'; import '../../services/cakepay/src/models/card.dart'; import '../../themes/stack_colors.dart'; @@ -87,9 +88,8 @@ class _CakePayVendorsViewState extends State { } Future _onCardTapped(CakePayCard card) async { - await Navigator.of( - context, - ).pushNamed(CakePayCardDetailView.routeName, arguments: card); + await Navigator.of(context) + .pushNamed(CakePayCardDetailView.routeName, arguments: card); } @override @@ -130,9 +130,9 @@ class _CakePayVendorsViewState extends State { condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () => Navigator.of(context).pop(), @@ -210,9 +210,9 @@ class _CakePayVendorsViewState extends State { child: Text( "No items", style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), ), @@ -226,9 +226,9 @@ class _CakePayVendorsViewState extends State { Text( error.toString(), style: STextStyles.w500_14(context).copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ), const SizedBox(height: 16), @@ -276,9 +276,9 @@ class _SearchField extends StatelessWidget { focusNode: focusNode, style: isDesktop ? STextStyles.desktopTextExtraSmall(context).copyWith( - color: Theme.of( - context, - ).extension()!.textFieldActiveText, + color: Theme.of(context) + .extension()! + .textFieldActiveText, ) : STextStyles.field(context), decoration: @@ -327,9 +327,8 @@ class _CountryDropdown extends StatelessWidget { ); final itemStyle = isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith(color: colors.textFieldActiveText) + ? STextStyles.desktopTextExtraSmall(context) + .copyWith(color: colors.textFieldActiveText) : STextStyles.w500_14(context); return ClipRRect( @@ -341,9 +340,8 @@ class _CountryDropdown extends StatelessWidget { hint: Text( "All countries", style: isDesktop - ? STextStyles.desktopTextExtraSmall( - context, - ).copyWith(color: colors.textFieldDefaultSearchIconLeft) + ? STextStyles.desktopTextExtraSmall(context) + .copyWith(color: colors.textFieldDefaultSearchIconLeft) : STextStyles.fieldLabel(context), ), items: [ @@ -446,8 +444,8 @@ class _CardTile extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(4), child: card.cardImageUrl != null - ? Image.network( - card.cardImageUrl!, + ? Image( + image: TorAwareNetworkImage(card.cardImageUrl!), width: isDesktop ? 60 : 48, height: isDesktop ? 40 : 32, fit: BoxFit.cover, @@ -483,9 +481,8 @@ class _CardTile extends StatelessWidget { ].join(' '), style: isDesktop ? STextStyles.desktopTextExtraExtraSmall(context) - : STextStyles.itemSubtitle12( - context, - ).copyWith(color: colors.textSubtitle1), + : STextStyles.itemSubtitle12(context) + .copyWith(color: colors.textSubtitle1), ), ], ), diff --git a/lib/pages/exchange_view/exchange_coin_selection/exchange_currency_selection_view.dart b/lib/pages/exchange_view/exchange_coin_selection/exchange_currency_selection_view.dart index 4a539ec9d4..b5ebfc9b34 100644 --- a/lib/pages/exchange_view/exchange_coin_selection/exchange_currency_selection_view.dart +++ b/lib/pages/exchange_view/exchange_coin_selection/exchange_currency_selection_view.dart @@ -19,6 +19,7 @@ import '../../../app_config.dart'; import '../../../models/exchange/aggregate_currency.dart'; import '../../../models/isar/exchange_cache/currency.dart'; import '../../../models/isar/exchange_cache/pair.dart'; +import '../../../networking/tor_aware_network_image.dart'; import '../../../services/exchange/exchange.dart'; import '../../../services/exchange/exchange_data_loading_service.dart'; import '../../../themes/stack_colors.dart'; @@ -73,19 +74,19 @@ class _ExchangeCurrencySelectionViewState showDialog( context: context, barrierDismissible: false, - builder: - (_) => WillPopScope( - onWillPop: () async => false, - child: Container( - color: Theme.of( - context, - ).extension()!.overlay.withOpacity(0.6), - child: const CustomLoadingOverlay( - message: "Loading currencies", - eventBus: null, - ), - ), + builder: (_) => WillPopScope( + onWillPop: () async => false, + child: Container( + color: Theme.of(context) + .extension()! + .overlay + .withOpacity(0.6), + child: const CustomLoadingOverlay( + message: "Loading currencies", + eventBus: null, ), + ), + ), ), ); @@ -101,36 +102,33 @@ class _ExchangeCurrencySelectionViewState Future> _loadCurrencies() async { await ExchangeDataLoadingService.instance.initDB(); final isar = await ExchangeDataLoadingService.instance.isar; - final currencies = - await isar.currencies - .where() - .filter() - .isFiatEqualTo(false) - .and() - .group( - (q) => - widget.isFixedRate - ? q - .rateTypeEqualTo(SupportedRateType.both) - .or() - .rateTypeEqualTo(SupportedRateType.fixed) - : q - .rateTypeEqualTo(SupportedRateType.both) - .or() - .rateTypeEqualTo(SupportedRateType.estimated), - ) - .sortByIsStackCoin() - .thenByName() - .findAll(); + final currencies = await isar.currencies + .where() + .filter() + .isFiatEqualTo(false) + .and() + .group( + (q) => widget.isFixedRate + ? q + .rateTypeEqualTo(SupportedRateType.both) + .or() + .rateTypeEqualTo(SupportedRateType.fixed) + : q + .rateTypeEqualTo(SupportedRateType.both) + .or() + .rateTypeEqualTo(SupportedRateType.estimated), + ) + .sortByIsStackCoin() + .thenByName() + .findAll(); // If using Tor, filter exchanges which do not support Tor. if (Prefs.instance.useTor) { if (Exchange.exchangeNamesWithTorSupport.isNotEmpty) { currencies.removeWhere( - (element) => - !Exchange.exchangeNamesWithTorSupport.contains( - element.exchangeName, - ), + (element) => !Exchange.exchangeNamesWithTorSupport.contains( + element.exchangeName, + ), ); } } @@ -163,20 +161,18 @@ class _ExchangeCurrencySelectionViewState results.remove(widget.pairedCurrency); } - final walletCoins = - results - .where( - (currency) => - AppConfig.coins - .where( - (coin) => - coin.ticker.toLowerCase() == - currency.ticker.toLowerCase() && - currency.fuzzyNet == coin.ticker.toLowerCase(), - ) - .isNotEmpty, - ) - .toList(); + final walletCoins = results + .where( + (currency) => AppConfig.coins + .where( + (coin) => + coin.ticker.toLowerCase() == + currency.ticker.toLowerCase() && + currency.fuzzyNet == coin.ticker.toLowerCase(), + ) + .isNotEmpty, + ) + .toList(); final list = results.toList(); @@ -240,8 +236,9 @@ class _ExchangeCurrencySelectionViewState builder: (child) { return Background( child: Scaffold( - backgroundColor: - Theme.of(context).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () async { @@ -286,45 +283,45 @@ class _ExchangeCurrencySelectionViewState focusNode: _searchFocusNode, onChanged: (value) => setState(() => _searchString = value), style: STextStyles.field(context), - decoration: standardInputDecoration( - "Search", - _searchFocusNode, - context, - desktopMed: isDesktop, - ).copyWith( - prefixIcon: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 16, - ), - child: SvgPicture.asset( - Assets.svg.search, - width: 16, - height: 16, - ), - ), - suffixIcon: - _searchController.text.isNotEmpty + decoration: + standardInputDecoration( + "Search", + _searchFocusNode, + context, + desktopMed: isDesktop, + ).copyWith( + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 16, + ), + child: SvgPicture.asset( + Assets.svg.search, + width: 16, + height: 16, + ), + ), + suffixIcon: _searchController.text.isNotEmpty ? Padding( - padding: const EdgeInsets.only(right: 0), - child: UnconstrainedBox( - child: Row( - children: [ - TextFieldIconButton( - child: const XIcon(), - onTap: () async { - setState(() { - _searchController.text = ""; - _searchString = ""; - }); - }, - ), - ], + padding: const EdgeInsets.only(right: 0), + child: UnconstrainedBox( + child: Row( + children: [ + TextFieldIconButton( + child: const XIcon(), + onTap: () async { + setState(() { + _searchController.text = ""; + _searchString = ""; + }); + }, + ), + ], + ), ), - ), - ) + ) : null, - ), + ), ), ), const SizedBox(height: 20), @@ -358,23 +355,20 @@ class _ExchangeCurrencySelectionViewState height: 24, child: AppConfig.isStackCoin( - items[index].ticker, - ) - ? CoinIconForTicker( - ticker: items[index].ticker, - size: 24, - ) - : hasImageUrl - ? _NetImage( - url: image, - key: ValueKey( - image + items[index].fuzzyNet, - ), - ) - : const SizedBox( - width: 24, - height: 24, + items[index].ticker, + ) + ? CoinIconForTicker( + ticker: items[index].ticker, + size: 24, + ) + : hasImageUrl + ? _NetImage( + url: image, + key: ValueKey( + image + items[index].fuzzyNet, ), + ) + : const SizedBox(width: 24, height: 24), ), const SizedBox(width: 10), Expanded( @@ -399,9 +393,8 @@ class _ExchangeCurrencySelectionViewState left: 12, ), child: CoinTickerTag( - ticker: - items[index].fuzzyNet - .toUpperCase(), + ticker: items[index].fuzzyNet + .toUpperCase(), ), ), ], @@ -409,14 +402,12 @@ class _ExchangeCurrencySelectionViewState const SizedBox(height: 2), Text( items[index].ticker.toUpperCase(), - style: STextStyles.smallMed12( - context, - ).copyWith( - color: - Theme.of(context) + style: STextStyles.smallMed12(context) + .copyWith( + color: Theme.of(context) .extension()! .textSubtitle1, - ), + ), ), ], ), @@ -447,15 +438,20 @@ class _NetImage extends StatelessWidget { @override Widget build(BuildContext context) { if (url.endsWith(".svg")) { - return SvgPicture.network( + return SvgPicture( key: key, - url, + TorAwareSvgNetworkLoader(url), width: 24, height: 24, placeholderBuilder: (_) => const LoadingIndicator(), ); } else { - return Image.network(url, width: 24, height: 24, key: key); + return Image( + image: TorAwareNetworkImage(url), + width: 24, + height: 24, + key: key, + ); } } } diff --git a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart index 700088664c..95d2ee5265 100644 --- a/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart +++ b/lib/pages/exchange_view/sub_widgets/exchange_provider_option.dart @@ -15,6 +15,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app_config.dart'; import '../../../models/exchange/aggregate_currency.dart'; import '../../../models/exchange/response_objects/estimate.dart'; +import '../../../networking/tor_aware_network_image.dart'; import '../../../providers/exchange/exchange_form_state_provider.dart'; import '../../../providers/global/locale_provider.dart'; import '../../../services/exchange/exchange.dart'; @@ -164,9 +165,9 @@ class _ExchangeOptionState extends ConsumerState { isDesktop ? Container( height: 1, - color: Theme.of( - context, - ).extension()!.background, + color: Theme.of(context) + .extension()! + .background, ) : const SizedBox(height: 16), child, @@ -215,9 +216,9 @@ class _ExchangeOptionState extends ConsumerState { estimate: null, pair: pair, rateString: message ?? "Failed to fetch rate", - rateColor: Theme.of( - context, - ).extension()!.textError, + rateColor: Theme.of(context) + .extension()! + .textError, ); }, ); @@ -354,9 +355,9 @@ class _ProviderOptionState extends ConsumerState { child: Padding( padding: EdgeInsets.only(top: isDesktop ? 20.0 : 15.0), child: Radio( - activeColor: Theme.of( - context, - ).extension()!.radioButtonIconEnabled, + activeColor: Theme.of(context) + .extension()! + .radioButtonIconEnabled, value: _id, groupValue: groupValue, onChanged: (_) { @@ -382,8 +383,10 @@ class _ProviderOptionState extends ConsumerState { widget.estimate!.exchangeProviderLogo!.isNotEmpty ? ClipRRect( borderRadius: BorderRadius.circular(5), - child: Image.network( - widget.estimate!.exchangeProviderLogo!, + child: Image( + image: TorAwareNetworkImage( + widget.estimate!.exchangeProviderLogo!, + ), loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) { @@ -427,9 +430,9 @@ class _ProviderOptionState extends ConsumerState { widget.estimate?.exchangeProvider ?? widget.exchange.name, style: STextStyles.titleBold12(context).copyWith( - color: Theme.of( - context, - ).extension()!.textDark2, + color: Theme.of(context) + .extension()! + .textDark2, ), ), ), @@ -443,9 +446,9 @@ class _ProviderOptionState extends ConsumerState { ], style: STextStyles.itemSubtitle12(context) .copyWith( - color: Theme.of( - context, - ).extension()!.textSubtitle1, + color: Theme.of(context) + .extension()! + .textSubtitle1, ), ) : Text( diff --git a/lib/pages/settings_views/global_settings_view/appearance_settings/sub_widgets/stack_theme_card.dart b/lib/pages/settings_views/global_settings_view/appearance_settings/sub_widgets/stack_theme_card.dart index b350fa527b..93e0f71da4 100644 --- a/lib/pages/settings_views/global_settings_view/appearance_settings/sub_widgets/stack_theme_card.dart +++ b/lib/pages/settings_views/global_settings_view/appearance_settings/sub_widgets/stack_theme_card.dart @@ -17,6 +17,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../../../../models/isar/stack_theme.dart'; +import '../../../../../networking/tor_aware_network_image.dart'; import '../../../../../notifications/show_flush_bar.dart'; import '../../../../../providers/db/main_db_provider.dart'; import '../../../../../providers/global/prefs_provider.dart'; @@ -69,18 +70,16 @@ class _StackThemeCardState extends ConsumerState { } Future _downloadPressed() async { - final result = - (await showLoading( - whileFuture: _downloadAndInstall(), - context: context, - message: "Downloading and installing theme...", - ))!; + final result = (await showLoading( + whileFuture: _downloadAndInstall(), + context: context, + message: "Downloading and installing theme...", + ))!; if (mounted) { - final message = - result - ? "${widget.data.name} theme installed!" - : "Failed to install ${widget.data.name} theme"; + final message = result + ? "${widget.data.name} theme installed!" + : "Failed to install ${widget.data.name} theme"; if (isDesktop) { await showFloatingFlushBar( type: result ? FlushBarType.success : FlushBarType.warning, @@ -90,16 +89,15 @@ class _StackThemeCardState extends ConsumerState { } else { await showDialog( context: context, - builder: - (_) => StackOkDialog( - title: message, - onOkPressed: (_) { - setState(() { - _needsUpdate = !result; - _hasTheme = result; - }); - }, - ), + builder: (_) => StackOkDialog( + title: message, + onOkPressed: (_) { + setState(() { + _needsUpdate = !result; + _hasTheme = result; + }); + }, + ), ); } } @@ -155,14 +153,13 @@ class _StackThemeCardState extends ConsumerState { } } - StackTheme? getInstalled() => - ref - .read(mainDBProvider) - .isar - .stackThemes - .where() - .themeIdEqualTo(widget.data.id) - .findFirstSync(); + StackTheme? getInstalled() => ref + .read(mainDBProvider) + .isar + .stackThemes + .where() + .themeIdEqualTo(widget.data.id) + .findFirstSync(); @override void initState() { @@ -207,39 +204,41 @@ class _StackThemeCardState extends ConsumerState { Widget build(BuildContext context) { return RoundedWhiteContainer( radiusMultiplier: isDesktop ? 2.5 : 1, - borderColor: - isDesktop - ? Theme.of(context).extension()!.textSubtitle6 - : null, + borderColor: isDesktop + ? Theme.of(context).extension()!.textSubtitle6 + : null, child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 18), - child: - widget.data.previewImageUrl.isNotEmpty - ? AspectRatio( - aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(100), - child: Image.network(widget.data.previewImageUrl), + child: widget.data.previewImageUrl.isNotEmpty + ? AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(100), + child: Image( + image: TorAwareNetworkImage( + widget.data.previewImageUrl, + ), ), - ) - : Builder( - builder: (context) { - final themePreview = - ref - .watch(pThemeService) - .getTheme(themeId: widget.data.id) - ?.assets - .themePreview ?? - ""; - - return (themePreview.endsWith(".png")) - ? Image.file(File(themePreview), height: 100) - : SvgPicture.file(File(themePreview), height: 100); - }, ), + ) + : Builder( + builder: (context) { + final themePreview = + ref + .watch(pThemeService) + .getTheme(themeId: widget.data.id) + ?.assets + .themePreview ?? + ""; + + return (themePreview.endsWith(".png")) + ? Image.file(File(themePreview), height: 100) + : SvgPicture.file(File(themePreview), height: 100); + }, + ), ), const SizedBox(height: 12), Text(widget.data.name, style: STextStyles.itemSubtitle12(context)), @@ -276,10 +275,9 @@ class _StackThemeCardState extends ConsumerState { const SizedBox(height: 12), AnimatedCrossFade( duration: const Duration(milliseconds: 300), - crossFadeState: - _hasTheme - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, + crossFadeState: _hasTheme + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, firstChild: PrimaryButton( label: "Download", buttonHeight: isDesktop ? ButtonHeight.s : ButtonHeight.l, diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 2a558ef5dc..5d99517f59 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -10,6 +10,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../db/drift/shared_db/shared_database.dart'; import '../../models/shopinbit/shopinbit_enums.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../notifications/show_flush_bar.dart'; import '../../providers/db/drift_provider.dart'; import '../../providers/global/shopin_bit_service_provider.dart'; @@ -432,9 +433,8 @@ class _ShopInBitTicketDetailState extends ConsumerState ); } if (context.mounted) { - await Navigator.of( - context, - ).pushNamed(ShopInBitOfferView.routeName, arguments: _id); + await Navigator.of(context) + .pushNamed(ShopInBitOfferView.routeName, arguments: _id); } } @@ -443,9 +443,9 @@ class _ShopInBitTicketDetailState extends ConsumerState padding: .only(bottom: isDesktop ? 12 : 8), child: RoundedWhiteContainer( borderColor: isDesktop - ? Theme.of( - context, - ).extension()!.textFieldDefaultBG + ? Theme.of(context) + .extension()! + .textFieldDefaultBG : null, child: ConditionalParent( condition: Util.isDesktop, @@ -559,9 +559,9 @@ class _ShopInBitTicketDetailState extends ConsumerState ? STextStyles.desktopTextExtraSmall(context) : STextStyles.field(context)) .copyWith( - color: Theme.of( - context, - ).extension()!.textDark, + color: Theme.of(context) + .extension()! + .textDark, ), decoration: InputDecoration( hintText: "Type a message...", @@ -585,9 +585,9 @@ class _ShopInBitTicketDetailState extends ConsumerState Assets.svg.send, width: 24, height: 24, - color: Theme.of( - context, - ).extension()!.accentColorBlue, + color: Theme.of(context) + .extension()! + .accentColorBlue, ), ), ], @@ -600,9 +600,9 @@ class _ShopInBitTicketDetailState extends ConsumerState padding: EdgeInsets.only(bottom: isDesktop ? 12 : 8), child: RoundedWhiteContainer( borderColor: isDesktop - ? Theme.of( - context, - ).extension()!.textFieldDefaultBG + ? Theme.of(context) + .extension()! + .textFieldDefaultBG : null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -698,9 +698,9 @@ class _ShopInBitTicketDetailState extends ConsumerState condition: !isDesktop, builder: (child) => Background( child: Scaffold( - backgroundColor: Theme.of( - context, - ).extension()!.background, + backgroundColor: Theme.of(context) + .extension()! + .background, appBar: AppBar( leading: AppBarBackButton( onPressed: () => Navigator.of(context).pop(), @@ -892,10 +892,13 @@ class _ProxyImageState extends State<_ProxyImage> { if (resp.hasError || resp.value == null) { return widget.fallback(); } - return Image.network( - resp.value!.toString(), + return Image( + image: ResizeImage.resizeIfNeeded( + null, + _kAttachmentDecodeHeight, + TorAwareNetworkImage(resp.value!.toString()), + ), fit: BoxFit.contain, - cacheHeight: _kAttachmentDecodeHeight, semanticLabel: "Image attachment", errorBuilder: (_, _, _) => widget.fallback(), ); diff --git a/lib/widgets/icon_widgets/eth_token_icon.dart b/lib/widgets/icon_widgets/eth_token_icon.dart index eb88200916..4162ed6e23 100644 --- a/lib/widgets/icon_widgets/eth_token_icon.dart +++ b/lib/widgets/icon_widgets/eth_token_icon.dart @@ -16,6 +16,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../models/isar/exchange_cache/currency.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; @@ -87,8 +88,8 @@ class _EthTokenIconState extends ConsumerState { height: widget.size, ); } else { - return SvgPicture.network( - imageUrl!, + return SvgPicture( + TorAwareSvgNetworkLoader(imageUrl!), width: widget.size, height: widget.size, placeholderBuilder: (_) => const LoadingIndicator(), diff --git a/lib/widgets/icon_widgets/sol_token_icon.dart b/lib/widgets/icon_widgets/sol_token_icon.dart index 8907651c3b..24b8e71333 100644 --- a/lib/widgets/icon_widgets/sol_token_icon.dart +++ b/lib/widgets/icon_widgets/sol_token_icon.dart @@ -15,6 +15,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../models/isar/exchange_cache/currency.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../services/exchange/change_now/change_now_exchange.dart'; import '../../services/exchange/exchange_data_loading_service.dart'; import '../../themes/coin_icon_provider.dart'; @@ -84,8 +85,8 @@ class _SolTokenIconState extends ConsumerState { ); } else { // Display token icon from network. - return SvgPicture.network( - imageUrl!, + return SvgPicture( + TorAwareSvgNetworkLoader(imageUrl!), width: widget.size, height: widget.size, placeholderBuilder: (_) => const LoadingIndicator(), diff --git a/lib/widgets/textfields/exchange_textfield.dart b/lib/widgets/textfields/exchange_textfield.dart index 3229b28d5c..b9f3530386 100644 --- a/lib/widgets/textfields/exchange_textfield.dart +++ b/lib/widgets/textfields/exchange_textfield.dart @@ -14,6 +14,7 @@ import 'package:flutter_svg/svg.dart'; import '../../app_config.dart'; import '../../models/exchange/aggregate_currency.dart'; +import '../../networking/tor_aware_network_image.dart'; import '../../pages/buy_view/sub_widgets/crypto_selection_view.dart'; import '../../providers/global/locale_provider.dart'; import '../../themes/stack_colors.dart'; @@ -181,8 +182,10 @@ class _ExchangeTextFieldState extends ConsumerState { } else if (widget.currency != null && widget.currency!.image.isNotEmpty) { return Center( - child: SvgPicture.network( - widget.currency!.image, + child: SvgPicture( + TorAwareSvgNetworkLoader( + widget.currency!.image, + ), height: 18, placeholderBuilder: (_) => Container( width: 18, diff --git a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart index e5450c39ad..e341a23ea1 100644 --- a/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart +++ b/lib/widgets/wallet_info_row/sub_widgets/wallet_info_row_coin_icon.dart @@ -16,6 +16,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:isar_community/isar.dart'; import '../../../models/isar/exchange_cache/currency.dart'; +import '../../../networking/tor_aware_network_image.dart'; import '../../../services/exchange/change_now/change_now_exchange.dart'; import '../../../services/exchange/exchange_data_loading_service.dart'; import '../../../themes/coin_icon_provider.dart'; @@ -48,18 +49,14 @@ class _WalletInfoCoinIconState extends ConsumerState { ExchangeDataLoadingService.instance.isar.then((isar) async { if (widget.contractAddress != null) { - final currency = - await isar.currencies - .where() - .exchangeNameEqualTo(ChangeNowExchange.exchangeName) - .filter() - .tokenContractEqualTo( - widget.contractAddress!, - caseSensitive: false, - ) - .and() - .imageIsNotEmpty() - .findFirst(); + final currency = await isar.currencies + .where() + .exchangeNameEqualTo(ChangeNowExchange.exchangeName) + .filter() + .tokenContractEqualTo(widget.contractAddress!, caseSensitive: false) + .and() + .imageIsNotEmpty() + .findFirst(); if (mounted) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -87,14 +84,17 @@ class _WalletInfoCoinIconState extends ConsumerState { ), child: Padding( padding: EdgeInsets.all(widget.size / 5), - child: - imageUrl != null && imageUrl!.isNotEmpty - ? SvgPicture.network(imageUrl!, width: 20, height: 20) - : SvgPicture.file( - File(ref.watch(coinIconProvider(widget.coin))), - width: 20, - height: 20, - ), + child: imageUrl != null && imageUrl!.isNotEmpty + ? SvgPicture( + TorAwareSvgNetworkLoader(imageUrl!), + width: 20, + height: 20, + ) + : SvgPicture.file( + File(ref.watch(coinIconProvider(widget.coin))), + width: 20, + height: 20, + ), ), ); } From aad936036ac3fd2b1e281c74f0a46200c8cd8b4b Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 28 Oct 2026 11:50:53 -0500 Subject: [PATCH 4/7] Load ShopinBit image attachments with header auth The attachment images were fetched from a query-auth URL carrying the token and customer key. Now that they go through the HTTP wrapper, which logs failed requests, and dart:io puts the request URL in its exceptions, those credentials could end up in the log file. Fetch the plain URL with the Authorization and External-Customer-Key headers instead. TorAwareNetworkImage takes the headers but keeps keying the image cache on the URL alone, so a re-mounted attachment still comes from the cache. client.dart wasn't formatted on staging, so its diff includes one formatting change. --- lib/networking/tor_aware_network_image.dart | 14 +++++-- .../shopinbit/shopinbit_ticket_detail.dart | 28 +++++++------ lib/services/shopinbit/src/client.dart | 30 ++++++++++++- .../tor_aware_network_image_test.dart | 42 +++++++++++++++++++ 4 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 test/networking/tor_aware_network_image_test.dart diff --git a/lib/networking/tor_aware_network_image.dart b/lib/networking/tor_aware_network_image.dart index ef4cb03eaa..09ecc55c50 100644 --- a/lib/networking/tor_aware_network_image.dart +++ b/lib/networking/tor_aware_network_image.dart @@ -14,9 +14,13 @@ import 'http.dart'; /// Fetches [url] through the app's [HTTP] client, routing over Tor when it is /// enabled. Throws if Tor is enabled but not connected, so nothing falls back /// to clearnet. -Future fetchTorAwareBytes(String url) async { +Future fetchTorAwareBytes( + String url, { + Map? headers, +}) async { final response = await const HTTP().get( url: Uri.parse(url), + headers: headers, proxyInfo: !AppConfig.hasFeature(AppFeature.tor) ? null : Prefs.instance.useTor @@ -35,10 +39,14 @@ Future fetchTorAwareBytes(String url) async { /// Use with [Image] instead of [Image.network], which always goes clearnet. @immutable class TorAwareNetworkImage extends ImageProvider { - const TorAwareNetworkImage(this.url); + const TorAwareNetworkImage(this.url, {this.headers}); final String url; + /// Sent with the request but not part of the cache key, so e.g. a refreshed + /// auth token doesn't cause a refetch. + final Map? headers; + @override Future obtainKey(ImageConfiguration configuration) => SynchronousFuture(this); @@ -70,7 +78,7 @@ class TorAwareNetworkImage extends ImageProvider { expectedTotalBytes: null, ), ); - final bytes = await fetchTorAwareBytes(key.url); + final bytes = await fetchTorAwareBytes(key.url, headers: key.headers); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); return await decode(buffer); } catch (_) { diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 5d99517f59..5e8650b91c 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -827,9 +827,9 @@ class _AttachmentChip extends StatelessWidget { /// Renders an authenticated `/attachment-proxy/` image. /// -/// The signed URL future is built once in [initState] (and only rebuilt when +/// The request future is built once in [initState] (and only rebuilt when /// [proxyPath] or [customerKey] actually change) so the surrounding 30s poll -/// can't re-fire `getAttachmentUrl`/re-fetch the image on every rebuild. +/// can't re-fire `getAttachmentRequest`/re-fetch the image on every rebuild. class _ProxyImage extends StatefulWidget { const _ProxyImage({ required this.client, @@ -848,18 +848,17 @@ class _ProxyImage extends StatefulWidget { } class _ProxyImageState extends State<_ProxyImage> { - late Future> _urlFuture; + late Future> _requestFuture; - Future> _buildFuture() => widget.client.getAttachmentUrl( - widget.proxyPath, - useQueryAuth: true, - customerKey: widget.customerKey, - ); + // Header auth: a query-auth URL would put the token and customer key into + // logged HTTP errors. + Future> _buildFuture() => widget.client + .getAttachmentRequest(widget.proxyPath, customerKey: widget.customerKey); @override void initState() { super.initState(); - _urlFuture = _buildFuture(); + _requestFuture = _buildFuture(); } @override @@ -867,7 +866,7 @@ class _ProxyImageState extends State<_ProxyImage> { super.didUpdateWidget(oldWidget); if (oldWidget.proxyPath != widget.proxyPath || oldWidget.customerKey != widget.customerKey) { - _urlFuture = _buildFuture(); + _requestFuture = _buildFuture(); } } @@ -879,8 +878,8 @@ class _ProxyImageState extends State<_ProxyImage> { borderRadius: BorderRadius.circular(8), child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: _kAttachmentMaxHeight), - child: FutureBuilder>( - future: _urlFuture, + child: FutureBuilder>( + future: _requestFuture, builder: (context, snapshot) { if (!snapshot.hasData) { return const SizedBox( @@ -896,7 +895,10 @@ class _ProxyImageState extends State<_ProxyImage> { image: ResizeImage.resizeIfNeeded( null, _kAttachmentDecodeHeight, - TorAwareNetworkImage(resp.value!.toString()), + TorAwareNetworkImage( + resp.value!.uri.toString(), + headers: resp.value!.headers, + ), ), fit: BoxFit.contain, semanticLabel: "Image attachment", diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart index eee9018e2e..e85a1bd2eb 100644 --- a/lib/services/shopinbit/src/client.dart +++ b/lib/services/shopinbit/src/client.dart @@ -309,6 +309,31 @@ class ShopInBitClient { } } + /// URL and auth headers for fetching an attachment via + /// `/attachment-proxy/` from an HTTP client that can set headers. + Future> getAttachmentRequest( + String attachmentPath, { + String? customerKey, + }) async { + try { + final token = await _tokenManager.getValidToken(); + final resolved = _resolvePath('/attachment-proxy/$attachmentPath'); + return ApiResponse( + value: ( + uri: Uri.parse('$baseUrl$resolved'), + headers: { + 'Authorization': 'Bearer $token', + if (customerKey != null) 'External-Customer-Key': customerKey, + }, + ), + ); + } on ApiException catch (e) { + return ApiResponse(exception: e); + } catch (e) { + return ApiResponse(exception: ApiException.network(e)); + } + } + /// Download an attachment from `/attachment-proxy/`. Future> getAttachment( String attachmentPath, { @@ -1055,6 +1080,8 @@ const kAllowedAttachmentExtensions = [ typedef ResolvedAttachment = ({String mimeType, AttachmentCategory category}); +typedef AttachmentRequest = ({Uri uri, Map headers}); + /// Path + content type, materialized into multipart bytes inside /// [_multipartRequest]: validation stays separate from encoding, and the /// encoded bytes can be resent on 401 without re-reading files from disk. @@ -1075,8 +1102,7 @@ ResolvedAttachment? resolveAttachmentType(String fileName) { "gif" => (mimeType: "image/gif", category: .image), "pdf" => (mimeType: "application/pdf", category: .document), "docx" => ( - mimeType: - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", category: .document, ), "xlsx" => ( diff --git a/test/networking/tor_aware_network_image_test.dart b/test/networking/tor_aware_network_image_test.dart new file mode 100644 index 0000000000..afa767004f --- /dev/null +++ b/test/networking/tor_aware_network_image_test.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stackwallet/networking/tor_aware_network_image.dart'; + +void main() { + late HttpServer server; + late List requests; + + setUp(() async { + requests = []; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) { + requests.add(request); + request.response.add([1]); + request.response.close(); + }); + }); + + tearDown(() => server.close(force: true)); + + String url(String path) => "http://127.0.0.1:${server.port}$path"; + + test("network image sends headers but keys the cache on the url", () async { + await fetchTorAwareBytes( + url("/a.png"), + headers: {"Authorization": "Bearer abc"}, + ); + expect(requests.single.headers.value("Authorization"), "Bearer abc"); + + expect( + TorAwareNetworkImage( + url("/a.png"), + headers: const {"Authorization": "a"}, + ), + TorAwareNetworkImage( + url("/a.png"), + headers: const {"Authorization": "b"}, + ), + ); + }); +} From c0d95ed71e9a122c969416f4ffba5c68b5ef0eae Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 28 Oct 2026 16:37:14 -0500 Subject: [PATCH 5/7] Migrate the Fact0rn default node to allow Tor updateDefaults keeps the saved node's tor and clearnet flags, so existing installs kept the old clearnet-only Fact0rn default even though it now allows Tor. With Tor turned on that node is rejected and Fact0rn cannot refresh or pass the node test. Add a one-time migration that enables Tor on the saved Fact0rn default node. Only a node that still matches the old shipped default on every field the user can edit (host, port, name, SSL, failover, login, clearnet only) is touched, so a node the user customised keeps its settings. Clearnet stays enabled, so nothing changes with Tor off, and later choices are left alone. --- lib/db/db_version_migration.dart | 44 ++++++++++++++++++++ lib/utilities/constants.dart | 2 +- test/db_version_migration_test.dart | 64 ++++++++++++++++++++++++++++- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/lib/db/db_version_migration.dart b/lib/db/db_version_migration.dart index 15ad3b9bcb..b0dc59fa74 100644 --- a/lib/db/db_version_migration.dart +++ b/lib/db/db_version_migration.dart @@ -25,6 +25,7 @@ import '../services/wallets_service.dart'; import '../utilities/amount/amount.dart'; import '../utilities/constants.dart'; import '../utilities/default_eth_tokens.dart'; +import '../utilities/default_nodes.dart'; import '../utilities/flutter_secure_storage_interface.dart'; import '../utilities/logger.dart'; import '../utilities/prefs.dart'; @@ -423,6 +424,16 @@ class DbVersionMigrator with WalletDB { ); return await migrate(17, secureStore: secureStore); + case 17: + await _v17(); + + await DB.instance.put( + boxName: DB.boxNameDBInfo, + key: "hive_data_version", + value: 18, + ); + return await migrate(18, secureStore: secureStore); + default: // finally return return; @@ -711,4 +722,37 @@ class DbVersionMigrator with WalletDB { await primariesBox.deleteFromDisk(); } + + Future _v17() async { + final nodesBox = await DB.instance.hive.openBox( + DB.boxNameNodeModels, + ); + final id = DefaultNodes.buildId(Fact0rn(CryptoCurrencyNetwork.main)); + final node = nodesBox.get(id); + + // The Fact0rn default node used to ship clearnet only. Only touch a node + // that still matches everything the user could have edited on it, so a + // customised node keeps its settings. + final untouched = + node != null && + node.host == "electrumx2.projectfactor.io" && + node.port == 50002 && + node.name == DefaultNodes.defaultName && + node.useSSL && + node.isFailover && + node.loginName == null && + !node.torEnabled && + node.clearnetEnabled; + + if (untouched) { + await nodesBox.put( + id, + node.copyWith( + loginName: node.loginName, + trusted: node.trusted, + torEnabled: true, + ), + ); + } + } } diff --git a/lib/utilities/constants.dart b/lib/utilities/constants.dart index 51de11e062..66fad1cedb 100644 --- a/lib/utilities/constants.dart +++ b/lib/utilities/constants.dart @@ -40,7 +40,7 @@ abstract class Constants { // Enable Logger.print statements static const bool disableLogger = false; - static const int currentDataVersion = 17; + static const int currentDataVersion = 18; static const int rescanV1 = 1; diff --git a/test/db_version_migration_test.dart b/test/db_version_migration_test.dart index cd4454b5c7..84db2c9e71 100644 --- a/test/db_version_migration_test.dart +++ b/test/db_version_migration_test.dart @@ -6,9 +6,11 @@ import 'package:stackwallet/db/db_version_migration.dart'; import 'package:stackwallet/db/hive/db.dart'; import 'package:stackwallet/db/isar/main_db.dart'; import 'package:stackwallet/models/isar/models/ethereum/eth_contract.dart'; +import 'package:stackwallet/models/node_model.dart'; import 'package:stackwallet/utilities/constants.dart'; import 'package:stackwallet/utilities/default_eth_tokens.dart'; import 'package:stackwallet/utilities/flutter_secure_storage_interface.dart'; +import 'package:stackwallet/wallets/crypto_currency/crypto_currency.dart'; import 'hive/hive_ce_test_utils.dart'; @@ -34,8 +36,8 @@ void main() { DbVersionMigrator().migrate(version, secureStore: FakeSecureStorage()); await migrate(16); - expect(Constants.currentDataVersion, 17); - expect(info.get('hive_data_version'), 17); + expect(Constants.currentDataVersion, 18); + expect(info.get('hive_data_version'), 18); expect(await db.getEthContracts().isEmpty(), isTrue); final custom = DefaultTokens.usdc.copyWith(name: 'Custom token'); @@ -58,4 +60,62 @@ void main() { await migrate(info.get('hive_data_version') as int); expect(await db.getEthContracts().count(), 1); }); + + test('v18 lets the default Fact0rn node use Tor', () async { + await setUpHiveCeTest(); + addTearDown(tearDownHiveCeTest); + final adapter = NodeModelAdapter(); + if (!DB.instance.hive.isAdapterRegistered(adapter.typeId)) { + DB.instance.hive.registerAdapter(adapter); + } + final info = await DB.instance.hive.openBox(DB.boxNameDBInfo); + final nodes = await DB.instance.hive.openBox( + DB.boxNameNodeModels, + ); + NodeModel clearOnly(CryptoCurrency coin) => coin + .defaultNode(isPrimary: true) + .copyWith(loginName: null, trusted: null, torEnabled: false); + final fact0rn = clearOnly(Fact0rn(CryptoCurrencyNetwork.main)); + final bitcoin = clearOnly(Bitcoin(CryptoCurrencyNetwork.main)); + await nodes.put(fact0rn.id, fact0rn); + await nodes.put(bitcoin.id, bitcoin); + + await DbVersionMigrator().migrate(17, secureStore: FakeSecureStorage()); + + expect(info.get('hive_data_version'), 18); + expect(nodes.get(fact0rn.id)!.torEnabled, isTrue); + expect(nodes.get(fact0rn.id)!.clearnetEnabled, isTrue); + expect(nodes.get(fact0rn.id)!.isPrimary, isTrue); + expect(nodes.get(bitcoin.id)!.torEnabled, isFalse); + }); + + test('v18 leaves an edited Fact0rn default node alone', () async { + await setUpHiveCeTest(); + addTearDown(tearDownHiveCeTest); + final adapter = NodeModelAdapter(); + if (!DB.instance.hive.isAdapterRegistered(adapter.typeId)) { + DB.instance.hive.registerAdapter(adapter); + } + await DB.instance.hive.openBox(DB.boxNameDBInfo); + final nodes = await DB.instance.hive.openBox( + DB.boxNameNodeModels, + ); + final shipped = Fact0rn(CryptoCurrencyNetwork.main) + .defaultNode(isPrimary: true) + .copyWith(loginName: null, trusted: null, torEnabled: false); + final edited = [ + shipped.copyWith(loginName: null, trusted: null, isFailover: false), + shipped.copyWith(loginName: null, trusted: null, host: "example.com"), + shipped.copyWith(loginName: null, trusted: null, port: 50001), + shipped.copyWith(loginName: null, trusted: null, useSSL: false), + shipped.copyWith(loginName: null, trusted: null, name: "Mine"), + shipped.copyWith(loginName: "user", trusted: null), + ]; + + for (final node in edited) { + await nodes.put(node.id, node); + await DbVersionMigrator().migrate(17, secureStore: FakeSecureStorage()); + expect(nodes.get(node.id)!.torEnabled, isFalse, reason: "$node"); + } + }); } From 356a3b9c62eec754c1e08209c5d3398023d4b9a5 Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 28 Oct 2026 16:37:14 -0500 Subject: [PATCH 6/7] Scope cached ShopinBit attachments by customer key TorAwareNetworkImage keys its cache on the url alone, so an attachment path fetched under one customer key would be served from cache to a later customer key without an authenticated request. Add an optional cacheScope that is part of the key, and pass the ShopinBit customer key there. The bearer token stays out of the key so a refreshed token does not refetch. The key is captured together with the request response rather than read from the widget when building the image: FutureBuilder keeps showing the previous snapshot while a new future is pending, and that snapshot must not be paired with a later customer's scope. --- lib/networking/tor_aware_network_image.dart | 13 +++++++--- .../shopinbit/shopinbit_ticket_detail.dart | 24 ++++++++++++++----- .../tor_aware_network_image_test.dart | 15 ++++++++++++ 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/lib/networking/tor_aware_network_image.dart b/lib/networking/tor_aware_network_image.dart index 09ecc55c50..0adf90bf9e 100644 --- a/lib/networking/tor_aware_network_image.dart +++ b/lib/networking/tor_aware_network_image.dart @@ -39,7 +39,7 @@ Future fetchTorAwareBytes( /// Use with [Image] instead of [Image.network], which always goes clearnet. @immutable class TorAwareNetworkImage extends ImageProvider { - const TorAwareNetworkImage(this.url, {this.headers}); + const TorAwareNetworkImage(this.url, {this.headers, this.cacheScope}); final String url; @@ -47,6 +47,11 @@ class TorAwareNetworkImage extends ImageProvider { /// auth token doesn't cause a refetch. final Map? headers; + /// Part of the cache key, unlike [headers]. Set it to a stable identity + /// (e.g. a customer key) when the same [url] can serve different content + /// depending on who is asking, so one identity never sees another's image. + final String? cacheScope; + @override Future obtainKey(ImageConfiguration configuration) => SynchronousFuture(this); @@ -94,10 +99,12 @@ class TorAwareNetworkImage extends ImageProvider { @override bool operator ==(Object other) => - other is TorAwareNetworkImage && other.url == url; + other is TorAwareNetworkImage && + other.url == url && + other.cacheScope == cacheScope; @override - int get hashCode => url.hashCode; + int get hashCode => Object.hash(url, cacheScope); @override String toString() => diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 5e8650b91c..fb355d7dbc 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -830,6 +830,11 @@ class _AttachmentChip extends StatelessWidget { /// The request future is built once in [initState] (and only rebuilt when /// [proxyPath] or [customerKey] actually change) so the surrounding 30s poll /// can't re-fire `getAttachmentRequest`/re-fetch the image on every rebuild. +typedef _AttachmentLoad = ({ + ApiResponse response, + String customerKey, +}); + class _ProxyImage extends StatefulWidget { const _ProxyImage({ required this.client, @@ -848,12 +853,18 @@ class _ProxyImage extends StatefulWidget { } class _ProxyImageState extends State<_ProxyImage> { - late Future> _requestFuture; + late Future<_AttachmentLoad> _requestFuture; // Header auth: a query-auth URL would put the token and customer key into - // logged HTTP errors. - Future> _buildFuture() => widget.client - .getAttachmentRequest(widget.proxyPath, customerKey: widget.customerKey); + // logged HTTP errors. The customer key travels with the response because + // FutureBuilder keeps showing the previous snapshot while a new future is + // pending, and that snapshot must not be paired with a newer key. + Future<_AttachmentLoad> _buildFuture() { + final customerKey = widget.customerKey; + return widget.client + .getAttachmentRequest(widget.proxyPath, customerKey: customerKey) + .then((response) => (response: response, customerKey: customerKey)); + } @override void initState() { @@ -878,7 +889,7 @@ class _ProxyImageState extends State<_ProxyImage> { borderRadius: BorderRadius.circular(8), child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: _kAttachmentMaxHeight), - child: FutureBuilder>( + child: FutureBuilder<_AttachmentLoad>( future: _requestFuture, builder: (context, snapshot) { if (!snapshot.hasData) { @@ -887,7 +898,7 @@ class _ProxyImageState extends State<_ProxyImage> { child: LoadingIndicator(width: _kAttachmentLoaderWidth), ); } - final resp = snapshot.data!; + final resp = snapshot.data!.response; if (resp.hasError || resp.value == null) { return widget.fallback(); } @@ -898,6 +909,7 @@ class _ProxyImageState extends State<_ProxyImage> { TorAwareNetworkImage( resp.value!.uri.toString(), headers: resp.value!.headers, + cacheScope: snapshot.data!.customerKey, ), ), fit: BoxFit.contain, diff --git a/test/networking/tor_aware_network_image_test.dart b/test/networking/tor_aware_network_image_test.dart index afa767004f..97288113ae 100644 --- a/test/networking/tor_aware_network_image_test.dart +++ b/test/networking/tor_aware_network_image_test.dart @@ -39,4 +39,19 @@ void main() { ), ); }); + + test("network image cache is scoped by cacheScope", () { + expect( + TorAwareNetworkImage(url("/a.png"), cacheScope: "customer-a"), + TorAwareNetworkImage(url("/a.png"), cacheScope: "customer-a"), + ); + expect( + TorAwareNetworkImage(url("/a.png"), cacheScope: "customer-a"), + isNot(TorAwareNetworkImage(url("/a.png"), cacheScope: "customer-b")), + ); + expect( + TorAwareNetworkImage(url("/a.png"), cacheScope: "customer-a"), + isNot(TorAwareNetworkImage(url("/a.png"))), + ); + }); } From 9f6dacfe65d565eb16109d574db83ac94f44d17e Mon Sep 17 00:00:00 2001 From: sneurlax Date: Wed, 28 Oct 2026 16:37:29 -0500 Subject: [PATCH 7/7] Fail ETH RPCs when the Tor preference changed after the client was built createWeb3Client binds the SOCKS proxy to the HttpClient when the client is built, so the Tor decision is fixed for that client's lifetime. getEthClient is called per operation, but updateTransactions (its local getClient closure), the tx loop above it and the token wallet's tx loop each build one client lazily and reuse it for many RPCs. Toggling the Tor preference while one of those runs left the remaining RPCs of that operation on the transport chosen before the toggle: direct after enabling Tor, or still through Tor after disabling it. Wrap the IOClient in a client that remembers whether it was built proxied and re-reads the preference on every send. If the two disagree it throws without sending. The operation fails and is logged by the existing catch blocks; the next operation calls getEthClient again and gets a client built under the current preference. The wrapper does not rebuild or reroute the request and there are no retries, so a broadcast that hit the check is simply not sent rather than replayed on a different transport. --- lib/wallets/wallet/impl/ethereum_wallet.dart | 43 +++++- test/wallets/ethereum_tor_proxy_test.dart | 138 +++++++++++++++++-- 2 files changed, 164 insertions(+), 17 deletions(-) diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart index e2b0674509..4bac18e532 100644 --- a/lib/wallets/wallet/impl/ethereum_wallet.dart +++ b/lib/wallets/wallet/impl/ethereum_wallet.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:decimal/decimal.dart'; import 'package:ethereum_addresses/ethereum_addresses.dart'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:http/io_client.dart'; import 'package:isar_community/isar.dart'; import 'package:socks5_proxy/socks.dart'; @@ -172,7 +173,8 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { } // static helper function for building a web3 client, proxied over tor if - // enabled + // enabled. The returned client refuses to send once the tor preference no + // longer matches the transport it was built with; see _TorPreferenceClient. static web3.Web3Client createWeb3Client( String url, Prefs prefs, @@ -180,16 +182,22 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { ) { final httpClient = HttpClient(); - if (AppConfig.hasFeature(AppFeature.tor) && prefs.useTor) { + if (_shouldProxy(prefs)) { final proxyInfo = torService.getProxyInfo(); SocksTCPClient.assignToHttpClient(httpClient, [ ProxySettings(proxyInfo.host, proxyInfo.port), ]); } - return web3.Web3Client(url, IOClient(httpClient)); + return web3.Web3Client( + url, + _TorPreferenceClient(IOClient(httpClient), prefs), + ); } + static bool _shouldProxy(Prefs prefs) => + AppConfig.hasFeature(AppFeature.tor) && prefs.useTor; + Amount estimateEthFee(BigInt feeRate, int gasLimit, int decimals) { final gweiAmount = feeRate.toDecimal() / (Decimal.ten.pow(9).toDecimal()); final fee = @@ -772,3 +780,32 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface { await super.exit(); } } + +// The proxy is bound to the HttpClient when it is built, but some operations +// (updateTransactions, the token wallet's tx loop) build one client and reuse +// it for many RPCs. If the tor preference flips meanwhile, the remaining RPCs +// would still use the old transport. Fail those instead of sending: the +// operation errors out and the next one builds a client under the current +// preference. Nothing is retried here, so a broadcast is never replayed. +class _TorPreferenceClient extends http.BaseClient { + _TorPreferenceClient(this._inner, this._prefs) + : _proxied = EthereumWallet._shouldProxy(_prefs); + + final http.Client _inner; + final Prefs _prefs; + final bool _proxied; + + @override + Future send(http.BaseRequest request) async { + if (EthereumWallet._shouldProxy(_prefs) != _proxied) { + throw StateError( + 'Tor preference changed since this client was created; ' + 'the request was not sent', + ); + } + return _inner.send(request); + } + + @override + void close() => _inner.close(); +} diff --git a/test/wallets/ethereum_tor_proxy_test.dart b/test/wallets/ethereum_tor_proxy_test.dart index a532408c8b..54d701ec40 100644 --- a/test/wallets/ethereum_tor_proxy_test.dart +++ b/test/wallets/ethereum_tor_proxy_test.dart @@ -9,12 +9,10 @@ import 'package:stackwallet/utilities/prefs.dart'; import 'package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart'; class _MockPrefs extends Mock implements Prefs { - _MockPrefs(this._useTor); - - final bool _useTor; + _MockPrefs(this.useTor); @override - bool get useTor => _useTor; + bool useTor; } class _FakeTorService extends Fake implements TorService { @@ -43,6 +41,7 @@ String _rpcHttpResponse() => // tunnelled HTTP request with a canned JSON-RPC eth_blockNumber result. class _FakeSocksServer { late final ServerSocket _server; + int connections = 0; int? addressType; String? target; int? port; @@ -53,6 +52,7 @@ class _FakeSocksServer { Future start() async { _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); _server.listen((socket) { + connections++; var stage = 0; socket.listen((Uint8List bytes) { switch (stage) { @@ -84,6 +84,36 @@ class _FakeSocksServer { Future stop() => _server.close(); } +// Local node that answers every RPC with the canned result and counts them. +class _FakeNode { + late final HttpServer _server; + int requests = 0; + + String get url => 'http://127.0.0.1:${_server.port}'; + + Future start() async { + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + _server.listen((req) async { + requests++; + await utf8.decoder.bind(req).join(); + req.response + ..headers.contentType = ContentType.json + ..write(_rpcBody); + await req.response.close(); + }); + } + + Future stop() => _server.close(force: true); +} + +final _torPreferenceChanged = throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Tor preference changed'), + ), +); + void main() { late _FakeSocksServer socks; @@ -118,31 +148,111 @@ void main() { }); test('web3 client connects directly when Tor is off', () async { - final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - server.listen((req) async { - await utf8.decoder.bind(req).join(); - req.response - ..headers.contentType = ContentType.json - ..write(_rpcBody); - await req.response.close(); - }); + final node = _FakeNode(); + await node.start(); final torService = _FakeTorService(( host: InternetAddress.loopbackIPv4, port: socks.listeningPort, )); final client = EthereumWallet.createWeb3Client( - 'http://127.0.0.1:${server.port}', + node.url, _MockPrefs(false), torService, ); final height = await client.getBlockNumber(); await client.dispose(); - await server.close(force: true); + await node.stop(); expect(height, 42); + expect(node.requests, 1); expect(torService.proxyInfoReads, 0); expect(socks.target, isNull); }); + + group('tor preference changed after the client was built', () { + late _FakeNode node; + late _FakeTorService torService; + + setUp(() async { + node = _FakeNode(); + await node.start(); + torService = _FakeTorService(( + host: InternetAddress.loopbackIPv4, + port: socks.listeningPort, + )); + }); + + tearDown(() => node.stop()); + + test('client built with Tor off refuses to send once Tor is on', () async { + final prefs = _MockPrefs(false); + final client = EthereumWallet.createWeb3Client( + node.url, + prefs, + torService, + ); + + expect(await client.getBlockNumber(), 42); + expect(node.requests, 1); + + prefs.useTor = true; + + await expectLater(client.getBlockNumber(), _torPreferenceChanged); + await client.dispose(); + + // Nothing went out directly and nothing was rerouted via the proxy. + expect(node.requests, 1); + expect(socks.connections, 0); + expect(torService.proxyInfoReads, 0); + }); + + test('client built with Tor on refuses to send once Tor is off', () async { + final prefs = _MockPrefs(true); + final client = EthereumWallet.createWeb3Client( + node.url, + prefs, + torService, + ); + + expect(await client.getBlockNumber(), 42); + expect(socks.connections, 1); + expect(socks.request.toString(), contains('eth_blockNumber')); + + prefs.useTor = false; + + await expectLater(client.getBlockNumber(), _torPreferenceChanged); + await client.dispose(); + + // No new proxy connection and nothing went to the node directly. + expect(socks.connections, 1); + expect(node.requests, 0); + expect(torService.proxyInfoReads, 1); + }); + + test('unchanged preference keeps a reused client working', () async { + final direct = EthereumWallet.createWeb3Client( + node.url, + _MockPrefs(false), + torService, + ); + expect(await direct.getBlockNumber(), 42); + expect(await direct.getBlockNumber(), 42); + await direct.dispose(); + expect(node.requests, 2); + expect(socks.connections, 0); + + final proxied = EthereumWallet.createWeb3Client( + node.url, + _MockPrefs(true), + torService, + ); + expect(await proxied.getBlockNumber(), 42); + expect(await proxied.getBlockNumber(), 42); + await proxied.dispose(); + expect(node.requests, 2); + expect(socks.connections, 2); + }); + }); }