Merge branch 'staging' into dev/navidr/spark-name-verification
What changed, and why it matters
This commit merges several changes into a development branch. The most notable security-relevant change is a fix for how the Trocador exchange service routes traffic: it now automatically uses Tor (an anonymity network) when the user has Tor enabled, instead of callers hardcoding non-Tor connections. Previously, exchange calls always used the regular internet even if Tor was turned on, which could leak a user's IP address and trading activity to Trocador. The commit also updates the SOCKS5 proxy library and adds a test to verify that proxy requests correctly send hostnames (including .onion addresses) to the SOCKS server. Separately, it adds a small UI convenience: when paying a Spark private address, a payment message is auto-filled into the memo field, matching Firo-QT's behavior.
Review the new Tor routing logic to ensure `_useTor` cannot be bypassed and that `TorService.sharedInstance.getProxyInfo()` returns a valid proxy before use. Verify the new .onion authority belongs to Trocador and is not a typo or malicious substitution. Confirm the `socks5_proxy` 2.x upgrade does not break other SOCKS consumers in the app. For the Spark memo change, ensure `paymentData.message` is sanitized before being placed in the memo controller to avoid UI injection or unexpected length issues.
Security signals we found
Trocador exchange API previously forced clearnet (`isOnion: false`) at every call site, bypassing Tor even when enabled
New `_useTor` getter centralizes Tor routing decision based on app feature flag and user preference
Onion service address rotated to a new v3 .onion hostname
SOCKS5 proxy library upgraded from a dev pre-release to a stable release
New unit test confirms HTTP client sends hostname (including .onion) to SOCKS5 proxy rather than resolving locally
Spark memo auto-fill copies payment URI `message` field into memo for Spark addresses
Evidence from the diff
The diff removes the isOnion boolean parameter from TrocadorAPI methods and replaces it with a private _useTor getter that checks AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor. _buildUri now uses the onion authority when Tor is enabled and the clearnet authority otherwise. All call sites in trocador_exchange.dart drop isOnion: false. The onion authority string is also updated to a new v3 address. The socks5_proxy dependency is bumped from 1.0.3+dev.3 to ^2.1.1, and a new test verifies that HTTP requests through the SOCKS proxy send the correct hostname target (including .onion domains) to the proxy. Two send-view files add logic to copy paymentData.message into the Spark memo controller when the address validates as a Spark address.
Changed components
lib/services/exchange/trocador/trocador_api.dartlib/services/exchange/trocador/trocador_exchange.dartlib/pages/send_view/send_view.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dartpubspec.lockscripts/app_config/templates/pubspec.template.yamltest/networking/http_socks_proxy_test.dartInspect captured patch +122 / −50
### lib/pages/send_view/send_view.dart
@@ -169,6 +169,16 @@ class _SendViewState extends ConsumerState<SendView> {
noteController.text = paymentData.label!;
}
+ // firo-qt treats message as the spark memo when paying a spark address
+ if (coin is Firo &&
+ paymentData.message != null &&
+ SparkInterface.validateSparkAddress(
+ address: _address!,
+ isTestNet: coin.network.isTestNet,
+ )) {
+ memoController.text = paymentData.message!;
+ }
+
// autofill amount field
if (paymentData.amount != null) {
final amount = Amount.tryParseCanonicalAmount(
### lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
@@ -1010,6 +1010,16 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
_note = paymentData.label;
}
+ // firo-qt treats message as the spark memo when paying a spark address
+ if (coin is Firo &&
+ paymentData.message != null &&
+ SparkInterface.validateSparkAddress(
+ address: _address!,
+ isTestNet: coin.network.isTestNet,
+ )) {
+ memoController.text = paymentData.message!;
+ }
+
// autofill amount field
if (paymentData.amount != null) {
final amount = Amount.tryParseCanonicalAmount(
### lib/services/exchange/trocador/trocador_api.dart
@@ -31,18 +31,17 @@ const kTrocadorRefCode = "9eHm9BkQfS";
abstract class TrocadorAPI {
static const String authority = "api.trocador.app";
static const String onionAuthority =
- "trocadorfyhlu27aefre5u7zri66gudtzdyelymftvr4yjwcxhfaqsid.onion";
+ "65bsisadnxvw4kfz7h7a3jwcyenrhluuj3kd5toslfzxbk5q4m3wy6qd.onion";
static const String markup = "1";
static const String minKYCRating = "C";
static HTTP client = HTTP();
- static Uri _buildUri({
- required String method,
- required bool isOnion,
- Map<String, String>? params,
- }) {
- return isOnion
+ static bool get _useTor =>
+ AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor;
+
+ static Uri _buildUri({required String method, Map<String, String>? params}) {
+ return _useTor
? Uri.http(onionAuthority, method, params)
: Uri.https(authority, method, params);
}
@@ -57,11 +56,7 @@ abstract class TrocadorAPI {
"Content-Type": "application/json",
"API-KEY": kTrocadorApiKey,
},
- proxyInfo: !AppConfig.hasFeature(AppFeature.tor)
- ? null
- : Prefs.instance.useTor
- ? TorService.sharedInstance.getProxyInfo()
- : null,
+ proxyInfo: _useTor ? TorService.sharedInstance.getProxyInfo() : null,
);
code = response.code;
@@ -87,14 +82,8 @@ abstract class TrocadorAPI {
}
/// fetch all supported coins
- static Future<ExchangeResponse<List<TrocadorCoin>>> getCoins({
- required bool isOnion,
- }) async {
- final uri = _buildUri(
- isOnion: isOnion,
- method: "coins",
- params: {"ref": kTrocadorRefCode},
- );
+ static Future<ExchangeResponse<List<TrocadorCoin>>> getCoins() async {
+ final uri = _buildUri(method: "coins", params: {"ref": kTrocadorRefCode});
try {
final json = await _makeGetRequest(uri);
@@ -122,11 +111,9 @@ abstract class TrocadorAPI {
/// get trade info
static Future<ExchangeResponse<TrocadorTrade>> getTrade({
- required bool isOnion,
required String tradeId,
}) async {
final uri = _buildUri(
- isOnion: isOnion,
method: "trade",
params: {"ref": kTrocadorRefCode, "id": tradeId},
);
@@ -149,7 +136,6 @@ abstract class TrocadorAPI {
/// get standard/floating rate
static Future<ExchangeResponse<TrocadorRate>> getNewStandardRate({
- required bool isOnion,
required String fromTicker,
required String fromNetwork,
required String toTicker,
@@ -168,12 +154,11 @@ abstract class TrocadorAPI {
"markup": markup,
};
- return await _getNewRate(isOnion: isOnion, params: params);
+ return await _getNewRate(params: params);
}
/// get fixed rate/payment rate
static Future<ExchangeResponse<TrocadorRate>> getNewPaymentRate({
- required bool isOnion,
required String fromTicker,
required String fromNetwork,
required String toTicker,
@@ -192,14 +177,13 @@ abstract class TrocadorAPI {
"markup": markup,
};
- return await _getNewRate(isOnion: isOnion, params: params);
+ return await _getNewRate(params: params);
}
static Future<ExchangeResponse<TrocadorRate>> _getNewRate({
- required bool isOnion,
required Map<String, String> params,
}) async {
- final uri = _buildUri(isOnion: isOnion, method: "new_rate", params: params);
+ final uri = _buildUri(method: "new_rate", params: params);
try {
final json = await _makeGetRequest(uri);
@@ -219,7 +203,6 @@ abstract class TrocadorAPI {
/// create new floating rate/standard trade
static Future<ExchangeResponse<TrocadorTradeNew>> createNewStandardRateTrade({
- required bool isOnion,
required String? rateId,
required String fromTicker,
required String fromNetwork,
@@ -255,11 +238,10 @@ abstract class TrocadorAPI {
params["id"] = rateId;
}
- return await _getNewTrade(isOnion: isOnion, params: params);
+ return await _getNewTrade(params: params);
}
static Future<ExchangeResponse<TrocadorTradeNew>> createNewPaymentRateTrade({
- required bool isOnion,
required String? rateId,
required String fromTicker,
required String fromNetwork,
@@ -295,18 +277,13 @@ abstract class TrocadorAPI {
params["id"] = rateId;
}
- return await _getNewTrade(isOnion: isOnion, params: params);
+ return await _getNewTrade(params: params);
}
static Future<ExchangeResponse<TrocadorTradeNew>> _getNewTrade({
- required bool isOnion,
required Map<String, String> params,
}) async {
- final uri = _buildUri(
- isOnion: isOnion,
- method: "new_trade",
- params: params,
- );
+ final uri = _buildUri(method: "new_trade", params: params);
try {
final json = await _makeGetRequest(uri);
### lib/services/exchange/trocador/trocador_exchange.dart
@@ -69,7 +69,6 @@ class TrocadorExchange extends Exchange {
}) async {
final response = reversed
? await TrocadorAPI.createNewPaymentRateTrade(
- isOnion: false,
rateId: estimate?.rateId,
fromTicker: from.toLowerCase(),
fromNetwork: onlySupportedNetwork,
@@ -84,7 +83,6 @@ class TrocadorExchange extends Exchange {
isFixedRate: fixedRate,
)
: await TrocadorAPI.createNewStandardRateTrade(
- isOnion: false,
rateId: estimate?.rateId,
fromTicker: from.toLowerCase(),
fromNetwork: onlySupportedNetwork,
@@ -139,7 +137,7 @@ class TrocadorExchange extends Exchange {
Future<ExchangeResponse<List<Currency>>> getAllCurrencies(
bool fixedRate,
) async {
- _cachedCurrencies ??= (await TrocadorAPI.getCoins(isOnion: false)).value;
+ _cachedCurrencies ??= (await TrocadorAPI.getCoins()).value;
_cachedCurrencies?.removeWhere((e) => e.network != onlySupportedNetwork);
@@ -222,15 +220,13 @@ class TrocadorExchange extends Exchange {
) async {
final response = reversed
? await TrocadorAPI.getNewPaymentRate(
- isOnion: false,
fromTicker: from,
fromNetwork: onlySupportedNetwork,
toTicker: to,
toNetwork: onlySupportedNetwork,
toAmount: amount.toString(),
)
: await TrocadorAPI.getNewStandardRate(
- isOnion: false,
fromTicker: from,
fromNetwork: onlySupportedNetwork,
toTicker: to,
@@ -367,10 +363,7 @@ class TrocadorExchange extends Exchange {
@override
Future<ExchangeResponse<Trade>> updateTrade(Trade trade) async {
- final response = await TrocadorAPI.getTrade(
- isOnion: false,
- tradeId: trade.tradeId,
- );
+ final response = await TrocadorAPI.getTrade(tradeId: trade.tradeId);
if (response.value != null) {
final updated = response.value!;
### pubspec.lock
@@ -2135,10 +2135,10 @@ packages:
dependency: "direct main"
description:
name: socks5_proxy
- sha256: e0cba6917cd374de6f6cb0ce081e50e6efc24c61644b8e9f20c8bf8b91bb0b75
+ sha256: "80fa31a9ebfc0dc8de7b0e568c8d8927b65558ef2c7591cbee5afac814fb8f74"
url: "https://pub.dev"
source: hosted
- version: "1.0.3+dev.3"
+ version: "2.1.1"
socks_socket:
dependency: transitive
description:
### scripts/app_config/templates/pubspec.template.yaml
@@ -216,7 +216,7 @@ dependencies:
git:
url: https://github.com/cypherstack/tezart.git
ref: 84c563104f1a19c26e49bafccb7da404b210b666
- socks5_proxy: 1.0.3+dev.3
+ socks5_proxy: ^2.1.1
convert: ^3.1.1
flutter_hooks: ^0.20.3
meta: ^1.9.1
### test/networking/http_socks_proxy_test.dart
@@ -0,0 +1,82 @@
+import 'dart:io';
+import 'dart:typed_data';
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/networking/http.dart';
+
+// Minimal SOCKS5 server that records the CONNECT target and answers any
+// request with a canned HTTP 200.
+class _FakeSocksServer {
+ late final ServerSocket _server;
+ int? addressType;
+ String? target;
+ int? port;
+
+ int get listeningPort => _server.port;
+
+ Future<void> 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;
+ default:
+ socket.write(
+ 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok',
+ );
+ socket.close();
+ }
+ });
+ });
+ }
+
+ Future<void> stop() => _server.close();
+}
+
+void main() {
+ late _FakeSocksServer socks;
+
+ setUp(() async {
+ socks = _FakeSocksServer();
+ await socks.start();
+ });
+
+ tearDown(() => socks.stop());
+
+ Future<Response> get(String host) => const HTTP().get(
+ url: Uri.http(host, '/'),
+ proxyInfo: (host: InternetAddress.loopbackIPv4, port: socks.listeningPort),
+ );
+
+ test('proxied request sends the hostname to the SOCKS5 proxy', () async {
+ final response = await get('example.invalid');
+
+ expect(response.code, 200);
+ expect(socks.addressType, 0x03);
+ expect(socks.target, 'example.invalid');
+ expect(socks.port, 80);
+ });
+
+ test('proxied request can target an onion address', () async {
+ const onion =
+ 'trocadorfyhlu27aefre5u7zri66gudtzdyelymftvr4yjwcxhfaqsid.onion';
+ final response = await get(onion);
+
+ expect(response.code, 200);
+ expect(socks.addressType, 0x03);
+ expect(socks.target, onion);
+ });
+}Why this scored 39/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.