Skip to content

Commit 2413a67

Browse files
committed
fix(send): re-assert relay floor on built tx; cap rate field at 2 decimals
The absolute custom-fee commit gate (finalizeArmedCustomFee) checks against the previous build's bitcoinTxSize, so an absolute fee that cleared the gate at a stale/small vsize could land below the relay floor at the real vsize. createTransaction now re-asserts the floor against the freshly built fee and vsize before broadcast, regardless of selection type or BDK coin-selection variance. The sat/vByte custom-fee field allowed 8 decimals (BTC-derived), so a typed 0.12345678 snapped to the nearest sat/kwu and redisplayed as 0.12 (typed != stored != shown). AmountInputFormatter gains an optional maxDecimals override and the rate field passes 2, matching _formatForInput.
1 parent fa5c425 commit 2413a67

5 files changed

Lines changed: 169 additions & 8 deletions

File tree

lib/core/widgets/fees/custom_fee_list_item.dart

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class CustomFeeListItem extends StatefulWidget {
5252
this.minRelay,
5353
this.onArm,
5454
this.onDisarm,
55+
this.onInvalid,
5556
this.onPreview,
5657
this.previewFeeSat,
5758
this.previewLoading = false,
@@ -119,6 +120,15 @@ class CustomFeeListItem extends StatefulWidget {
119120
/// Ignored in RBF mode (nothing is armed there).
120121
final VoidCallback? onDisarm;
121122

123+
/// RBF mode only ([commitOnChange] = true). Called on every keystroke that
124+
/// produces a below-floor or empty/invalid value — i.e. exactly when
125+
/// [onCommit] is *suppressed*. Lets the RBF parent mark its selection
126+
/// invalid so the displayed (rejected) rate and the broadcast rate can't
127+
/// diverge: without this, [onCommit] silently keeps the last valid value
128+
/// while the field shows a below-floor rate and a red banner, and Broadcast
129+
/// would send the stale higher rate. Ignored in modal mode (use [onDisarm]).
130+
final VoidCallback? onInvalid;
131+
122132
/// When false, hide the absolute/relative toggle. Input is treated as
123133
/// relative (sat/vByte) only. RBF passes false — its fee API is
124134
/// rate-only.
@@ -282,6 +292,9 @@ class _CustomFeeListItemState extends State<CustomFeeListItem> {
282292
floorSatPerKwu:
283293
(widget.minRelay ?? widget.feePresets?.minRelay)?.satPerKwu,
284294
)) {
295+
// Below floor — don't commit, and tell the parent its selection is
296+
// now invalid so Broadcast can't fire the last valid (stale) rate.
297+
widget.onInvalid?.call();
285298
return;
286299
}
287300
widget.onCommit(fee);
@@ -303,7 +316,13 @@ class _CustomFeeListItemState extends State<CustomFeeListItem> {
303316
_previewDebounce?.cancel();
304317
// Empty/invalid input — disarm so dismissal rolls back to the prior
305318
// selection instead of committing the last valid armed value.
306-
if (!widget.commitOnChange) widget.onDisarm?.call();
319+
if (!widget.commitOnChange) {
320+
widget.onDisarm?.call();
321+
} else {
322+
// RBF mode — invalidate the parent's selection so an emptied field
323+
// can't broadcast the last valid (stale) rate.
324+
widget.onInvalid?.call();
325+
}
307326
}
308327
}
309328

@@ -478,7 +497,11 @@ class _CustomFeeListItemState extends State<CustomFeeListItem> {
478497
if (_isAbsolute)
479498
FilteringTextInputFormatter.digitsOnly
480499
else
481-
AmountInputFormatter(BitcoinUnit.btc.code),
500+
// Cap the sat/vByte rate at 2 decimals so the typed value
501+
// matches what _formatForInput renders back (also 2dp) and
502+
// what the sat/kwu store can represent — no typed-vs-stored
503+
// -vs-shown drift in the sub-1 regime this targets.
504+
AmountInputFormatter(BitcoinUnit.btc.code, maxDecimals: 2),
482505
],
483506
onChanged: _onValueChanged,
484507
style: TextStyle(color: context.appColors.onSurface),

lib/core/widgets/inputs/amount_input_formatter.dart

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,29 @@
11
import 'package:flutter/services.dart';
22

