Skip to content

Support fractional fee rates in custom fees and presets - #460

Open
peachbits wants to merge 4 commits into
masterfrom
matthew/decimal-custom-fee
Open

peachbits wants to merge 4 commits into
masterfrom
matthew/decimal-custom-fee

Conversation

@peachbits

@peachbits peachbits commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Asana: https://app.asana.com/1/9976422036640/project/1213843652804305/task/1208974186635443

A 1.8 sat/vB custom fee was charged as 1 sat/vB, and the fee presets were rounded to whole sat/vB and floored at 2. Fee rates now keep their decimals from input to signed transaction.

Do not release until Edge's BTC blockbook nodes are upgraded. btc-wusa1 (Core 28.0.0) and btc-eu1 (Core 29.0.0) refuse transactions under 1 sat/vB, and the Low preset can now go below 1 (mempool.space floors halfHourFee at 0.5). Broadcasts still succeed through Trezor and NOWNodes, but a wallet syncing through our nodes won't see its pending transaction until it confirms. Both nodes need Core 29.1 or newer first.

1. Honor fractional custom fee rates

makeSpend ran the rate through parseInt. It now parses with Number, which rejects a comma-decimal 1,8 instead of reading it as 1. Comma-decimal locales will see that error until the GUI normalizes the custom fee input. Every satoshi amount derived from a rate goes through feeForBytes, which rounds up.

2. Add engine-level coverage for fractional custom fees

makeSpend and signTx tests at fractional rates, plus rejection of rates that aren't plain numbers.

3. Support fractional fee presets

Fetch mempool.space /api/v1/fees/precise instead of /recommended, drop the 2 sat/vB LOW_FEE floor, and round presets to 3 decimals instead of integers. A 0.1 sat/vB preset used to round to 0 and fail the send.

4. Keep accelerated transactions at a fractional fee rate

Accelerate doubled the replaced rate through Math.round, which sent anything under 0.25 sat/vB to zero. The doubled rate now rounds up to 3 decimals.

Testing

  • Unit and engine tests for each change. Every commit passes tsc, eslint and the full suite on its own.
  • Mainnet send-to-self from an iOS simulator build, run before the final review fixes: BTC Low preset at 1.432 sat/vB, BTC custom 1.8 sat/vB, and DOGE Low preset.

Note

Medium Risk
Changes core UTXO fee math, preset sourcing, and Doge floors—incorrect rounding could underpay or break sends; sub-1 sat/vB BTC txs may not appear on Edge Blockbook until nodes upgrade.

Overview
UTXO wallets now treat fee rates as fractional sat/vB from quote through coin selection and signing, instead of truncating or rounding to whole sat/vB.

Presets and mempool data: Bitcoin fee presets come from mempool.space /fees/precise, keep up to three decimal places, and no longer use the old 2 sat/vB floor or integer rounding in calcMinerFeePerByte (sub-1 rates no longer become 0 and fail sends). Standard-tier interpolation keeps decimal precision in the biggystring math.

Spends: makeSpend parses rates with Number (not parseInt), rejects invalid strings like comma decimals, and passes the rate into pickers that compute fees via feeForBytes (ceil to whole satoshis). RBF accelerate doubles the replaced tx rate with calcReplacementFeeRate (ceil to 3 decimals) so low rates are not doubled to zero.

Dogecoin: Custom fee clamping uses the 100 koinu/vB relay floor, not the 1000 mining default (fixes silent 10× bumps).

Note for release: Low BTC presets can fall below 1 sat/vB; Edge’s own Blockbook nodes on older Core may not show those pending txs until nodes are upgraded (per PR description).

Reviewed by Cursor Bugbot for commit a95e0e5. Bugbot is set up for automated code reviews on this repo. Configure here.

makeSpend ran the fee rate through parseInt, so a 1.8 sat/vB custom fee
became 1 sat/vB and the user paid roughly half what they asked for. The
GUI passes the typed value straight through, and calcMinerFeePerByte
returns a custom rate verbatim, so the truncation was the only place the
decimal was lost.

