Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 106 additions & 97 deletions lib/core/exchange/data/datasources/bullbitcoin_api_datasource.dart
Original file line number Diff line number Diff line change
Expand Up @@ -171,29 +171,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
final error = resp.data['error'];
if (statusCode != 200) throw Exception('Failed to create order');
if (error != null) {
final reason = error['data']['reason'];
if (reason != null) {
final limitReason = reason['limit'];
if (limitReason != null) {
final isBelowLimit =
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
final limitAmount = limitReason['amount'] as String;
final limitCurrency = limitReason['currencyCode'] as String;
if (isBelowLimit) {
throw BullBitcoinApiMinAmountException(
minAmount: double.parse(limitAmount),
currency: limitCurrency,
);
} else {
throw BullBitcoinApiMaxAmountException(
maxAmount: double.parse(limitAmount),
currency: limitCurrency,
);
}
}
} else {
throw Exception('Failed to create buy order: ${error['message']}');
}
_throwOrderApiError(error, 'Failed to create buy order');
}
return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
}
Expand Down Expand Up @@ -357,29 +335,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
final error = resp.data['error'];
if (statusCode != 200) throw Exception('Failed to create sell order');
if (error != null) {
final reason = error['data']['reason'];
if (reason != null) {
final limitReason = reason['limit'];
if (limitReason != null) {
final isBelowLimit =
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
final limitAmount = limitReason['amount'] as String;
final limitCurrency = limitReason['currencyCode'] as String;
if (isBelowLimit) {
throw BullBitcoinApiMinAmountException(
minAmount: double.parse(limitAmount),
currency: limitCurrency,
);
} else {
throw BullBitcoinApiMaxAmountException(
maxAmount: double.parse(limitAmount),
currency: limitCurrency,
);
}
}
} else {
throw Exception('Failed to create sell order: ${error['message']}');
}
_throwOrderApiError(error, 'Failed to create sell order');
}
return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
}
Expand Down Expand Up @@ -424,31 +380,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
throw Exception('Failed to create sell to recipient order');
}
if (error != null) {
final reason = error['data']['reason'];
if (reason != null) {
final limitReason = reason['limit'];
if (limitReason != null) {
final isBelowLimit =
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
final limitAmount = limitReason['amount'] as String;
final limitCurrency = limitReason['currencyCode'] as String;
if (isBelowLimit) {
throw BullBitcoinApiMinAmountException(
minAmount: double.parse(limitAmount),
currency: limitCurrency,
);
} else {
throw BullBitcoinApiMaxAmountException(
maxAmount: double.parse(limitAmount),
currency: limitCurrency,
);
}
}
} else {
throw Exception(
'Failed to create sell to recipient order: ${error['message']}',
);
}
_throwOrderApiError(error, 'Failed to create sell to recipient order');
}

return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
Expand Down Expand Up @@ -492,32 +424,7 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
final error = resp.data['error'];
if (statusCode != 200) throw Exception('Failed to create withdrawal order');
if (error != null) {
final reason = error['data']['reason'];
if (reason != null) {
final limitReason = reason['limit'];
if (limitReason != null) {
final isBelowLimit =
limitReason['conditionalOperator'] == 'GREATER_THAN_OR_EQUAL';
final limitAmount = limitReason['amount'] as String;
final limitCurrency = limitReason['currencyCode'] as String;
if (isBelowLimit) {
throw BullBitcoinApiMinAmountException(
minAmount: double.parse(limitAmount),
currency: limitCurrency,
);
} else {
throw BullBitcoinApiMaxAmountException(
maxAmount: double.parse(limitAmount),
currency: limitCurrency,
);
}
}
throw Exception('Failed to create withdrawal order: $reason');
} else {
throw Exception(
'Failed to create withdrawal order: ${error['message']}',
);
}
_throwOrderApiError(error, 'Failed to create withdrawal order');
}
return OrderModel.fromJson(resp.data['result'] as Map<String, dynamic>);
}
Expand Down Expand Up @@ -882,6 +789,108 @@ class BullbitcoinApiDatasource implements BitcoinPriceDatasource {
}
}

const _minLimitOperator = 'GREATER_THAN_OR_EQUAL';
const _maxLimitOperator = 'LESS_THAN_OR_EQUAL';

