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
4 changes: 4 additions & 0 deletions lib/core/primitives/payment_network.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// The network a payment moves through: on-chain Bitcoin, Lightning, or
/// Liquid. Shared across features (e.g. `swap`, `dca`) that need to name a
/// network without depending on each other's internal types.
enum PaymentNetwork { bitcoin, lightning, liquid }
12 changes: 12 additions & 0 deletions lib/core/primitives/payment_network_l10n.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import 'package:bb_mobile/core/primitives/payment_network.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:flutter/widgets.dart';

/// Kept in its own file so [PaymentNetwork] itself stays Flutter-free.
extension PaymentNetworkL10n on PaymentNetwork {
String toTranslated(BuildContext context) => switch (this) {
PaymentNetwork.bitcoin => context.loc.transactionNetworkBitcoin,
PaymentNetwork.lightning => context.loc.transactionNetworkLightning,
PaymentNetwork.liquid => context.loc.transactionNetworkLiquid,
};
}
12 changes: 12 additions & 0 deletions lib/features/receive/domain/receive_failure.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:bb_mobile/core/failures/failure.dart';
import 'package:bb_mobile/core/primitives/payment_network.dart';

sealed class ReceiveFailure extends Failure {
const ReceiveFailure([super.logMessage]);
Expand All @@ -23,6 +24,17 @@ final class ReceiveSwapUnavailableFailure extends ReceiveFailure {
const ReceiveSwapUnavailableFailure([super.logMessage]);
}

final class ReceiveSwapRouteUnavailableFailure extends ReceiveFailure {
final PaymentNetwork? inNetwork;
final PaymentNetwork? outNetwork;

const ReceiveSwapRouteUnavailableFailure({
this.inNetwork,
this.outNetwork,
String? logMessage,
}) : super(logMessage);
}

final class ReceiveNetworkFailure extends ReceiveFailure {
const ReceiveNetworkFailure([super.logMessage]);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,12 @@ class CreateReceiveOrderSwapUsecase {
isMinimum: isMinimum,
logMessage: logMessage,
),
SwapNoPaymentOptionFailure() ||
SwapNoPaymentOptionFailure(:final inNetwork, :final outNetwork) =>
ReceiveSwapRouteUnavailableFailure(
inNetwork: inNetwork,
outNetwork: outNetwork,
logMessage: failure.logMessage,
),
SwapValidationFailure() ||
SwapProviderFailure() => ReceiveSwapUnavailableFailure(failure.logMessage),
SwapRateLimitedFailure(:final retryAfter) => ReceiveRateLimitedFailure(
Expand Down
8 changes: 8 additions & 0 deletions lib/features/receive/presentation/receive_failure_l10n.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:bb_mobile/core/primitives/payment_network_l10n.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/features/receive/domain/receive_failure.dart';
import 'package:flutter/widgets.dart';
Expand All @@ -18,6 +19,13 @@ extension ReceiveFailureL10n on ReceiveFailure {
ReceiveInvalidInvoiceFailure() ||
ReceiveSwapUnavailableFailure() ||
ReceiveUnexpectedFailure() => context.loc.oopsSomethingWentWrong,
ReceiveSwapRouteUnavailableFailure(:final inNetwork?, :final outNetwork?) =>
context.loc.swapErrorRouteUnavailable(
inNetwork.toTranslated(context),
outNetwork.toTranslated(context),
),
ReceiveSwapRouteUnavailableFailure() =>
context.loc.swapErrorRouteUnavailableGeneric,
ReceiveRateLimitedFailure(:final retryAfter) =>
context.loc.swapErrorRateLimited(retryAfter?.inSeconds ?? 30),
ReceiveNetworkFailure() => context.loc.payNetworkError,
Expand Down
12 changes: 12 additions & 0 deletions lib/features/send/domain/send_failure.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:bb_mobile/core/failures/failure.dart';
import 'package:bb_mobile/core/primitives/payment_network.dart';

sealed class SendFailure extends Failure {
const SendFailure([super.logMessage]);
Expand Down Expand Up @@ -46,6 +47,17 @@ final class SendSwapCreationFailure extends SendFailure {
const SendSwapCreationFailure([super.logMessage]);
}

final class SendSwapRouteUnavailableFailure extends SendFailure {
final PaymentNetwork? inNetwork;
final PaymentNetwork? outNetwork;

const SendSwapRouteUnavailableFailure({
this.inNetwork,
this.outNetwork,
String? logMessage,
}) : super(logMessage);
}

final class SendRateLimitedFailure extends SendFailure {
final Duration? retryAfter;

Expand Down
7 changes: 6 additions & 1 deletion lib/features/send/domain/swap_failure_to_send_failure.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ SendFailure mapSwapFailureToSendFailure(SwapFailure failure) =>
SwapValidationFailure() => SendInvalidPaymentRequestFailure(
logMessage: failure.logMessage,
),
SwapNoPaymentOptionFailure() ||
SwapNoPaymentOptionFailure(:final inNetwork, :final outNetwork) =>
SendSwapRouteUnavailableFailure(
inNetwork: inNetwork,
outNetwork: outNetwork,
logMessage: failure.logMessage,
),
SwapOrderExpiredFailure() ||
SwapCreationUnknownFailure() ||
SwapOrderMismatchFailure() ||
Expand Down
8 changes: 8 additions & 0 deletions lib/features/send/presentation/send_failure_l10n.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:bb_mobile/core/primitives/payment_network_l10n.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/features/send/domain/send_failure.dart';
import 'package:flutter/widgets.dart';
Expand All @@ -22,6 +23,13 @@ extension SendFailureL10n on SendFailure {
SendAmountOutOfBoundsFailure() =>
context.loc.sendErrorAmountBelowSwapLimits,
SendSwapCreationFailure() => context.loc.sendErrorSwapCreationFailed,
SendSwapRouteUnavailableFailure(:final inNetwork?, :final outNetwork?) =>
context.loc.swapErrorRouteUnavailable(
inNetwork.toTranslated(context),
outNetwork.toTranslated(context),
),
SendSwapRouteUnavailableFailure() =>
context.loc.swapErrorRouteUnavailableGeneric,
SendRateLimitedFailure(:final retryAfter) =>
context.loc.swapErrorRateLimited(retryAfter?.inSeconds ?? 30),
SendTransactionBuildFailure() => context.loc.sendErrorBuildFailed,
Expand Down
30 changes: 25 additions & 5 deletions lib/features/swap/data/order_swap_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ class OrderSwapRepositoryImpl implements OrderSwapRepository {
);
return Ok(model.toEntity(inNetwork: inNetwork, outNetwork: outNetwork));
} catch (error) {
final failure = _mapFailure(error);
final failure = _mapFailure(
error,
inNetwork: inNetwork,
outNetwork: outNetwork,
);
log.warning(
'[OrderSwap] quote failed environment=${environment.name} '
'route=${inNetwork.name}->${outNetwork.name} '
Expand Down Expand Up @@ -175,7 +179,13 @@ class OrderSwapRepositoryImpl implements OrderSwapRepository {
} catch (error) {
if (error is ArgumentError) {
await _saveRecordLocked(matching.markFailed());
return Err(_mapFailure(error));
return Err(
_mapFailure(
error,
inNetwork: inNetwork,
outNetwork: outNetwork,
),
);
}
return const Err(
SwapCreationUnknownFailure(
Expand Down Expand Up @@ -280,7 +290,9 @@ class OrderSwapRepositoryImpl implements OrderSwapRepository {
return Err(SwapStorageFailure(storageError.toString()));
}
}
return Err(_mapFailure(error));
return Err(
_mapFailure(error, inNetwork: inNetwork, outNetwork: outNetwork),
);
}
}

Expand Down Expand Up @@ -654,7 +666,11 @@ class OrderSwapRepositoryImpl implements OrderSwapRepository {
return current;
}

SwapFailure _mapFailure(Object error) {
SwapFailure _mapFailure(
Object error, {
OrderSwapNetwork? inNetwork,
OrderSwapNetwork? outNetwork,
}) {
if (error is ArgumentError) {
return SwapOrderMismatchFailure(error.message.toString());
}
Expand All @@ -680,7 +696,11 @@ class OrderSwapRepositoryImpl implements OrderSwapRepository {
}
if (error is ExchangeRpcException) {
return switch (error.apiCode) {
'ERR_ORD_PO404' => SwapNoPaymentOptionFailure(error.logMessage),
'ERR_ORD_PO404' => SwapNoPaymentOptionFailure(
inNetwork: inNetwork?.toPaymentNetwork,
outNetwork: outNetwork?.toPaymentNetwork,
logMessage: error.logMessage,
),
'ERR_ORD_LMT001' => SwapAmountOutOfBoundsFailure(
limitAmountSat: error.limit == null
? null
Expand Down
8 changes: 8 additions & 0 deletions lib/features/swap/domain/entities/order_swap_network.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import 'package:bb_mobile/core/primitives/payment_network.dart';

enum OrderSwapNetwork {
bitcoin,
liquid,
lightning;

String get apiName => name;

PaymentNetwork get toPaymentNetwork => switch (this) {
OrderSwapNetwork.bitcoin => PaymentNetwork.bitcoin,
OrderSwapNetwork.liquid => PaymentNetwork.liquid,
OrderSwapNetwork.lightning => PaymentNetwork.lightning,
};
}
10 changes: 9 additions & 1 deletion lib/features/swap/domain/swap_failure.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import 'package:bb_mobile/core/failures/failure.dart';
import 'package:bb_mobile/core/primitives/payment_network.dart';

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

final class SwapNoPaymentOptionFailure extends SwapFailure {
const SwapNoPaymentOptionFailure([super.logMessage]);
final PaymentNetwork? inNetwork;
final PaymentNetwork? outNetwork;

const SwapNoPaymentOptionFailure({
this.inNetwork,
this.outNetwork,
String? logMessage,
}) : super(logMessage);
}

final class SwapAmountOutOfBoundsFailure extends SwapFailure {
Expand Down
9 changes: 8 additions & 1 deletion lib/features/swap/presentation/swap_failure_l10n.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:bb_mobile/core/primitives/payment_network_l10n.dart';
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/features/swap/public/swap_facade.dart';
import 'package:flutter/widgets.dart';
Expand All @@ -14,8 +15,14 @@ extension SwapFailureL10n on SwapFailure {
isMinimum: false,
) =>
context.loc.swapErrorAmountAboveMaximum(limit.toString()),
SwapNoPaymentOptionFailure(:final inNetwork?, :final outNetwork?) =>
context.loc.swapErrorRouteUnavailable(
inNetwork.toTranslated(context),
outNetwork.toTranslated(context),
),
SwapNoPaymentOptionFailure() =>
context.loc.swapErrorRouteUnavailableGeneric,
SwapAmountOutOfBoundsFailure() ||
SwapNoPaymentOptionFailure() ||
SwapValidationFailure() ||
SwapOrderNotFoundFailure() ||
SwapOrderExpiredFailure() ||
Expand Down
16 changes: 16 additions & 0 deletions localization/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -14566,5 +14566,21 @@
"transactionPayjoinFallbackProcessing": "Processing as a regular transaction…",
"@transactionPayjoinFallbackProcessing": {
"description": "Status shown while broadcasting a payjoin fallback transaction"
},
"swapErrorRouteUnavailable": "{fromNetwork} to {toNetwork} is not available.",
"@swapErrorRouteUnavailable": {
"description": "Shown when the exchange rejects a swap because no payment option/route exists for it (API ERR_ORD_PO404), naming the source and destination networks.",
"placeholders": {
"fromNetwork": {
"type": "String"
},
"toNetwork": {
"type": "String"
}
}
},
"swapErrorRouteUnavailableGeneric": "This swap is not available.",
"@swapErrorRouteUnavailableGeneric": {
"description": "Fallback for ERR_ORD_PO404 when the source or destination network is unknown."
}
}
4 changes: 3 additions & 1 deletion localization/app_fr.arb
Original file line number Diff line number Diff line change
Expand Up @@ -5024,5 +5024,7 @@
"sendSwapExpiredMessage": "Cette commande a expiré avant l'envoi de fonds. Rien n'a été envoyé depuis votre portefeuille.",
"announcementAppUpdateRequiredTitle": "Mettez BULL à jour",
"announcementAppUpdateRequiredDescription": "Pour utiliser le service de paiement Lightning et de swap, veuillez mettre à jour l’application mobile BULL vers la nouvelle version.",
"mempoolErrorNetworkMismatch": "Ce serveur est sur un autre réseau Bitcoin."
"mempoolErrorNetworkMismatch": "Ce serveur est sur un autre réseau Bitcoin.",
"swapErrorRouteUnavailable": "{fromNetwork} vers {toNetwork} n'est pas disponible.",
"swapErrorRouteUnavailableGeneric": "Ce swap n'est pas disponible."
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:bb_mobile/core/primitives/payment_network.dart';
import 'package:bb_mobile/core/utils/payment_request.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/entities/signer_entity.dart';
Expand Down Expand Up @@ -228,6 +229,39 @@ void main() {
},
);

test('maps a missing payment route to a dedicated failure', () async {
when(
() => swapFacade.getQuote(
environment: OrderSwapEnvironment.testnet,
amountSat: BigInt.from(50000),
isInAmountFixed: true,
inNetwork: OrderSwapNetwork.lightning,
outNetwork: OrderSwapNetwork.liquid,
),
).thenAnswer(
(_) async => const Err(
SwapNoPaymentOptionFailure(
inNetwork: PaymentNetwork.lightning,
outNetwork: PaymentNetwork.liquid,
logMessage: 'no route',
),
),
);

final result = await usecase.execute(
wallet: _wallet(Network.liquidTestnet),
amountSat: 50000,
);

final failure = (result as Err<OrderSwapRecord, ReceiveFailure>).failure;
expect(failure, isA<ReceiveSwapRouteUnavailableFailure>());
expect(
(failure as ReceiveSwapRouteUnavailableFailure).inNetwork,
PaymentNetwork.lightning,
);
expect(failure.outNetwork, PaymentNetwork.liquid);
});

test('routes mainnet through the Exchange facade', () async {
when(
() => swapFacade.getQuote(
Expand Down
18 changes: 18 additions & 0 deletions test/features/send/domain/swap_failure_to_send_failure_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:bb_mobile/core/primitives/payment_network.dart';
import 'package:bb_mobile/features/send/domain/send_failure.dart';
import 'package:bb_mobile/features/send/domain/swap_failure_to_send_failure.dart';
import 'package:bb_mobile/features/swap/public/swap_facade.dart';
Expand All @@ -15,4 +16,21 @@ void main() {
const Duration(seconds: 45),
);
});

test('maps a missing payment route to a dedicated failure', () {
final failure = mapSwapFailureToSendFailure(
const SwapNoPaymentOptionFailure(
inNetwork: PaymentNetwork.bitcoin,
outNetwork: PaymentNetwork.lightning,
logMessage: 'no route',
),
);

expect(failure, isA<SendSwapRouteUnavailableFailure>());
expect(
(failure as SendSwapRouteUnavailableFailure).inNetwork,
PaymentNetwork.bitcoin,
);
expect(failure.outNetwork, PaymentNetwork.lightning);
});
}
Loading
Loading