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
267 changes: 125 additions & 142 deletions lib/features/swap/presentation/transfer_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,33 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
TransferSwapCreated event,
Emitter<TransferState> emit,
) async {
// Authoritative guard: re-validate the external receive address against
// the counter network here — before anything is created and before
// continueClicked is set. Don't trust only the UI error flag; a rejected
// address returns early and leaves Continue re-enabled once corrected.
String resolvedExternalAddress = '';
if (state.sendToExternal) {
if (state.externalAddress.isEmpty) {
emit(
state.copyWith(
swapCreationException: SwapCreationException(
'Enter an external address',
),
),
);
return;
}
try {
final resolved = await _resolveCounterNetworkAddress(
state.externalAddress,
state.fromWallet!,
);
resolvedExternalAddress = resolved.address;
} on FormatException catch (e) {
emit(state.copyWith(externalAddressError: e.message));
return;
}
}
emit(
state.copyWith(
swap: null,
Expand Down Expand Up @@ -429,24 +456,13 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
int? liquidAbsoluteFeesSat;

if (state.sendToExternal) {
if (state.externalAddress.isEmpty) {
emit(
state.copyWith(
swapCreationException: SwapCreationException(
'Enter an external address',
),
),
);
return;
}

final swapType = state.fromWallet!.isLiquid
? SwapType.liquidToBitcoin
: SwapType.bitcoinToLiquid;

swap = await _createChainSwapToExternalUsecase.execute(
sendWalletId: state.fromWallet!.id,
receiveAddress: state.externalAddress,
receiveAddress: resolvedExternalAddress,
type: swapType,
amountSat: paymentAmountSat,
);
Expand Down Expand Up @@ -744,147 +760,114 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
return;
}

final fromWallet = state.fromWallet;
if (fromWallet == null) {
emit(
state.copyWith(
externalAddress: _sanitizeAddress(event.address),
externalAddressError: 'Please select a wallet first',
),
);
return;
}

try {
final sanitizedText = event.address.trim().replaceAll(
RegExp(r'^["\"]+|["\"]+$'),
'',
final resolved = await _resolveCounterNetworkAddress(
event.address,
fromWallet,
);

final fromWallet = state.fromWallet;
if (fromWallet == null) {
emit(
state.copyWith(
externalAddress: sanitizedText,
externalAddressError: 'Please select a wallet first',
),
);
return;
String? bip21AmountText;
final bip21AmountSat = resolved.bip21AmountSat;
if (bip21AmountSat != null) {
try {
bip21AmountText = state.bitcoinUnit == BitcoinUnit.sats
? bip21AmountSat.toString()
: ConvertAmount.satsToBtc(bip21AmountSat).toString();
} catch (e) {
bip21AmountText = null;
}
}

PaymentRequest paymentRequest;
try {
paymentRequest = await _detectBitcoinStringUsecase.execute(
data: sanitizedText,
);
} catch (e) {
final errorMessage = fromWallet.isLiquid == true
? 'Please enter a valid Bitcoin address'
: 'Please enter a valid Liquid address';
emit(
state.copyWith(
externalAddress: sanitizedText,
externalAddressError: errorMessage,
),
);
return;
}
emit(
state.copyWith(
externalAddress: resolved.address,
externalAddressError: null,
receiveExactAmount:
// ignore: avoid_bool_literals_in_conditional_expressions
bip21AmountSat != null ? true : state.receiveExactAmount,
amount: bip21AmountText ?? state.amount,
),
);
} on FormatException catch (e) {
emit(
state.copyWith(
externalAddress: _sanitizeAddress(event.address),
externalAddressError: e.message,
),
);
}
}

try {
String address = '';
int? bip21AmountSat;

if (paymentRequest.isBip21) {
final bip21 = paymentRequest as Bip21PaymentRequest;
address = bip21.address;
bip21AmountSat = bip21.amountSat;

if (fromWallet.isLiquid) {
if (!bip21.network.isBitcoin) {
emit(
state.copyWith(
externalAddress: sanitizedText,
externalAddressError: 'Please enter a valid Bitcoin address',
),
);
return;
}
} else {
if (!bip21.network.isLiquid) {
emit(
state.copyWith(
externalAddress: sanitizedText,
externalAddressError: 'Please enter a valid Liquid address',
),
);
return;
}
}
} else {
if (fromWallet.isLiquid) {
if (!paymentRequest.isBitcoinAddress) {
emit(
state.copyWith(
externalAddress: sanitizedText,
externalAddressError: 'Please enter a valid Bitcoin address',
),
);
return;
}
final bitcoinAddress = paymentRequest as BitcoinPaymentRequest;
address = bitcoinAddress.address;
} else {
if (!paymentRequest.isLiquidAddress) {
emit(
state.copyWith(
externalAddress: sanitizedText,
externalAddressError: 'Please enter a valid Liquid address',
),
);
return;
}
final liquidAddress = paymentRequest as LiquidPaymentRequest;
address = liquidAddress.address;
}
}
String _sanitizeAddress(String input) =>
input.trim().replaceAll(RegExp(r'^["\"]+|["\"]+$'), '');

/// Validates [input] against the counter network of [fromWallet] — a swap to
/// an external wallet must receive on the opposite chain (Bitcoin wallet →
/// Liquid address, and vice versa). Returns the normalized on-chain address
/// plus any BIP21 amount, or throws [FormatException] with a user-facing
/// message when the input is unparseable or on the wrong network. Single
/// source of truth for both the inline field error and the swap-creation
/// guard in [_onSwapCreated].
Future<({String address, int? bip21AmountSat})> _resolveCounterNetworkAddress(
String input,
Wallet fromWallet,
) async {
final sanitizedText = _sanitizeAddress(input);
final expectedMessage = fromWallet.isLiquid
? 'Please enter a valid Bitcoin address'
: 'Please enter a valid Liquid address';

String? bip21AmountText;
if (bip21AmountSat != null) {
try {
bip21AmountText = state.bitcoinUnit == BitcoinUnit.sats
? bip21AmountSat.toString()
: ConvertAmount.satsToBtc(bip21AmountSat).toString();
} catch (e) {
bip21AmountText = null;
}
}
final PaymentRequest paymentRequest;
try {
paymentRequest = await _detectBitcoinStringUsecase.execute(
data: sanitizedText,
);
} catch (_) {
throw FormatException(expectedMessage);
}

emit(
state.copyWith(
externalAddress: address,
externalAddressError: null,
receiveExactAmount:
// ignore: avoid_bool_literals_in_conditional_expressions
bip21AmountSat != null ? true : state.receiveExactAmount,
amount: bip21AmountText ?? state.amount,
),
);
} catch (e) {
final errorMessage = fromWallet.isLiquid == true
? 'Please enter a valid Bitcoin address'
: 'Please enter a valid Liquid address';
emit(
state.copyWith(
externalAddress: sanitizedText,
externalAddressError: errorMessage,
),
try {
if (paymentRequest.isBip21) {
final bip21 = paymentRequest as Bip21PaymentRequest;
final onCounterNetwork = fromWallet.isLiquid
? bip21.network.isBitcoin
: bip21.network.isLiquid;
if (!onCounterNetwork) throw FormatException(expectedMessage);
return (address: bip21.address, bip21AmountSat: bip21.amountSat);
}

if (fromWallet.isLiquid) {
if (!paymentRequest.isBitcoinAddress) {
throw FormatException(expectedMessage);
}
return (
address: (paymentRequest as BitcoinPaymentRequest).address,
bip21AmountSat: null,
);
return;
}
} catch (e) {
final sanitizedText = event.address.trim().replaceAll(
RegExp(r'^["\"]+|["\"]+$'),
'',
);
final fromWallet = state.fromWallet;
final errorMessage = fromWallet?.isLiquid == true
? 'Please enter a valid Bitcoin address'
: 'Please enter a valid Liquid address';
emit(
state.copyWith(
externalAddress: sanitizedText,
externalAddressError: errorMessage,
),

if (!paymentRequest.isLiquidAddress) {
throw FormatException(expectedMessage);
}
return (
address: (paymentRequest as LiquidPaymentRequest).address,
bip21AmountSat: null,
);
} on FormatException {
rethrow;
} catch (_) {
throw FormatException(expectedMessage);
}
}

Expand Down
7 changes: 7 additions & 0 deletions lib/features/swap/presentation/transfer_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ sealed class TransferState with _$TransferState {
return amountValidationError != null || isInsufficientBalance;
}

/// Continue must stay disabled while sending to an external wallet if the
/// address is missing or fails counter-network validation.
bool get isExternalAddressBlocking {
if (!sendToExternal) return false;
return externalAddress.isEmpty || externalAddressError != null;
}

String? get amountValidationError {
if (amount.isEmpty) return null;

Expand Down
3 changes: 2 additions & 1 deletion lib/features/swap/ui/pages/swap_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ class SwapPageState extends State<SwapPage> {
state.isStarting ||
state.isCreatingSwap ||
state.continueClicked ||
state.hasAmountError,
state.hasAmountError ||
state.isExternalAddressBlocking,
builder: (context, disabled) {
return BBButton.big(
label: context.loc.swapContinueButton,
Expand Down
36 changes: 36 additions & 0 deletions test/features/swap/transfer_state_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'package:bb_mobile/features/swap/presentation/transfer_bloc.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('TransferState.isExternalAddressBlocking', () {
test('is false for internal transfers regardless of address error', () {
const state = TransferState(
sendToExternal: false,
externalAddressError: 'Please enter a valid Liquid address',
);
expect(state.isExternalAddressBlocking, isFalse);
});

test('blocks when sending external with an empty address', () {
const state = TransferState(sendToExternal: true, externalAddress: '');
expect(state.isExternalAddressBlocking, isTrue);
});

test('blocks when the address is on the wrong (non-counter) network', () {
const state = TransferState(
sendToExternal: true,
externalAddress: 'bc1qwrongnetwork',
externalAddressError: 'Please enter a valid Liquid address',
);
expect(state.isExternalAddressBlocking, isTrue);
});

test('does not block for a valid counter-network address', () {
const state = TransferState(
sendToExternal: true,
externalAddress: 'lq1qvalidliquidaddress',
);
expect(state.isExternalAddressBlocking, isFalse);
});
});
}
Loading