Skip to content
Open
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
13 changes: 0 additions & 13 deletions lib/core/exchange/domain/errors/sell_error.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'sell_error.freezed.dart';
Expand All @@ -21,15 +19,4 @@ sealed class SellError with _$SellError {
}) = InsufficientBalanceSellError;

const SellError._();

/// Returns the localized error message.
String toTranslated(BuildContext context) => when(
unauthenticated: () => context.loc.sellUnauthenticatedError,
belowMinAmount: (_) => context.loc.sellBelowMinAmountError,
aboveMaxAmount: (_) => context.loc.sellAboveMaxAmountError,
orderNotFound: () => context.loc.sellOrderNotFoundError,
orderAlreadyConfirmed: () => context.loc.sellOrderAlreadyConfirmedError,
unexpected: (message) => message,
insufficientBalance: (_) => context.loc.sellInsufficientBalanceError,
);
}
2 changes: 1 addition & 1 deletion lib/core/exchange/exchange_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import 'package:bb_mobile/features/buy/domain/confirm_buy_order_usecase.dart';
import 'package:bb_mobile/features/buy/domain/create_buy_order_usecase.dart';
import 'package:bb_mobile/features/buy/domain/refresh_buy_order_usecase.dart';
import 'package:bb_mobile/features/pay/domain/create_pay_order_usecase.dart';
import 'package:bb_mobile/features/sell/domain/refresh_sell_order_usecase.dart';
import 'package:bb_mobile/features/sell/domain/usecases/refresh_sell_order_usecase.dart';
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';

Expand Down
50 changes: 50 additions & 0 deletions lib/features/sell/domain/sell_failure.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import 'package:bb_mobile/core/failures/failure.dart';

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

final class SellUnauthenticatedFailure extends SellFailure {
const SellUnauthenticatedFailure();
}

final class SellBelowMinAmountFailure extends SellFailure {
final int minAmountSat;
const SellBelowMinAmountFailure({
required this.minAmountSat,
String? logMessage,
}) : super(logMessage);
}

final class SellAboveMaxAmountFailure extends SellFailure {
final int maxAmountSat;
const SellAboveMaxAmountFailure({
required this.maxAmountSat,
String? logMessage,
}) : super(logMessage);
}

final class SellInsufficientBalanceFailure extends SellFailure {
final int requiredAmountSat;
const SellInsufficientBalanceFailure({
required this.requiredAmountSat,
String? logMessage,
}) : super(logMessage);
}

final class SellPrepareTransactionFailure extends SellFailure {
const SellPrepareTransactionFailure([super.logMessage]);
}

final class SellSendPaymentFailure extends SellFailure {
const SellSendPaymentFailure([super.logMessage]);
}

final class SellLoadUtxosFailure extends SellFailure {
const SellLoadUtxosFailure([super.logMessage]);
}

/// Catch-all. [logMessage] is for logs/Sentry ONLY and MUST never reach the UI.
final class SellUnexpectedFailure extends SellFailure {
const SellUnexpectedFailure([super.logMessage]);
}
147 changes: 147 additions & 0 deletions lib/features/sell/domain/usecases/confirm_sell_payin_usecase.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_bitcoin_transaction_usecase.dart';
import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_liquid_transaction_usecase.dart';
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
import 'package:bb_mobile/core/utils/amount_conversions.dart';
import 'package:bb_mobile/core/utils/bitcoin_tx.dart';
import 'package:bb_mobile/core/utils/liquid_tx.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet_utxo.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/prepare_bitcoin_send_usecase.dart';
import 'package:bb_mobile/features/labels/labels_facade.dart';
import 'package:bb_mobile/features/sell/domain/sell_failure.dart';
import 'package:bb_mobile/features/send/domain/usecases/prepare_liquid_send_usecase.dart';
import 'package:bb_mobile/features/send/domain/usecases/sign_bitcoin_tx_usecase.dart';
import 'package:bb_mobile/features/send/domain/usecases/sign_liquid_tx_usecase.dart';
import 'package:meta/meta.dart';

/// Derives a bitcoin txid from an (unsigned) PSBT. Injectable so the money path
/// can be unit-tested without a real BDK transaction.
typedef BitcoinTxidFromPsbt = Future<String> Function(String psbt);

/// Derives a liquid txid from a signed PSET. Injectable for the same reason.
typedef LiquidTxidFromPset = Future<String> Function(String pset);

Future<String> _defaultBitcoinTxidFromPsbt(String psbt) async =>
(await BitcoinTx.fromPsbt(psbt)).txid;

Future<String> _defaultLiquidTxidFromPset(String pset) async =>
(await LiquidTx.fromPset(pset)).txid;

