Skip to content

fix(fees): reallow sub-1 sat/vByte transactions#2199

Draft
ethicnology wants to merge 15 commits into
developfrom
2133-allow-creating-transactions-with-less-than-1-satvbyte-fee-rate
Draft

fix(fees): reallow sub-1 sat/vByte transactions#2199
ethicnology wants to merge 15 commits into
developfrom
2133-allow-creating-transactions-with-less-than-1-satvbyte-fee-rate

Conversation

@ethicnology
Copy link
Copy Markdown
Member

@ethicnology ethicnology commented May 26, 2026

Why

Issue #2133: BDK's FeeRate.fromSatPerVb takes an int sat/vByte. Our call site was .round()-ing the user's double, 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/kwu unit — 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 — never rate × vsize arithmetic. 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's BumpFeeTxBuilder hits fromSatPerKwu directly.

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 so FeesDatasource is back to pure HTTP + delegate.
  • BitcoinFeePreviewSlot + BitcoinFeePreviewCache — value objects collapsing 11 nullable cache fields per state into one composite keyed by FeeSelection.

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. Both SendCubit and TransferBloc implements FeeModalActions, FeeModalViewState. The widget has no knowledge of the underlying cubit/bloc shape. Deletes send/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.dart consumes it with commitOnChange: true).

Testing

192 unit/widget tests, flutter analyze --fatal-warnings --fatal-infos clean. 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=true on commit, broadcast fee == modal fee, same-rate presets show identical fees).

Follow-ups

  • Move PrepareBitcoinSendUsecase + CalculateBitcoinAbsoluteFeesUsecase from lib/features/send/domain/usecases/ to lib/core/wallet/domain/usecases/ to fix the pre-existing swap→send feature-isolation violation.
  • Translate the new "hours" string into the 25 non-English locales (English value updated; other locales keep their pre-rename string).
  • Sweep loadFees's cache-clear: currently unconditional, could be gated on actual rate change.

@ethicnology ethicnology self-assigned this May 26, 2026
@ethicnology ethicnology linked an issue May 26, 2026 that may be closed by this pull request
9 tasks
ethicnology and others added 11 commits May 28, 2026 07:34
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.
@ethicnology ethicnology force-pushed the 2133-allow-creating-transactions-with-less-than-1-satvbyte-fee-rate branch from de03675 to 47ddf76 Compare May 28, 2026 17:41
…+ 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.
@ethicnology
Copy link
Copy Markdown
Member Author

@claude can you review please

@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Claude finished @ethicnology's task in 6m 25s —— View job


Code review

The architectural shift — store relative fees in sat/kwu, show only psbt.fee(), cache the unsigned PSBT and reuse it verbatim at commit — is the right way to fix #2133, and the dedupe-by-rate in PreviewBitcoinFeePresetsUsecase plus the two-port FeeModalViewState / FeeModalActions split are the right primitives. The reproducer-anchored tests (f0b40a72…, b734968d…) are good documentation as well as good assertions. Found one bug that's the same divergence class the PR is fixing, plus a missing relay-floor gate on the RBF path. Details below.


1. (High) Swap custom-fee arm doesn't invalidate the cached PSBT — same broadcast-at-wrong-fee bug, just on the swap surface

SendCubit.armCustomFee at lib/features/send/presentation/bloc/send_cubit.dart:1244-1272 clears feePreviewCache.custom on every arm — exactly so that a stale slot can't be reused at commit:

final cleared = state.feePreviewCache.withSlot(
  FeeSelection.custom,
  const BitcoinFeePreviewSlot(),
);

TransferBloc._onCustomFeeArmed at lib/features/swap/presentation/transfer_bloc.dart:929-954 does not. The cache slot from the previous typed rate stays put until _onCustomFeePreviewRequested finishes its build.

