fix(fees): reallow sub-1 sat/vByte transactions#2199
Conversation
Store relative fees in BDK's native sat/kwu unit so fractional rates survive the SDK boundary without precision loss. BDK's fromSatPerVb takes an int sat/vByte; the call site was .round()-ing the user's double, turning 0.5 into 1 — the root of #2133. LWK and BDK's BumpFeeTxBuilder are rate-only, so absolute Liquid fees are now converted back to a rate via a placeholder PSET (new CalculateLiquidPsetSizeUsecase). The custom-fee UI warns between 0.1 and 1 sat/vByte and blocks below 0.1.
BDK applies ceil rounding + sub-dust change absorption when building a transaction (rust-bitcoin FeeRate::mul_by_weight uses div_ceil). At normal rates the overshoot is <1% and invisible. At the sub-1 sat/vByte rates allowed by #2133 it is material — 0.1 sat/vB displayed 14 sat but broadcast 16 (f0b40a72…); 0.2 sat/vB displayed 28 but broadcast 30 (b734968d…). Add bitcoinAbsoluteFeesSat to SendState. Capture it from the built PSBT in prepareTransaction (software- and HW-wallet branches), signTransaction, and the chain-swap sendMax preview — mirroring the existing liquidAbsoluteFees flow. Clear at createTransaction start so a rate change cannot pair a stale real fee with new inputs. The getter falls back to the rate × vsize prediction only in the pre-build window.
The custom-fee tile only showed as selected after the user tapped "Confirm Custom Fee" — so while they were typing, the radio icon and elevation stayed muted as if a preset were still active. Drive the visual selection off focus: tapping the input (or anywhere on the tile via the existing InkWell, which already calls requestFocus) lights it up immediately. The cubit/bloc-level selection still commits only on submit, so we don't trigger createTransaction rebuilds on every keystroke. Applied symmetrically to send and swap.
Send and swap each had a ~230-line custom-fee tile widget that differed only in: state container (Cubit vs Bloc), default toggle value (relative vs absolute), and two theme tokens. Extract the shared body to lib/core/widgets/fees/custom_fee_list_item.dart; the two feature-side files become ~60-line wrappers that bind the common widget to their state container via callbacks. Also fixes the double-selection visual bug: when the user tapped into the custom-fee field while a preset (Fastest/Economic/Slow) was committed, both tiles appeared selected. The widget now drives selection through an arm/disarm protocol — armCustomFee commits selectedFeeOption=custom + the typed fee without triggering a createTransaction rebuild (so the preset deselects cleanly while typing), and disarmCustomFee in dispose rolls back if the user closed the modal without Confirm. didUpdateWidget drops focus if the cubit/bloc moves the selection away from custom. RBF still uses its own custom-fee section; folding it into the shared widget is a follow-up — the eager-commit-on-keystroke vs commit-on-Confirm divergence is a real semantic split worth designing separately.
Add three optional flags to CustomFeeListItem so RBF can reuse it
without the modal-specific UX bits:
- allowAbsoluteToggle (default true) — hide the sat/sat-per-vByte
toggle. RBF passes false; its parent API is rate-only.
- showConfirmButton (default true) — hide the "Confirm Custom
Fee" button. RBF passes false; confirmation lives on the
parent screen.
- commitOnChange (default false) — call onCommit on every valid
keystroke (and on tap with a prefilled value) instead of
routing through onArm. RBF passes true; its parent takes
whatever the latest commit is.
Also hide the fiat parenthetical when exchangeRate is 0 or the
currency code is empty, so RBF (no exchange rate threaded through)
doesn't render "(~ 0 )".
The RBF tile previously lived in a 200-line _buildCustomFeeSection
method on BumpFeeSelectorWidget. Replaced with a CustomFeeListItem
configured for inline mode; the Fastest tile stays as a sibling
StatelessWidget. Net: -161/+177 across this commit, ~150 lines of
duplication eliminated across all three features over scope A+B.
Also completes the double-selection bug fix from scope A. The
arm/disarm logic was correct, but the parent modals
(FeeOptionsModal, SwapFeeOptionsModal) were StatelessWidgets
reading state once via context.read — armCustomFee emitted a new
selectedFeeOption but the preset list never re-rendered, leaving
two tiles highlighted. Wrap the SelectableList in a BlocSelector
that watches selectedFeeOption; preset radios now clear the
instant the user starts typing in the custom field.
After confirming a custom fee at 0.132 sat/vB, the user re-opened the fee modal and saw the preview line show "27.46 sats" (naive rate × vsize) while the send screen behind it correctly showed "29 sats" (the real BDK-broadcast fee). Same bug class as the original #2133 reproducers: BDK pays 1-3 sats more than naive math at sub-1 sat/vByte rates due to ceil rounding + sub-dust change absorption. Three coordinated fixes so every fee surface shows either the real PSBT fee or a clean integer prediction, never a misleading decimal: 1. armCustomFee (SendCubit) and _onCustomFeeArmed (TransferBloc) now also null bitcoinAbsoluteFeesSat. While editing, the cubit's real-fee field is null — both the send screen and the modal preview fall back to the same prediction formula instead of one showing the stale pre-arm real fee. 2. CustomFeeListItem takes a new committedAbsoluteFeesSat prop. When the typed value still matches the committed customFee AND the caller has a real fee for it, the preview line renders the real fee. Once edited, falls back to prediction. Re-opening the modal with the value just confirmed shows the same number the send screen shows. 3. FeeOptionsDisplay.display() preset tiles use NetworkFee.toAbsolute integer math. Drops the misleading ".0" decimals (208.0 → 208) and kills IEEE noise (14.100000000000001 → 14). Broadcast PSBT review (TransactionReviewView) was already correct — feeSat = totalInputsSat - totalOutputsSat, the real PSBT delta. 11 widget tests in test/core_test/widgets/fees/ lock in the contract: real fee shown when unedited, prediction when edited, prediction when committedAbsoluteFeesSat is null.
Remove the "Confirm Custom Fee" button from the custom-fee modal.
Typing into the input is now the selection signal — the radio
lights up via focus + arm — and dismissing the bottom sheet via
any path (tap-outside, swipe, back/Escape, soft-keyboard "Done",
desktop Enter) commits the typed value. No more explicit button
between editing and applying.
How:
- CustomFeeListItem drops the BBButton.big, the submitCustomFee
helper, and the showConfirmButton / onConfirmed / onDisarm
props. Same widget for modal mode (send / swap) and inline
mode (RBF) — only the optional onArm callback differs.
- TextFormField.onFieldSubmitted → Navigator.maybePop(context)
in modal mode so the keyboard Done / desktop Enter route
behaves the same as tap-outside. Suppressed in RBF mode
(commitOnChange=true) since the widget is inline on a
screen — popping there would close the parent.
- SendCubit.finalizeArmedCustomFee() and
TransferEvent.customFeeFinalized: the parent's modal-result
handler calls this on null (= "user dismissed without picking
a preset"). It commits via customFeesChanged when armed and
the rate is ≥ 0.1 sat/vB, rolls back via disarmCustomFee
otherwise, and is a no-op if the user never typed.
- send_screen.dart and swap_confirm_page.dart: when the modal
returns a preset name → feeOptionSelected (clears arm
separately); when it returns null → finalizeArmedCustomFee.
- Preview line connector: "0.6 sats/vB ~ 167 sats" instead of
"= 167 sats". The sat-count is a prediction (rate × vsize,
integer-rounded); BDK pays 1-3 sats more at sub-1 sat/vByte
rates due to ceil + sub-dust change absorption. `~` is the
honest connector in both the modal preview and the preset
tiles (FeeOptionsDisplay.display).
Widget tests: 14 total, 3 new for the dismiss flow. Cover
keyboard Enter dismissing the modal in modal mode, the same
key being suppressed in RBF mode (would otherwise pop the
parent screen), and the contract that in modal mode the widget
itself never invokes onCommit — that's the parent's job.
Unifies the fee-preview state machine that was duplicated between SendCubit and TransferBloc — same parallel build with rate-dedupe, same custom-rate debounce, same 11 nullable cache fields, identical 0.1-floor check. New primitives in lib/core/fees/domain/: - BitcoinFeePreviewSlot + BitcoinFeePreviewCache value objects collapse the 11 nullable fields per state into one composite keyed by FeeSelection. - NetworkFeeRelayPolicy with a single minRelay constant. - BitcoinFeePresetPolicy owns the Slow-pin (0.1 sat/vB) decision instead of FeesDatasource. New use cases in lib/features/send/domain/usecases/ (swap imports them via the same pre-existing pattern it uses for PrepareBitcoinSendUsecase): - PreviewBitcoinFeeUsecase builds one PSBT and reports the real psbt.fee() + cached bytes. - PreviewBitcoinFeePresetsUsecase builds fastest/economic/slow in parallel and dedupes by rate — same-rate presets share one PSBT so a quiet mempool can't make Slow look more expensive than Economic. Behaviour fixes folded in: - Use case logs build failures instead of silently swallowing them. - The custom-fee tile prefills the input with the previously committed value on reopen. - L10n key sendEstimatedDeliveryFewHours renamed to sendEstimatedDeliveryHours; English value is now "hours". Other locales keep their pre-rename strings (translation follow-up). 3 new test files cover the slot/cache invariants, the minRelay policy boundary, and the preset-policy slow-pin. Widget tests gain 3 prefill cases. 192 tests pass; analyze --fatal-warnings --fatal-infos clean. Deferred: - Single-modal widget unification across send/swap — needs careful bloc-provider rewiring with a manual smoke test. - Moving PrepareBitcoinSendUsecase + CalculateBitcoinAbsoluteFees- Usecase to lib/core/wallet/ to fix the pre-existing swap → send feature-isolation violation. - Translating "hours" into the 25 non-English locales.
de03675 to
47ddf76
Compare
…+ swap Unifies the fee-preview state machine and modal UI that was duplicated between SendCubit / TransferBloc and their respective modal containers — same parallel build with rate-dedupe, same custom-rate debounce, same 11 nullable cache fields, identical 0.1-floor check, near-identical modal shell. New primitives in lib/core/fees/domain/: - BitcoinFeePreviewSlot + BitcoinFeePreviewCache value objects collapse the 11 nullable fields per state into one composite keyed by FeeSelection. - NetworkFeeRelayPolicy holds the single minRelay constant. - BitcoinFeePresetPolicy owns the Slow-pin (0.1 sat/vB) decision — FeesDatasource is back to pure HTTP + delegate. New use cases in lib/features/send/domain/usecases/ (swap imports them via the same pattern it already uses for PrepareBitcoinSendUsecase): - PreviewBitcoinFeeUsecase builds one PSBT and reports the real psbt.fee() + cached bytes. - PreviewBitcoinFeePresetsUsecase builds fastest/economic/slow in parallel and dedupes by rate — same-rate presets share one PSBT so a quiet mempool can't make Slow look more expensive than Economic. New UI ports in lib/core/widgets/fees/: - FeeModalSnapshot — the state slice the modal reads. - FeeModalViewState (read-only) + FeeModalActions (commands) — two abstractions instead of one so a future read-only embedding can subscribe without dispatch. - FeeOptionsModal — single shared modal. Both SendCubit and TransferBloc `implements FeeModalActions, FeeModalViewState`, mapping their own state to a snapshot stream. The widget has no knowledge of the underlying cubit/bloc shape. Deleted as redundant: send/ui/widgets/fee_options_modal.dart, send/ui/widgets/selectable_custom_fee_list_item.dart, swap/ui/widgets/swap_fee_options_modal.dart. Behaviour fixes folded in: - Use case logs build failures instead of silently swallowing them. - The custom-fee tile prefills the input with the previously committed value on reopen. - L10n key sendEstimatedDeliveryFewHours renamed to sendEstimatedDeliveryHours; English value is now "hours". Other locales keep their pre-rename strings (translation follow-up). 3 new test files cover the slot/cache invariants, the minRelay policy boundary, and the preset-policy slow-pin. Widget tests gain 3 prefill cases. 192 tests pass; analyze --fatal-warnings --fatal-infos clean. Deferred (out of scope, flagged for follow-up): - Moving PrepareBitcoinSendUsecase + CalculateBitcoinAbsoluteFees- Usecase to lib/core/wallet/ to fix the pre-existing swap → send feature-isolation violation. - Translating "hours" into the 25 non-English locales.
|
@claude can you review please |
|
Claude finished @ethicnology's task in 6m 25s —— View job Code reviewThe architectural shift — store relative fees in sat/kwu, show only 1. (High) Swap custom-fee arm doesn't invalidate the cached PSBT — same broadcast-at-wrong-fee bug, just on the swap surface
final cleared = state.feePreviewCache.withSlot(
FeeSelection.custom,
const BitcoinFeePreviewSlot(),
);
Repro path on the swap surface:
This is the same class of divergence the rest of the PR is fixing — the swap path just missed the invalidation. Mirror the send-side fix: clear 2. (Medium) RBF accepts sub-0.1 sat/vByte rates — relay-floor gate is missing on the commit-on-change path
Effect: in RBF, typing Either gate 3. (Low) Preset-rate bucketing in
|
- TransferBloc._onCustomFeeArmed now clears the cached custom PSBT slot — mirrors SendCubit.armCustomFee. Without it, dismissing the swap modal inside the 350ms debounce window would broadcast the previous rate's PSBT (same divergence class the PR fixes elsewhere). - CustomFeeListItem._onValueChanged blocks below-floor commits in RBF mode (commitOnChange=true). Typing 0.05 used to hit BDK with a sub-minrelay rate — BDK would build the PSBT, no node would relay. - Preset-rate bucketing in CustomFeeListItem compares sat/kwu ints instead of round-tripped doubles, removing a float-precision footgun at the bucket boundary. New widget test pins the RBF below-floor gate.
PrepareBitcoinSendUsecase and CalculateBitcoinAbsoluteFeesUsecase were in lib/features/send/domain/usecases/ but consumed by swap, sell, pay, and payjoin too — every cross-feature consumer was importing send's domain directly, in violation of AGENTS.md rule #1 (feature isolation). Both are generic wallet operations (build PSBT, read psbt.fee()), not send-specific. Move them to lib/core/wallet/domain/usecases/ next to the existing wallet-level use cases. Locators and consumers re-import from the new path. No behaviour change.
Best-effort translations of the new "hours" string for the 22 locales that still carried the pre-rename "few hours" equivalent. cs / th were already correct. Native-speaker review welcome — these are the straightforward translations of the bare word "hours" without the quantifier modifier the previous string used.
|
@claude new commits pushed, satisfied? |
|
Claude finished @ethicnology's task in 1m 21s —— View job Re-review of
|
Why
Issue #2133: BDK's
FeeRate.fromSatPerVbtakes anint sat/vByte. Our call site was.round()-ing the user'sdouble, turning 0.5 into 1 — silently dropping every sub-1-sat/vByte fee at the SDK boundary. The same divergence class showed up in two more places: the modal preview vs broadcast fee diverged by 1–3 sats at sub-1 rates (ceil + sub-dust change absorption in BDK), and again by an arbitrary amount when BDK's randomized coin selection picked different UTXOs between preview and commit (logs showed 113/154/195 vbyte variance for identical inputs).What
Storage: relative fees live in BDK's native
sat/kwuunit — fractional rates survive the SDK boundary losslessly (1 sat/vB = 250 sat/kwu, 0.1 sat/vB = 25 sat/kwu, exact).Display: every fee shown to the user is
psbt.fee()from a real unsigned PSBT — neverrate × vsizearithmetic. The send screen and the modal preview tiles shimmer until the build completes; we never render a number we'd then have to revise.Cache: the unsigned PSBT built for each preset (and for the typed custom rate) is cached, then reused verbatim at commit time. Defeats BDK's randomized coin selection — broadcast tx has the exact vsize/fee the modal displayed. Same-rate presets (Economic == Slow at 1 sat/vB during quiet blocks) share one PSBT via per-rate dedupe, so Slow can't look more expensive than Economic.
Invalidation: the cache is dropped on every input-shape change — amount, recipient, UTXO selection, wallet swap, RBF toggle, mempool rate refresh. No path keeps a stale PSBT alive long enough to broadcast it.
LWK / RBF rate-only contract: absolute Liquid fees are converted back to a rate via a placeholder PSET (new
CalculateLiquidPsetSizeUsecase). BDK'sBumpFeeTxBuilderhitsfromSatPerKwudirectly.UI rules: custom-fee field warns between 0.1 and 1 sat/vByte (slow-but-relays) and blocks below 0.1 (won't propagate). The field prefills with the previously committed value on reopen. Typing IS the selection; dismissing IS the apply — no explicit Confirm button. Slow preset is pinned to 0.1 sat/vByte and labelled "hours".
Architecture
lib/core/fees/domain/— new domain primitives:NetworkFeeRelayPolicy— single source of truth for the 0.1 sat/vByte floor (was duplicated in cubit + bloc + widget).BitcoinFeePresetPolicy— owns the Slow-pin decision soFeesDatasourceis back to pure HTTP + delegate.BitcoinFeePreviewSlot+BitcoinFeePreviewCache— value objects collapsing 11 nullable cache fields per state into one composite keyed byFeeSelection.lib/features/send/domain/usecases/— new use cases shared by send and swap:PreviewBitcoinFeeUsecase— builds one PSBT, returns the real fee + cached bytes.PreviewBitcoinFeePresetsUsecase— builds the three presets in parallel, dedupes by rate.lib/core/widgets/fees/— single shared modal:FeeModalSnapshot— state slice the modal reads.FeeModalViewState(read-only) +FeeModalActions(commands) — two ports instead of one, so a future read-only embedding can subscribe without dispatch.FeeOptionsModal— one widget. BothSendCubitandTransferBlocimplements FeeModalActions, FeeModalViewState. The widget has no knowledge of the underlying cubit/bloc shape. Deletessend/ui/widgets/fee_options_modal.dart,send/ui/widgets/selectable_custom_fee_list_item.dart,swap/ui/widgets/swap_fee_options_modal.dart.lib/core/widgets/fees/custom_fee_list_item.dart— already-shared leaf widget. Now also serves RBF (features/replace_by_fee/ui/fee_selector_widget.dartconsumes it withcommitOnChange: true).Testing
192 unit/widget tests,
flutter analyze --fatal-warnings --fatal-infosclean. New coverage:test/core_test/fees/— fee primitives, relay-policy boundary, preset-policy slow-pin, cache slot/composite invariants.test/features/send/presentation/bloc/send_state_test.dart— "displayed fee = on-chain fee verbatim" invariant pinned via the two Allow creating transactions with less than 1 sat/vbyte fee rate #2133 reproducer txids.test/core_test/widgets/fees/custom_fee_list_item_test.dart— keystroke dispatch (modal arm + debounce; RBF commit-per-keystroke), prefill on reopen (relative, absolute, trailing-zero strip), sub-0.1 floor error, no Confirm button anywhere, keyboard-Enter dismissal.Manual smoke tests against the user's prior reproducer scenarios (logs show
cacheHit=trueon commit, broadcast fee == modal fee, same-rate presets show identical fees).Follow-ups
PrepareBitcoinSendUsecase+CalculateBitcoinAbsoluteFeesUsecasefromlib/features/send/domain/usecases/tolib/core/wallet/domain/usecases/to fix the pre-existing swap→send feature-isolation violation.loadFees's cache-clear: currently unconditional, could be gated on actual rate change.