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
54 changes: 25 additions & 29 deletions lib/core/bbqr/bbqr.dart
Original file line number Diff line number Diff line change
Expand Up @@ -87,35 +87,31 @@ class Bbqr {
}

static Future<List<String>> splitPsbt(String psbt) async {
try {
// check if the PSBT is valid, will throw if not
final parsedPsbt = bdk.Psbt(psbtBase64: psbt);
final validPstb = parsedPsbt.serialize();
final psbtBytes = base64.decode(validPstb);

// The more we split the easier it is to scan the QR code.
var minSplitNumber = BigInt.from(psbtBytes.length ~/ 1000);
if (minSplitNumber < BigInt.from(1)) minSplitNumber = BigInt.from(1);

final defaultOptions = await bbqr.SplitOptions.default_();
final bbqrOptions = bbqr.SplitOptions(
minVersion: defaultOptions.minVersion,
maxVersion: defaultOptions.maxVersion,
encoding: defaultOptions.encoding,
maxSplitNumber: defaultOptions.maxSplitNumber,
minSplitNumber: minSplitNumber,
);

final split = await bbqr.Split.tryFromData(
bytes: psbtBytes,
fileType: bbqr.FileType.psbt,
options: bbqrOptions,
);

return split.parts;
} catch (e) {
rethrow;
}
// check if the PSBT is valid, will throw if not
final parsedPsbt = bdk.Psbt(psbtBase64: psbt);
final validPstb = parsedPsbt.serialize();
final psbtBytes = base64.decode(validPstb);

// The more we split the easier it is to scan the QR code.
var minSplitNumber = BigInt.from(psbtBytes.length ~/ 1000);
if (minSplitNumber < BigInt.from(1)) minSplitNumber = BigInt.from(1);

final defaultOptions = await bbqr.SplitOptions.default_();
final bbqrOptions = bbqr.SplitOptions(
minVersion: defaultOptions.minVersion,
maxVersion: defaultOptions.maxVersion,
encoding: defaultOptions.encoding,
maxSplitNumber: defaultOptions.maxSplitNumber,
minSplitNumber: minSplitNumber,
);

final split = await bbqr.Split.tryFromData(
bytes: psbtBytes,
fileType: bbqr.FileType.psbt,
options: bbqrOptions,
);

return split.parts;
}
}

Expand Down
32 changes: 11 additions & 21 deletions lib/core/urqr/urqr.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bs58check/bs58check.dart' as base58;
import 'package:cbor/cbor.dart';
import 'package:satoshifier/satoshifier.dart';
Expand All @@ -11,27 +10,18 @@ import 'package:ur/ur_encoder.dart';

