What changed, and why it matters
This commit makes several small adjustments to the CypherGoat exchange integration in Stack Wallet. It adds a new 'other' field to trade records to store a tracking URL, makes some CypherGoat API response fields optional instead of required, filters out some competing exchange providers from CypherGoat rate quotes, and fixes a minor error-message formatting bug. There is no clear security vulnerability being patched, and the commit message does not describe any security relevance.
No immediate security action is required. As a defensive review, consider validating the `Trade.other` URL before opening it in an external browser, ensuring it is an HTTPS cyphergoat/app URL to reduce phishing risk. Also confirm that relaxing `Status` and `SafeRoute*` parsing does not hide malformed API responses that should fail closed.
Security signals we found
Error-message handling change: `json['error']` cast to String replaced with `.toString()` to avoid cast exceptions when the error field is not a string.
Response parsing relaxed: previously required boolean and decimal fields (`SafeRouteOK`, `SafeRouteScore`) now treated as optional with safe parsing.
Response parsing relaxed: `Status` field in transaction response now optional with default 'waiting'.
New `Trade.other` field used as external URL in UI; value comes from CypherGoat API `Track` field. No visible URL validation or origin check in diff.
Provider filtering excludes three named exchange providers from CypherGoat aggregated quotes.
Evidence from the diff
The diff is a feature/maintenance patch for the CypherGoat exchange provider. Key changes: (1) adds other nullable String field to Trade model, Hive adapter, copyWith, toMap/fromMap; (2) stores CypherGoat tx.track URL in Trade.other and uses it in TradeDetailsView to open the provider’s checkout page; (3) makes CgEstimateResult.safeRouteOk and safeRouteScore nullable/optional and parses them safely; (4) makes CgTransaction.status optional, defaulting to ‘waiting’; (5) filters CypherGoat estimates to exclude quotes whose provider is changenow, letsexchange, or exolix; (6) fixes json['error'] cast by calling .toString(); (7) formatting and import changes. No explicit security fix or vulnerability disclosure is present.
Changed components
lib/models/exchange/response_objects/trade.dartlib/models/exchange/response_objects/trade.g.dartlib/models/isar/exchange_cache/currency.dartlib/pages/exchange_view/trade_details_view.dartlib/services/exchange/cyphergoat/cyphergoat_api.dartlib/services/exchange/cyphergoat/cyphergoat_exchange.dartlib/services/exchange/cyphergoat/response_objects/cg_estimate.dartlib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dartlib/services/exchange/cyphergoat/response_objects/cg_transaction.dartlib/services/exchange/exchange_data_loading_service.dartInspect captured patch +96 / −45
diff --git a/lib/models/exchange/response_objects/trade.dart b/lib/models/exchange/response_objects/trade.dart
index 1669b94..a00d531 100644
--- a/lib/models/exchange/response_objects/trade.dart
+++ b/lib/models/exchange/response_objects/trade.dart
@@ -86,6 +86,9 @@ class Trade {
@HiveField(21)
final String exchangeName;
+ @HiveField(22)
+ final String? other;
+
const Trade({
required this.uuid,
required this.tradeId,
@@ -109,6 +112,7 @@ class Trade {
required this.refundExtraId,
required this.status,
required this.exchangeName,
+ this.other,
});
Trade copyWith({
@@ -133,6 +137,7 @@ class Trade {
String? refundExtraId,
String? status,
String? exchangeName,
+ String? other,
}) {
return Trade(
uuid: uuid,
@@ -157,6 +162,7 @@ class Trade {
refundExtraId: refundExtraId ?? this.refundExtraId,
status: status ?? this.status,
exchangeName: exchangeName ?? this.exchangeName,
+ other: other ?? this.other,
);
}
@@ -184,6 +190,7 @@ class Trade {
"refundExtraId": refundExtraId,
"status": status,
"exchangeName": exchangeName,
+ if (other != null) "other": other!,
};
}
@@ -211,6 +218,7 @@ class Trade {
refundExtraId: map["refundExtraId"] as String,
status: map["status"] as String,
exchangeName: map["exchangeName"] as String,
+ other: map["other"] as String?,
);
}
diff --git a/lib/models/exchange/response_objects/trade.g.dart b/lib/models/exchange/response_objects/trade.g.dart
index c0c54c4..4bee556 100644
--- a/lib/models/exchange/response_objects/trade.g.dart
+++ b/lib/models/exchange/response_objects/trade.g.dart
@@ -39,13 +39,14 @@ class TradeAdapter extends TypeAdapter<Trade> {
refundExtraId: fields[19] as String,
status: fields[20] as String,
exchangeName: fields[21] as String,
+ other: fields[22] as String?,
);
}
@override
void write(BinaryWriter writer, Trade obj) {
writer
- ..writeByte(22)
+ ..writeByte(23)
..writeByte(0)
..write(obj.uuid)
..writeByte(1)
@@ -89,7 +90,9 @@ class TradeAdapter extends TypeAdapter<Trade> {
..writeByte(20)
..write(obj.status)
..writeByte(21)
- ..write(obj.exchangeName);
+ ..write(obj.exchangeName)
+ ..writeByte(22)
+ ..write(obj.other);
}
@override
diff --git a/lib/models/isar/exchange_cache/currency.dart b/lib/models/isar/exchange_cache/currency.dart
index 9036385..414deff 100644
--- a/lib/models/isar/exchange_cache/currency.dart
+++ b/lib/models/isar/exchange_cache/currency.dart
@@ -12,6 +12,7 @@ import 'package:isar_community/isar.dart';
import '../../../app_config.dart';
import '../../../services/exchange/change_now/change_now_exchange.dart';
+import '../../../services/exchange/cyphergoat/cyphergoat_exchange.dart';
import '../../../services/exchange/exchange.dart';
import '../../../services/exchange/exolix/exolix_exchange.dart';
import '../../../services/exchange/lets_exchange/lets_exchange_exchange.dart';
@@ -104,6 +105,9 @@ class Currency {
const (LetsExchangeExchange) => network.toLowerCase(),
+ const (CypherGoatExchange) =>
+ network.isNotEmpty ? network.toLowerCase() : ticker.toLowerCase(),
+
_ => throw Exception("Unknown exchange: $exchangeName"),
};
}
diff --git a/lib/pages/exchange_view/trade_details_view.dart b/lib/pages/exchange_view/trade_details_view.dart
index b8aef6d..b5799e0 100644
--- a/lib/pages/exchange_view/trade_details_view.dart
+++ b/lib/pages/exchange_view/trade_details_view.dart
@@ -28,6 +28,7 @@ import '../../providers/global/trades_service_provider.dart';
import '../../providers/providers.dart';
import '../../route_generator.dart';
import '../../services/exchange/change_now/change_now_exchange.dart';
+import '../../services/exchange/cyphergoat/cyphergoat_exchange.dart';
import '../../services/exchange/exchange.dart';
import '../../services/exchange/exolix/exolix_exchange.dart';
import '../../services/exchange/lets_exchange/lets_exchange_exchange.dart';
@@ -1185,6 +1186,10 @@ class _TradeDetailsViewState extends ConsumerState<TradeDetailsView> {
)) {
url =
"https://trocador.app/en/checkout/${trade.tradeId}";
+ } else if (trade.exchangeName.startsWith(
+ CypherGoatExchange.exchangeName,
+ )) {
+ url = trade.other ?? "error";
}
}
return ConditionalParent(
diff --git a/lib/services/exchange/cyphergoat/cyphergoat_api.dart b/lib/services/exchange/cyphergoat/cyphergoat_api.dart
index 0c754f6..5f11225 100644
--- a/lib/services/exchange/cyphergoat/cyphergoat_api.dart
+++ b/lib/services/exchange/cyphergoat/cyphergoat_api.dart
@@ -20,10 +20,7 @@ abstract class CypherGoatAPI {
static const HTTP _client = HTTP();
- static Uri _buildUri({
- required String path,
- Map<String, String>? params,
- }) {
+ static Uri _buildUri({required String path, Map<String, String>? params}) {
return Uri.https(authority, path, params);
}
@@ -53,7 +50,7 @@ abstract class CypherGoatAPI {
final json = jsonDecode(response.body);
if (code != 200) {
- final errMsg = (json is Map ? json["error"] : null) as String?;
+ final errMsg = (json is Map ? json["error"].toString() : null);
throw Exception(errMsg ?? "HTTP $code: ${response.body}");
}
diff --git a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
index 3b2e784..0189908 100644
--- a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
+++ b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
@@ -29,7 +29,12 @@ class _CgCoin {
// Static coin list derived from CypherGoat's coins.json.
const List<_CgCoin> _kCgCoins = [
_CgCoin(ticker: 'btc', name: 'Bitcoin', network: 'btc', min: 4.449e-05),
- _CgCoin(ticker: 'btc', name: 'Bitcoin (Lightning)', network: 'lightning', min: 4.449e-05),
+ _CgCoin(
+ ticker: 'btc',
+ name: 'Bitcoin (Lightning)',
+ network: 'lightning',
+ min: 4.449e-05,
+ ),
_CgCoin(ticker: 'eth', name: 'Ethereum', network: 'eth', min: 0.001114),
_CgCoin(ticker: 'xmr', name: 'Monero', network: 'xmr', min: 0.01886),
_CgCoin(ticker: 'ltc', name: 'Litecoin', network: 'ltc', min: 0.04444),
@@ -74,18 +79,33 @@ const List<_CgCoin> _kCgCoins = [
_CgCoin(ticker: 'wow', name: 'Wownero', network: 'wow', min: 163.8),
_CgCoin(ticker: 'ban', name: 'Banano', network: 'banano', min: 2614.0),
_CgCoin(ticker: 'arrr', name: 'Pirate Chain', network: 'arrr', min: 4.8),
- _CgCoin(ticker: 'arrrbsc', name: 'Pirate Chain (BSC)', network: 'arrrbsc', min: 4.8),
+ _CgCoin(
+ ticker: 'arrrbsc',
+ name: 'Pirate Chain (BSC)',
+ network: 'arrrbsc',
+ min: 4.8,
+ ),
_CgCoin(ticker: 'dcr', name: 'Decred', network: 'dcr', min: 0.3045),
_CgCoin(ticker: 'aave', name: 'Aave', network: 'aave', min: 0.01574),
_CgCoin(ticker: 'avax', name: 'Avalanche', network: 'avax', min: 0.4263),
- _CgCoin(ticker: 'bat', name: 'Basic Attention Token', network: 'bat', min: 32.09),
+ _CgCoin(
+ ticker: 'bat',
+ name: 'Basic Attention Token',
+ network: 'bat',
+ min: 32.09,
+ ),
_CgCoin(ticker: 'link', name: 'Chainlink (BSC)', network: 'bsc', min: 0.2014),
_CgCoin(ticker: 'gusd', name: 'Gemini Dollar', network: 'gusd'),
_CgCoin(ticker: 'paxg', name: 'Paxos Gold', network: 'paxg', min: 0.002),
_CgCoin(ticker: 'hbar', name: 'Hedera', network: 'hbar', min: 12),
_CgCoin(ticker: 'ark', name: 'Ark', network: 'ark', min: 10.96),
_CgCoin(ticker: 'firo', name: 'Firo', network: 'firo', min: 14.24),
- _CgCoin(ticker: 'wbtc', name: 'Wrapped Bitcoin', network: 'wbtc', min: 4.444e-05),
+ _CgCoin(
+ ticker: 'wbtc',
+ name: 'Wrapped Bitcoin',
+ network: 'wbtc',
+ min: 4.444e-05,
+ ),
_CgCoin(ticker: '1inch', name: '1inch', network: '1inch', min: 19.87),
_CgCoin(ticker: 'dash', name: 'Dash', network: 'dash', min: 0.2152),
_CgCoin(ticker: 'zano', name: 'Zano', network: 'zano', min: 0.3358),
@@ -99,13 +119,28 @@ const List<_CgCoin> _kCgCoins = [
_CgCoin(ticker: 'tslax', name: 'TSLA xStock', network: 'tslax', min: 0.4),
_CgCoin(ticker: 'qqqx', name: 'Nasdaq xStock', network: 'qqqx', min: 0.3),
_CgCoin(ticker: 'crclx', name: 'Circle xStock', network: 'crclx', min: 1.3),
- _CgCoin(ticker: 'mstrx', name: 'MicroStrategy xStock', network: 'mstrx', min: 0.4),
+ _CgCoin(
+ ticker: 'mstrx',
+ name: 'MicroStrategy xStock',
+ network: 'mstrx',
+ min: 0.4,
+ ),
_CgCoin(ticker: 'aaplx', name: 'Apple xStock', network: 'aaplx', min: 0.6),
_CgCoin(ticker: 'coinx', name: 'Coinbase xStock', network: 'coinx', min: 0.5),
- _CgCoin(ticker: 'googlx', name: 'Alphabet xStock', network: 'googlx', min: 0.7),
+ _CgCoin(
+ ticker: 'googlx',
+ name: 'Alphabet xStock',
+ network: 'googlx',
+ min: 0.7,
+ ),
_CgCoin(ticker: 'amznx', name: 'Amazon xStock', network: 'amznx', min: 0.6),
_CgCoin(ticker: 'metax', name: 'Meta xStock', network: 'metax', min: 0.2),
- _CgCoin(ticker: 'hoodx', name: 'Robinhood xStock', network: 'hoodx', min: 1.3),
+ _CgCoin(
+ ticker: 'hoodx',
+ name: 'Robinhood xStock',
+ network: 'hoodx',
+ min: 1.3,
+ ),
_CgCoin(ticker: 'gmex', name: 'Gamestop xStock', network: 'gmex', min: 5),
];
@@ -267,22 +302,27 @@ class CypherGoatExchange extends Exchange {
final data = response.value!;
final estimateIdStr = data.rates.estimateId.toString();
- final estimates = data.rates.results
- .where((r) => r.amount > Decimal.zero)
- .map(
- (r) => Estimate(
- estimatedAmount: r.amount,
+ final List<Estimate> estimates = [];
+ for (final quote in response.value!.rates.results) {
+ final provider = quote.exchange.toLowerCase();
+ if (provider != "changenow" &&
+ provider != "letsexchange" &&
+ provider != "exolix") {
+ estimates.add(
+ Estimate(
+ estimatedAmount: quote.amount,
fixedRate: false,
reversed: false,
- exchangeProvider: r.exchange,
+ exchangeProvider: quote.exchange,
rateId: estimateIdStr,
+ // exchangeProviderLogo: quote.providerLogo,
+ // kycRating: quote.kycRating,
),
- )
- .toList();
+ );
+ }
+ }
- estimates.sort(
- (a, b) => b.estimatedAmount.compareTo(a.estimatedAmount),
- );
+ estimates.sort((a, b) => b.estimatedAmount.compareTo(a.estimatedAmount));
if (estimates.isEmpty) {
return ExchangeResponse(
@@ -382,6 +422,7 @@ class CypherGoatExchange extends Exchange {
refundExtraId: "",
status: tx.status,
exchangeName: exchangeName,
+ other: tx.track,
),
);
} on ExchangeException catch (e) {
@@ -431,6 +472,7 @@ class CypherGoatExchange extends Exchange {
refundExtraId: "",
status: tx.status,
exchangeName: exchangeName,
+ other: tx.track,
),
);
} on ExchangeException catch (e) {
@@ -487,6 +529,7 @@ class CypherGoatExchange extends Exchange {
refundExtraId: trade.refundExtraId,
status: tx.status,
exchangeName: exchangeName,
+ other: tx.track,
),
);
} on ExchangeException catch (e) {
diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart b/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart
index ea9fc16..74efacc 100644
--- a/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart
+++ b/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart
@@ -6,8 +6,8 @@ class CgEstimateResult {
final String exchange;
final Decimal amount;
final int kycScore;
- final bool safeRouteOk;
- final Decimal safeRouteScore;
+ final bool? safeRouteOk;
+ final Decimal? safeRouteScore;
CgEstimateResult({
required this.exchange,
@@ -22,8 +22,8 @@ class CgEstimateResult {
exchange: requireCgString(map, "Exchange"),
amount: requireCgDecimal(map, "Amount"),
kycScore: requireCgInt(map, "KYCScore"),
- safeRouteOk: requireCgBool(map, "SafeRouteOK"),
- safeRouteScore: requireCgDecimal(map, "SafeRouteScore"),
+ safeRouteOk: map["SafeRouteOK"] as bool?,
+ safeRouteScore: Decimal.tryParse(map["SafeRouteScore"].toString()),
);
}
}
diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart b/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart
index 8df72ec..68f69c8 100644
--- a/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart
+++ b/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart
@@ -13,9 +13,7 @@ class CgResponseFormatException implements Exception {
String requireCgString(Map<String, dynamic> map, String key) {
final v = map[key];
if (v is! String || v.isEmpty) {
- throw CgResponseFormatException(
- "Missing or empty required field '$key'",
- );
+ throw CgResponseFormatException("Missing or empty required field '$key'");
}
return v;
}
@@ -29,9 +27,7 @@ String? optionalCgString(Map<String, dynamic> map, String key) {
Decimal requireCgDecimal(Map<String, dynamic> map, String key) {
final v = map[key];
if (v is! num && v is! String) {
- throw CgResponseFormatException(
- "Missing required numeric field '$key'",
- );
+ throw CgResponseFormatException("Missing required numeric field '$key'");
}
return Decimal.parse(v.toString());
}
@@ -39,9 +35,7 @@ Decimal requireCgDecimal(Map<String, dynamic> map, String key) {
int requireCgInt(Map<String, dynamic> map, String key) {
final v = map[key];
if (v is! num) {
- throw CgResponseFormatException(
- "Missing required numeric field '$key'",
- );
+ throw CgResponseFormatException("Missing required numeric field '$key'");
}
return v.toInt();
}
@@ -49,9 +43,7 @@ int requireCgInt(Map<String, dynamic> map, String key) {
bool requireCgBool(Map<String, dynamic> map, String key) {
final v = map[key];
if (v is! bool) {
- throw CgResponseFormatException(
- "Missing required boolean field '$key'",
- );
+ throw CgResponseFormatException("Missing required boolean field '$key'");
}
return v;
}
diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart b/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart
index df38456..b46802f 100644
--- a/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart
+++ b/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart
@@ -62,6 +62,7 @@ class CgTransaction {
}
factory CgTransaction.fromMap(Map<String, dynamic> map) {
+ print(map);
return CgTransaction(
coin1: requireCgString(map, "Coin1"),
coin2: requireCgString(map, "Coin2"),
@@ -73,7 +74,7 @@ class CgTransaction {
id: requireCgString(map, "Id"),
sendAmount: requireCgDecimal(map, "SendAmount"),
track: optionalCgString(map, "Track"),
- status: requireCgString(map, "Status"),
+ status: optionalCgString(map, "Status") ?? "waiting",
kyc: optionalCgString(map, "KYC"),
token: optionalCgString(map, "Token"),
done: requireCgBool(map, "Done"),
diff --git a/lib/services/exchange/exchange_data_loading_service.dart b/lib/services/exchange/exchange_data_loading_service.dart
index 6b10a22..549074d 100644
--- a/lib/services/exchange/exchange_data_loading_service.dart
+++ b/lib/services/exchange/exchange_data_loading_service.dart
@@ -394,9 +394,7 @@ class ExchangeDataLoadingService {
await (await isar).currencies.putAll(responseCurrencies.value!);
});
} else {
- Logging.instance.w(
- "loadCypherGoatCurrencies: $responseCurrencies",
- );
+ Logging.instance.w("loadCypherGoatCurrencies: $responseCurrencies");
}
}
Why this scored 22/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.