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
3 changes: 1 addition & 2 deletions integration_test/coins_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
import 'package:bb_mobile/core/seed/data/models/seed_model.dart';
import 'package:bb_mobile/core/settings/domain/settings_entity.dart';
import 'package:bb_mobile/core/storage/sqlite_database.dart';
import 'package:bb_mobile/core/wallet/data/datasources/bdk_wallet_datasource.dart'
show NoSpendableUtxoException;
import 'package:bb_mobile/core/wallet/data/datasources/frozen_wallet_utxo_datasource.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_address_repository.dart';
import 'package:bb_mobile/core/wallet/domain/wallet_build_tx_exceptions.dart';
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_utxo_repository.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/prepare_bitcoin_send_usecase.dart';
import 'package:bb_mobile/features/settings/domain/usecases/set_environment_usecase.dart';
Expand Down
48 changes: 45 additions & 3 deletions lib/core/swaps/domain/usecases/auto_swap_execution_usecase.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,21 @@ import 'package:bb_mobile/core/swaps/domain/entity/swap.dart';
import 'package:bb_mobile/core/swaps/domain/ports/blockchain_port.dart';
import 'package:bb_mobile/core/utils/constants.dart';
import 'package:bb_mobile/core/wallet/data/repositories/liquid_wallet_repository.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_address_repository.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bb_mobile/core/wallet/domain/entities/liquid_tx_output.dart';
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_transaction_repository.dart';
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_utxo_repository.dart';
import 'package:bb_mobile/core/wallet/domain/wallet_build_tx_exceptions.dart';
import 'package:bb_mobile/features/labels/labels_facade.dart';
import 'package:bb_mobile/core/utils/logger.dart';