Repro path on the swap surface:

  1. Modal open → presets built and cached.
  2. User types 1.5customFeeArmed. Debounce fires after 350 ms → customFeePreviewRequested builds and caches a PSBT for rate 1.5. feePreviewCache.custom is now { feeSat, psbt, txSize } keyed to 1.5.
  3. User types 0.5customFeeArmed only. feePreviewCache.custom still holds the 1.5 PSBT.
  4. User dismisses within 350 ms → customFeeFinalized_onCustomFeeChanged emits customFee = 0.5 and calls _rebuildTransactionWithState.
  5. _rebuildTransactionWithState at line 1123 reads cachedSlot.isCacheReady = true, takes the 1.5 PSBT, signs and broadcasts at 1.5 while the UI shows 0.5.

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 feePreviewCache.custom inside _onCustomFeeArmed before emitting. Fix this →

2. (Medium) RBF accepts sub-0.1 sat/vByte rates — relay-floor gate is missing on the commit-on-change path

CustomFeeListItem._onValueChanged at lib/core/widgets/fees/custom_fee_list_item.dart:236-239 calls widget.onCommit(fee) on every valid keystroke when commitOnChange: true, without consulting belowFloor. The InkWell guard at lib/core/widgets/fees/custom_fee_list_item.dart:324 is the only floor check, and it only fires on tap, not on type. ReplaceByFeeCubit.onChangeFee at lib/features/replace_by_fee/presentation/cubit.dart:94 writes the fee straight into state; BumpFeeUsecase.execute at lib/features/replace_by_fee/domain/bump_fee_usecase.dart:11-22 accepts any RelativeFee and hands it to BDK.

Effect: in RBF, typing 0.05 and tapping broadcast hits fromSatPerKwu(satKwu: 12), which BDK will accept and produce a PSBT that no node on the network will relay. The send/swap surface gates this in finalizeArmedCustomFee/_onCustomFeeFinalized via fee.aboveMinRelay(...); the RBF surface has no equivalent gate.

Either gate _onValueChanged on !belowFloor before calling onCommit (the simplest fix; matches the InkWell behavior), or gate inside ReplaceByFeeCubit.broadcast with state.newFeeRate!.feeRate.aboveMinRelay() and surface the existing FeeRateTooLowError. Fix this →

3. (Low) Preset-rate bucketing in CustomFeeListItem uses floating-point comparisons of round-tripped values

lib/core/widgets/fees/custom_fee_list_item.dart:286-304 buckets the typed rate against fastest/economic/slow via >= on doubles produced by satPerKwu / 250.0. The round-trip is exact for the values users actually type (0.1, 0.5, 1, 2, …), so this works today, but the threshold logic is fragile and would silently swap labels if the conversion ever loses precision (e.g. typing the exact preset value rendering as "hours-to-days" instead of "hours"). Comparing on satPerKwu directly (ints) would remove the failure mode entirely.

4. (Low) Cache-clear is unconditional on every amount keystroke and on every loadFees call

Already called out as a follow-up. _onAmountChanged (transfer_bloc.dart:341), onAmountChanged (send_cubit.dart:937), loadFees (send_cubit.dart:1198) all clear regardless of whether the underlying value actually changed. Doesn't affect correctness, just causes the modal to re-shimmer on no-op edits.

5. (Low) Locale debt acknowledged but partially unfixed

Only English (and the keys whose values just shifted) is updated for sendEstimatedDeliveryHours, sendSubSatVbyteWarning, sendBelowMinFeeRateError, sendEstimatedDeliveryHoursToDays. Other 25 locales fall back to English. Already in your follow-ups; flagging for visibility.

6. (Note) _resolveLiquidFeeRate two-build dance

Building a placeholder PSET at RelativeFee(25) to read vsize before converting absolute → rate is the right move given LWK's rate-only API. The ±1 sat structural cost is acknowledged in the comment. Works because Liquid vsize is essentially rate-independent at fixed inputs. Fine as-is.


Things done well

  • Storing relative fees in BDK's native sat/kwu makes the SDK-boundary round-trip lossless and const-able. Eliminating FeeRate.fromSatPerVb(...round()) at the build site is the load-bearing change for Allow creating transactions with less than 1 sat/vbyte fee rate #2133.
  • PreviewBitcoinFeePresetsUsecase's byRate dedupe is the right fix for "Slow looks more expensive than Economic at the same rate during a quiet mempool" — solves it at the build layer, not the display layer.
  • The PSBT cache + cachedSlot.isCacheReady short-circuit in createTransaction / _rebuildTransactionWithState is the right way to defeat BDK's randomized coin selection. The log lines (cacheHit=$canUseCache, realFee=...) make this auditable from a log alone.
  • BitcoinFeePreviewCache / BitcoinFeePreviewSlot collapsing 11 nullable fields into one composite is a real readability win; the slotFor / withSlot API is the right shape.
  • Splitting FeeModalViewState (read) from FeeModalActions (write) is more disciplined than necessary today but cheap and pays off the moment any read-only consumer wants to subscribe.
  • SendState.absoluteFees returning null (and the UI rendering '…') instead of falling back to rate × vsize is the right invariant; the reproducer-anchored test (tx f0b40a72…, b734968d…) pins it well.

