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
8 changes: 1 addition & 7 deletions lib/features/dca/dca_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,6 @@ class DcaLocator {
testnetExchangeUserRepository: locator<ExchangeUserRepository>(
instanceName: 'testnetExchangeUserRepository',
),
mainnetExchangeOrderRepository: locator<ExchangeOrderRepository>(
instanceName: 'mainnetExchangeOrderRepository',
),
testnetExchangeOrderRepository: locator<ExchangeOrderRepository>(
instanceName: 'testnetExchangeOrderRepository',
),
),
);
locator.registerFactory<SetDcaUsecase>(
Expand All @@ -44,6 +38,7 @@ class DcaLocator {
wallet: locator<WalletRepository>(),
settingsRepository: locator<SettingsRepository>(),
walletAddressRepository: locator<WalletAddressRepository>(),
saveUserPreferencesUsecase: locator<SaveUserPreferencesUsecase>(),
),
);
}
Expand All @@ -53,7 +48,6 @@ class DcaLocator {
() => DcaBloc(
startDcaUsecase: locator<StartDcaUsecase>(),
setDcaUsecase: locator<SetDcaUsecase>(),
saveUserPreferencesUsecase: locator<SaveUserPreferencesUsecase>(),
),
);
}
Expand Down
38 changes: 38 additions & 0 deletions lib/features/dca/domain/dca_failure.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'package:bb_mobile/core/failures/failure.dart';

sealed class DcaFailure extends Failure {
const DcaFailure([super.logMessage]);
}

/// The exchange account summary could not be loaded, so the flow cannot
/// start (no balances, no currency, no default lightning address). Also
/// covers the not-logged-in case: the core repository returns null instead
/// of a typed exception when no API key is stored, so the two are not
/// distinguishable at this boundary.
final class DcaAccountUnavailableFailure extends DcaFailure {
const DcaAccountUnavailableFailure([super.logMessage]);
}

/// Lightning was selected as the receive network but no lightning address
/// was provided. Defensive: the wallet-selection screen validates this
/// before the use-case runs.
final class DcaLightningAddressRequiredFailure extends DcaFailure {
const DcaLightningAddressRequiredFailure();
}

/// No default wallet exists for the selected network, or generating a
/// receive address on it failed — either way no destination for the buys.
final class DcaReceiveAddressFailure extends DcaFailure {
const DcaReceiveAddressFailure([super.logMessage]);
}

/// The exchange rejected the DCA order itself.
final class DcaOrderCreationFailure extends DcaFailure {
const DcaOrderCreationFailure([super.logMessage]);
}

/// Catch-all. [logMessage] is for logs ONLY and MUST never reach the UI —
/// the presentation extension returns the shared generic string.
final class DcaUnexpectedFailure extends DcaFailure {
const DcaUnexpectedFailure([super.logMessage]);
}
104 changes: 71 additions & 33 deletions lib/features/dca/domain/usecases/set_dca_usecase.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_order_repository.dart';
import 'package:bb_mobile/core/exchange/domain/usecases/save_user_preferences_usecase.dart';
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_address_repository.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bb_mobile/features/dca/domain/dca.dart';
import 'package:bb_mobile/features/dca/domain/dca_failure.dart';
import 'package:meta/meta.dart';

