Skip to content

Commit e3135db

Browse files
refactor(dca): sanitize user-facing error messages
1 parent 6559b75 commit e3135db

11 files changed

Lines changed: 575 additions & 124 deletions

File tree

lib/features/dca/dca_locator.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class DcaLocator {
4444
wallet: locator<WalletRepository>(),
4545
settingsRepository: locator<SettingsRepository>(),
4646
walletAddressRepository: locator<WalletAddressRepository>(),
47+
saveUserPreferencesUsecase: locator<SaveUserPreferencesUsecase>(),
4748
),
4849
);
4950
}
@@ -53,7 +54,6 @@ class DcaLocator {
5354
() => DcaBloc(
5455
startDcaUsecase: locator<StartDcaUsecase>(),
5556
setDcaUsecase: locator<SetDcaUsecase>(),
56-
saveUserPreferencesUsecase: locator<SaveUserPreferencesUsecase>(),
5757
),
5858
);
5959
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import 'package:bb_mobile/core/failures/failure.dart';
2+
3+
sealed class DcaFailure extends Failure {
4+
const DcaFailure([super.logMessage]);
5+
}
6+
7+
/// The exchange account summary could not be loaded, so the flow cannot
8+
/// start (no balances, no currency, no default lightning address). Also
9+
/// covers the not-logged-in case: the core repository returns null instead
10+
/// of a typed exception when no API key is stored, so the two are not
11+
/// distinguishable at this boundary.
12+
final class DcaAccountUnavailableFailure extends DcaFailure {
13+
const DcaAccountUnavailableFailure([super.logMessage]);
14+
}
15+
16+
/// Lightning was selected as the receive network but no lightning address
17+
/// was provided. Defensive: the wallet-selection screen validates this
18+
/// before the use-case runs.
19+
final class DcaLightningAddressRequiredFailure extends DcaFailure {
20+
const DcaLightningAddressRequiredFailure();
21+
}
22+
23+
/// No default wallet exists for the selected network, or generating a
24+
/// receive address on it failed — either way no destination for the buys.
25+
final class DcaReceiveAddressFailure extends DcaFailure {
26+
const DcaReceiveAddressFailure([super.logMessage]);
27+
}
28+
29+
/// The exchange rejected the DCA order itself.
30+
final class DcaOrderCreationFailure extends DcaFailure {
31+
const DcaOrderCreationFailure([super.logMessage]);
32+
}
33+
34+
/// Catch-all. [logMessage] is for logs ONLY and MUST never reach the UI —
35+
/// the presentation extension returns the shared generic string.
36+
final class DcaUnexpectedFailure extends DcaFailure {
37+
const DcaUnexpectedFailure([super.logMessage]);
38+
}
Lines changed: 71 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
22
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_order_repository.dart';
3+
import 'package:bb_mobile/core/exchange/domain/usecases/save_user_preferences_usecase.dart';
34
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
5+
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
6+
import 'package:bb_mobile/core/utils/logger.dart';
7+
import 'package:bb_mobile/core/utils/result.dart';
48
import 'package:bb_mobile/core/wallet/data/repositories/wallet_address_repository.dart';
59
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
610
import 'package:bb_mobile/features/dca/domain/dca.dart';
11+
import 'package:bb_mobile/features/dca/domain/dca_failure.dart';
12+
import 'package:meta/meta.dart';
713

