Skip to content

Commit 7d1b354

Browse files
refactor(psbt_flow): sanitize user-facing error messages
1 parent 0aac8c5 commit 7d1b354

36 files changed

Lines changed: 325 additions & 1428 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import 'package:bb_mobile/core/bbqr/bbqr.dart';
2+
import 'package:bb_mobile/core/entities/signer_device_entity.dart';
3+
import 'package:bb_mobile/core/urqr/urqr.dart';
4+
import 'package:bb_mobile/core/utils/logger.dart';
5+
import 'package:bb_mobile/core/utils/result.dart';
6+
import 'package:bb_mobile/features/psbt_flow/domain/psbt_flow_failure.dart';
7+
import 'package:bull_sdk/bdk.dart' as bdk;
8+
import 'package:meta/meta.dart';
9+
10+
class GeneratePsbtQrPartsUsecase {
11+
@useResult
12+
Future<Result<List<String>, PsbtFlowFailure>> execute({
13+
required String psbt,
14+
required QrType qrType,
15+
required int fragmentLength,
16+
}) async {
17+
// The device signs over NFC or USB; there is nothing to encode.
18+
if (qrType == QrType.none) return const Ok(<String>[]);
19+
20+
// Reachable: `psbt_router` falls back to '' when no PSBT is passed in.
21+
if (psbt.isEmpty) return const Err(PsbtFlowInvalidPsbtFailure());
22+
23+
try {
24+
final parts = switch (qrType) {
25+
QrType.bbqr => await Bbqr.splitPsbt(psbt),
26+
QrType.urqr => UrQrGenerator.generatePsbtUr(
27+
psbt,
28+
fragmentLength: fragmentLength,
29+
),
30+
// Unreachable — returned above; present so the switch stays exhaustive.
31+
QrType.none => const <String>[],
32+
};
33+
34+
// A readable PSBT always yields at least one part, so an empty result
35+
// means the encoder gave up quietly. Guarded rather than trusted, so such
36+
// a failure can never reach the user as a blank "no parts" screen.
37+
if (parts.isEmpty) {
38+
log.warning('PSBT QR encoder returned no parts for $qrType');
39+
return const Err(PsbtFlowQrEncodingFailure());
40+
}
41+
42+
return Ok(parts);
43+
} on FormatException catch (e, st) {
44+
log.warning(
45+
'PSBT is not valid base64: ${e.message} at offset ${e.offset}',
46+
trace: st,
47+
);
48+
return const Err(PsbtFlowInvalidPsbtFailure());
49+
} on bdk.PsbtException catch (e, st) {
50+
log.warning('PSBT rejected by the parser', error: e, trace: st);
51+
return const Err(PsbtFlowInvalidPsbtFailure());
52+
} catch (e, st) {
53+
log.severe(
54+
message: 'Failed to encode PSBT as $qrType QR',
55+
error: e,
56+
trace: st,
57+
);
58+
return const Err(PsbtFlowQrEncodingFailure());
59+
}
60+
}
61+
}
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 PsbtFlowFailure extends Failure {
4+
const PsbtFlowFailure([super.logMessage]);
5+
}
6+
7+
/// The PSBT could not be read at all — not valid base64, or rejected by the
8+
/// PSBT parser. The user has to go back and rebuild the transaction.
9+
final class PsbtFlowInvalidPsbtFailure extends PsbtFlowFailure {
10+
const PsbtFlowInvalidPsbtFailure();
11+
}
12+
13+
/// The PSBT was readable but could not be encoded into QR parts: the encoder
14+
/// threw, or returned nothing (a well-formed PSBT always yields at least one
15+
/// part, so an empty result is a failure, not an empty state).
16+
final class PsbtFlowQrEncodingFailure extends PsbtFlowFailure {
17+
const PsbtFlowQrEncodingFailure();
18+
}
19+
20+
final class PsbtFlowUnexpectedFailure extends PsbtFlowFailure {
21+
const PsbtFlowUnexpectedFailure([super.logMessage]);
22+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import 'package:bb_mobile/core/utils/build_context_x.dart';
2+
import 'package:bb_mobile/features/psbt_flow/domain/psbt_flow_failure.dart';
3+
import 'package:flutter/widgets.dart';
4+
5+
extension PsbtFlowFailureL10n on PsbtFlowFailure {
6+
String toTranslated(BuildContext context) => switch (this) {
7+
PsbtFlowInvalidPsbtFailure() => context.loc.psbtFlowInvalidPsbtError,
8+
PsbtFlowQrEncodingFailure() => context.loc.psbtFlowQrEncodingError,
9+
PsbtFlowUnexpectedFailure() => context.loc.oopsSomethingWentWrong,
10+
};
11+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import 'package:bb_mobile/core/entities/signer_device_entity.dart';
2+
import 'package:bb_mobile/features/psbt_flow/domain/generate_psbt_qr_parts_usecase.dart';
3+
import 'package:bb_mobile/features/psbt_flow/show_animated_qr/show_animated_qr_cubit.dart';
4+
import 'package:get_it/get_it.dart';
5+
6+
class PsbtFlowLocator {
7+
static void setup(GetIt locator) {
8+
registerUsecases(locator);
9+
registerBlocs(locator);
10+
}
11+
12+
static void registerUsecases(GetIt locator) {
13+
locator.registerLazySingleton<GeneratePsbtQrPartsUsecase>(
14+
GeneratePsbtQrPartsUsecase.new,
15+
);
16+
}
17+
18+
static void registerBlocs(GetIt locator) {
19+
locator.registerFactoryParam<ShowAnimatedQrCubit, String, QrType>(
20+
(psbt, qrType) => ShowAnimatedQrCubit(
21+
generatePsbtQrPartsUsecase: locator<GeneratePsbtQrPartsUsecase>(),
22+
psbt: psbt,
23+
qrType: qrType,
24+
),
25+
);
26+
}
27+
}

lib/features/psbt_flow/show_animated_qr/show_animated_qr_cubit.dart

Lines changed: 27 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,47 @@
11
import 'dart:async';
22

3-
import 'package:bb_mobile/core/bbqr/bbqr.dart';
43
import 'package:bb_mobile/core/entities/signer_device_entity.dart';
5-
import 'package:bb_mobile/core/urqr/urqr.dart';
4+
import 'package:bb_mobile/core/utils/result.dart';
5+
import 'package:bb_mobile/features/psbt_flow/domain/generate_psbt_qr_parts_usecase.dart';
66
import 'package:bb_mobile/features/psbt_flow/show_animated_qr/show_animated_qr_state.dart';
77
import 'package:flutter_bloc/flutter_bloc.dart';
88

99
class ShowAnimatedQrCubit extends Cubit<ShowAnimatedQrState> {
10+
final GeneratePsbtQrPartsUsecase _generatePsbtQrPartsUsecase;
1011
final String psbt;
1112
final QrType qrType;
1213
Timer? _timer;
1314

14-
ShowAnimatedQrCubit({required this.psbt, required this.qrType})
15-
: super(const ShowAnimatedQrState()) {
15+
ShowAnimatedQrCubit({
16+
required this._generatePsbtQrPartsUsecase,
17+
required this.psbt,
18+
required this.qrType,
19+
}) : super(const ShowAnimatedQrState()) {
1620
_generateQrParts();
1721
}
1822

1923
Future<void> _generateQrParts() async {
20-
try {
21-
emit(state.copyWith(isLoading: true, error: null));
24+
emit(state.copyWith(isLoading: true, failure: null));
2225

23-
final parts = switch (qrType) {
24-
QrType.bbqr => await Bbqr.splitPsbt(psbt),
25-
QrType.urqr => UrQrGenerator.generatePsbtUr(
26-
psbt,
27-
fragmentLength: state.fragmentLength,
28-
),
29-
QrType.none => <String>[],
30-
};
26+
final result = await _generatePsbtQrPartsUsecase.execute(
27+
psbt: psbt,
28+
qrType: qrType,
29+
fragmentLength: state.fragmentLength,
30+
);
3131

32-
emit(
33-
state.copyWith(
34-
isLoading: false,
35-
parts: parts,
36-
currentIndex: 0,
37-
error: null,
38-
),
39-
);
40-
41-
if (parts.isNotEmpty) {
42-
_startCycling();
43-
}
44-
} catch (e) {
45-
emit(state.copyWith(isLoading: false, error: e.toString()));
32+
switch (result) {
33+
case Ok(:final value):
34+
emit(
35+
state.copyWith(
36+
isLoading: false,
37+
parts: value,
38+
currentIndex: 0,
39+
failure: null,
40+
),
41+
);
42+
if (value.isNotEmpty) _startCycling();
43+
case Err(:final failure):
44+
emit(state.copyWith(isLoading: false, failure: failure));
4645
}
4746
}
4847

lib/features/psbt_flow/show_animated_qr/show_animated_qr_state.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:bb_mobile/features/psbt_flow/domain/psbt_flow_failure.dart';
12
import 'package:freezed_annotation/freezed_annotation.dart';
23

34
part 'show_animated_qr_state.freezed.dart';
@@ -9,6 +10,6 @@ abstract class ShowAnimatedQrState with _$ShowAnimatedQrState {
910
@Default([]) List<String> parts,
1011
@Default(100) int fragmentLength,
1112
@Default(false) bool isLoading,
12-
String? error,
13+
PsbtFlowFailure? failure,
1314
}) = _ShowAnimatedQrState;
1415
}

lib/features/psbt_flow/show_animated_qr/show_animated_qr_widget.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import 'package:bb_mobile/core/themes/app_theme.dart';
55
import 'package:bb_mobile/core/utils/build_context_x.dart';
66
import 'package:bb_mobile/core/widgets/qr_display_widget.dart';
77
import 'package:bb_mobile/core/widgets/text/text.dart';
8+
import 'package:bb_mobile/features/psbt_flow/presentation/psbt_flow_failure_l10n.dart';
89
import 'package:bb_mobile/features/psbt_flow/show_animated_qr/show_animated_qr_cubit.dart';
910
import 'package:bb_mobile/features/psbt_flow/show_animated_qr/show_animated_qr_state.dart';
11+
import 'package:bb_mobile/locator.dart';
1012
import 'package:flutter/material.dart';
1113
import 'package:flutter_bloc/flutter_bloc.dart';
1214
import 'package:gap/gap.dart';
@@ -26,7 +28,7 @@ class ShowAnimatedQrWidget extends StatelessWidget {
2628
@override
2729
Widget build(BuildContext context) {
2830
return BlocProvider(
29-
create: (_) => ShowAnimatedQrCubit(psbt: psbt, qrType: qrType),
31+
create: (_) => locator<ShowAnimatedQrCubit>(param1: psbt, param2: qrType),
3032
child: _ShowAnimatedQrView(showSlider: showSlider),
3133
);
3234
}
@@ -73,7 +75,7 @@ class _ShowAnimatedQrViewState extends State<_ShowAnimatedQrView> {
7375
);
7476
}
7577

76-
if (state.error != null) {
78+
if (state.failure case final failure?) {
7779
return Container(
7880
width: 300,
7981
height: 300,
@@ -83,7 +85,8 @@ class _ShowAnimatedQrViewState extends State<_ShowAnimatedQrView> {
8385
),
8486
child: Center(
8587
child: Text(
86-
context.loc.psbtFlowError(state.error!),
88+
failure.toTranslated(context),
89+
textAlign: TextAlign.center,
8790
style: context.font.bodyMedium?.copyWith(
8891
color: context.appColors.error,
8992
),

lib/locator.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import 'package:bb_mobile/features/legacy_seed_view/legacy_seed_view_locator.dar
3333
import 'package:bb_mobile/features/onboarding/onboarding_locator.dart';
3434
import 'package:bb_mobile/features/pay/pay_locator.dart';
3535
import 'package:bb_mobile/features/pin_code/pin_code_locator.dart';
36+
import 'package:bb_mobile/features/psbt_flow/psbt_flow_locator.dart';
3637
import 'package:bb_mobile/features/receive/receive_locator.dart';
3738
import 'package:bb_mobile/features/recipients/recipients_locator.dart';
3839
import 'package:bb_mobile/features/replace_by_fee/locator.dart';
@@ -106,6 +107,7 @@ class AppLocator {
106107
TestWalletBackupLocator.setup(locator);
107108
ImportWatchOnlyLocator.setup(locator);
108109
BroadcastSignedTxLocator.setup(locator);
110+
PsbtFlowLocator.setup(locator);
109111
SwapLocator.setup(locator);
110112

111113
ExchangeLocator.setup(locator);

0 commit comments

Comments
 (0)