Skip to content
Merged
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
54 changes: 0 additions & 54 deletions lib/core/errors/autoswap_errors.dart
Original file line number Diff line number Diff line change
@@ -1,58 +1,4 @@
import 'package:bb_mobile/core/errors/bull_exception.dart';
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/utils/amount_conversions.dart';
import 'package:bb_mobile/generated/l10n/localization.dart';

class MinimumAmountThresholdException extends BullException {
final int minimumThresholdSats;
final BitcoinUnit bitcoinUnit;

MinimumAmountThresholdException(this.minimumThresholdSats, this.bitcoinUnit)
: super(
'Minimum balance threshold is $minimumThresholdSats ${bitcoinUnit.code}',
);

String displayMessage([AppLocalizations? loc]) {
if (loc == null) {
if (bitcoinUnit == BitcoinUnit.btc) {
final btcAmount = ConvertAmount.satsToBtc(minimumThresholdSats);
return 'Minimum balance threshold is $btcAmount BTC';
}
return 'Minimum balance threshold is $minimumThresholdSats sats';
}

if (bitcoinUnit == BitcoinUnit.btc) {
final btcAmount = ConvertAmount.satsToBtc(minimumThresholdSats);
return loc.autoswapMinimumThresholdErrorBtc(btcAmount.toString());
}
return loc.autoswapMinimumThresholdErrorSats(
minimumThresholdSats.toString(),
);
}
}

class MaximumFeeThresholdException extends BullException {
final int maximumThreshold;

MaximumFeeThresholdException(this.maximumThreshold)
: super('Maximum fee threshold is $maximumThreshold%');

String displayMessage([AppLocalizations? loc]) {
if (loc == null) {
return 'Maximum fee threshold is $maximumThreshold%';
}
return loc.autoswapMaximumFeeError(maximumThreshold.toString());
}
}

class AutoSwapProcessException extends BullException {
final Object? error;

AutoSwapProcessException(super.message, {this.error});

@override
String toString() => error != null ? '$message: $error' : message;
}

