cyphergoat: use Decimal for money values, error instead of default on missing fields
What changed, and why it matters
This commit hardens how Stack Wallet's CypherGoat exchange integration handles API responses. It replaces imprecise floating-point math with exact decimal arithmetic for money amounts, and stops silently substituting default values (like 0 or empty strings) when expected fields are missing. Instead, the app now throws a clear error if required fields are absent or malformed. This reduces the risk of incorrect trade amounts being shown or accepted, and makes the app fail more safely if the exchange server sends an unexpected or malformed response.
Treat this as a defensive hardening change. Review whether upstream callers catch CgResponseFormatException gracefully to avoid user-facing crashes when the exchange API omits a field. Verify Decimal parsing handles all locale/serialization formats returned by CypherGoat. Consider regression testing trade amount display, minimum-amount enforcement, and transaction refresh flows. No urgent patch is required unless the previous double/default behavior was causing active incorrect trades.
Security signals we found
Switch from double to Decimal for monetary values, mitigating floating-point precision errors in exchange amount calculations
Replace silent default-value substitution with explicit required-field validation and CgResponseFormatException on missing/invalid fields
Nullable optional fields instead of empty-string defaults for fields like memo, cgid, kyc, token, source, affiliate, track
Removal of fallback-to-old-trade-value logic when updated transaction fields were empty, reducing stale/incorrect trade state
No explicit security framing, CVE, or vendor advisory in commit message or diff
Evidence from the diff
The patch refactors CypherGoat response parsing in lib/services/exchange/cyphergoat. It introduces Decimal from the decimal package for all monetary values (amounts, trade values, scores) instead of double, eliminating binary floating-point rounding errors in exchange calculations. A new cg_parse_utils.dart adds requireCgString/requireCgDecimal/requireCgInt/requireCgBool/optionalCgString helpers and a CgResponseFormatException. Previously many fields defaulted to 0.0/0/false/”” when missing; now required fields throw CgResponseFormatException, while optional fields become nullable. cg_estimate.dart and cg_transaction.dart are updated to use these helpers. cg_transaction.dart also makes several previously defaulted fields nullable (track, kyc, token, cgid, affiliate, memo, source) and removes fallback-to-previous-value logic in cyphergoat_exchange.dart, so updates use the actual returned values. The API layer now parses min as Decimal rather than double.
Changed components
lib/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.dartInspect captured patch +141 / −78
diff --git a/lib/services/exchange/cyphergoat/cyphergoat_api.dart b/lib/services/exchange/cyphergoat/cyphergoat_api.dart
index b421a6b..0c754f6 100644
--- a/lib/services/exchange/cyphergoat/cyphergoat_api.dart
+++ b/lib/services/exchange/cyphergoat/cyphergoat_api.dart
@@ -1,5 +1,7 @@
import 'dart:convert';
+import 'package:decimal/decimal.dart';
+
import '../../../app_config.dart';
import '../../../exceptions/exchange/exchange_exception.dart';
import '../../../external_api_keys.dart';
@@ -68,7 +70,7 @@ abstract class CypherGoatAPI {
/// GET /estimate
/// Returns all exchange provider estimates for the given pair and amount.
- static Future<ExchangeResponse<({CgEstimatesResponse rates, double min})>>
+ static Future<ExchangeResponse<({CgEstimatesResponse rates, Decimal min})>>
getEstimate({
required String coin1,
required String network1,
@@ -103,7 +105,9 @@ abstract class CypherGoatAPI {
final rates = CgEstimatesResponse.fromMap(
Map<String, dynamic>.from(ratesMap),
);
- final min = (map["min"] as num?)?.toDouble() ?? rates.min;
+ final min = map["min"] != null
+ ? Decimal.parse(map["min"].toString())
+ : rates.min;
return ExchangeResponse(value: (rates: rates, min: min));
} catch (e, s) {
diff --git a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
index 0764c2c..3b2e784 100644
--- a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
+++ b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
@@ -208,8 +208,8 @@ class CypherGoatExchange extends Exchange {
if (response.value != null) {
final liveMin = response.value!.min;
- if (liveMin > 0) {
- min = Decimal.parse(liveMin.toString());
+ if (liveMin > Decimal.zero) {
+ min = liveMin;
}
}
@@ -268,10 +268,10 @@ class CypherGoatExchange extends Exchange {
final estimateIdStr = data.rates.estimateId.toString();
final estimates = data.rates.results
- .where((r) => r.amount > 0)
+ .where((r) => r.amount > Decimal.zero)
.map(
(r) => Estimate(
- estimatedAmount: Decimal.parse(r.amount.toString()),
+ estimatedAmount: r.amount,
fixedRate: false,
reversed: false,
exchangeProvider: r.exchange,
@@ -361,7 +361,7 @@ class CypherGoatExchange extends Exchange {
return ExchangeResponse(
value: Trade(
uuid: const Uuid().v1(),
- tradeId: tx.cgid.isNotEmpty ? tx.cgid : tx.id,
+ tradeId: tx.cgid ?? tx.id,
rateType: "estimated",
direction: "direct",
timestamp: tx.createdAt,
@@ -370,7 +370,7 @@ class CypherGoatExchange extends Exchange {
payInAmount: tx.sendAmount.toString(),
payInAddress: tx.address,
payInNetwork: tx.network1,
- payInExtraId: tx.memo,
+ payInExtraId: tx.memo ?? "",
payInTxid: "",
payOutCurrency: tx.coin2.toUpperCase(),
payOutAmount: tx.estimateAmount.toString(),
@@ -410,7 +410,7 @@ class CypherGoatExchange extends Exchange {
return ExchangeResponse(
value: Trade(
uuid: const Uuid().v1(),
- tradeId: tx.cgid.isNotEmpty ? tx.cgid : tradeId,
+ tradeId: tx.cgid ?? tradeId,
rateType: "estimated",
direction: "direct",
timestamp: tx.createdAt,
@@ -419,7 +419,7 @@ class CypherGoatExchange extends Exchange {
payInAmount: tx.sendAmount.toString(),
payInAddress: tx.address,
payInNetwork: tx.network1,
- payInExtraId: tx.memo,
+ payInExtraId: tx.memo ?? "",
payInTxid: "",
payOutCurrency: tx.coin2.toUpperCase(),
payOutAmount: tx.estimateAmount.toString(),
@@ -471,32 +471,21 @@ class CypherGoatExchange extends Exchange {
direction: trade.direction,
timestamp: trade.timestamp,
updatedAt: DateTime.now(),
- payInCurrency: tx.coin1.isNotEmpty
- ? tx.coin1.toUpperCase()
- : trade.payInCurrency,
- payInAmount: tx.sendAmount > 0
- ? tx.sendAmount.toString()
- : trade.payInAmount,
- payInAddress:
- tx.address.isNotEmpty ? tx.address : trade.payInAddress,
+ payInCurrency: tx.coin1.toUpperCase(),
+ payInAmount: tx.sendAmount.toString(),
+ payInAddress: tx.address,
payInNetwork: trade.payInNetwork,
- payInExtraId: tx.memo.isNotEmpty ? tx.memo : trade.payInExtraId,
+ payInExtraId: tx.memo ?? trade.payInExtraId,
payInTxid: trade.payInTxid,
- payOutCurrency: tx.coin2.isNotEmpty
- ? tx.coin2.toUpperCase()
- : trade.payOutCurrency,
- payOutAmount: tx.estimateAmount > 0
- ? tx.estimateAmount.toString()
- : trade.payOutAmount,
- payOutAddress: tx.destinationAddress.isNotEmpty
- ? tx.destinationAddress
- : trade.payOutAddress,
+ payOutCurrency: tx.coin2.toUpperCase(),
+ payOutAmount: tx.estimateAmount.toString(),
+ payOutAddress: tx.destinationAddress,
payOutNetwork: trade.payOutNetwork,
payOutExtraId: trade.payOutExtraId,
payOutTxid: trade.payOutTxid,
refundAddress: trade.refundAddress,
refundExtraId: trade.refundExtraId,
- status: tx.status.isNotEmpty ? tx.status : trade.status,
+ status: tx.status,
exchangeName: exchangeName,
),
);
diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart b/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart
index 6eedb6b..ea9fc16 100644
--- a/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart
+++ b/lib/services/exchange/cyphergoat/response_objects/cg_estimate.dart
@@ -1,9 +1,13 @@
+import 'package:decimal/decimal.dart';
+
+import 'cg_parse_utils.dart';
+
class CgEstimateResult {
final String exchange;
- final double amount;
+ final Decimal amount;
final int kycScore;
final bool safeRouteOk;
- final double safeRouteScore;
+ final Decimal safeRouteScore;
CgEstimateResult({
required this.exchange,
@@ -15,20 +19,20 @@ class CgEstimateResult {
factory CgEstimateResult.fromMap(Map<String, dynamic> map) {
return CgEstimateResult(
- exchange: map["Exchange"] as String? ?? "",
- amount: (map["Amount"] as num?)?.toDouble() ?? 0.0,
- kycScore: (map["KYCScore"] as num?)?.toInt() ?? 0,
- safeRouteOk: map["SafeRouteOK"] as bool? ?? false,
- safeRouteScore: (map["SafeRouteScore"] as num?)?.toDouble() ?? 0.0,
+ exchange: requireCgString(map, "Exchange"),
+ amount: requireCgDecimal(map, "Amount"),
+ kycScore: requireCgInt(map, "KYCScore"),
+ safeRouteOk: requireCgBool(map, "SafeRouteOK"),
+ safeRouteScore: requireCgDecimal(map, "SafeRouteScore"),
);
}
}
class CgEstimatesResponse {
final List<CgEstimateResult> results;
- final double min;
- final double tradeValueFiat;
- final double tradeValueBtc;
+ final Decimal min;
+ final Decimal tradeValueFiat;
+ final Decimal tradeValueBtc;
final int estimateId;
CgEstimatesResponse({
@@ -40,15 +44,21 @@ class CgEstimatesResponse {
});
factory CgEstimatesResponse.fromMap(Map<String, dynamic> map) {
- final resultsRaw = map["Results"] as List<dynamic>? ?? [];
+ final resultsRaw = map["Results"];
+ if (resultsRaw is! List) {
+ throw CgResponseFormatException("Missing required field 'Results'");
+ }
return CgEstimatesResponse(
results: resultsRaw
- .map((e) => CgEstimateResult.fromMap(Map<String, dynamic>.from(e as Map)))
+ .map(
+ (e) =>
+ CgEstimateResult.fromMap(Map<String, dynamic>.from(e as Map)),
+ )
.toList(),
- min: (map["Min"] as num?)?.toDouble() ?? 0.0,
- tradeValueFiat: (map["TradeValue_fiat"] as num?)?.toDouble() ?? 0.0,
- tradeValueBtc: (map["TradeValue_btc"] as num?)?.toDouble() ?? 0.0,
- estimateId: (map["EstimateId"] as num?)?.toInt() ?? 0,
+ min: requireCgDecimal(map, "Min"),
+ tradeValueFiat: requireCgDecimal(map, "TradeValue_fiat"),
+ tradeValueBtc: requireCgDecimal(map, "TradeValue_btc"),
+ estimateId: requireCgInt(map, "EstimateId"),
);
}
}
diff --git a/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart b/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart
new file mode 100644
index 0000000..8df72ec
--- /dev/null
+++ b/lib/services/exchange/cyphergoat/response_objects/cg_parse_utils.dart
@@ -0,0 +1,57 @@
+import 'package:decimal/decimal.dart';
+
+/// Thrown when a CypherGoat API response is missing a field the client
+/// treats as mandatory, instead of silently substituting a default value.
+class CgResponseFormatException implements Exception {
+ final String message;
+ CgResponseFormatException(this.message);
+
+ @override
+ String toString() => "CgResponseFormatException: $message";
+}
+
+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'",
+ );
+ }
+ return v;
+}
+
+String? optionalCgString(Map<String, dynamic> map, String key) {
+ final v = map[key];
+ if (v is String && v.isNotEmpty) return v;
+ return null;
+}
+
+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'",
+ );
+ }
+ return Decimal.parse(v.toString());
+}
+
+int requireCgInt(Map<String, dynamic> map, String key) {
+ final v = map[key];
+ if (v is! num) {
+ throw CgResponseFormatException(
+ "Missing required numeric field '$key'",
+ );
+ }
+ return v.toInt();
+}
+
+bool requireCgBool(Map<String, dynamic> map, String key) {
+ final v = map[key];
+ if (v is! bool) {
+ 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 e82817d..df38456 100644
--- a/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart
+++ b/lib/services/exchange/cyphergoat/response_objects/cg_transaction.dart
@@ -1,23 +1,27 @@
+import 'package:decimal/decimal.dart';
+
+import 'cg_parse_utils.dart';
+
class CgTransaction {
final String coin1;
final String coin2;
final String network1;
final String network2;
final String address;
- final double estimateAmount;
+ final Decimal estimateAmount;
final String provider;
final String id;
- final double sendAmount;
- final String track;
+ final Decimal sendAmount;
+ final String? track;
final String status;
- final String kyc;
- final String token;
+ final String? kyc;
+ final String? token;
final bool done;
- final String cgid;
+ final String? cgid;
final DateTime createdAt;
- final String affiliate;
- final String memo;
- final String source;
+ final String? affiliate;
+ final String? memo;
+ final String? source;
final String destinationAddress;
final bool payment;
final DateTime? completedAt;
@@ -51,8 +55,7 @@ class CgTransaction {
// Go's zero time ("0001-01-01T00:00:00Z") is returned when the field isn't
// set yet; treat it as now rather than storing year 1.
- static DateTime _parseDate(String? s) {
- if (s == null) return DateTime.now();
+ static DateTime _parseDate(String s) {
final dt = DateTime.tryParse(s);
if (dt == null || dt.year <= 1) return DateTime.now();
return dt;
@@ -60,31 +63,31 @@ class CgTransaction {
factory CgTransaction.fromMap(Map<String, dynamic> map) {
return CgTransaction(
- coin1: map["Coin1"] as String? ?? "",
- coin2: map["Coin2"] as String? ?? "",
- network1: map["Network1"] as String? ?? "",
- network2: map["Network2"] as String? ?? "",
- address: map["Address"] as String? ?? "",
- estimateAmount: (map["EstimateAmount"] as num?)?.toDouble() ?? 0.0,
- provider: map["Provider"] as String? ?? "",
- id: map["Id"] as String? ?? "",
- sendAmount: (map["SendAmount"] as num?)?.toDouble() ?? 0.0,
- track: map["Track"] as String? ?? "",
- status: map["Status"] as String? ?? "waiting",
- kyc: map["KYC"] as String? ?? "",
- token: map["Token"] as String? ?? "",
- done: map["Done"] as bool? ?? false,
- cgid: map["CGID"] as String? ?? "",
- createdAt: _parseDate(map["CreatedAt"] as String?),
- affiliate: map["Affiliate"] as String? ?? "",
- memo: map["Memo"] as String? ?? "",
- source: map["Source"] as String? ?? "",
- destinationAddress: map["DestinationAddress"] as String? ?? "",
- payment: map["Payment"] as bool? ?? false,
+ coin1: requireCgString(map, "Coin1"),
+ coin2: requireCgString(map, "Coin2"),
+ network1: requireCgString(map, "Network1"),
+ network2: requireCgString(map, "Network2"),
+ address: requireCgString(map, "Address"),
+ estimateAmount: requireCgDecimal(map, "EstimateAmount"),
+ provider: requireCgString(map, "Provider"),
+ id: requireCgString(map, "Id"),
+ sendAmount: requireCgDecimal(map, "SendAmount"),
+ track: optionalCgString(map, "Track"),
+ status: requireCgString(map, "Status"),
+ kyc: optionalCgString(map, "KYC"),
+ token: optionalCgString(map, "Token"),
+ done: requireCgBool(map, "Done"),
+ cgid: optionalCgString(map, "CGID"),
+ createdAt: _parseDate(requireCgString(map, "CreatedAt")),
+ affiliate: optionalCgString(map, "Affiliate"),
+ memo: optionalCgString(map, "Memo"),
+ source: optionalCgString(map, "Source"),
+ destinationAddress: requireCgString(map, "DestinationAddress"),
+ payment: requireCgBool(map, "Payment"),
completedAt: map["CompletedAt"] != null
? DateTime.tryParse(map["CompletedAt"] as String)
: null,
- estimateId: (map["EstimateId"] as num?)?.toInt() ?? 0,
+ estimateId: requireCgInt(map, "EstimateId"),
);
}
}
Why this scored 44/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.