/// Builds, signs and broadcasts the payin transaction for a sell order.
///
/// The broadcast is the point of no return: once it succeeds the money has
/// moved, so the payin is reported as a success even if the after-the-fact
/// bookkeeping (transaction labelling) fails — that is logged but never turned
/// into a [SellSendPaymentFailure], which would otherwise invite a double-spend
/// retry. Every failure before or during broadcast is mapped to a sealed
/// [SellFailure]; the raw exception is logged for diagnosis but never carried
/// in the failure value that reaches bloc state.
class ConfirmSellPayinUsecase {
final PrepareBitcoinSendUsecase _prepareBitcoinSendUsecase;
final PrepareLiquidSendUsecase _prepareLiquidSendUsecase;
final SignBitcoinTxUsecase _signBitcoinTxUsecase;
final SignLiquidTxUsecase _signLiquidTxUsecase;
final BroadcastBitcoinTransactionUsecase _broadcastBitcoinTransactionUsecase;
final BroadcastLiquidTransactionUsecase _broadcastLiquidTransactionUsecase;
final LabelsFacade _labelsFacade;
final BitcoinTxidFromPsbt _bitcoinTxidFromPsbt;
final LiquidTxidFromPset _liquidTxidFromPset;

ConfirmSellPayinUsecase({
required this._prepareBitcoinSendUsecase,
required this._prepareLiquidSendUsecase,
required this._signBitcoinTxUsecase,
required this._signLiquidTxUsecase,
required this._broadcastBitcoinTransactionUsecase,
required this._broadcastLiquidTransactionUsecase,
required this._labelsFacade,
this._bitcoinTxidFromPsbt = _defaultBitcoinTxidFromPsbt,
this._liquidTxidFromPset = _defaultLiquidTxidFromPset,
});

@useResult
Future<Result<String, SellFailure>> execute({
required Wallet wallet,
required SellOrder sellOrder,
required int? absoluteFees,
List<WalletUtxo> selectedInputs = const [],
bool replaceByFee = false,
}) async {
final payinAmountSat = ConvertAmount.btcToSats(sellOrder.payinAmount);

final String txid;
try {
if (wallet.isLiquid) {
final pset = await _prepareLiquidSendUsecase.execute(
walletId: wallet.id,
address: sellOrder.liquidAddress!,
amountSat: payinAmountSat,
// 0.1 sat/vByte = 25 sat/kwu — Liquid's network minrelayfee default.
feeRate: const RelativeFee(25),
);
final signedPset = await _signLiquidTxUsecase.execute(
pset: pset,
walletId: wallet.id,
);
// Derive the txid before broadcasting so nothing fallible runs after
// the money has moved.
txid = await _liquidTxidFromPset(signedPset);
await _broadcastLiquidTransactionUsecase.execute(signedPset);
} else {
if (absoluteFees == null) {
return const Err(SellPrepareTransactionFailure());
}
final preparedSend = await _prepareBitcoinSendUsecase.execute(
walletId: wallet.id,
address: sellOrder.bitcoinAddress!,
amountSat: payinAmountSat,
networkFee: NetworkFee.absolute(absoluteFees),
selectedInputs: selectedInputs.isNotEmpty ? selectedInputs : null,
replaceByFee: replaceByFee,
);
final signedTx = await _signBitcoinTxUsecase.execute(
psbt: preparedSend.unsignedPsbt,
walletId: wallet.id,
);
txid = await _bitcoinTxidFromPsbt(preparedSend.unsignedPsbt);
await _broadcastBitcoinTransactionUsecase.execute(
signedTx.signedPsbt,
isPsbt: true,
);
}
} catch (e, st) {
log.severe(message: 'sell confirm payin failed', error: e, trace: st);
return Err(SellSendPaymentFailure(e.runtimeType.toString()));
}

// Broadcast succeeded: the payin is done. Labelling is best-effort and must
// never demote a completed payin to a failure.
await _storeSellLabel(walletId: wallet.id, txid: txid);

return Ok(txid);
}

Future<void> _storeSellLabel({
required String walletId,
required String txid,
}) async {
try {
await _labelsFacade.store(
NewLabel.tx(
transactionId: txid,
label: LabelSystem.exchangeSell.label,
origin: walletId,
),
);
} catch (e, st) {
log.warning(
'sell payin label store failed (payin already broadcast)',
error: e,
trace: st,
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import 'package:bb_mobile/core/exchange/domain/errors/sell_error.dart';
import 'package:bb_mobile/core/exchange/domain/repositories/exchange_order_repository.dart';
import 'package:bb_mobile/core/settings/data/settings_repository.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/features/sell/domain/sell_failure.dart';
import 'package:meta/meta.dart';

class CreateSellOrderUsecase {
final ExchangeOrderRepository _mainnetExchangeOrderRepository;
Expand All @@ -15,7 +18,8 @@ class CreateSellOrderUsecase {
required this._settingsRepository,
});

Future<SellOrder> execute({
@useResult
Future<Result<SellOrder, SellFailure>> execute({
required OrderAmount orderAmount,
required FiatCurrency currency,
required OrderBitcoinNetwork network,
Expand All @@ -31,12 +35,16 @@ class CreateSellOrderUsecase {
currency: currency,
network: network,
);
return order;
} on SellError {
rethrow;
} catch (e) {
log.severe(error: e, trace: StackTrace.current);
throw SellError.unexpected(message: '$e');
return Ok(order);
} on UnauthenticatedSellError {
return const Err(SellUnauthenticatedFailure());
} on BelowMinAmountSellError catch (e) {
return Err(SellBelowMinAmountFailure(minAmountSat: e.minAmountSat));
} on AboveMaxAmountSellError catch (e) {
return Err(SellAboveMaxAmountFailure(maxAmountSat: e.maxAmountSat));
} catch (e, st) {
log.severe(message: 'create sell order failed', error: e, trace: st);
return Err(SellUnexpectedFailure(e.toString()));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import 'package:bb_mobile/core/exchange/domain/entity/order.dart';
import 'package:bb_mobile/core/exchange/domain/usecases/convert_sats_to_currency_amount_usecase.dart';
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart';
import 'package:bb_mobile/core/utils/amount_conversions.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/calculate_bitcoin_absolute_fees_usecase.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/get_address_at_index_usecase.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/prepare_bitcoin_send_usecase.dart';
import 'package:bb_mobile/features/sell/domain/sell_failure.dart';
import 'package:bb_mobile/features/send/domain/usecases/calculate_liquid_absolute_fees_usecase.dart';
import 'package:bb_mobile/features/send/domain/usecases/prepare_liquid_send_usecase.dart';
import 'package:meta/meta.dart';

/// Estimates the on-chain payin cost of selling from [wallet] before the sell
/// order is created: fetches the exchange rate, derives the required amount,
/// enforces the sufficient-balance rule, and computes the absolute network fee
/// by building a throwaway transaction to a wallet-owned address.
class EstimateSellPayinFeesUsecase {
final ConvertSatsToCurrencyAmountUsecase _convertSatsToCurrencyAmountUsecase;
final GetAddressAtIndexUsecase _getAddressAtIndexUsecase;
final GetNetworkFeesUsecase _getNetworkFeesUsecase;
final PrepareBitcoinSendUsecase _prepareBitcoinSendUsecase;
final PrepareLiquidSendUsecase _prepareLiquidSendUsecase;
final CalculateBitcoinAbsoluteFeesUsecase
_calculateBitcoinAbsoluteFeesUsecase;
final CalculateLiquidAbsoluteFeesUsecase _calculateLiquidAbsoluteFeesUsecase;

EstimateSellPayinFeesUsecase({
required this._convertSatsToCurrencyAmountUsecase,
required this._getAddressAtIndexUsecase,
required this._getNetworkFeesUsecase,
required this._prepareBitcoinSendUsecase,
required this._prepareLiquidSendUsecase,
required this._calculateBitcoinAbsoluteFeesUsecase,
required this._calculateLiquidAbsoluteFeesUsecase,
});

@useResult
Future<Result<({int absoluteFees, double exchangeRateEstimate}), SellFailure>>
execute({
required Wallet wallet,
required OrderAmount orderAmount,
required FiatCurrency fiatCurrency,
}) async {
try {
final exchangeRateEstimate = await _convertSatsToCurrencyAmountUsecase
.execute(currencyCode: fiatCurrency.code);

final int requiredAmountSat;
if (orderAmount.isFiat) {
requiredAmountSat = ConvertAmount.fiatToSats(
orderAmount.amount,
exchangeRateEstimate,
);
} else {
requiredAmountSat = ConvertAmount.btcToSats(orderAmount.amount);
}

if (wallet.balanceSat.toInt() < requiredAmountSat) {
return Err(
SellInsufficientBalanceFailure(requiredAmountSat: requiredAmountSat),
);
}

final dummyAddressForFeeCalculation = await _getAddressAtIndexUsecase
.execute(walletId: wallet.id, index: 0);

final int absoluteFees;
if (wallet.isLiquid) {
final pset = await _prepareLiquidSendUsecase.execute(
walletId: wallet.id,
address: dummyAddressForFeeCalculation.address,
amountSat: requiredAmountSat,
// 0.1 sat/vByte = 25 sat/kwu — Liquid's network minrelayfee default.
feeRate: const RelativeFee(25),
);
absoluteFees = await _calculateLiquidAbsoluteFeesUsecase.execute(
pset: pset,
);
} else {
final bitcoinFees = await _getNetworkFeesUsecase.execute(
isLiquid: false,
);
final preparedSend = await _prepareBitcoinSendUsecase.execute(
walletId: wallet.id,
address: dummyAddressForFeeCalculation.address,
amountSat: requiredAmountSat,
networkFee: bitcoinFees.fastest,
);
absoluteFees = await _calculateBitcoinAbsoluteFeesUsecase.execute(
psbt: preparedSend.unsignedPsbt,
);
}

return Ok((
absoluteFees: absoluteFees,
exchangeRateEstimate: exchangeRateEstimate,
));
} catch (e, st) {
log.severe(message: 'sell fee estimation failed', error: e, trace: st);
return Err(SellPrepareTransactionFailure(e.toString()));
}
}
}
Loading
Loading