33
class AmountInputFormatter extends TextInputFormatter {
4-
AmountInputFormatter(this.inputCurrencyCode);
4+
AmountInputFormatter(this.inputCurrencyCode, {this.maxDecimals});
55

66
final String inputCurrencyCode;
77

8+
/// Optional hard cap on decimal places, overriding the value derived from
9+
/// [inputCurrencyCode]. The custom-fee tile passes 2 here so a sat/vByte
10+
/// rate can't be typed with more precision than it can store/redisplay —
11+
/// the BTC-derived default of 8 would let "0.12345678" be entered, snap to
12+
/// the nearest sat/kwu, then redisplay as "0.12" (typed ≠ stored ≠ shown).
13+
final int? maxDecimals;
14+
815
@override
916
TextEditingValue formatEditUpdate(
1017
TextEditingValue oldValue,
1118
TextEditingValue newValue,
1219
) {
1320
final decimalPlaces =
14-
(inputCurrencyCode == 'sats' || inputCurrencyCode == 'L-sats')
15-
? 0
16-
: inputCurrencyCode == 'BTC' || inputCurrencyCode == 'L-BTC'
17-
? 8
18-
: 2; // Fiat currencies default to 2 decimals, can be adjusted if needed with a map
21+
maxDecimals ??
22+
((inputCurrencyCode == 'sats' || inputCurrencyCode == 'L-sats')
23+
? 0
24+
: inputCurrencyCode == 'BTC' || inputCurrencyCode == 'L-BTC'
25+
? 8
26+
: 2); // Fiat currencies default to 2 decimals, can be adjusted if needed with a map
1927

2028
var newText = newValue.text;
2129

lib/features/send/presentation/bloc/send_cubit.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,13 @@ class SendCubit extends Cubit<SendState>
14781478
if (state.chainSwap != null) return state.chainSwap!.paymentAmount;
14791479
final input = state.inputAmountSat;
14801480
if (input > 0) return input;
1481+
// A BIP21 URI with an embedded amount sets confirmedAmountSat but leaves
1482+
// state.amount (→ inputAmountSat) empty. createTransaction builds from
1483+
// confirmedAmountSat, so the preview must use the same source — otherwise
1484+
// previews are skipped (modal shimmers forever) and the cache stays empty
1485+
// for that payment class.
1486+
final confirmed = state.confirmedAmountSat;
1487+
if (confirmed != null && confirmed > 0) return confirmed;
14811488
return null;
14821489
}
14831490

@@ -1670,6 +1677,36 @@ class SendCubit extends Cubit<SendState>
16701677
'(impliedRate=${txPreparation.txSize > 0 ? (builtFee / txPreparation.txSize).toStringAsFixed(4) : "n/a"} sat/vB)',
16711678
);
16721679

1680+
// Belt-and-suspenders relay-floor re-assert. The commit gate in
1681+
// finalizeArmedCustomFee checks an ABSOLUTE custom fee against the
1682+
// *previous* build's bitcoinTxSize (or the 140 fallback); if the real
1683+
// tx is larger, an absolute fee that cleared the gate can land below
1684+
// the relay floor at the actual vsize. Re-check the freshly built fee
1685+
// against the freshly built vsize so no below-relay tx ever reaches
1686+
// broadcast, regardless of selection type or BDK's coin-selection
1687+
// vsize variance. Don't rely on BDK rejecting sub-minrelay itself.
1688+
final clearsRelay = NetworkFee.absolute(builtFee).aboveMinRelay(
1689+
txSize: txPreparation.txSize,
1690+
floorSatPerKwu: state.bitcoinFeesList?.minRelay.satPerKwu,
1691+
);
1692+
if (!clearsRelay) {
1693+
log.warning(
1694+
'[create-tx] ABORT — built fee $builtFee sats at '
1695+
'${txPreparation.txSize} vbytes is below the relay floor '
1696+
'(${state.bitcoinFeesList?.minRelay.satPerVbyte ?? NetworkFeeRelayPolicy.minRelaySatPerVbyte} sat/vB)',
1697+
);
1698+
emit(
1699+
state.copyWith(
1700+
buildTransactionException: BuildTransactionException(
1701+
'Built fee $builtFee sats at ${txPreparation.txSize} vbytes '
1702+
'is below the relay floor',
1703+
),
1704+
buildingTransaction: false,
1705+
),
1706+
);
1707+
return;
1708+
}
1709+
16731710
if (state.chainSwap != null) {
16741711
// [CHAIN SWAP LIFECYCLE — Step 3b: fail-safe verification]
16751712
// See note on the liquid branch above. Do not remove.

test/core_test/fees/network_fee_relay_policy_test.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,28 @@ void main() {
9292
);
9393
});
9494
});
95+
96+
group('NetworkFee.aboveMinRelay — stale-vsize divergence (createTx re-assert)', () {
97+
// The send commit gate (finalizeArmedCustomFee) checks an ABSOLUTE custom
98+
// fee against the PREVIOUS build's bitcoinTxSize (or a 140 fallback). If
99+
// the real tx is larger, an absolute fee that cleared the gate at the
100+
// stale/small vsize lands BELOW the relay floor at the actual vsize.
101+
// createTransaction now re-asserts the floor against the freshly built
102+
// fee/vsize before broadcast — these cases pin that exact divergence.
103+
test('14 sat clears the floor at the stale 140 vsize but fails at 250', () {
104+
const fee = AbsoluteFee(14);
105+
// The gate saw 0.1 sat/vB (14/140) and let it through…
106+
expect(fee.aboveMinRelay(txSize: 140), isTrue);
107+
// …but the real tx weighed 250 vbytes → 0.056 sat/vB, below the floor.
108+
expect(fee.aboveMinRelay(txSize: 250), isFalse);
109+
});
110+
111+
test('a fee that clears the floor at both vsizes is unaffected', () {
112+
// 25 sat @ 250 vsize = 0.1 sat/vB exactly — still relayable at the real
113+
// size, so the re-assert is a no-op for honest fees.
114+
const fee = AbsoluteFee(25);
115+
expect(fee.aboveMinRelay(txSize: 140), isTrue);
116+
expect(fee.aboveMinRelay(txSize: 250), isTrue);
117+
});
118+
});
95119
}