class UrQrGenerator {
static List<String> generatePsbtUr(String psbt, {int fragmentLength = 100}) {
try {
final psbtBytes = base64.decode(psbt);
final cryptoPsbt = CryptoPsbt.fromPayload(psbtBytes);
final ur = cryptoPsbt.toUR();
final encoder = UREncoder(ur, fragmentLength);

final parts = <String>[];
while (!encoder.isComplete) {
final part = encoder.nextPart();
parts.add(part);
}

return parts;
} catch (e) {
log.severe(
message: 'Failed to generate PSBT UR',
error: e,
trace: StackTrace.current,
);
return [];
final psbtBytes = base64.decode(psbt);
final cryptoPsbt = CryptoPsbt.fromPayload(psbtBytes);
final ur = cryptoPsbt.toUR();
final encoder = UREncoder(ur, fragmentLength);

final parts = <String>[];
while (!encoder.isComplete) {
final part = encoder.nextPart();
parts.add(part);
}

return parts;
}
}

Expand Down
61 changes: 61 additions & 0 deletions lib/features/psbt_flow/domain/generate_psbt_qr_parts_usecase.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import 'package:bb_mobile/core/bbqr/bbqr.dart';
import 'package:bb_mobile/core/entities/signer_device_entity.dart';
import 'package:bb_mobile/core/urqr/urqr.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/features/psbt_flow/domain/psbt_flow_failure.dart';
import 'package:bull_sdk/bdk.dart' as bdk;
import 'package:meta/meta.dart';

class GeneratePsbtQrPartsUsecase {
@useResult
Future<Result<List<String>, PsbtFlowFailure>> execute({
required String psbt,
required QrType qrType,
required int fragmentLength,
}) async {
// The device signs over NFC or USB; there is nothing to encode.
if (qrType == QrType.none) return const Ok(<String>[]);

// Reachable: `psbt_router` falls back to '' when no PSBT is passed in.
if (psbt.isEmpty) return const Err(PsbtFlowInvalidPsbtFailure());

try {
final parts = switch (qrType) {
QrType.bbqr => await Bbqr.splitPsbt(psbt),
QrType.urqr => UrQrGenerator.generatePsbtUr(
psbt,
fragmentLength: fragmentLength,
),
// Unreachable — returned above; present so the switch stays exhaustive.
QrType.none => const <String>[],
};

// A readable PSBT always yields at least one part, so an empty result
// means the encoder gave up quietly. Guarded rather than trusted, so such
// a failure can never reach the user as a blank "no parts" screen.
if (parts.isEmpty) {
log.warning('PSBT QR encoder returned no parts for $qrType');
return const Err(PsbtFlowQrEncodingFailure());
}

return Ok(parts);
} on FormatException catch (e, st) {
log.warning(
'PSBT is not valid base64: ${e.message} at offset ${e.offset}',
trace: st,
);
return const Err(PsbtFlowInvalidPsbtFailure());
} on bdk.PsbtParseException catch (e, st) {
log.warning('PSBT rejected by the parser', error: e, trace: st);
return const Err(PsbtFlowInvalidPsbtFailure());
} catch (e, st) {
log.severe(
message: 'Unexpected failure encoding PSBT as $qrType QR',
error: e,
trace: st,
);
return Err(PsbtFlowUnexpectedFailure(e.toString()));
}
}
}
22 changes: 22 additions & 0 deletions lib/features/psbt_flow/domain/psbt_flow_failure.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'package:bb_mobile/core/failures/failure.dart';

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

/// The PSBT could not be read at all — not valid base64, or rejected by the
/// PSBT parser. The user has to go back and rebuild the transaction.
final class PsbtFlowInvalidPsbtFailure extends PsbtFlowFailure {
const PsbtFlowInvalidPsbtFailure();
}

/// The PSBT was readable but could not be encoded into QR parts: the encoder
/// threw, or returned nothing (a well-formed PSBT always yields at least one
/// part, so an empty result is a failure, not an empty state).
final class PsbtFlowQrEncodingFailure extends PsbtFlowFailure {
const PsbtFlowQrEncodingFailure();
}

final class PsbtFlowUnexpectedFailure extends PsbtFlowFailure {
const PsbtFlowUnexpectedFailure([super.logMessage]);
}
11 changes: 11 additions & 0 deletions lib/features/psbt_flow/presentation/psbt_flow_failure_l10n.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/features/psbt_flow/domain/psbt_flow_failure.dart';
import 'package:flutter/widgets.dart';

extension PsbtFlowFailureL10n on PsbtFlowFailure {
String toTranslated(BuildContext context) => switch (this) {
PsbtFlowInvalidPsbtFailure() => context.loc.psbtFlowInvalidPsbtError,
PsbtFlowQrEncodingFailure() => context.loc.psbtFlowQrEncodingError,
PsbtFlowUnexpectedFailure() => context.loc.oopsSomethingWentWrong,
};
}
27 changes: 27 additions & 0 deletions lib/features/psbt_flow/psbt_flow_locator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:bb_mobile/core/entities/signer_device_entity.dart';
import 'package:bb_mobile/features/psbt_flow/domain/generate_psbt_qr_parts_usecase.dart';
import 'package:bb_mobile/features/psbt_flow/show_animated_qr/show_animated_qr_cubit.dart';
import 'package:get_it/get_it.dart';

class PsbtFlowLocator {
static void setup(GetIt locator) {
registerUsecases(locator);
registerBlocs(locator);
}

static void registerUsecases(GetIt locator) {
locator.registerLazySingleton<GeneratePsbtQrPartsUsecase>(
GeneratePsbtQrPartsUsecase.new,
);
}

static void registerBlocs(GetIt locator) {
locator.registerFactoryParam<ShowAnimatedQrCubit, String, QrType>(
(psbt, qrType) => ShowAnimatedQrCubit(
generatePsbtQrPartsUsecase: locator<GeneratePsbtQrPartsUsecase>(),
psbt: psbt,
qrType: qrType,
),
);
}
}
60 changes: 33 additions & 27 deletions lib/features/psbt_flow/show_animated_qr/show_animated_qr_cubit.dart
Original file line number Diff line number Diff line change
@@ -1,48 +1,54 @@
import 'dart:async';

import 'package:bb_mobile/core/bbqr/bbqr.dart';
import 'package:bb_mobile/core/entities/signer_device_entity.dart';
import 'package:bb_mobile/core/urqr/urqr.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/features/psbt_flow/domain/generate_psbt_qr_parts_usecase.dart';
import 'package:bb_mobile/features/psbt_flow/show_animated_qr/show_animated_qr_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class ShowAnimatedQrCubit extends Cubit<ShowAnimatedQrState> {
final GeneratePsbtQrPartsUsecase _generatePsbtQrPartsUsecase;
final String psbt;
final QrType qrType;
Timer? _timer;

ShowAnimatedQrCubit({required this.psbt, required this.qrType})
: super(const ShowAnimatedQrState()) {
ShowAnimatedQrCubit({
required this._generatePsbtQrPartsUsecase,
required this.psbt,
required this.qrType,
}) : super(const ShowAnimatedQrState()) {
_generateQrParts();
}

Future<void> _generateQrParts() async {
try {
emit(state.copyWith(isLoading: true, error: null));
emit(state.copyWith(isLoading: true, failure: null));

final parts = switch (qrType) {
QrType.bbqr => await Bbqr.splitPsbt(psbt),
QrType.urqr => UrQrGenerator.generatePsbtUr(
psbt,
fragmentLength: state.fragmentLength,
),
QrType.none => <String>[],
};
final result = await _generatePsbtQrPartsUsecase.execute(
psbt: psbt,
qrType: qrType,
fragmentLength: state.fragmentLength,
);

emit(
state.copyWith(
isLoading: false,
parts: parts,
currentIndex: 0,
error: null,
),
);
// The screen can be popped while the encoder runs (BBQr splits are async
// FFI work); emitting on a closed cubit throws a StateError.
if (isClosed) return;

if (parts.isNotEmpty) {
_startCycling();
}
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
switch (result) {
case Ok(:final value):
emit(
state.copyWith(
isLoading: false,
parts: value,
currentIndex: 0,
failure: null,
),
);
if (value.isNotEmpty) _startCycling();
case Err(:final failure):
// Stop a timer from an earlier success cycling stale parts behind
// the error screen.
_timer?.cancel();
emit(state.copyWith(isLoading: false, failure: failure));
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:bb_mobile/features/psbt_flow/domain/psbt_flow_failure.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'show_animated_qr_state.freezed.dart';
Expand All @@ -9,6 +10,6 @@ abstract class ShowAnimatedQrState with _$ShowAnimatedQrState {
@Default([]) List<String> parts,
@Default(100) int fragmentLength,
@Default(false) bool isLoading,
String? error,
PsbtFlowFailure? failure,
}) = _ShowAnimatedQrState;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import 'package:bb_mobile/core/themes/app_theme.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/core/widgets/qr_display_widget.dart';
import 'package:bb_mobile/core/widgets/text/text.dart';
import 'package:bb_mobile/features/psbt_flow/presentation/psbt_flow_failure_l10n.dart';
import 'package:bb_mobile/features/psbt_flow/show_animated_qr/show_animated_qr_cubit.dart';
import 'package:bb_mobile/features/psbt_flow/show_animated_qr/show_animated_qr_state.dart';
import 'package:bb_mobile/locator.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:gap/gap.dart';
Expand All @@ -26,7 +28,7 @@ class ShowAnimatedQrWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => ShowAnimatedQrCubit(psbt: psbt, qrType: qrType),
create: (_) => locator<ShowAnimatedQrCubit>(param1: psbt, param2: qrType),
child: _ShowAnimatedQrView(showSlider: showSlider),
);
}
Expand Down Expand Up @@ -73,7 +75,7 @@ class _ShowAnimatedQrViewState extends State<_ShowAnimatedQrView> {
);
}

if (state.error != null) {
if (state.failure case final failure?) {
return Container(
width: 300,
height: 300,
Expand All @@ -83,7 +85,8 @@ class _ShowAnimatedQrViewState extends State<_ShowAnimatedQrView> {
),
child: Center(
child: Text(
context.loc.psbtFlowError(state.error!),
failure.toTranslated(context),
textAlign: TextAlign.center,
style: context.font.bodyMedium?.copyWith(
color: context.appColors.error,
),
Expand Down
2 changes: 2 additions & 0 deletions lib/locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import 'package:bb_mobile/features/legacy_seed_view/legacy_seed_view_locator.dar
import 'package:bb_mobile/features/onboarding/onboarding_locator.dart';
import 'package:bb_mobile/features/pay/pay_locator.dart';
import 'package:bb_mobile/features/pin_code/pin_code_locator.dart';
import 'package:bb_mobile/features/psbt_flow/psbt_flow_locator.dart';
import 'package:bb_mobile/features/receive/receive_locator.dart';
import 'package:bb_mobile/features/recipients/recipients_locator.dart';
import 'package:bb_mobile/features/replace_by_fee/locator.dart';
Expand Down Expand Up @@ -106,6 +107,7 @@ class AppLocator {
TestWalletBackupLocator.setup(locator);
ImportWatchOnlyLocator.setup(locator);
BroadcastSignedTxLocator.setup(locator);
PsbtFlowLocator.setup(locator);
SwapLocator.setup(locator);

ExchangeLocator.setup(locator);
Expand Down
Loading
Loading