Checklist

- 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.
@ethicnology
Copy link
Copy Markdown
Member Author

@claude new commits pushed, satisfied?

@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Claude finished @ethicnology's task in 1m 21s —— View job


Re-review of 02f9b7415 — satisfied

Walked the fix commit (fix(fees): three divergence-class bugs caught in PR review) against my prior review. All three findings are addressed correctly, and one of the Low items (no-op cache clears) was opportunistically swept too.

1. (High) Swap custom-fee arm — fixed

transfer_bloc.dart:929-970 now constructs cleared = state.feePreviewCache.withSlot(FeeSelection.custom, const BitcoinFeePreviewSlot()) and threads it into the copyWith on both the armPriorSelection == null and else branches. Symmetric with SendCubit.armCustomFee. The repro path I described (type 1.5 → wait for build → type 0.5 → dismiss within debounce) is now impossible: the 1.5 slot is dropped at arm time, so commit's cachedSlot.isCacheReady returns false and it falls through to a fresh build at 0.5.

2. (Medium) RBF relay-floor gate — fixed

custom_fee_list_item.dart:236-244:

if (widget.commitOnChange) {
  if (!fee.aboveMinRelay(txSize: widget.txSize)) return;
  widget.onCommit(fee);
}

Below-floor keystrokes are now silently swallowed; the build-time "Fee Rate Too Low" banner is what tells the user why nothing's getting committed. Matches the modal-mode gate in finalizeArmedCustomFee / _onCustomFeeFinalized. Pinned by a new widget test (custom_fee_list_item_test.dart:182-198) that types 0.05 and asserts committed stays null.

3. (Low) Float-fragile preset bucketing — fixed

New _satPerKwuOf helper (custom_fee_list_item.dart:288-296) returns the canonical sat/kwu int directly for RelativeFee, and computes (sats * 250 + txSize/2) ~/ txSize for AbsoluteFee (rounded half-up). Bucket comparisons (customKwu >= fastestKwu, etc.) now use ints — the round-trip-through-/250.0 failure mode is gone.

4. (Low) Unconditional cache clears — also addressed

Beyond what I called out, transfer_bloc.dart now early-returns when the value didn't actually change:

  • _onAmountChanged (line 340): if (state.amount == event.amount) return;
  • _onExternalAddressChanged (line 702): if (state.externalAddress == event.address) return;
  • _onReplaceByFeeChanged (line 866): if (state.replaceByFee == event.replaceByFee) return;
  • _onUtxosSelected (line 874-881): set-diff on selectedUtxos — reordered same selection no longer invalidates.
  • _onFromWalletChanged (line 324): gated on wasFromWalletChanged instead of always clearing.

The loadFees unconditional clear (still in send_cubit.dart:1198) is the remaining sweep target — that's the one left in your follow-ups, fine to leave for a separate PR.

5. (Low) Locale debt — resolved

Commit 2f44b48c4 (chore(l10n): translate sendEstimatedDeliveryHours into all locales) covers the 25 non-English locales. No longer outstanding.

6. (Note) _resolveLiquidFeeRate — unchanged, still fine


Verdict

Satisfied. The PR is in the state I'd merge it in. The send-vs-swap symmetry that was broken at the time of my prior review is now restored, the RBF surface no longer accepts non-relayable rates, and the bucket-label boundary is precision-safe.

CI (Analyze and Test, run 26604462771) is still in-progress — green there would be the last thing I'd want to see before merge, but nothing in the diff makes me expect a failure.


Checklist

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow creating transactions with less than 1 sat/vbyte fee rate

2 participants