test/core_test/widgets/fees/custom_fee_list_item_test.dart

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ void main() {
2929
RelativeFee? minRelay,
3030
void Function(NetworkFee fee)? onArm,
3131
VoidCallback? onDisarm,
32+
VoidCallback? onInvalid,
3233
void Function(NetworkFee fee)? onPreview,
3334
Future<void> Function(NetworkFee fee)? onCommit,
3435
}) async {
@@ -51,6 +52,7 @@ void main() {
5152
unselectedIconColor: Colors.grey,
5253
onArm: onArm,
5354
onDisarm: onDisarm,
55+
onInvalid: onInvalid,
5456
onPreview: onPreview,
5557
onCommit: onCommit ?? (_) async {},
5658
minRelay: minRelay,
@@ -188,10 +190,12 @@ void main() {
188190
'PSBT no node would relay)',
189191
(tester) async {
190192
NetworkFee? committed;
193+
bool invalidated = false;
191194
await pumpTile(
192195
tester,
193196
commitOnChange: true,
194197
onCommit: (fee) async => committed = fee,
198+
onInvalid: () => invalidated = true,
195199
);
196200
// 0.05 sat/vByte < NetworkFeeRelayPolicy.minRelaySatPerVbyte.
197201
// The build-time banner ("Fee Rate Too Low") shows the user why
@@ -200,6 +204,51 @@ void main() {
200204
await tester.enterText(find.byType(TextFormField), '0.05');
201205
await tester.pump();
202206
expect(committed, isNull);
207+
// …and the parent is told its selection is now invalid, so Broadcast
208+
// can't fire the last valid (stale) rate.
209+
expect(invalidated, isTrue);
210+
},
211+
);
212+
213+
testWidgets(
214+
'RBF mode lowers a valid rate below floor → onInvalid fires, no commit',
215+
(tester) async {
216+
final committed = <NetworkFee>[];
217+
var invalidCount = 0;
218+
await pumpTile(
219+
tester,
220+
commitOnChange: true,
221+
onCommit: (fee) async => committed.add(fee),
222+
onInvalid: () => invalidCount++,
223+
);
224+
// First a valid above-floor rate commits…
225+
await tester.enterText(find.byType(TextFormField), '2');
226+
await tester.pump();
227+
expect(committed, hasLength(1));
228+
expect((committed.single as RelativeFee).satPerVbyte, closeTo(2, 0.003));
229+
// …then lowering it below the floor must invalidate, not re-commit.
230+
await tester.enterText(find.byType(TextFormField), '0.05');
231+
await tester.pump();
232+
expect(committed, hasLength(1), reason: 'stale rate not re-committed');
233+
expect(invalidCount, 1);
234+
},
235+
);
236+
237+
testWidgets(
238+
'RBF mode emptying the field fires onInvalid',
239+
(tester) async {
240+
var invalidCount = 0;
241+
await pumpTile(
242+
tester,
243+
commitOnChange: true,
244+
onCommit: (_) async {},
245+
onInvalid: () => invalidCount++,
246+
);
247+
await tester.enterText(find.byType(TextFormField), '2');
248+
await tester.pump();
249+
await tester.enterText(find.byType(TextFormField), '');
250+
await tester.pump();
251+
expect(invalidCount, 1);
203252
},
204253
);
205254

@@ -246,6 +295,26 @@ void main() {
246295
expect(previewCount, 0);
247296
},
248297
);
298+
299+
testWidgets(
300+
'the sat/vByte rate field caps input at 2 decimals',
301+
(tester) async {
302+
// The field used to allow 8 decimals (BTC-derived): a user could type
303+
// "0.12345678", the model snapped it to the nearest sat/kwu, and the
304+
// prefill re-rendered it as "0.12" — typed ≠ stored ≠ shown. The
305+
// 2-decimal cap keeps the field consistent with _formatForInput.
306+
NetworkFee? armed;
307+
await pumpTile(tester, onArm: (fee) => armed = fee);
308+
309+
await tester.enterText(find.byType(TextFormField), '0.12345678');
310+
await tester.pump();
311+
312+
final field = tester.widget<TextFormField>(find.byType(TextFormField));
313+
expect(field.controller!.text, '0.12');
314+
// What was armed matches what's shown — no hidden extra precision.
315+
expect((armed! as RelativeFee).satPerVbyte, closeTo(0.12, 0.003));
316+
},
317+
);
249318
});
250319

251320
group('CustomFeeListItem — preview line', () {

0 commit comments

Comments
 (0)