Skip to content

Commit aedd33d

Browse files
committed
fix(swap): restore send symmetry — relay-floor re-assert and utxo cache clear
_rebuildTransactionWithState computed the absolute fee but never re-checked the relay floor against the freshly built vsize, so an absolute custom fee that cleared the pre-build gate could broadcast below relay — the swap mirror of the send-side hole. Both Bitcoin branches now re-assert via _builtFeeClearsRelay; a below-floor build clears signedPsbt and sets TransferState.buildTransactionException, surfaced on the confirm page (the buildError slot was hardcoded null). _onLoadUtxos refreshed the available coin set without dropping cached preview PSBTs, so a wallet sync landing mid-flow could leave a stale PSBT staged for broadcast. It now clears the cache on an actual set change, guarded by setEquals — matching SendCubit.loadUtxos.
1 parent 2413a67 commit aedd33d

3 files changed

Lines changed: 83 additions & 1 deletion

File tree

lib/features/swap/presentation/transfer_bloc.dart

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import 'package:bb_mobile/features/send/domain/usecases/preview_bitcoin_fee_pres
3737
import 'package:bb_mobile/features/send/domain/usecases/preview_bitcoin_fee_usecase.dart';
3838
import 'package:bb_mobile/features/send/domain/usecases/sign_bitcoin_tx_usecase.dart';
3939
import 'package:bb_mobile/features/send/domain/usecases/sign_liquid_tx_usecase.dart';
40+
import 'package:flutter/foundation.dart';
4041
import 'package:flutter_bloc/flutter_bloc.dart';
4142
import 'package:freezed_annotation/freezed_annotation.dart';
4243

@@ -909,7 +910,16 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
909910
final utxos = await _getWalletUtxosUsecase.execute(
910911
walletId: state.fromWallet!.id,
911912
);
913+
// A wallet sync can change the available coins; any cached preview PSBT
914+
// was built against the prior set, so drop it — otherwise a sync landing
915+
// mid-flow could leave a stale PSBT staged for broadcast. Guarded so a
916+
// no-op refresh doesn't needlessly re-shimmer. Mirrors SendCubit.loadUtxos.
917+
final utxosChanged = !setEquals(
918+
(state.utxos ?? const <WalletUtxo>[]).toSet(),
919+
utxos.toSet(),
920+
);
912921
emit(state.copyWith(utxos: utxos));
922+
if (utxosChanged) _clearBitcoinFeePreviews(emit);
913923
} catch (e) {
914924
log.severe(
915925
message: 'Error loading UTXOs',
@@ -1141,6 +1151,25 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
11411151
emit(state.copyWith(feePreviewCache: BitcoinFeePreviewCache.empty));
11421152
}
11431153

1154+
/// Belt-and-suspenders relay-floor re-assert, mirroring
1155+
/// `SendCubit.createTransaction`. The pre-build gate in
1156+
/// [_onCustomFeeFinalized] checks an absolute custom fee against the
1157+
/// *previous* `bitcoinTxSize`; if the real tx is larger, an absolute fee
1158+
/// that cleared that gate can land below the floor at the actual vsize.
1159+
/// Re-checking the freshly built fee against the freshly built vsize closes
1160+
/// the only below-relay-broadcast vector on the swap surface — don't rely on
1161+
/// BDK rejecting sub-minrelay itself.
1162+
bool _builtFeeClearsRelay({
1163+
required TransferState stateToUse,
1164+
required int builtFeeSat,
1165+
required int txSize,
1166+
}) {
1167+
return NetworkFee.absolute(builtFeeSat).aboveMinRelay(
1168+
txSize: txSize,
1169+
floorSatPerKwu: stateToUse.bitcoinNetworkFees?.minRelay.satPerKwu,
1170+
);
1171+
}
1172+
11441173
Future<void> _rebuildTransactionWithState(
11451174
Emitter<TransferState> emit,
11461175
TransferState stateToUse,
@@ -1203,11 +1232,33 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
12031232
psbt: signedPsbtAndTxSize.signedPsbt,
12041233
);
12051234

1235+
if (!_builtFeeClearsRelay(
1236+
stateToUse: stateToUse,
1237+
builtFeeSat: bitcoinAbsoluteFeesSat,
1238+
txSize: signedPsbtAndTxSize.txSize,
1239+
)) {
1240+
log.warning(
1241+
'Rebuild aborted — built fee $bitcoinAbsoluteFeesSat sats at '
1242+
'${signedPsbtAndTxSize.txSize} vbytes is below the relay floor',
1243+
);
1244+
emit(
1245+
stateToUse.copyWith(
1246+
signedPsbt: '',
1247+
buildTransactionException: BuildTransactionException(
1248+
'Built fee $bitcoinAbsoluteFeesSat sats at '
1249+
'${signedPsbtAndTxSize.txSize} vbytes is below the relay floor',
1250+
),
1251+
),
1252+
);
1253+
return;
1254+
}
1255+
12061256
emit(
12071257
stateToUse.copyWith(
12081258
signedPsbt: signedPsbtAndTxSize.signedPsbt,
12091259
bitcoinAbsoluteFeesSat: bitcoinAbsoluteFeesSat,
12101260
bitcoinTxSize: signedPsbtAndTxSize.txSize,
1261+
buildTransactionException: null,
12111262
),
12121263
);
12131264
} else if (stateToUse.swap != null && stateToUse.swap is ChainSwap) {
@@ -1253,6 +1304,27 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
12531304
psbt: signedPsbtAndTxSize.signedPsbt,
12541305
);
12551306

