Skip to content

Commit 128102d

Browse files
committed
fix(fees): gate RBF below-floor broadcast; tolerate non-JSON precise body
In RBF mode the custom field commits per keystroke and silently ignored below-floor or emptied values, leaving newFeeRate pinned to the last valid rate — so Broadcast fired a rate the user no longer saw. CustomFeeListItem gains an onInvalid callback (RBF-only); the cubit tracks customFeeBelowFloor (newFeeRate doubles as the init sentinel and can't be nulled) and broadcast refuses while it's set. A valid edit or the Fastest tile clears it. FeesDatasource dropped a precise 200 whose body arrived as a JSON string (a self-hosted mempool sending text/plain), silently degrading to rounded recommended fees. It now jsonDecodes a string body before the Map check, falling back only when the string isn't JSON. Also corrects the relativeFromAbsoluteAndVsize rounding comment (half rounds down on odd vsize; bias < 1 sat/kwu).
1 parent aedd33d commit 128102d

8 files changed

Lines changed: 211 additions & 4 deletions

File tree

lib/core/fees/data/fees_datasource.dart

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:convert';
2+
13
import 'package:bb_mobile/core/errors/bull_exception.dart';
24
import 'package:bb_mobile/core/fees/data/models/mempool_fees_model.dart';
35
import 'package:bb_mobile/core/mempool/application/usecases/get_active_mempool_server_usecase.dart';
@@ -81,8 +83,18 @@ class FeesDatasource {
8183
Future<MempoolFeesModel?> _getFees(Dio http, String path) async {
8284
try {
8385
final resp = await http.get<dynamic>(path);
84-
final data = resp.data;
85-
if (resp.statusCode == 200 && data is Map<String, dynamic>) {
86+
if (resp.statusCode != 200) return null;
87+
var data = resp.data;
88+
// Dio only auto-decodes when the server sends a JSON content-type. A
89+
// working-but-misconfigured self-hosted mempool returning the body as
90+
// text/plain would otherwise silently drop precise → recommended,
91+
// losing the sub-1 sat/vByte rates this whole path exists for. Decode
92+
// a string body before the Map check; a non-JSON string throws and is
93+
// caught below (→ fallback).
94+
if (data is String && data.isNotEmpty) {
95+
data = jsonDecode(data);
96+
}
97+
if (data is Map<String, dynamic>) {
8698
return MempoolFeesModel.fromJson(data);
8799
}
88100
return null;

lib/core/fees/domain/fees_entity.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,10 @@ sealed class NetworkFee with _$NetworkFee {
5757
}) {
5858
assert(vsize > 0, 'vsize must be positive');
5959
// sat/kwu = (absoluteSats / vsize) * 250
60-
// = (absoluteSats * 250) / vsize, rounded half-up via integer math.
60+
// = (absoluteSats * 250) / vsize, rounded to nearest via integer
61+
// math. The `vsize ~/ 2` bias term truncates on odd vsize, so an exact
62+
// half rounds down there; the resulting bias is under 1 sat/kwu
63+
// (< 0.004 sat/vByte), well inside the ±1 sat tolerance noted above.
6164
return RelativeFee((absoluteSats * 250 + vsize ~/ 2) ~/ vsize);
6265
}
6366

lib/features/replace_by_fee/presentation/cubit.dart

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ class ReplaceByFeeCubit extends Cubit<ReplaceByFeeState> {
7070
return;
7171
}
7272

73+
// The custom field currently shows a below-floor/empty rate. newFeeRate
74+
// still holds the last valid value, so without this guard Broadcast
75+
// would fire the stale rate the user no longer sees.
76+
if (state.customFeeBelowFloor) {
77+
emit(state.copyWith(error: FeeRateTooLowError()));
78+
return;
79+
}
80+
7381
final psbt = await bumpFeeUsecase.execute(
7482
walletId: originalTransaction.walletId,
7583
txid: originalTransaction.txId,
@@ -92,5 +100,14 @@ class ReplaceByFeeCubit extends Cubit<ReplaceByFeeState> {
92100
}
93101
}
94102

95-
void onChangeFee(FeeEntity fee) => emit(state.copyWith(newFeeRate: fee));
103+
/// A valid (above-floor) selection — from a custom keystroke or the Fastest
104+
/// tile. Clears any prior below-floor flag.
105+
void onChangeFee(FeeEntity fee) =>
106+
emit(state.copyWith(newFeeRate: fee, customFeeBelowFloor: false));
107+
108+
/// The custom field went below the relay floor or was emptied. Keep
109+
/// [newFeeRate] (the last valid value / init sentinel) but flag the field so
110+
/// [broadcast] refuses the stale rate.
111+
void markCustomFeeBelowFloor() =>
112+
emit(state.copyWith(customFeeBelowFloor: true));
96113
}

lib/features/replace_by_fee/presentation/state.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ sealed class ReplaceByFeeState with _$ReplaceByFeeState {
1111
@Default(null) ReplaceByFeeError? error,
1212
@Default(null) FeeEntity? fastestFeeRate,
1313
@Default(null) FeeEntity? newFeeRate,
14+
15+
/// True while the custom bump field holds a below-floor or empty/invalid
16+
/// rate. [newFeeRate] still pins the last valid value (it doubles as the
17+
/// "init complete" sentinel — nulling it would collapse the screen), so
18+
/// this flag is what blocks [broadcast] from firing the stale rate while
19+
/// the field shows a rejected value.
20+
@Default(false) bool customFeeBelowFloor,
1421
@Default(null) String? txid,
1522

1623
/// Live relay floor (mempool `minimumFee`, clamped to 0.1) so the custom

lib/features/replace_by_fee/ui/fee_selector_widget.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class BumpFeeSelectorWidget extends StatelessWidget {
1414
required this.selected,
1515
required this.txSize,
1616
required this.onChanged,
17+
required this.onInvalid,
1718
required this.focusNode,
1819
this.minRelay,
1920
});
@@ -22,6 +23,10 @@ class BumpFeeSelectorWidget extends StatelessWidget {
2223
final FeeEntity selected;
2324
final int txSize;
2425
final void Function(FeeEntity fee) onChanged;
26+
27+
/// Called when the custom bump field goes below the relay floor or is
28+
/// emptied — see [CustomFeeListItem.onInvalid].
29+
final VoidCallback onInvalid;
2530
final FocusNode focusNode;
2631

2732
/// Live relay floor for the custom bump field (null → static 0.1).
@@ -59,6 +64,7 @@ class BumpFeeSelectorWidget extends StatelessWidget {
5964
allowAbsoluteToggle: false,
6065
commitOnChange: true,
6166
focusNode: focusNode,
67+
onInvalid: onInvalid,
6268
onCommit: (fee) async {
6369
// Safe cast: allowAbsoluteToggle is false so the widget
6470
// only ever produces a RelativeFee here.

lib/features/replace_by_fee/ui/home_page.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class _ReplaceByFeeHomePageState extends State<ReplaceByFeeHomePage> {
6565
selected: state.newFeeRate!,
6666
txSize: widget.tx.vsize,
6767
onChanged: cubit.onChangeFee,
68+
onInvalid: cubit.markCustomFeeBelowFloor,
6869
focusNode: _feeNode,
6970
minRelay: state.minRelay,
7071
),

test/core_test/fees/fees_datasource_test.dart

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,51 @@ void main() {
168168
verify(() => dio.get<dynamic>(_recommended)).called(1);
169169
});
170170

171+
test('parses a precise 200 whose body arrived as a JSON string', () async {
172+
// A working-but-misconfigured self-hosted mempool can return the body as
173+
// text/plain, so Dio leaves it undecoded as a String. We must still
174+
// parse it (jsonDecode) and keep the sub-1 precision, not silently
175+
// fall back to the rounded recommended endpoint.
176+
when(() => dio.get<dynamic>(_precise)).thenAnswer(
177+
(_) async => Response(
178+
requestOptions: RequestOptions(path: _precise),
179+
statusCode: 200,
180+
data:
181+
'{"fastestFee":1.203,"halfHourFee":0.92,"hourFee":0.65,'
182+
'"economyFee":0.2,"minimumFee":0.1}',
183+
),
184+
);
185+
186+
final fees = await datasource.fetchBitcoinNetworkFees(isTestnet: false);
187+
188+
expect(fees.fastestFee, 1.203);
189+
expect(fees.hourFee, 0.65);
190+
verifyNever(() => dio.get<dynamic>(_recommended));
191+
});
192+
193+
test('falls back when a precise 200 string body is not JSON', () async {
194+
when(() => dio.get<dynamic>(_precise)).thenAnswer(
195+
(_) async => Response(
196+
requestOptions: RequestOptions(path: _precise),
197+
statusCode: 200,
198+
data: '<html>maintenance</html>',
199+
),
200+
);
201+
when(() => dio.get<dynamic>(_recommended)).thenAnswer(
202+
(_) async => _ok(_recommended, {
203+
'fastestFee': 3,
204+
'halfHourFee': 2,
205+
'hourFee': 1,
206+
'economyFee': 1,
207+
'minimumFee': 1,
208+
}),
209+
);
210+
211+
final fees = await datasource.fetchBitcoinNetworkFees(isTestnet: false);
212+
expect(fees.fastestFee, 3.0);
213+
verify(() => dio.get<dynamic>(_recommended)).called(1);
214+
});
215+
171216
test('throws MempoolFeesException when both endpoints fail', () async {
172217
when(
173218
() => dio.get<dynamic>(_precise),
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import 'package:bb_mobile/core/blockchain/domain/usecases/broadcast_bitcoin_transaction_usecase.dart';
2+
import 'package:bb_mobile/core/fees/domain/fees_entity.dart';
3+
import 'package:bb_mobile/core/fees/domain/get_network_fees_usecase.dart';
4+
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
5+
import 'package:bb_mobile/core/wallet/domain/entities/wallet_transaction.dart';
6+
import 'package:bb_mobile/features/replace_by_fee/domain/bump_fee_usecase.dart';
7+
import 'package:bb_mobile/features/replace_by_fee/domain/fee_entity.dart';
8+
import 'package:bb_mobile/features/replace_by_fee/errors.dart';
9+
import 'package:bb_mobile/features/replace_by_fee/presentation/cubit.dart';
10+
import 'package:flutter_test/flutter_test.dart';
11+
import 'package:mocktail/mocktail.dart';
12+
13+
class _MockBumpFee extends Mock implements BumpFeeUsecase {}
14+
15+
class _MockBroadcast extends Mock
16+
implements BroadcastBitcoinTransactionUsecase {}
17+
18+
class _MockGetFees extends Mock implements GetNetworkFeesUsecase {}
19+
20+
WalletTransaction _tx() => const WalletTransaction(
21+
walletId: 'w1',
22+
network: Network.bitcoinMainnet,
23+
direction: WalletTransactionDirection.outgoing,
24+
status: WalletTransactionStatus.pending,
25+
txId: 'abc',
26+
amountSat: 100000,
27+
feeSat: 200,
28+
vsize: 140,
29+
inputs: [],
30+
outputs: [],
31+
isRbf: true,
32+
);
33+
34+
FeeOptions _fees() => const FeeOptions(
35+
fastest: RelativeFee(2000), // 8 sat/vB
36+
economic: RelativeFee(500),
37+
slow: RelativeFee(250),
38+
minRelay: RelativeFee(25), // 0.1 sat/vB
39+
);
40+
41+
void main() {
42+
setUpAll(() {
43+
registerFallbackValue(const RelativeFee(250));
44+
});
45+
46+
late _MockBumpFee bumpFee;
47+
late _MockBroadcast broadcast;
48+
late _MockGetFees getFees;
49+
50+
Future<ReplaceByFeeCubit> buildCubit() async {
51+
when(
52+
() => getFees.execute(isLiquid: false),
53+
).thenAnswer((_) async => _fees());
54+
final cubit = ReplaceByFeeCubit(
55+
originalTransaction: _tx(),
56+
bumpFeeUsecase: bumpFee,
57+
broadcastBitcoinTransactionUsecase: broadcast,
58+
getNetworkFeesUsecase: getFees,
59+
);
60+
// init() is async in the constructor — let it settle.
61+
await Future<void>.delayed(Duration.zero);
62+
return cubit;
63+
}
64+
65+
setUp(() {
66+
bumpFee = _MockBumpFee();
67+
broadcast = _MockBroadcast();
68+
getFees = _MockGetFees();
69+
});
70+
71+
group('ReplaceByFeeCubit — below-floor selection gate', () {
72+
test('broadcast refuses while the custom field is below floor', () async {
73+
final cubit = await buildCubit();
74+
// newFeeRate is seeded above floor by init(); the user then types a
75+
// sub-floor rate, which the widget reports via markCustomFeeBelowFloor.
76+
cubit.markCustomFeeBelowFloor();
77+
78+
await cubit.broadcast();
79+
80+
expect(cubit.state.error, isA<FeeRateTooLowError>());
81+
verifyNever(
82+
() => bumpFee.execute(
83+
walletId: any(named: 'walletId'),
84+
txid: any(named: 'txid'),
85+
newFeeRate: any(named: 'newFeeRate'),
86+
),
87+
);
88+
});
89+
90+
test('a valid selection clears the flag and broadcast proceeds', () async {
91+
final cubit = await buildCubit();
92+
cubit.markCustomFeeBelowFloor();
93+
// Re-typing a valid above-floor rate (or tapping Fastest) re-commits.
94+
cubit.onChangeFee(
95+
const FeeEntity(type: FeeType.custom, feeRate: RelativeFee(500)),
96+
);
97+
expect(cubit.state.customFeeBelowFloor, isFalse);
98+
99+
when(
100+
() => bumpFee.execute(
101+
walletId: any(named: 'walletId'),
102+
txid: any(named: 'txid'),
103+
newFeeRate: any(named: 'newFeeRate'),
104+
),
105+
).thenAnswer((_) async => 'psbt');
106+
when(
107+
() => broadcast.execute(any(), isPsbt: any(named: 'isPsbt')),
108+
).thenAnswer((_) async => 'txid-1');
109+
110+
await cubit.broadcast();
111+
112+
expect(cubit.state.txid, 'txid-1');
113+
expect(cubit.state.error, isNull);
114+
});
115+
});
116+
}

0 commit comments

Comments
 (0)