class SetDcaUsecase {
final ExchangeOrderRepository _mainnetDcaRepository;
Expand All @@ -13,65 +19,97 @@ class SetDcaUsecase {
final WalletRepository _wallet;
final SettingsRepository _settingsRepository;
final WalletAddressRepository _walletAddressRepository;
final SaveUserPreferencesUsecase _saveUserPreferencesUsecase;

SetDcaUsecase({
required ExchangeOrderRepository mainnetExchangeOrderRepository,
required ExchangeOrderRepository testnetExchangeOrderRepository,
required this._wallet,
required this._settingsRepository,
required this._walletAddressRepository,
required this._saveUserPreferencesUsecase,
}) : _mainnetDcaRepository = mainnetExchangeOrderRepository,
_testnetDcaRepository = testnetExchangeOrderRepository;

Future<Dca> execute({
@useResult
Future<Result<Dca, DcaFailure>> execute({
required double amount,
required FiatCurrency currency,
required DcaBuyFrequency frequency,
required DcaNetwork network,
String? lightningAddress,
}) async {
final settings = await _settingsRepository.fetch();
final SettingsEntity settings;
try {
settings = await _settingsRepository.fetch();
} catch (e, st) {
log.severe(message: 'Failed to load settings', error: e, trace: st);
return Err(DcaUnexpectedFailure(e.toString()));
}
final environment = settings.environment;
String address;

final String address;
if (network == DcaNetwork.lightning) {
// Defensive: the wallet-selection screen validates this before we run.
if (lightningAddress == null || lightningAddress.isEmpty) {
throw Exception(
'Lightning address is required for Lightning network DCA',
);
return const Err(DcaLightningAddressRequiredFailure());
}
address = lightningAddress;
} else {
final wallets = await _wallet.getWallets(
environment: environment,
onlyDefaults: true,
onlyBitcoin: network == DcaNetwork.bitcoin,
onlyLiquid: network == DcaNetwork.liquid,
);
try {
final wallets = await _wallet.getWallets(
environment: environment,
onlyDefaults: true,
onlyBitcoin: network == DcaNetwork.bitcoin,
onlyLiquid: network == DcaNetwork.liquid,
);

if (wallets.isEmpty) {
log.warning('No default wallet found for DCA network $network');
return const Err(DcaReceiveAddressFailure());
}

if (wallets.isEmpty) {
throw Exception('No default wallet found');
final walletAddress = await _walletAddressRepository
.generateNewReceiveAddress(walletId: wallets.first.id);
address = walletAddress.address;
} catch (e, st) {
log.severe(
message: 'Failed to resolve a DCA receive address',
error: e.runtimeType,
trace: st,
);
return Err(DcaReceiveAddressFailure(e.runtimeType.toString()));
}
}

final Dca dca;
try {
final repository = environment.isMainnet
? _mainnetDcaRepository
: _testnetDcaRepository;
dca = await repository.createDca(
amount: amount,
currency: currency,
frequency: frequency,
network: network,
address: address,
);
} catch (e, st) {
log.warning('Exchange rejected DCA creation', error: e, trace: st);
return Err(DcaOrderCreationFailure(e.toString()));
}

final defaultWallet = wallets.first;
final walletAddress = await _walletAddressRepository
.generateNewReceiveAddress(walletId: defaultWallet.id);
address = walletAddress.address;
try {
await _saveUserPreferencesUsecase.execute(dcaEnabled: true);
} catch (e, st) {
log.severe(
message: 'DCA created but enabling the preference failed',
error: e,
trace: st,
);
return Err(DcaUnexpectedFailure(e.toString()));
}

return environment.isMainnet
? _mainnetDcaRepository.createDca(
amount: amount,
currency: currency,
frequency: frequency,
network: network,
address: address,
)
: _testnetDcaRepository.createDca(
amount: amount,
currency: currency,
frequency: frequency,
network: network,
address: address,
);
return Ok(dca);
}
}
69 changes: 37 additions & 32 deletions lib/features/dca/domain/usecases/start_dca_usecase.dart
Original file line number Diff line number Diff line change
@@ -1,44 +1,54 @@
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
import 'package:bb_mobile/core/exchange/domain/entity/user_summary.dart';
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_order_repository.dart';
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_user_repository.dart';
import 'package:bb_mobile/core/exchange/domain/usecases/get_exchange_user_summary_usecase.dart';
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/features/dca/domain/dca_failure.dart';
import 'package:meta/meta.dart';

typedef DcaStartData = ({
List<UserBalance> balances,
FiatCurrency? currency,
String? lightningAddress,
});

class StartDcaUsecase {
final SettingsRepository _settingsRepository;
final ExchangeUserRepository _mainnetExchangeUserRepository;
final ExchangeUserRepository _testnetExchangeUserRepository;
final ExchangeOrderRepository _mainnetDcaRepository;
final ExchangeOrderRepository _testnetDcaRepository;

StartDcaUsecase({
required this._settingsRepository,
required this._mainnetExchangeUserRepository,
required this._testnetExchangeUserRepository,
required ExchangeOrderRepository mainnetExchangeOrderRepository,
required ExchangeOrderRepository testnetExchangeOrderRepository,
}) : _mainnetDcaRepository = mainnetExchangeOrderRepository,
_testnetDcaRepository = testnetExchangeOrderRepository;

Future<
({
List<UserBalance> balances,
FiatCurrency? currency,
String? lightningAddress,
Map<String, dynamic> buyLimits,
})
>
execute() async {
final settings = await _settingsRepository.fetch();
final environment = settings.environment;
});

final userSummary = environment.isMainnet
? await _mainnetExchangeUserRepository.getUserSummary()
: await _testnetExchangeUserRepository.getUserSummary();
@useResult
Future<Result<DcaStartData, DcaFailure>> execute() async {
final bool isMainnet;
try {
final settings = await _settingsRepository.fetch();
isMainnet = settings.environment.isMainnet;
} catch (e, st) {
log.severe(message: 'Failed to load settings', error: e, trace: st);
return Err(DcaUnexpectedFailure(e.toString()));
}

if (userSummary == null) {
throw GetExchangeUserSummaryException('User summary is null');
final UserSummary userSummary;
try {
final summary = isMainnet
? await _mainnetExchangeUserRepository.getUserSummary()
: await _testnetExchangeUserRepository.getUserSummary();
if (summary == null) {
// Null also covers "no API key stored" — the repository returns null
// instead of throwing in that case.
return const Err(DcaAccountUnavailableFailure());
}
userSummary = summary;
} catch (e, st) {
log.warning('Failed to fetch user summary', error: e, trace: st);
return Err(DcaAccountUnavailableFailure(e.toString()));
}

final balances = userSummary.balances.where((b) => b.amount > 0).toList();
Expand All @@ -56,15 +66,10 @@ class StartDcaUsecase {
: FiatCurrency.fromCode(currencyCode);
final defaultLightningAddress = userSummary.autoBuy.addresses.lightning;

final buyLimits = environment.isMainnet
? await _mainnetDcaRepository.getBuyLimits()
: await _testnetDcaRepository.getBuyLimits();

return (
return Ok((
balances: balances,
currency: currency,
lightningAddress: defaultLightningAddress,
buyLimits: buyLimits,
);
));
}
}
Loading
Loading