fix(fees): sub-1 sat/vByte fees via mempool precise endpoint (#2133)#2199
Merged
ethicnology merged 27 commits intoJun 24, 2026
Merged
Conversation
9 tasks
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.
Member
Author
|
@claude can you review please |
This comment was marked as outdated.
This comment was marked as outdated.
- 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.
Member
Author
|
@claude new commits pushed, satisfied? |
This comment was marked as outdated.
This comment was marked as outdated.
…g-transactions-with-less-than-1-satvbyte-fee-rate # Conflicts: # integration_test/payjoin_test.dart # lib/features/replace_by_fee/ui/fee_selector_widget.dart # lib/features/send/presentation/bloc/send_cubit.dart # lib/features/send/ui/screens/send_screen.dart # lib/features/send/ui/widgets/fee_options_modal.dart # lib/features/send/ui/widgets/selectable_custom_fee_list_item.dart # lib/features/swap/presentation/transfer_bloc.dart # lib/features/swap/ui/widgets/swap_fee_options_modal.dart
Both are the same divergence class #2199 set out to kill — a cached unsigned PSBT reused at broadcast after its inputs moved. 1. Wallet-sync UTXO reload didn't invalidate the preview cache. send_cubit.loadUtxos now drops cached previews when the available UTXO set actually changes (setEquals guard avoids needless reshimmer), so a background sync landing mid-flow can't leave a stale PSBT staged. 2. Stale-async overwrite race: an in-flight preset/custom preview build could copyWith its result back into a cache that was cleared during the await, repopulating an emptied slot with a PSBT for the prior tx shape. Added a monotonic _bitcoinPreviewEpoch captured before the build and re-checked before the emit; stale results are discarded. Applied symmetrically to SendCubit and TransferBloc.
armCustomFee / _onCustomFeeArmed clear the cached custom-slot PSBT on every keystroke but did not bump _bitcoinPreviewEpoch, so the prior fix's stale-build guard didn't cover the arm path: two overlapping previewBitcoinCustomFee builds captured the same epoch, and a slower build for an earlier rate could land last and overwrite the slot for the current rate — staging a wrong-rate (or below-floor) PSBT for broadcast. Bumping the epoch on arm makes any in-flight preview for the prior rate discard on return. Applied to both SendCubit and TransferBloc.
Collaborator
|
We need to use this endpoint to get precise fees: https://mempool.bullbitcoin.com/docs/api/rest#get-recommended-fees-precise |
Collaborator
|
API response: In our app: Fastest - response.fastestFee |
Collaborator
|
Custom fee validation > minimum |
Switch Bitcoin fee estimation from /api/v1/fees/recommended (integer sat/vByte) to /api/v1/fees/precise (decimals) so presets can express sub-1 sat/vByte rates, falling back to the recommended endpoint when a custom or self-hosted mempool doesn't expose the precise route. Remap tiers to the precise fields: Fastest <- fastestFee, Economic <- hourFee, Slow <- economyFee, each floored at the network minrelayfee (0.1 sat/vByte) so no preset drops below what the network relays and the Fastest >= Economic >= Slow ordering is preserved. Restructure to the layered architecture: a MempoolFeesModel wire model that parses every field as num then toDouble (fixing the latent 'as int' crash on decimal and mixed int/double payloads), a MempoolFeesMapper that owns the tier policy, and FeesRepository promoted to a domain interface with FeesRepositoryImpl in data/. The datasource takes an injectable Dio builder so the precise/recommended fallback is unit-tested. Tests: add MempoolFeesModel, MempoolFeesMapper, and FeesDatasource (fallback matrix) suites; remove the obsolete BitcoinFeePresetPolicy test.
…g-transactions-with-less-than-1-satvbyte-fee-rate # Conflicts: # lib/core/swaps/data/services/swap_watcher.dart # lib/features/send/presentation/bloc/send_cubit.dart # lib/features/swap/presentation/transfer_bloc.dart
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
Validate custom fees against the mempool minimumFee instead of a hardcoded 0.1 (i5hi's 'custom fee validation > minimum'). The precise endpoint's minimumFee was parsed then discarded; FeeOptions now carries a minRelay rate = max(minimumFee, 0.1), the mapper floors every tier at it, and the custom-fee field, RBF gate, and send/swap commit gates reject anything below it via aboveMinRelay(floorSatPerKwu:). Under congestion the app no longer accepts a fee the network won't relay; never drops below the 0.1 safety floor. fix(swap): the two Bitcoin fee-preview builders hardcoded drain:false while the commit path uses drain:isMaxSelected, so a max-send could cache and broadcast a non-draining PSBT (wrong amount, residual left in wallet). Pass the real flag, mirroring SendCubit. fix(fees): a malformed-but-200 precise response (missing/non-numeric field) threw uncaught instead of falling back — parsing now happens inside _getFees so it falls back to the recommended endpoint. fix(fees): clearing the custom-fee field (abs/rel toggle flip or emptied input) now disarms the parent, so a stale pre-clear value can't commit on dismissal. fix(send): preserve isToSelf on the cached-PSBT commit instead of hardcoding false (was flipping the to-self badge and mis-gating payjoin on self-sends). chore(fees): localize the preset-row unit labels and the Estimated-delivery prefix; correct the mapper doc on minimumFee. Adds regression tests for all of the above.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
loadFees reset selectedFeeOption to Fastest on every call, silently clobbering a committed tier (or custom) when mempool rates refresh. Default to Fastest only on the first successful load; preserve the existing selection afterwards. The cache-clear was already gated on an actual rate change.
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.
…imals 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.
…he clear _rebuildTransactionWithState computed the absolute fee but never re-checked the relay floor against the freshly built vsize, so an absolute custom fee that cleared the pre-build gate could broadcast below relay — the swap mirror of the send-side hole. Both Bitcoin branches now re-assert via _builtFeeClearsRelay; a below-floor build clears signedPsbt and sets TransferState.buildTransactionException, surfaced on the confirm page (the buildError slot was hardcoded null). _onLoadUtxos refreshed the available coin set without dropping cached preview PSBTs, so a wallet sync landing mid-flow could leave a stale PSBT staged for broadcast. It now clears the cache on an actual set change, guarded by setEquals — matching SendCubit.loadUtxos.
…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).
Collaborator
|
Minor issue: #2340 |
Collaborator
|
tACK |
…s-than-1-satvbyte-fee-rate # Conflicts: # lib/features/send/ui/screens/send_screen.dart # test/features/send/presentation/bloc/send_state_test.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Issue #2133: BDK's
FeeRate.fromSatPerVbtakes anintsat/vByte. Our call site.round()-ed 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.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).
Precise mempool fees. Bitcoin presets now come from
/api/v1/fees/precise(decimal rates), falling back to/api/v1/fees/recommended(rounded ints) for older/self-hosted mempool servers — parsing tolerates either, including a mixed int/double or malformed body. Mapping: Fastest ←fastestFee, Economic ←hourFee, Slow ←economyFee, each floored at the relay minimum.Live relay floor.
FeeOptions.minRelay = max(mempool.minimumFee, 0.1 sat/vByte)— the network's current minimum clamped up to the static 0.1 safety floor. The custom-fee field, the RBF gate, and the send/swap commit gates all reject anything below it, so under congestion the app won't build a tx the network won't relay (and never goes below 0.1).Display. Every fee shown to the user is
psbt.fee()from a real unsigned PSBT — neverrate × vsize. The send screen and modal tiles shimmer until the build completes.Cache. The unsigned PSBT built for each preset (and the typed custom rate) is cached and reused verbatim at commit — defeats BDK's randomized coin selection so the broadcast tx has the exact vsize/fee the modal displayed. Same-rate presets share one PSBT via per-rate dedupe. The drain/max-send flag flows identically through preview and commit on both send and swap.
Invalidation. The cache is dropped (and a monotonic preview epoch bumped) on every input-shape change — amount, recipient, UTXO selection, wallet swap, RBF toggle, mempool rate refresh, and the custom-fee arm. No stale PSBT survives to broadcast. Clearing the custom-fee field (toggle flip or empty input) disarms the selection so a stale value can't commit on dismissal.
LWK / RBF rate-only contract. Absolute Liquid fees are converted back to a rate via a placeholder PSET (
CalculateLiquidPsetSizeUsecase). BDK'sBumpFeeTxBuilderhitsfromSatPerKwudirectly.UI rules. The custom-fee field warns between the floor and 1 sat/vByte (slow-but-relays) and blocks below the floor (won't propagate). It prefills with the previously committed value on reopen. Typing IS the selection; dismissing IS the apply — no Confirm button.
Architecture
lib/core/fees/— data layer split to the repository pattern:data/models/mempool_fees_model.dart— wire model, parses every field asnum → double(noas intcrash on decimals).data/mappers/mempool_fees_mapper.dart— owns the tier policy + relay-floor clamp.domain/repositories/fees_repository.dart— abstract interface;data/fees_repository_impl.dartthe impl.FeesDatasourceis pure HTTP with an injectedDiobuilder (unit-testable fallback).domain/fees_entity.dart—NetworkFee(sat/kwu),FeeOptions(+minRelay),NetworkFeeRelayPolicy.aboveMinRelay({floorSatPerKwu}).domain/fee_preview_cache.dart—BitcoinFeePreviewSlot+BitcoinFeePreviewCachevalue objects, one composite keyed byFeeSelection.lib/features/send/domain/usecases/—PreviewBitcoinFeeUsecase+PreviewBitcoinFeePresetsUsecase(parallel, dedupe-by-rate), shared by send and swap.PrepareBitcoinSendUsecase+CalculateBitcoinAbsoluteFeesUsecasemoved tolib/core/wallet/domain/usecases/.lib/core/widgets/fees/— one shared modal.FeeModalViewState(read) +FeeModalActions(write) ports;FeeOptionsModal+CustomFeeListItemdepend only on the ports. BothSendCubitandTransferBlocimplement them. Replaces the deleted per-featuresend/ui/widgets/fee_options_modal.dart,selectable_custom_fee_list_item.dart, andswap/ui/widgets/swap_fee_options_modal.dart.CustomFeeListItemalso serves RBF (commitOnChange: true).Testing
306 unit/widget tests,
flutter analyzeclean. Coverage includes: fee primitives + relay-policy boundary (static and dynamic floor), the precise→recommended fallback matrix (incl. malformed-200), the mapper tier policy + congested-minimumFeefloor, the preview cache slot/composite invariants, the "displayed fee = on-chain fee verbatim" invariant (pinned via the #2133 reproducer txids), custom-fee keystroke dispatch / prefill / disarm-on-clear, and the no-Confirm-button contract.Follow-ups
developis done (merged); the deadhttppackage (0 imports, dio is the sole HTTP client) can be dropped in a separatechore(deps).loadFees's cache-clear inSendCubitis still unconditional — could be gated on an actual rate change (cosmetic re-shimmer only).