class FeeBlockException extends BullException {
final double currentFeePercent;
Expand Down
59 changes: 59 additions & 0 deletions lib/core/swaps/domain/entity/auto_swap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'auto_swap.freezed.dart';
part 'auto_swap.g.dart';

/// Which rule a set of auto swap settings breaks, in the order they are
/// checked. Callers map this to whatever they show the user.
enum AutoSwapSettingsViolation {
recipientWalletMissing,
balanceThresholdTooLow,
triggerBalanceTooLow,
feeThresholdTooHigh,
}

@freezed
sealed class AutoSwap with _$AutoSwap {
const factory AutoSwap({
Expand All @@ -21,6 +30,56 @@ sealed class AutoSwap with _$AutoSwap {
factory AutoSwap.fromJson(Map<String, dynamic> json) =>
_$AutoSwapFromJson(json);

/// Below this a swap would move less than it costs in fees.
static const int minimumBalanceThresholdSats = 50000;

/// Accepting a fee ceiling above this is almost certainly a mistake.
static const int maximumFeeThresholdPercent = 10;

/// Applied when a fee ceiling cannot be determined.
static const double defaultFeeThresholdPercent = 3.0;

static bool isBalanceThresholdTooLow(int balanceThresholdSats) =>
balanceThresholdSats < minimumBalanceThresholdSats;

/// A swap has to leave the wallet at its target, so the trigger must be at
/// least twice the target — otherwise a swap would fire and immediately
/// leave the balance below where it started.
static bool isTriggerBalanceTooLow({
required int balanceThresholdSats,
required int triggerBalanceSats,
}) => triggerBalanceSats < 2 * balanceThresholdSats;

static bool isFeeThresholdTooHigh(double feeThresholdPercent) =>
feeThresholdPercent > maximumFeeThresholdPercent;

/// The first rule these settings break, or null when they are acceptable.
///
/// Deliberately not enforced in the constructor: these settings are
/// persisted and deserialized, so a row written by an older version must
/// still load even if it would no longer be accepted.
AutoSwapSettingsViolation? get violation {
// Disabled settings are inert. Re-enabling validates the complete form.
if (!enabled) return null;

if (recipientWalletId == null) {
return AutoSwapSettingsViolation.recipientWalletMissing;
}
if (isBalanceThresholdTooLow(balanceThresholdSats)) {
return AutoSwapSettingsViolation.balanceThresholdTooLow;
}
if (isTriggerBalanceTooLow(
balanceThresholdSats: balanceThresholdSats,
triggerBalanceSats: triggerBalanceSats,
)) {
return AutoSwapSettingsViolation.triggerBalanceTooLow;
}
if (isFeeThresholdTooHigh(feeThresholdPercent)) {
return AutoSwapSettingsViolation.feeThresholdTooHigh;
}
return null;
}

bool passedRequiredBalance(int balanceSat) {
return balanceSat >= triggerBalanceSats && enabled;
}
Expand Down
27 changes: 23 additions & 4 deletions lib/features/autoswap/autoswap_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,38 @@ import 'package:bb_mobile/core/settings/domain/get_settings_usecase.dart';
import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.dart';
import 'package:bb_mobile/core/swaps/domain/usecases/save_auto_swap_settings_usecase.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bb_mobile/features/autoswap/domain/usecases/load_autoswap_settings_usecase.dart';
import 'package:bb_mobile/features/autoswap/domain/usecases/save_autoswap_settings_usecase.dart';
import 'package:bb_mobile/features/autoswap/presentation/autoswap_settings_cubit.dart';
import 'package:get_it/get_it.dart';

class AutoSwapLocator {
static void setup(GetIt locator) {
// Register the cubit
locator.registerFactory<AutoSwapSettingsCubit>(
() => AutoSwapSettingsCubit(
registerUsecases(locator);
registerBlocs(locator);
}

static void registerUsecases(GetIt locator) {
locator.registerFactory<LoadAutoswapSettingsUsecase>(
() => LoadAutoswapSettingsUsecase(
getAutoSwapSettingsUsecase: locator<GetAutoSwapSettingsUsecase>(),
saveAutoSwapSettingsUsecase: locator<SaveAutoSwapSettingsUsecase>(),
getSettingsUsecase: locator<GetSettingsUsecase>(),
walletRepository: locator<WalletRepository>(),
),
);
locator.registerFactory<SaveAutoswapSettingsUsecase>(
() => SaveAutoswapSettingsUsecase(
saveAutoSwapSettingsUsecase: locator<SaveAutoSwapSettingsUsecase>(),
),
);
}

static void registerBlocs(GetIt locator) {
locator.registerFactory<AutoSwapSettingsCubit>(
() => AutoSwapSettingsCubit(
loadAutoswapSettingsUsecase: locator<LoadAutoswapSettingsUsecase>(),
saveAutoswapSettingsUsecase: locator<SaveAutoswapSettingsUsecase>(),
),
);
}
}
47 changes: 47 additions & 0 deletions lib/features/autoswap/domain/autoswap_failure.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import 'package:bb_mobile/core/failures/failure.dart';

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

/// The stored settings, the wallet list or the app settings could not be read,
/// so the form cannot be populated.
final class AutoswapSettingsUnavailableFailure extends AutoswapFailure {
const AutoswapSettingsUnavailableFailure([super.logMessage]);
}

/// Writing the settings failed.
final class AutoswapSettingsSaveFailure extends AutoswapFailure {
const AutoswapSettingsSaveFailure([super.logMessage]);
}

/// Auto swap is being enabled without a wallet to send the swapped funds to.
/// Only enforced while enabling — disabling needs no recipient.
final class AutoswapRecipientWalletRequiredFailure extends AutoswapFailure {
const AutoswapRecipientWalletRequiredFailure();
}

/// The target balance is below the minimum a swap can be made for.
///
/// Carries the limit in sats because the message states it. The *unit* it is
/// rendered in is the user's current display preference, so that choice stays
/// in presentation — `BitcoinUnit` lives in a Flutter-importing file and must
/// not reach this layer.
final class AutoswapBalanceThresholdTooLowFailure extends AutoswapFailure {
final int minimumSats;

const AutoswapBalanceThresholdTooLowFailure(this.minimumSats);
}

/// The trigger balance is not at least twice the target balance, so a swap
/// would leave the wallet below its target immediately.
final class AutoswapTriggerBalanceTooLowFailure extends AutoswapFailure {
const AutoswapTriggerBalanceTooLowFailure();
}

/// The accepted fee ceiling is above what we allow to be set.
final class AutoswapFeeThresholdTooHighFailure extends AutoswapFailure {
final int maximumPercent;

const AutoswapFeeThresholdTooHighFailure(this.maximumPercent);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'package:bb_mobile/core/settings/domain/get_settings_usecase.dart';
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/swaps/domain/entity/auto_swap.dart';
import 'package:bb_mobile/core/swaps/domain/usecases/get_auto_swap_settings_usecase.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_repository.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/features/autoswap/domain/autoswap_failure.dart';
import 'package:meta/meta.dart';

/// Everything the settings form needs to populate itself.
typedef AutoswapSettingsData = ({
AutoSwap settings,
BitcoinUnit bitcoinUnit,
List<Wallet> bitcoinWallets,
String? recipientWalletId,
});

class LoadAutoswapSettingsUsecase {
final GetAutoSwapSettingsUsecase _getAutoSwapSettingsUsecase;
final GetSettingsUsecase _getSettingsUsecase;
final WalletRepository _walletRepository;

const LoadAutoswapSettingsUsecase({
required this._getAutoSwapSettingsUsecase,
required this._getSettingsUsecase,
required this._walletRepository,
});

@useResult
Future<Result<AutoswapSettingsData, AutoswapFailure>> execute() async {
try {
final appSettings = await _getSettingsUsecase.execute();
final autoSwapSettings = await _getAutoSwapSettingsUsecase.execute();

final wallets = await _walletRepository.getWallets(
environment: appSettings.environment,
);
final bitcoinWallets = wallets.where((w) => !w.isLiquid).toList();
final defaultBitcoinWallet = bitcoinWallets
.where((w) => w.isDefault)
.firstOrNull;

return Ok((
settings: autoSwapSettings,
bitcoinUnit: appSettings.bitcoinUnit,
bitcoinWallets: bitcoinWallets,
recipientWalletId:
autoSwapSettings.recipientWalletId ?? defaultBitcoinWallet?.id,
));
} catch (e, st) {
log.severe(
message: 'Failed to load auto swap settings',
error: e,
trace: st,
);
return Err(AutoswapSettingsUnavailableFailure(e.runtimeType.toString()));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'package:bb_mobile/core/swaps/domain/entity/auto_swap.dart';
import 'package:bb_mobile/core/swaps/domain/usecases/save_auto_swap_settings_usecase.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/features/autoswap/domain/autoswap_failure.dart';
import 'package:meta/meta.dart';

class SaveAutoswapSettingsUsecase {
final SaveAutoSwapSettingsUsecase _saveAutoSwapSettingsUsecase;

const SaveAutoswapSettingsUsecase({
required this._saveAutoSwapSettingsUsecase,
});

/// Refuses settings the entity rejects, then writes them.
///
/// Validating here rather than in the caller means no future caller can
/// persist settings that break the rules.
@useResult
Future<Result<void, AutoswapFailure>> execute(AutoSwap settings) async {
final violation = settings.violation;
if (violation != null) return Err(_failureFor(violation));

try {
await _saveAutoSwapSettingsUsecase.execute(settings);
return const Ok(null);
} catch (e, st) {
log.severe(
message: 'Failed to save auto swap settings',
error: e,
trace: st,
);
return Err(AutoswapSettingsSaveFailure(e.runtimeType.toString()));
}
}

AutoswapFailure _failureFor(AutoSwapSettingsViolation violation) =>
switch (violation) {
AutoSwapSettingsViolation.recipientWalletMissing =>
const AutoswapRecipientWalletRequiredFailure(),
AutoSwapSettingsViolation.balanceThresholdTooLow =>
const AutoswapBalanceThresholdTooLowFailure(
AutoSwap.minimumBalanceThresholdSats,
),
AutoSwapSettingsViolation.triggerBalanceTooLow =>
const AutoswapTriggerBalanceTooLowFailure(),
AutoSwapSettingsViolation.feeThresholdTooHigh =>
const AutoswapFeeThresholdTooHighFailure(
AutoSwap.maximumFeeThresholdPercent,
),
};
}
27 changes: 27 additions & 0 deletions lib/features/autoswap/presentation/autoswap_failure_l10n.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/utils/amount_conversions.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/features/autoswap/domain/autoswap_failure.dart';
import 'package:flutter/widgets.dart';

extension AutoswapFailureL10n on AutoswapFailure {
String toTranslated(BuildContext context, {BitcoinUnit? unit}) =>
switch (this) {
AutoswapSettingsUnavailableFailure() =>
context.loc.autoswapLoadSettingsError,
AutoswapSettingsSaveFailure() =>
context.loc.autoswapUpdateSettingsError,
AutoswapRecipientWalletRequiredFailure() =>
context.loc.autoswapSelectWalletError,
AutoswapBalanceThresholdTooLowFailure(:final minimumSats) =>
unit == BitcoinUnit.btc
? context.loc.autoswapMinimumThresholdErrorBtc(
ConvertAmount.satsToBtc(minimumSats).toString(),
)
: context.loc.autoswapMinimumThresholdErrorSats('$minimumSats'),
AutoswapTriggerBalanceTooLowFailure() =>
context.loc.autoswapTriggerBalanceError,
AutoswapFeeThresholdTooHighFailure(:final maximumPercent) =>
context.loc.autoswapMaximumFeeError('$maximumPercent'),
};
}
Loading
Loading