/// A single limit that the api rejected an order against.
class _OrderLimit {
final double amount;
final String currency;
final String conditionalOperator;

const _OrderLimit({
required this.amount,
required this.currency,
required this.conditionalOperator,
});

/// Returns null for anything that isn't a limit we can act on, so a payload
/// change on the api side degrades to the generic error instead of throwing
/// a cast error.
static _OrderLimit? tryParse(dynamic json) {
if (json is! Map) return null;
final rawAmount = json['amount'];
final amount = switch (rawAmount) {
final num n => n.toDouble(),
final String s => double.tryParse(s),
_ => null,
};
final currency = json['currencyCode'];
final conditionalOperator = json['conditionalOperator'];
if (amount == null ||
currency is! String ||
conditionalOperator is! String) {
return null;
}
return _OrderLimit(
amount: amount,
currency: currency,
conditionalOperator: conditionalOperator,
);
}
}

/// Translates a JSON-RPC `error` object from the orders api into a typed
/// exception. Always throws: an error response must never fall through to
/// parsing `result`.
///
/// Two payload shapes are supported. A single rejected payment option carries
/// `data.reason.limit`; when every payment option was rejected the aggregate
/// error carries one structured reason per rejection in `data.reasons`.
///
/// `reasons` is legitimately empty for rejections that produce no structured
/// reason (group-access denial, non-positive amounts), so an empty or absent
/// array has to reach the generic error rather than be treated as a bug. The
/// aggregate also carries a legacy `data.details`, always an array of nulls;
/// it is deliberately never read.
Never _throwOrderApiError(dynamic error, String contextMessage) {
final errorMap = error is Map ? error : const <dynamic, dynamic>{};
final data = errorMap['data'];
final dataMap = data is Map ? data : const <dynamic, dynamic>{};

final limits = <_OrderLimit>[];
final reasons = dataMap['reasons'];
if (reasons is List) {
for (final reason in reasons) {
final limit = _OrderLimit.tryParse(
reason is Map ? reason['limit'] : null,
);
if (limit != null) limits.add(limit);
}
}
final singleReason = dataMap['reason'];
final singleLimit = _OrderLimit.tryParse(
singleReason is Map ? singleReason['limit'] : null,
);
if (singleLimit != null) limits.add(singleLimit);

final operators = limits.map((l) => l.conditionalOperator).toSet();
// Amounts are only comparable within one currency, and a single message can
// only name one limit, so anything mixed falls back to the generic error.
final currencies = limits.map((l) => l.currency).toSet();
if (operators.length == 1 && currencies.length == 1) {
switch (operators.single) {
case _minLimitOperator:
// Every option was below its minimum, so the lowest minimum is the
// amount that unlocks at least one of them.
final min = limits.reduce((a, b) => a.amount <= b.amount ? a : b);
throw BullBitcoinApiMinAmountException(
minAmount: min.amount,
currency: min.currency,
);
case _maxLimitOperator:
final max = limits.reduce((a, b) => a.amount >= b.amount ? a : b);
throw BullBitcoinApiMaxAmountException(
maxAmount: max.amount,
currency: max.currency,
);
}
}

final message = errorMap['message'];
throw Exception('$contextMessage${message is String ? ': $message' : ''}');
}