Parse the rate with Number and validate it explicitly, since parseInt was
also acting as an accidental guard against unparseable input. Number
rejects a string unless all of it is a number. parseFloat would stop at
the comma in '1,8', which is how a comma decimal locale writes 1.8, and
charge 1 sat/vB without an error. The picker now accepts fractional rates
via feeRateOrNaN, while output values keep using uintOrNaN and stay whole
satoshis.

Every satoshi amount derived from a rate goes through feeForBytes, which
rounds up so a fractional rate can never underpay or produce a fractional
output value. It snaps the product to 12 significant digits first, because
1.1 * 10 evaluates to 11.000000000000002 and would otherwise cost an extra
satoshi.
Exercises the real makeSpend path with a custom rate of 1.8 sat/vB and
asserts on the returned EdgeTransaction: the decimal survives into
feeRateUsed, networkFee is a whole satoshi, inputs minus outputs equals
the fee, the target is paid exactly, and the signed transaction lands
within a percent of the requested rate. It also asserts that a rate that
is not entirely a number, such as '1,8', is rejected rather than read as 1.
The custom fee path already honors decimals. The presets did not: four
separate places rounded or clamped a rate back to a whole sat/vB.

Fetch from mempool.space's /fees/precise instead of /fees/recommended.
Both run the same calculation over the same projected blocks, but
/recommended rounds each step to a whole sat/vB and never reports less
than 1, so the fractional rates were never visible to us. /precise
works in steps of 0.001 sat/vB and adds a priority offset to its two
fastest tiers, which keeps fastestFee at 1 or more and halfHourFee at
0.5 or more. Of the presets, only lowFee can fall below 1 sat/vB.

Drop the hardcoded LOW_FEE floor of 2 sat/vB. Bitcoin Core lowered its
minrelaytxfee default to 0.1 sat/vB, so the floor sat 20x above what
the network enforces and above the rate most blocks are clearing at.
Nodes older than Core 28.3 or 29.1 still refuse anything under 1 sat/vB,
so Edge's own Blockbook servers need one of those versions before this
ships, or they will not see transactions sent at those rates.

Multiply through biggystring rather than JS numbers, since 0.575 * 1.3
evaluates to 0.7474999999999999.

Round the presets to three decimal places rather than to an integer,
matching the precision /fees/precise reports. Note biggystring's round
takes the power of ten to round at, so three decimals is -3, while div
takes a count of decimal places. The interpolated standard rate needs
the latter, having previously truncated to a whole sat/vB.

Rounding a preset to an integer was not only imprecise, it was fatal
below 1 sat/vB: a 0.1 rate became 0, which makeSpend then rejects as an
invalid fee rate.
Accelerate derives its rate by doubling the replaced transaction's, then
rounded the result. That truncated the decimal the rest of the fee path
now preserves, and worse, Math.round returns 0 for anything below 0.5,
so any replaced rate under 0.25 sat/vB doubled to a zero fee rate.

Zero is not caught downstream. The feeRate > 0 guard lives in makeSpend,
which accelerate does not go through: it takes an EdgeTransaction, never
an EdgeSpendInfo, and never calls fees.getRate. The picker's own check is
feeRateOrNaN, and isFinite(0) is true, so a zero-fee replacement would be
built and signed.

That was unreachable while the presets floored at 2 sat/vB. It is not
anymore, now that Bitcoin relays at 0.1 and we pass those rates through.

calcReplacementFeeRate now doubles the rate and rounds it up to
FEE_RATE_DECIMALS places. Rounding up means the replacement never pays
less than double and a positive rate never reaches zero. Rounding to
three places keeps feeRateUsed readable, since the GUI shows it verbatim
in a transaction's advanced details. Pulling the calculation out of the
engine also gives it the direct test coverage accelerate had none of.
@peachbits
peachbits force-pushed the matthew/decimal-custom-fee branch from a95e0e5 to a8890fe Compare September 16, 2026 20:31
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.

2 participants