Skip to content

fix(fees): sub-1 sat/vByte fees via mempool precise endpoint (#2133)#2199

Merged
ethicnology merged 27 commits into
developfrom
2133-allow-creating-transactions-with-less-than-1-satvbyte-fee-rate
Jun 24, 2026
Merged

fix(fees): sub-1 sat/vByte fees via mempool precise endpoint (#2133)#2199
ethicnology merged 27 commits into
developfrom
2133-allow-creating-transactions-with-less-than-1-satvbyte-fee-rate

Conversation

@ethicnology

@ethicnology ethicnology commented May 26, 2026

Copy link
Copy Markdown
Member

Why

Issue #2133: BDK's FeeRate.fromSatPerVb takes an int sat/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 — never rate × 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's BumpFeeTxBuilder hits fromSatPerKwu directly.

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 as num → double (no as int crash 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.dart the impl. FeesDatasource is pure HTTP with an injected Dio builder (unit-testable fallback).
  • domain/fees_entity.dartNetworkFee (sat/kwu), FeeOptions (+ minRelay), NetworkFeeRelayPolicy.aboveMinRelay({floorSatPerKwu}).
  • domain/fee_preview_cache.dartBitcoinFeePreviewSlot + BitcoinFeePreviewCache value objects, one composite keyed by FeeSelection.

lib/features/send/domain/usecases/PreviewBitcoinFeeUsecase + PreviewBitcoinFeePresetsUsecase (parallel, dedupe-by-rate), shared by send and swap. PrepareBitcoinSendUsecase + CalculateBitcoinAbsoluteFeesUsecase moved to lib/core/wallet/domain/usecases/.

lib/core/widgets/fees/ — one shared modal. FeeModalViewState (read) + FeeModalActions (write) ports; FeeOptionsModal + CustomFeeListItem depend only on the ports. Both SendCubit and TransferBloc implement them. Replaces the deleted per-feature send/ui/widgets/fee_options_modal.dart, selectable_custom_fee_list_item.dart, and swap/ui/widgets/swap_fee_options_modal.dart. CustomFeeListItem also serves RBF (commitOnChange: true).

Testing

306 unit/widget tests, flutter analyze clean. Coverage includes: fee primitives + relay-policy boundary (static and dynamic floor), the precise→recommended fallback matrix (incl. malformed-200), the mapper tier policy + congested-minimumFee floor, 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

  • Update this branch off the latest develop is done (merged); the dead http package (0 imports, dio is the sole HTTP client) can be dropped in a separate chore(deps).
  • loadFees's cache-clear in SendCubit is still unconditional — could be gated on an actual rate change (cosmetic re-shimmer only).

@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

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.
@ethicnology

Copy link
Copy Markdown
Member Author

@claude new commits pushed, satisfied?

@claude

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.
@i5hi

i5hi commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

We need to use this endpoint to get precise fees:

https://mempool.bullbitcoin.com/docs/api/rest#get-recommended-fees-precise

@i5hi

i5hi commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

API response:

{
  "fastestFee": 2.023,
  "halfHourFee": 1.094,
  "hourFee": 0.502,
  "economyFee": 0.2,
  "minimumFee": 0.1
}

In our app:

Fastest - response.fastestFee
Economic - response.hourFee
Slow - response. economyFee

@i5hi

i5hi commented Jun 15, 2026

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

This comment was marked as outdated.

@claude

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.
@ethicnology ethicnology changed the title fix(fees): reallow sub-1 sat/vByte transactions fix(fees): sub-1 sat/vByte fees via mempool precise endpoint (#2133) Jun 15, 2026
@ethicnology

This comment was marked as outdated.

@claude

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.
@ethicnology ethicnology marked this pull request as ready for review June 15, 2026 15:28
@ethicnology ethicnology requested a review from i5hi June 15, 2026 15:31
…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).
@i5hi

i5hi commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Minor issue: #2340
Not blocking

@i5hi

i5hi commented Jun 24, 2026

Copy link
Copy Markdown
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
@ethicnology ethicnology merged commit 3cb336c into develop Jun 24, 2026
1 check passed
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