class AutoSwapExecutionUsecase {
final BoltzSwapRepository _repository;
final WalletRepository _walletRepository;
final LiquidWalletRepository _liquidWalletRepository;
final WalletUtxoRepository _walletUtxoRepository;
final WalletAddressRepository _walletAddressRepository;
final BlockchainPort _blockchainPort;
final WalletTransactionRepository _walletTxRepository;
final LabelsFacade _labelsFacade;
Expand All @@ -23,6 +29,8 @@ class AutoSwapExecutionUsecase {
required this._repository,
required this._walletRepository,
required this._liquidWalletRepository,
required this._walletUtxoRepository,
required this._walletAddressRepository,
required this._blockchainPort,
required this._walletTxRepository,
required this._labelsFacade,
Expand Down Expand Up @@ -130,10 +138,44 @@ class AutoSwapExecutionUsecase {
);

log.fine('Building PSET...');
final pset = await _liquidWalletRepository.buildPset(
// Built via buildCustomTx (an explicit UTXO list), not buildPset (LWK's
// own coin selection, which is known to add every L-BTC UTXO regardless
// of amount — see PrepareLiquidSendUsecase) — this is a background,
// unattended payment, so a frozen UTXO (e.g. a consolidation decoy,
// which must never be re-spent) must never be able to slip in here
// either, same as an ordinary user-initiated send.
final confirmed = await _liquidWalletRepository.getConfirmedLbtcOutpoints(
walletId: defaultLiquidWallet.id,
address: swap.paymentAddress,
amountSat: swap.paymentAmount,
);
final frozen = (await _walletUtxoRepository.getAllFrozenOutpoints())
.toSet();
final usable = confirmed.where((o) => !frozen.contains(o)).toList();

if (usable.isEmpty) {
throw NoSpendableUtxoException(
'Wallet ${defaultLiquidWallet.id} has no spendable (confirmed, '
'unfrozen) L-BTC UTXOs',
);
}
if (_liquidWalletRepository.exceedsLiquidInputLimit(usable.length)) {
throw LiquidInputLimitExceededException(
'Wallet ${defaultLiquidWallet.id} exceeds the Liquid confidential-tx '
'input limit',
);
}

final changeAddress = await _walletAddressRepository
.generateNewReceiveAddress(walletId: defaultLiquidWallet.id);
final pset = await _liquidWalletRepository.buildCustomTx(
walletId: defaultLiquidWallet.id,
utxos: usable,
outputs: [
LiquidTxOutput(
address: swap.paymentAddress,
satoshi: swap.paymentAmount,
),
],
drainToAddress: changeAddress.address,
// 0.1 sat/vByte = 25 sat/kwu — Liquid's network minrelayfee default.
feeRate: const RelativeFee(25),
);
Expand Down
4 changes: 4 additions & 0 deletions lib/core/swaps/swaps_locator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ import 'package:bb_mobile/core/swaps/interface_adapters/blockchain_adapter.dart'
import 'package:bb_mobile/core/utils/constants.dart';
import 'package:bb_mobile/core/wallet/data/repositories/liquid_wallet_repository.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_address_repository.dart';
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_utxo_repository.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_transaction_repository.dart';
import 'package:bb_mobile/core/wallet/domain/repositories/wallet_utxo_repository.dart';
import 'package:bb_mobile/features/labels/labels_facade.dart';
import 'package:get_it/get_it.dart';

Expand Down Expand Up @@ -240,6 +242,8 @@ class SwapsLocator {
),
walletRepository: locator<WalletRepository>(),
liquidWalletRepository: locator<LiquidWalletRepository>(),
walletUtxoRepository: locator<WalletUtxoRepository>(),
walletAddressRepository: locator<WalletAddressRepository>(),
blockchainPort: locator<BlockchainPort>(),
walletTxRepository: locator<WalletTransactionRepository>(),
labelsFacade: locator<LabelsFacade>(),
Expand Down
5 changes: 1 addition & 4 deletions lib/core/wallet/data/datasources/bdk_wallet_datasource.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import 'package:bb_mobile/core/wallet/data/models/wallet_transaction_model.dart'
import 'package:bb_mobile/core/wallet/data/models/wallet_utxo_model.dart';
import 'package:bb_mobile/core/electrum/domain/value_objects/electrum_connection.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/core/wallet/domain/wallet_build_tx_exceptions.dart';
import 'package:bull_sdk/bdk.dart' as bdk;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
Expand Down Expand Up @@ -826,10 +827,6 @@ class UnsupportedBdkNetworkException extends BullException {
UnsupportedBdkNetworkException(super.message);
}

class NoSpendableUtxoException extends BullException {
NoSpendableUtxoException(super.message);
}

/// Confirmation count for an output confirmed at [height], given the current
/// chain [tip]. A `null` height (unconfirmed) returns 0; the result is clamped
/// to 0 to guard a reorg / mid-sync window where `tip < height` (D2).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import 'package:shared_preferences/shared_preferences.dart';

/// Persists, per Liquid wallet, the highest receive-address index this app
/// has ever handed out to the UI — independent of LWK's own sync state.
///
/// LWK's "last unused address" is derived purely from what the wallet has
/// synced as used; two receive requests issued before an intervening sync
/// would otherwise return the identical address (there's no LWK-side
/// reservation mechanism, unlike BDK's persisted, sync-independent
/// reveal-index — see `BdkWalletDatasource.getNewAddress` /
/// `BdkFacade.saveWallet`). This datasource is that missing reservation for
/// Liquid, so the same class of address reuse can't happen there either.
class LiquidReceiveAddressIndexDatasource {
static String _key(String walletId) =>
'liquid_last_receive_address_index_$walletId';

// Per-wallet async mutex: each call for a given walletId waits for the
// previous one (for that same wallet) to finish before reading, so a
// concurrent reservation race can't have two callers read the same
// "current" value before either writes back. Different wallets never
// block each other.
final Map<String, Future<void>> _locks = {};

/// The persisted index, or null if nothing has ever been reserved for
/// this wallet yet.
Future<int?> read(String walletId) async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(_key(walletId));
}

/// Atomically reserves and persists a new "last issued" index for
/// [walletId]: `max(persisted, atLeast) + 1`. [atLeast] should be LWK's
/// own sync-derived next-unused index, so a wallet restored on a new
/// device (where the local persisted counter starts at 0) still advances
/// correctly. Concurrent calls for the same wallet are serialized, so
/// each one is guaranteed a distinct reserved index.
Future<int> reserveNext(String walletId, {required int atLeast}) {
final previous = _locks[walletId] ?? Future.value();
final chained = previous.then((_) async {
final prefs = await SharedPreferences.getInstance();
final current = prefs.getInt(_key(walletId)) ?? 0;
final next = (current > atLeast ? current : atLeast) + 1;
await prefs.setInt(_key(walletId), next);
return next;
});
// Release the lock once this reservation settles, whatever the outcome,
// so a failure here can't deadlock every later call for this wallet.
_locks[walletId] = chained.then((_) {}, onError: (_) {});
return chained;
}

/// Self-heals the persisted index up to [atLeast] without reserving a new
/// one — used when the address actually returned to the UI (after any
/// system-label skip-forward) ended up higher than what's currently
/// persisted, so the next reservation still starts from the right place.
Future<void> ensureAtLeast(String walletId, int atLeast) async {
final prefs = await SharedPreferences.getInstance();
final current = prefs.getInt(_key(walletId)) ?? 0;
if (atLeast > current) {
await prefs.setInt(_key(walletId), atLeast);
}
}
}
Loading
Loading