Skip to content

Commit fa5c425

Browse files
committed
fix(replace_by_fee): reject sub-minimum bumps under congestion
RBF validated custom bumps against the static 0.1 floor while send/swap track the live mempool minimumFee — its custom-fee field carries no presets, so FeeOptions.minRelay was unavailable. Thread minRelay from ReplaceByFeeCubit (already fetched, previously discarded) into CustomFeeListItem via a dedicated minRelay param that takes precedence over feePresets and falls back to 0.1 until the fetch completes. An RBF bump below the network's current minimum is now rejected at the keystroke gate, matching send/swap. Adds regression tests for the override floor and the RBF commit gate.
1 parent 5afbea8 commit fa5c425

6 files changed

Lines changed: 73 additions & 3 deletions

File tree

lib/core/widgets/fees/custom_fee_list_item.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class CustomFeeListItem extends StatefulWidget {
4949
required this.tileShadowColor,
5050
required this.unselectedIconColor,
5151
required this.onCommit,
52+
this.minRelay,
5253
this.onArm,
5354
this.onDisarm,
5455
this.onPreview,
@@ -69,8 +70,16 @@ class CustomFeeListItem extends StatefulWidget {
6970

7071
/// The preset fees, used to label the "Estimated delivery" subtitle by
7172
/// comparing the user's value against fastest/economic/slow thresholds.
73+
/// Also supplies the relay floor via [FeeOptions.minRelay] in modal mode.
7274
final FeeOptions? feePresets;
7375

76+
/// Explicit relay floor (the live mempool `minimumFee`, clamped to 0.1)
77+
/// for callers that don't carry a full [feePresets] — i.e. RBF, which
78+
/// has no preset tiers but still must reject sub-minimum bumps under
79+
/// congestion. Takes precedence over [feePresets]'s `minRelay`; falls
80+
/// back to the static 0.1 floor when neither is supplied.
81+
final RelativeFee? minRelay;
82+
7483
/// Estimated tx vsize (vbytes). Used to convert relative → absolute for
7584
/// the preview line, and to convert absolute → rate for the sub-1 sat/vB
7685
/// warning logic.
@@ -270,7 +279,8 @@ class _CustomFeeListItemState extends State<CustomFeeListItem> {
270279
// [TransferBloc._onCustomFeeFinalized] via aboveMinRelay.
271280
if (!fee.aboveMinRelay(
272281
txSize: widget.txSize,
273-
floorSatPerKwu: widget.feePresets?.minRelay.satPerKwu,
282+
floorSatPerKwu:
283+
(widget.minRelay ?? widget.feePresets?.minRelay)?.satPerKwu,
274284
)) {
275285
return;
276286
}
@@ -369,9 +379,9 @@ class _CustomFeeListItemState extends State<CustomFeeListItem> {
369379
// Falls back to the static 0.1 when no presets are loaded yet (e.g. RBF).
370380
// Below 1 sat/vByte (but at/above the floor) we still warn the tx may
371381
// take longer to confirm and may not propagate to every node.
382+
final RelativeFee? floor = widget.minRelay ?? feeOptions?.minRelay;
372383
final double floorSatPerVbyte =
373-
feeOptions?.minRelay.satPerVbyte ??
374-
NetworkFeeRelayPolicy.minRelaySatPerVbyte;
384+
floor?.satPerVbyte ?? NetworkFeeRelayPolicy.minRelaySatPerVbyte;
375385
final bool belowFloor = customRate != null && customRate < floorSatPerVbyte;
376386
final bool subOneSatPerVbyte =
377387
customRate != null && customRate < 1.0 && !belowFloor;

lib/features/replace_by_fee/presentation/cubit.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class ReplaceByFeeCubit extends Cubit<ReplaceByFeeState> {
5252
state.copyWith(
5353
fastestFeeRate: fastestFeeRate,
5454
newFeeRate: recommendedBumpRate,
55+
minRelay: fees.minRelay,
5556
),
5657
);
5758
}

lib/features/replace_by_fee/presentation/state.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
12
import 'package:bb_mobile/features/replace_by_fee/domain/fee_entity.dart';
23
import 'package:bb_mobile/features/replace_by_fee/errors.dart';
34
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -11,6 +12,12 @@ sealed class ReplaceByFeeState with _$ReplaceByFeeState {
1112
@Default(null) FeeEntity? fastestFeeRate,
1213
@Default(null) FeeEntity? newFeeRate,
1314
@Default(null) String? txid,
15+
16+
/// Live relay floor (mempool `minimumFee`, clamped to 0.1) so the custom
17+
/// bump field rejects sub-minimum rates under congestion. Null until the
18+
/// initial fee fetch completes → custom field falls back to the static
19+
/// 0.1 floor.
20+
@Default(null) RelativeFee? minRelay,
1421
}) = _ReplaceByFeeState;
1522

1623
const ReplaceByFeeState._();

lib/features/replace_by_fee/ui/fee_selector_widget.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class BumpFeeSelectorWidget extends StatelessWidget {
1515
required this.txSize,
1616
required this.onChanged,
1717
required this.focusNode,
18+
this.minRelay,
1819
});
1920

2021
final FeeEntity fastestFeeRate;
@@ -23,6 +24,9 @@ class BumpFeeSelectorWidget extends StatelessWidget {
2324
final void Function(FeeEntity fee) onChanged;
2425
final FocusNode focusNode;
2526

27+
/// Live relay floor for the custom bump field (null → static 0.1).
28+
final RelativeFee? minRelay;
29+
2630
@override
2731
Widget build(BuildContext context) {
2832
return SafeArea(
@@ -44,6 +48,7 @@ class BumpFeeSelectorWidget extends StatelessWidget {
4448
initialFee: selected.feeRate,
4549
isCommittedAsCustom: selected.type == FeeType.custom,
4650
feePresets: null,
51+
minRelay: minRelay,
4752
txSize: txSize,
4853
exchangeRate: 0.0,
4954
fiatCurrencyCode: '',

lib/features/replace_by_fee/ui/home_page.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class _ReplaceByFeeHomePageState extends State<ReplaceByFeeHomePage> {
6666
txSize: widget.tx.vsize,
6767
onChanged: cubit.onChangeFee,
6868
focusNode: _feeNode,
69+
minRelay: state.minRelay,
6970
),
7071
if (state.error != null) ...[
7172
const Gap(16),

test/core_test/widgets/fees/custom_fee_list_item_test.dart

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ void main() {
2626
int txSize = 140,
2727
int? previewFeeSat,
2828
bool previewLoading = false,
29+
RelativeFee? minRelay,
2930
void Function(NetworkFee fee)? onArm,
3031
VoidCallback? onDisarm,
3132
void Function(NetworkFee fee)? onPreview,
@@ -52,6 +53,7 @@ void main() {
5253
onDisarm: onDisarm,
5354
onPreview: onPreview,
5455
onCommit: onCommit ?? (_) async {},
56+
minRelay: minRelay,
5557
allowAbsoluteToggle: allowAbsoluteToggle,
5658
commitOnChange: commitOnChange,
5759
previewFeeSat: previewFeeSat,
@@ -379,6 +381,50 @@ void main() {
379381
});
380382
});
381383

384+
group('CustomFeeListItem — dynamic floor (minRelay override)', () {
385+
testWidgets('blocks a rate above 0.1 but below the live minimum', (
386+
tester,
387+
) async {
388+
// Congested floor = 0.5 sat/vB (125 sat/kwu). 0.3 clears the static
389+
// 0.1 floor but not the live minimum → below-floor error shows. This
390+
// is the RBF path, which carries minRelay but no feePresets.
391+
await pumpTile(tester, minRelay: const RelativeFee(125));
392+
await tester.enterText(find.byType(TextFormField), '0.3');
393+
await tester.pumpAndSettle();
394+
expect(find.textContaining('must be at least'), findsOneWidget);
395+
});
396+
397+
testWidgets('accepts the same rate with no override (static 0.1)', (
398+
tester,
399+
) async {
400+
await pumpTile(tester);
401+
await tester.enterText(find.byType(TextFormField), '0.3');
402+
await tester.pumpAndSettle();
403+
expect(find.textContaining('must be at least'), findsNothing);
404+
});
405+
406+
testWidgets('RBF commit-on-change is gated by the override floor', (
407+
tester,
408+
) async {
409+
NetworkFee? committed;
410+
await pumpTile(
411+
tester,
412+
commitOnChange: true,
413+
allowAbsoluteToggle: false,
414+
minRelay: const RelativeFee(125),
415+
onCommit: (fee) async => committed = fee,
416+
);
417+
// 0.3 > static 0.1 but < live 0.5 → must not commit.
418+
await tester.enterText(find.byType(TextFormField), '0.3');
419+
await tester.pumpAndSettle();
420+
expect(committed, isNull);
421+
// 0.6 ≥ live floor → commits.
422+
await tester.enterText(find.byType(TextFormField), '0.6');
423+
await tester.pumpAndSettle();
424+
expect(committed, isNotNull);
425+
});
426+
});
427+
382428
group('CustomFeeListItem — keyboard dismissal', () {
383429
testWidgets('Enter / Done pops the modal in modal mode', (tester) async {
384430
late BuildContext routeContext;

0 commit comments

Comments
 (0)