class BullBitcoinApiMinAmountException extends BullException {
final double minAmount;
final String currency;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import 'package:bb_mobile/core/exchange/domain/errors/pay_error.dart';
import 'package:bb_mobile/core/exchange/domain/errors/sell_error.dart';
import 'package:bb_mobile/core/exchange/domain/errors/withdraw_error.dart';
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_order_repository.dart';
import 'package:bb_mobile/core/utils/amount_conversions.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/features/dca/domain/dca.dart';

Expand Down Expand Up @@ -183,13 +182,15 @@ class ExchangeOrderRepositoryImpl implements ExchangeOrderRepository {

return order;
} on BullBitcoinApiMinAmountException catch (e) {
final minAmountBtc = e.minAmount;
final minAmountSat = ConvertAmount.btcToSats(minAmountBtc);
throw BuyError.belowMinAmount(minAmountSat: minAmountSat);
throw BuyError.belowMinAmount(
minAmount: e.minAmount,
currency: e.currency,
);
} on BullBitcoinApiMaxAmountException catch (e) {
final maxAmountBtc = e.maxAmount;
final maxAmountSat = ConvertAmount.btcToSats(maxAmountBtc);
throw BuyError.aboveMaxAmount(maxAmountSat: maxAmountSat);
throw BuyError.aboveMaxAmount(
maxAmount: e.maxAmount,
currency: e.currency,
);
} catch (e) {
throw Exception('Failed to place buy order: $e');
}
Expand Down Expand Up @@ -221,13 +222,15 @@ class ExchangeOrderRepositoryImpl implements ExchangeOrderRepository {

return order;
} on BullBitcoinApiMinAmountException catch (e) {
final minAmountBtc = e.minAmount;
final minAmountSat = ConvertAmount.btcToSats(minAmountBtc);
throw SellError.belowMinAmount(minAmountSat: minAmountSat);
throw SellError.belowMinAmount(
minAmount: e.minAmount,
currency: e.currency,
);
} on BullBitcoinApiMaxAmountException catch (e) {
final maxAmountBtc = e.maxAmount;
final maxAmountSat = ConvertAmount.btcToSats(maxAmountBtc);
throw SellError.aboveMaxAmount(maxAmountSat: maxAmountSat);
throw SellError.aboveMaxAmount(
maxAmount: e.maxAmount,
currency: e.currency,
);
} catch (e) {
throw Exception('Failed to place sell order: $e');
}
Expand Down Expand Up @@ -262,13 +265,15 @@ class ExchangeOrderRepositoryImpl implements ExchangeOrderRepository {

return order;
} on BullBitcoinApiMinAmountException catch (e) {
final minAmountBtc = e.minAmount;
final minAmountSat = ConvertAmount.btcToSats(minAmountBtc);
throw PayError.belowMinAmount(minAmountSat: minAmountSat);
throw PayError.belowMinAmount(
minAmount: e.minAmount,
currency: e.currency,
);
} on BullBitcoinApiMaxAmountException catch (e) {
final maxAmountBtc = e.maxAmount;
final maxAmountSat = ConvertAmount.btcToSats(maxAmountBtc);
throw PayError.aboveMaxAmount(maxAmountSat: maxAmountSat);
throw PayError.aboveMaxAmount(
maxAmount: e.maxAmount,
currency: e.currency,
);
} catch (e) {
throw Exception('Failed to place pay order: $e');
}
Expand Down Expand Up @@ -524,13 +529,15 @@ class ExchangeOrderRepositoryImpl implements ExchangeOrderRepository {

return order;
} on BullBitcoinApiMinAmountException catch (e) {
final minAmountBtc = e.minAmount;
final minAmountSat = ConvertAmount.btcToSats(minAmountBtc);
throw WithdrawError.belowMinAmount(minAmountSat: minAmountSat);
throw WithdrawError.belowMinAmount(
minAmount: e.minAmount,
currency: e.currency,
);
} on BullBitcoinApiMaxAmountException catch (e) {
final maxAmountBtc = e.maxAmount;
final maxAmountSat = ConvertAmount.btcToSats(maxAmountBtc);
throw WithdrawError.aboveMaxAmount(maxAmountSat: maxAmountSat);
throw WithdrawError.aboveMaxAmount(
maxAmount: e.maxAmount,
currency: e.currency,
);
} catch (e) {
throw Exception('Failed to create withdrawal order: $e');
}
Expand Down
22 changes: 15 additions & 7 deletions lib/core/exchange/domain/errors/buy_error.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ part 'buy_error.freezed.dart';
@freezed
sealed class BuyError with _$BuyError {
const factory BuyError.unauthenticated() = UnauthenticatedBuyError;
const factory BuyError.belowMinAmount({required int minAmountSat}) =
BelowMinAmountBuyError;
const factory BuyError.aboveMaxAmount({required int maxAmountSat}) =
AboveMaxAmountBuyError;

/// [minAmount] is denominated in [currency], which the api picks and can be
/// either a fiat currency or BTC/LBTC.
const factory BuyError.belowMinAmount({
required double minAmount,
required String currency,
}) = BelowMinAmountBuyError;
const factory BuyError.aboveMaxAmount({
required double maxAmount,
required String currency,
}) = AboveMaxAmountBuyError;
const factory BuyError.insufficientFunds() = InsufficientFundsBuyError;
const factory BuyError.orderNotFound() = OrderNotFoundBuyError;
const factory BuyError.orderAlreadyConfirmed() =
Expand All @@ -23,11 +30,12 @@ sealed class BuyError with _$BuyError {
/// Returns the localized error message.
String toTranslated(BuildContext context) => when(
unauthenticated: () => context.loc.buyUnauthenticatedError,
belowMinAmount: (_) => context.loc.buyBelowMinAmountError,
aboveMaxAmount: (_) => context.loc.buyAboveMaxAmountError,
belowMinAmount: (_, _) => context.loc.buyBelowMinAmountError,
aboveMaxAmount: (_, _) => context.loc.buyAboveMaxAmountError,
insufficientFunds: () => context.loc.buyInsufficientFundsError,
orderNotFound: () => context.loc.buyOrderNotFoundError,
orderAlreadyConfirmed: () => context.loc.buyOrderAlreadyConfirmedError,
unexpected: (message) => message,
// The raw message is logged by the bloc; it is never fit to show to a user.
unexpected: (_) => context.loc.buyUnexpectedError,
);
}
Loading
Loading