Skip to content

Commit c0cb31f

Browse files
authored
Merge pull request #2344 from SatoshiPortal/refactor-replace-by-fee-errors
refactor(replace_by_fee): sanitize user-facing error messages
2 parents fe197c0 + ca67263 commit c0cb31f

34 files changed

Lines changed: 306 additions & 283 deletions
Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
1+
import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_bitcoin_transaction_usecase.dart';
12
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
3+
import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart';
24
import 'package:bb_mobile/core/utils/logger.dart';
5+
import 'package:bb_mobile/core/utils/result.dart';
36
import 'package:bb_mobile/core/wallet/data/repositories/bitcoin_wallet_repository.dart';
7+
import 'package:bb_mobile/features/replace_by_fee/domain/replace_by_fee_failure.dart';
8+
import 'package:bdk_dart/bdk.dart' as bdk;
9+
import 'package:meta/meta.dart';
410

511
class BumpFeeUsecase {
612
final BitcoinWalletRepository _bitcoinWalletRepository;
13+
final BroadcastBitcoinTransactionUsecase _broadcastBitcoinTransactionUsecase;
14+
final GetNetworkFeesUsecase _getNetworkFeesUsecase;
715

8-
BumpFeeUsecase({required this._bitcoinWalletRepository});
16+
BumpFeeUsecase({
17+
required this._bitcoinWalletRepository,
18+
required this._broadcastBitcoinTransactionUsecase,
19+
required this._getNetworkFeesUsecase,
20+
});
921

10-
Future<String> execute({
22+
@useResult
23+
Future<Result<FeeOptions, ReplaceByFeeFailure>> getNetworkFees() async {
24+
try {
25+
final fees = await _getNetworkFeesUsecase.execute(isLiquid: false);
26+
return Ok(fees);
27+
} catch (e, st) {
28+
log.warning('Failed to fetch network fees for RBF', error: e, trace: st);
29+
return const Err(ReplaceByFeeNetworkFeesFailure());
30+
}
31+
}
32+
33+
@useResult
34+
Future<Result<String, ReplaceByFeeFailure>> execute({
1135
required String walletId,
1236
required String txid,
1337
required RelativeFee newFeeRate,
@@ -18,11 +42,16 @@ class BumpFeeUsecase {
1842
txid: txid,
1943
newFeeRate: newFeeRate,
2044
);
21-
return psbt;
22-
} catch (e) {
23-
log.severe(error: e, trace: StackTrace.current);
24-
// Re-throw BDK exceptions to be caught and handled by the cubit
25-
rethrow;
45+
final broadcastedTxid = await _broadcastBitcoinTransactionUsecase.execute(
46+
psbt,
47+
isPsbt: true,
48+
);
49+
return Ok(broadcastedTxid);
50+
} on bdk.FeeRateTooLowCreateTxException {
51+
return const Err(ReplaceByFeeFeeRateTooLowFailure());
52+
} catch (e, st) {
53+
log.severe(message: 'Bump fee failed', error: e, trace: st);
54+
return Err(ReplaceByFeeUnexpectedFailure(e.toString()));
2655
}
2756
}
2857
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import 'package:bb_mobile/core/failures/failure.dart';
2+
3+
sealed class ReplaceByFeeFailure extends Failure {
4+
const ReplaceByFeeFailure([super.logMessage]);
5+
}
6+
7+
final class ReplaceByFeeNoFeeRateSelectedFailure extends ReplaceByFeeFailure {
8+
const ReplaceByFeeNoFeeRateSelectedFailure();
9+
}
10+
11+
final class ReplaceByFeeFeeRateTooLowFailure extends ReplaceByFeeFailure {
12+
const ReplaceByFeeFeeRateTooLowFailure();
13+
}
14+
15+
final class ReplaceByFeeNetworkFeesFailure extends ReplaceByFeeFailure {
16+
const ReplaceByFeeNetworkFeesFailure();
17+
}
18+
19+
/// Catch-all. [logMessage] is for logs/Sentry ONLY and MUST never reach the UI.
20+
final class ReplaceByFeeUnexpectedFailure extends ReplaceByFeeFailure {
21+
const ReplaceByFeeUnexpectedFailure([super.logMessage]);
22+
}

lib/features/replace_by_fee/errors.dart

Lines changed: 0 additions & 36 deletions
This file was deleted.

lib/features/replace_by_fee/locator.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_bitcoin_transaction_usecase.dart';
2+
import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart';
13
import 'package:bb_mobile/core/wallet/data/repositories/bitcoin_wallet_repository.dart';
24
import 'package:bb_mobile/features/replace_by_fee/domain/bump_fee_usecase.dart';
35
import 'package:get_it/get_it.dart';
@@ -7,6 +9,9 @@ class ReplaceByFeeLocator {
79
locator.registerFactory<BumpFeeUsecase>(
810
() => BumpFeeUsecase(
911
bitcoinWalletRepository: locator<BitcoinWalletRepository>(),
12+
broadcastBitcoinTransactionUsecase:
13+
locator<BroadcastBitcoinTransactionUsecase>(),
14+
getNetworkFeesUsecase: locator<GetNetworkFeesUsecase>(),
1015
),
1116
);
1217
}

lib/features/replace_by_fee/presentation/cubit.dart

Lines changed: 51 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,84 @@
1-
import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_bitcoin_transaction_usecase.dart';
21
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
3-
import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart';
2+
import 'package:bb_mobile/core/utils/result.dart';
43
import 'package:bb_mobile/core/wallet/domain/entities/wallet_transaction.dart';
54
import 'package:bb_mobile/features/replace_by_fee/domain/bump_fee_usecase.dart';
65
import 'package:bb_mobile/features/replace_by_fee/domain/fee_entity.dart';
7-
import 'package:bb_mobile/features/replace_by_fee/errors.dart';
6+
import 'package:bb_mobile/features/replace_by_fee/domain/replace_by_fee_failure.dart';
87
import 'package:bb_mobile/features/replace_by_fee/presentation/state.dart';
9-
import 'package:bdk_dart/bdk.dart' as bdk;
108
import 'package:flutter_bloc/flutter_bloc.dart';
119

1210
class ReplaceByFeeCubit extends Cubit<ReplaceByFeeState> {
1311
final WalletTransaction originalTransaction;
1412
final BumpFeeUsecase bumpFeeUsecase;
15-
final BroadcastBitcoinTransactionUsecase broadcastBitcoinTransactionUsecase;
16-
final GetNetworkFeesUsecase getNetworkFeesUsecase;
1713

1814
ReplaceByFeeCubit({
1915
required this.originalTransaction,
2016
required this.bumpFeeUsecase,
21-
required this.broadcastBitcoinTransactionUsecase,
22-
required this.getNetworkFeesUsecase,
2317
}) : super(const ReplaceByFeeState()) {
2418
init();
2519
}
2620

2721
Future<void> init() async {
28-
final fees = await getNetworkFeesUsecase.execute(isLiquid: false);
29-
// Mempool fees are already RelativeFee (sat/kwu) by construction;
30-
// the AbsoluteFee branch is unreachable today but kept for exhaustiveness.
31-
final fastestRate = switch (fees.fastest) {
32-
final RelativeFee r => r,
33-
AbsoluteFee(:final sats) => NetworkFee.relativeFromAbsoluteAndVsize(
34-
absoluteSats: sats,
35-
vsize: originalTransaction.vsize,
36-
),
37-
};
38-
final fastestFeeRate = FeeEntity(
39-
type: FeeType.fastest,
40-
feeRate: fastestRate,
41-
);
42-
// BIP-125 requires a higher *rate* than the original to replace, with at
43-
// least one extra sat/vByte. Build the recommendation in sat/vByte then
44-
// narrow to RelativeFee in a single rounding step.
45-
final originalSatPerVbyte =
46-
originalTransaction.feeSat / originalTransaction.vsize;
47-
final recommendedBumpRate = FeeEntity(
48-
type: FeeType.custom,
49-
feeRate: NetworkFee.relativeFromSatPerVbyte(originalSatPerVbyte + 1),
50-
);
51-
emit(
52-
state.copyWith(
53-
fastestFeeRate: fastestFeeRate,
54-
newFeeRate: recommendedBumpRate,
55-
minRelay: fees.minRelay,
56-
),
57-
);
22+
switch (await bumpFeeUsecase.getNetworkFees()) {
23+
case Ok(:final value):
24+
// Mempool fees are already RelativeFee (sat/kwu) by construction;
25+
// the AbsoluteFee branch is unreachable today but kept for exhaustiveness.
26+
final fastestRate = switch (value.fastest) {
27+
final RelativeFee r => r,
28+
AbsoluteFee(:final sats) => NetworkFee.relativeFromAbsoluteAndVsize(
29+
absoluteSats: sats,
30+
vsize: originalTransaction.vsize,
31+
),
32+
};
33+
final originalSatPerVbyte =
34+
originalTransaction.feeSat / originalTransaction.vsize;
35+
emit(
36+
state.copyWith(
37+
fastestFeeRate: FeeEntity(type: FeeType.fastest, feeRate: fastestRate),
38+
newFeeRate: FeeEntity(
39+
type: FeeType.custom,
40+
feeRate: NetworkFee.relativeFromSatPerVbyte(originalSatPerVbyte + 1),
41+
),
42+
minRelay: value.minRelay,
43+
),
44+
);
45+
case Err(:final failure):
46+
emit(state.copyWith(failure: failure));
47+
}
5848
}
5949

60-
void clearError() => emit(state.copyWith(error: null));
61-
62-
void reset() => emit(const ReplaceByFeeState());
63-
6450
Future<void> broadcast() async {
65-
try {
66-
emit(state.copyWith(error: null));
67-
68-
if (state.newFeeRate == null) {
69-
emit(state.copyWith(error: NoFeeRateSelectedError()));
70-
return;
71-
}
51+
emit(state.copyWith(failure: null));
7252

73-
// The custom field currently shows a below-floor/empty rate. newFeeRate
74-
// still holds the last valid value, so without this guard Broadcast
75-
// would fire the stale rate the user no longer sees.
76-
if (state.customFeeBelowFloor) {
77-
emit(state.copyWith(error: FeeRateTooLowError()));
78-
return;
79-
}
80-
81-
final psbt = await bumpFeeUsecase.execute(
82-
walletId: originalTransaction.walletId,
83-
txid: originalTransaction.txId,
84-
newFeeRate: state.newFeeRate!.feeRate,
85-
);
53+
if (state.newFeeRate == null) {
54+
emit(state.copyWith(failure: const ReplaceByFeeNoFeeRateSelectedFailure()));
55+
return;
56+
}
8657

87-
final txid = await broadcastBitcoinTransactionUsecase.execute(
88-
psbt,
89-
isPsbt: true,
90-
);
58+
// The custom field currently shows a below-floor/empty rate. newFeeRate
59+
// still holds the last valid value, so without this guard broadcast
60+
// would fire the stale rate the user no longer sees.
61+
if (state.customFeeBelowFloor) {
62+
emit(state.copyWith(failure: const ReplaceByFeeFeeRateTooLowFailure()));
63+
return;
64+
}
9165

92-
emit(state.copyWith(txid: txid));
93-
// Just replacing with a similar bdk_dart error here for migration from bdk_flutter,
94-
// but no library errors should leak into the presentation layer,
95-
// we should catch them in the usecase and throw application errors instead
96-
} on bdk.FeeRateTooLowCreateTxException catch (_) {
97-
emit(state.copyWith(error: FeeRateTooLowError()));
98-
} catch (e) {
99-
emit(state.copyWith(error: GenericError()));
66+
switch (await bumpFeeUsecase.execute(
67+
walletId: originalTransaction.walletId,
68+
txid: originalTransaction.txId,
69+
newFeeRate: state.newFeeRate!.feeRate,
70+
)) {
71+
case Ok(:final value):
72+
emit(state.copyWith(txid: value));
73+
case Err(:final failure):
74+
emit(state.copyWith(failure: failure));
10075
}
10176
}
10277

10378
/// A valid (above-floor) selection — from a custom keystroke or the Fastest
104-
/// tile. Clears any prior below-floor flag.
79+
/// tile. Clears any prior below-floor flag and any broadcast failure.
10580
void onChangeFee(FeeEntity fee) =>
106-
emit(state.copyWith(newFeeRate: fee, customFeeBelowFloor: false));
81+
emit(state.copyWith(newFeeRate: fee, customFeeBelowFloor: false, failure: null));
10782

10883
/// The custom field went below the relay floor or was emptied. Keep
10984
/// [newFeeRate] (the last valid value / init sentinel) but flag the field so
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import 'package:bb_mobile/core/utils/build_context_x.dart';
2+
import 'package:bb_mobile/features/replace_by_fee/domain/replace_by_fee_failure.dart';
3+
import 'package:flutter/widgets.dart';
4+
5+
extension ReplaceByFeeFailureL10n on ReplaceByFeeFailure {
6+
String toTranslated(BuildContext context) => switch (this) {
7+
ReplaceByFeeNoFeeRateSelectedFailure() =>
8+
context.loc.replaceByFeeErrorNoFeeRateSelected,
9+
ReplaceByFeeFeeRateTooLowFailure() =>
10+
context.loc.replaceByFeeErrorFeeRateTooLow,
11+
ReplaceByFeeNetworkFeesFailure() => context.loc.oopsSomethingWentWrong,
12+
ReplaceByFeeUnexpectedFailure() => context.loc.oopsSomethingWentWrong,
13+
};
14+
}

lib/features/replace_by_fee/presentation/state.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
22
import 'package:bb_mobile/features/replace_by_fee/domain/fee_entity.dart';
3-
import 'package:bb_mobile/features/replace_by_fee/errors.dart';
3+
import 'package:bb_mobile/features/replace_by_fee/domain/replace_by_fee_failure.dart';
44
import 'package:freezed_annotation/freezed_annotation.dart';
55

66
part 'state.freezed.dart';
77

88
@freezed
99
sealed class ReplaceByFeeState with _$ReplaceByFeeState {
1010
const factory ReplaceByFeeState({
11-
@Default(null) ReplaceByFeeError? error,
11+
@Default(null) ReplaceByFeeFailure? failure,
1212
@Default(null) FeeEntity? fastestFeeRate,
1313
@Default(null) FeeEntity? newFeeRate,
1414

lib/features/replace_by_fee/router.dart

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_bitcoin_transaction_usecase.dart';
2-
import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart';
31
import 'package:bb_mobile/core/wallet/domain/entities/wallet_transaction.dart';
42
import 'package:bb_mobile/features/replace_by_fee/domain/bump_fee_usecase.dart';
53
import 'package:bb_mobile/features/replace_by_fee/presentation/cubit.dart';
@@ -30,9 +28,6 @@ class ReplaceByFeeRouter {
3028
(_) => ReplaceByFeeCubit(
3129
originalTransaction: tx,
3230
bumpFeeUsecase: locator<BumpFeeUsecase>(),
33-
broadcastBitcoinTransactionUsecase:
34-
locator<BroadcastBitcoinTransactionUsecase>(),
35-
getNetworkFeesUsecase: locator<GetNetworkFeesUsecase>(),
3631
),
3732
child: BlocListener<ReplaceByFeeCubit, ReplaceByFeeState>(
3833
listenWhen:

lib/features/replace_by_fee/ui/home_page.dart

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:bb_mobile/core/widgets/buttons/button.dart';
55
import 'package:bb_mobile/core/widgets/inputs/bb_keyboard_actions.dart';
66
import 'package:bb_mobile/core/widgets/text/text.dart';
77
import 'package:bb_mobile/features/replace_by_fee/presentation/cubit.dart';
8+
import 'package:bb_mobile/features/replace_by_fee/presentation/replace_by_fee_failure_l10n.dart';
89
import 'package:bb_mobile/features/replace_by_fee/presentation/state.dart';
910
import 'package:bb_mobile/features/replace_by_fee/ui/fee_selector_widget.dart';
1011
import 'package:flutter/material.dart';
@@ -47,6 +48,19 @@ class _ReplaceByFeeHomePageState extends State<ReplaceByFeeHomePage> {
4748
builder: (context, state) {
4849
final cubit = context.read<ReplaceByFeeCubit>();
4950

51+
if (state.failure != null && state.newFeeRate == null) {
52+
return Center(
53+
child: Padding(
54+
padding: const EdgeInsets.all(16.0),
55+
child: BBText(
56+
state.failure!.toTranslated(context),
57+
style: context.font.bodyMedium,
58+
color: context.appColors.error,
59+
),
60+
),
61+
);
62+
}
63+
5064
if (state.newFeeRate == null) {
5165
return const Center(child: CircularProgressIndicator());
5266
}
@@ -69,10 +83,10 @@ class _ReplaceByFeeHomePageState extends State<ReplaceByFeeHomePage> {
6983
focusNode: _feeNode,
7084
minRelay: state.minRelay,
7185
),
72-
if (state.error != null) ...[
86+
if (state.failure != null) ...[
7387
const Gap(16),
7488
BBText(
75-
state.error!.toTranslated(context),
89+
state.failure!.toTranslated(context),
7690
style: context.font.bodyMedium,
7791
color: context.appColors.error,
7892
),

0 commit comments

Comments
 (0)