814
class SetDcaUsecase {
915
final ExchangeOrderRepository _mainnetDcaRepository;
@@ -13,65 +19,97 @@ class SetDcaUsecase {
1319
final WalletRepository _wallet;
1420
final SettingsRepository _settingsRepository;
1521
final WalletAddressRepository _walletAddressRepository;
22+
final SaveUserPreferencesUsecase _saveUserPreferencesUsecase;
1623

1724
SetDcaUsecase({
1825
required ExchangeOrderRepository mainnetExchangeOrderRepository,
1926
required ExchangeOrderRepository testnetExchangeOrderRepository,
2027
required this._wallet,
2128
required this._settingsRepository,
2229
required this._walletAddressRepository,
30+
required this._saveUserPreferencesUsecase,
2331
}) : _mainnetDcaRepository = mainnetExchangeOrderRepository,
2432
_testnetDcaRepository = testnetExchangeOrderRepository;
2533

26-
Future<Dca> execute({
34+
@useResult
35+
Future<Result<Dca, DcaFailure>> execute({
2736
required double amount,
2837
required FiatCurrency currency,
2938
required DcaBuyFrequency frequency,
3039
required DcaNetwork network,
3140
String? lightningAddress,
3241
}) async {
33-
final settings = await _settingsRepository.fetch();
42+
final SettingsEntity settings;
43+
try {
44+
settings = await _settingsRepository.fetch();
45+
} catch (e, st) {
46+
log.severe(message: 'Failed to load settings', error: e, trace: st);
47+
return Err(DcaUnexpectedFailure(e.toString()));
48+
}
3449
final environment = settings.environment;
35-
String address;
50+
51+
final String address;
3652
if (network == DcaNetwork.lightning) {
53+
// Defensive: the wallet-selection screen validates this before we run.
3754
if (lightningAddress == null || lightningAddress.isEmpty) {
38-
throw Exception(
39-
'Lightning address is required for Lightning network DCA',
40-
);
55+
return const Err(DcaLightningAddressRequiredFailure());
4156
}
4257
address = lightningAddress;
4358
} else {
44-
final wallets = await _wallet.getWallets(
45-
environment: environment,
46-
onlyDefaults: true,
47-
onlyBitcoin: network == DcaNetwork.bitcoin,
48-
onlyLiquid: network == DcaNetwork.liquid,
49-
);
59+
try {
60+
final wallets = await _wallet.getWallets(
61+
environment: environment,
62+
onlyDefaults: true,
63+
onlyBitcoin: network == DcaNetwork.bitcoin,
64+
onlyLiquid: network == DcaNetwork.liquid,
65+
);
66+
67+
if (wallets.isEmpty) {
68+
log.warning('No default wallet found for DCA network $network');
69+
return const Err(DcaReceiveAddressFailure());
70+
}
5071

51-
if (wallets.isEmpty) {
52-
throw Exception('No default wallet found');
72+
final walletAddress = await _walletAddressRepository
73+
.generateNewReceiveAddress(walletId: wallets.first.id);
74+
address = walletAddress.address;
75+
} catch (e, st) {
76+
log.severe(
77+
message: 'Failed to resolve a DCA receive address',
78+
error: e.runtimeType,
79+
trace: st,
80+
);
81+
return Err(DcaReceiveAddressFailure(e.toString()));
5382
}
83+
}
84+
85+
final Dca dca;
86+
try {
87+
final repository = environment.isMainnet
88+
? _mainnetDcaRepository
89+
: _testnetDcaRepository;
90+
dca = await repository.createDca(
91+
amount: amount,
92+
currency: currency,
93+
frequency: frequency,
94+
network: network,
95+
address: address,
96+
);
97+
} catch (e, st) {
98+
log.warning('Exchange rejected DCA creation', error: e, trace: st);
99+
return Err(DcaOrderCreationFailure(e.toString()));
100+
}
54101

55-
final defaultWallet = wallets.first;
56-
final walletAddress = await _walletAddressRepository
57-
.generateNewReceiveAddress(walletId: defaultWallet.id);
58-
address = walletAddress.address;
102+
try {
103+
await _saveUserPreferencesUsecase.execute(dcaEnabled: true);
104+
} catch (e, st) {
105+
log.severe(
106+
message: 'DCA created but enabling the preference failed',
107+
error: e,
108+
trace: st,
109+
);
110+
return Err(DcaUnexpectedFailure(e.toString()));
59111
}
60112

61-
return environment.isMainnet
62-
? _mainnetDcaRepository.createDca(
63-
amount: amount,
64-
currency: currency,
65-
frequency: frequency,
66-
network: network,
67-
address: address,
68-
)
69-
: _testnetDcaRepository.createDca(
70-
amount: amount,
71-
currency: currency,
72-
frequency: frequency,
73-
network: network,
74-
address: address,
75-
);
113+
return Ok(dca);
76114
}
77115
}

lib/features/dca/domain/usecases/start_dca_usecase.dart

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,18 @@ import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
22
import 'package:bb_mobile/core/exchange/domain/entity/user_summary.dart';
33
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_order_repository.dart';
44
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_user_repository.dart';
5-
import 'package:bb_mobile/core/exchange/domain/usecases/get_exchange_user_summary_usecase.dart';
65
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
6+
import 'package:bb_mobile/core/utils/logger.dart';
7+
import 'package:bb_mobile/core/utils/result.dart';
8+
import 'package:bb_mobile/features/dca/domain/dca_failure.dart';
9+
import 'package:meta/meta.dart';
10+
11+
typedef DcaStartData = ({
12+
List<UserBalance> balances,
13+
FiatCurrency? currency,
14+
String? lightningAddress,
15+
Map<String, dynamic> buyLimits,
16+
});
717

818
class StartDcaUsecase {
919
final SettingsRepository _settingsRepository;
@@ -21,24 +31,31 @@ class StartDcaUsecase {
2131
}) : _mainnetDcaRepository = mainnetExchangeOrderRepository,
2232
_testnetDcaRepository = testnetExchangeOrderRepository;
2333

24-
Future<
25-
({
26-
List<UserBalance> balances,
27-
FiatCurrency? currency,
28-
String? lightningAddress,
29-
Map<String, dynamic> buyLimits,
30-
})
31-
>
32-
execute() async {
33-
final settings = await _settingsRepository.fetch();
34-
final environment = settings.environment;
35-
36-
final userSummary = environment.isMainnet
37-
? await _mainnetExchangeUserRepository.getUserSummary()
38-
: await _testnetExchangeUserRepository.getUserSummary();
34+
@useResult
35+
Future<Result<DcaStartData, DcaFailure>> execute() async {
36+
final bool isMainnet;
37+
try {
38+
final settings = await _settingsRepository.fetch();
39+
isMainnet = settings.environment.isMainnet;
40+
} catch (e, st) {
41+
log.severe(message: 'Failed to load settings', error: e, trace: st);
42+
return Err(DcaUnexpectedFailure(e.toString()));
43+
}
3944

40-
if (userSummary == null) {
41-
throw GetExchangeUserSummaryException('User summary is null');
45+
final UserSummary userSummary;
46+
try {
47+
final summary = isMainnet
48+
? await _mainnetExchangeUserRepository.getUserSummary()
49+
: await _testnetExchangeUserRepository.getUserSummary();
50+
if (summary == null) {
51+
// Null also covers "no API key stored" — the repository returns null
52+
// instead of throwing in that case.
53+
return const Err(DcaAccountUnavailableFailure());
54+
}
55+
userSummary = summary;
56+
} catch (e, st) {
57+
log.warning('Failed to fetch user summary', error: e, trace: st);
58+
return Err(DcaAccountUnavailableFailure(e.toString()));
4259
}
4360

4461
final balances = userSummary.balances.where((b) => b.amount > 0).toList();
@@ -56,15 +73,21 @@ class StartDcaUsecase {
5673
: FiatCurrency.fromCode(currencyCode);
5774
final defaultLightningAddress = userSummary.autoBuy.addresses.lightning;
5875

59-
final buyLimits = environment.isMainnet
60-
? await _mainnetDcaRepository.getBuyLimits()
61-
: await _testnetDcaRepository.getBuyLimits();
76+
final Map<String, dynamic> buyLimits;
77+
try {
78+
buyLimits = isMainnet
79+
? await _mainnetDcaRepository.getBuyLimits()
80+
: await _testnetDcaRepository.getBuyLimits();
81+
} catch (e, st) {
82+
log.warning('Failed to fetch buy limits', error: e, trace: st);
83+
return Err(DcaAccountUnavailableFailure(e.toString()));
84+
}
6285

63-
return (
86+
return Ok((
6487
balances: balances,
6588
currency: currency,
6689
lightningAddress: defaultLightningAddress,
6790
buyLimits: buyLimits,
68-
);
91+
));
6992
}
7093
}

0 commit comments

Comments
 (0)