1307+
if (!_builtFeeClearsRelay(
1308+
stateToUse: stateToUse,
1309+
builtFeeSat: bitcoinAbsoluteFeesSat,
1310+
txSize: signedPsbtAndTxSize.txSize,
1311+
)) {
1312+
log.warning(
1313+
'Rebuild aborted — built fee $bitcoinAbsoluteFeesSat sats at '
1314+
'${signedPsbtAndTxSize.txSize} vbytes is below the relay floor',
1315+
);
1316+
emit(
1317+
stateToUse.copyWith(
1318+
signedPsbt: '',
1319+
buildTransactionException: BuildTransactionException(
1320+
'Built fee $bitcoinAbsoluteFeesSat sats at '
1321+
'${signedPsbtAndTxSize.txSize} vbytes is below the relay floor',
1322+
),
1323+
),
1324+
);
1325+
return;
1326+
}
1327+
12561328
final updatedSwap = await _updateSendSwapLockupFeesUsecase.execute(
12571329
swapId: swap.id,
12581330
lockupFees: bitcoinAbsoluteFeesSat,
@@ -1264,6 +1336,7 @@ class TransferBloc extends Bloc<TransferEvent, TransferState>
12641336
signedPsbt: signedPsbtAndTxSize.signedPsbt,
12651337
bitcoinAbsoluteFeesSat: bitcoinAbsoluteFeesSat,
12661338
bitcoinTxSize: signedPsbtAndTxSize.txSize,
1339+
buildTransactionException: null,
12671340
),
12681341
);
12691342
}

lib/features/swap/presentation/transfer_state.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ sealed class TransferState with _$TransferState {
2323
int? liquidAbsoluteFeesSat,
2424
@Default(false) bool isConfirming,
2525
ConfirmTransactionException? confirmTransactionException,
26+
// Set when a freshly-built Bitcoin tx fails the relay-floor re-assert
27+
// (an absolute custom fee that cleared the pre-build gate against a stale
28+
// vsize but lands below the floor at the real, larger vsize). Mirrors
29+
// SendState.buildTransactionException — surfaced on the confirm page and
30+
// accompanied by a cleared signedPsbt so the below-relay tx can't broadcast.
31+
BuildTransactionException? buildTransactionException,
2632
@Default('') String txId,
2733
@Default(false) bool sendToExternal,
2834
@Default('') String externalAddress,

lib/features/swap/ui/pages/swap_confirm_page.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class SwapConfirmPage extends StatelessWidget {
2828
final confirmError = context.select(
2929
(TransferBloc bloc) => bloc.state.confirmTransactionException,
3030
);
31+
final buildError = context.select(
32+
(TransferBloc bloc) => bloc.state.buildTransactionException,
33+
);
3134
final absoluteFeesFormatted = context.select(
3235
(TransferBloc bloc) => bloc.state.absoluteFeesFormatted,
3336
);
@@ -174,7 +177,7 @@ class SwapConfirmPage extends StatelessWidget {
174177
const Gap(24),
175178
CommonConfirmSendErrorSection(
176179
confirmError: confirmError,
177-
buildError: null,
180+
buildError: buildError,
178181
),
179182
],
180183
),

0 commit comments

Comments
 (0)