Skip to content

fix: replace underpriced dapp-suggested and saved gas fees with suggested estimates - #9704

Open
cloudonshore wants to merge 9 commits into
mainfrom
fix/underpriced-dapp-gas-fees
Open

fix: replace underpriced dapp-suggested and saved gas fees with suggested estimates#9704
cloudonshore wants to merge 9 commits into
mainfrom
fix/underpriced-dapp-gas-fees

Conversation

@cloudonshore

@cloudonshore cloudonshore commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Explanation

Underpriced gas fees — a maxFeePerGas below (or barely above) the current base fee — produce transactions that are very unlikely to be included before fee conditions change further. They typically end up stuck as pending until eventually marked as failed (e.g. Transaction not found on network after timeout), with no actionable feedback to the user. Telemetry shows this is a meaningful contributor to dropped/timed-out transactions.

Two fee sources can produce such values, and nothing in the client validates either against current network conditions. This PR adds a feature-flag-gated staleness gate for both, at transaction creation time — the last point where fees can still be changed before signing.

1. Dapp-suggested fees (replaceUnderpricedDappGasFees flag)

Currently:

  • updateGasFees uses initialParams.maxFeePerGas as-is.
  • Gas estimation deliberately strips fee fields before calling the RPC node, so the node's own fee-cap validation never runs.
  • GasFeePoller only refreshes fees for preset levels, so dappSuggested fees stay frozen while the confirmation is open.

A dapp that computes fees from stale data can therefore produce a signed transaction that cannot mine. With the flag enabled:

  • A new shouldIgnoreDappGasFees check fires when the dapp-suggested maxFeePerGas (or a legacy gasPrice used as maxFeePerGas on EIP-1559 networks) is below the current low estimate. The low estimate is the floor rather than the raw base fee, since fees between the base fee and the low estimate have near-zero effective priority fee and no headroom for base fee movement, so they share the same failure mode.
  • When it fires, the transaction falls through to the existing suggested (medium) fee logic for both maxFeePerGas and maxPriorityFeePerGas.
  • userFeeLevel is set to medium instead of dappSuggested, which also opts the transaction into GasFeePoller refresh while unapproved.
  • The original values remain available via dappSuggestedGasFees on the transaction metadata, so UIs can still display the site suggestion and users can revert via the edit modal.

Note: for transactions with complete dapp-suggested fees, getSuggestedGasFees previously skipped fetching estimates entirely. When the flag is enabled, estimates are now fetched for these transactions so the comparison can run — one additional gas fee estimate call per dapp transaction on enabled chains.

2. Saved (advanced) gas fee preferences (replaceUnderpricedSavedGasFees flag)

A saved custom preference is a static maxBaseFee captured at some point in the past; once the base fee rises past it, every transaction priced from it strands. Nothing else guards this source: the dapp gate deliberately excludes savedGasFees, and hasInitialGasFeeParams only protects transactions carrying explicit fee params — saved fees apply precisely when they don't. This is increasingly relevant as clients expand saved-preference usage (per-account custom gas settings persisted from the confirmation flow, and #9682 extending saved fees to wallet-initiated transfers).

With the flag enabled:

  • After suggested fees are fetched, if the saved preference has a custom maxBaseFee below the current low estimate, the saved preference is ignored for this transaction and the suggested (medium) values are used instead.
  • Level-based saved preferences (low/medium/high) are never touched — they track current estimates by construction.
  • userFeeLevel becomes medium instead of custom, with the same GasFeePoller refresh benefit.
  • No fetch-path changes needed: transactions eligible for saved fees carry no fee params, so estimates are already fetched.

Guards / fail-open behavior (both gates)

Fees are kept unchanged when: the respective feature flag is not enabled for the chain (both default to disabled; per-chain rollout via perChainConfig with default fallback, mirroring timeoutAttempts); the transaction is internal (dapp gate; swaps/bridges are priced by the aggregator/relay); the fee is at or above the low estimate; or estimates are unavailable / not fee-market type.

The product decision (saved-fees gate)

Overriding a value the user saved is a stronger intervention than overriding a dapp's computed value. Three defensible behaviors when the saved-fees gate fires: (1) silently use suggested fees (this PR's behavior), (2) use suggested fees + surface an alert ("your saved gas setting is below current network fees"), (3) keep the saved value + warn only. The flag ships disabled, so this can be settled before enablement.

An argument for intervening (1 or 2) rather than deferring entirely to the saved value: saved preferences already do not grant full autonomy. The controller already ignores them for swaps and bridges (SAVED_GAS_FEES_IGNORED_TRANSACTION_TYPES, #9401/#9682) — explicitly because a saved fee could underprice aggregator-priced transactions — and already yields them to explicit fee params (hasInitialGasFeeParams, #8993). The established principle is that saved preferences apply where they can work and yield where they would predictably break the transaction. A custom maxBaseFee below the current low estimate is the clearest instance of the latter — this extends existing precedent rather than setting new one.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Changes pre-sign gas selection for dapp and saved custom fees when flags are enabled; incorrect thresholds or rollout could surprise users or sites, though behavior is gated and fail-open when estimates are missing.

Overview
Adds feature-flag-gated gas fee validation at transaction creation so fees below the current low estimate are swapped for wallet medium suggestions instead of leaving txs stuck pending.

When replaceUnderpricedDappGasFees is on, external dapp EIP-1559 fees (or legacy gasPrice used as max fee) below the low tier are ignored; userFeeLevel becomes medium so GasFeePoller can refresh while unapproved. Original values stay on dappSuggestedGasFees.

When replaceUnderpricedSavedGasFees is on, saved custom maxBaseFee preferences below low are dropped (level-based saved fees are unchanged), with the same medium fallback and userFeeLevel behavior.

getSuggestedGasFees now fetches estimates for complete dapp-suggested fee txs when the dapp flag is enabled, exposes the low tier on fee-market responses, and skips RPC fallback on that path so estimate failures do not block creation. Both flags default off with per-chain perChainConfig.

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

@cloudonshore
cloudonshore requested review from a team as code owners July 30, 2026 06:42
@cloudonshore

Copy link
Copy Markdown
Contributor Author

Known edge case: dapp max-spend calculations

If a dapp computes a maximum spendable amount from its own fee suggestion (e.g. value = balance - gas × dappMaxFeePerGas for a native max send/wrap/bridge), replacing an underpriced fee with the medium estimate can push the worst-case reservation (value + gas × maxFeePerGas) above the account balance, surfacing an insufficient-funds state for a transaction the dapp's UI presented as affordable.

Worth noting: for the gate to fire, the base fee has already risen past the dapp's data — so the dapp's amount is stale too, not just its fee. There is no fee assignment that both fits the balance and can currently be included: the affordable (dapp) fee can't mine, and any mineable fee breaks the max-spend arithmetic. The replacement converts a silent, un-actionable failure (stuck pending, nonce occupied) into an explicit one, but it can still contradict the dapp's arithmetic in a user-visible way.

Proposed refinement (follow-up): clamp the replacement to what the balance can bear, since the actual charge (baseFee + tip) is far below the reservation:

replacementMaxFee = min(medium, (balance − value) / gas)

and only proceed if the result is still ≥ the low estimate. If it is, the transaction fits the balance and can mine — the max-spend flow works unchanged. If it isn't, the amount is genuinely unaffordable at any viable fee, and the replacement is skipped (fail open, current behavior). Cost is one balance read, only when the gate fires. The clamp should also be skipped for sponsored-gas / gas-fee-token transactions, where maxFeePerGas never draws on the native balance (those are safe to bump freely).

Suggested alongside: break down the eventual metrics by outcome (replaced / replaced_clamped / skipped_unaffordable) so the real-world rate of this edge case is measurable.

@cloudonshore cloudonshore changed the title fix: replace underpriced dapp-suggested gas fees with suggested estimates fix: replace underpriced dapp-suggested and saved gas fees with suggested estimates Jul 30, 2026
},
// TODO: Replace `any` with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as GasFeeFlowResponse);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use unknown here or try to define this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 7f37616 — replaced with as unknown as GasFeeFlowResponse and dropped the eslint suppression.

Comment on lines +553 to +567
const hasCompleteDappFees =
eip1559 &&
Boolean(txMeta.txParams.maxFeePerGas) &&
Boolean(txMeta.txParams.maxPriorityFeePerGas);

// Estimates are still required for transactions with complete dapp-suggested
// fees if they may be replaced due to being underpriced.
const isEstimateRequiredForDappFees =
hasCompleteDappFees &&
!txMeta.isInternal &&
getReplaceUnderpricedDappGasFeesEnabled(txMeta.chainId, messenger);

if (
(!eip1559 && txMeta.txParams.gasPrice) ||
(eip1559 &&
txMeta.txParams.maxFeePerGas &&
txMeta.txParams.maxPriorityFeePerGas)
(hasCompleteDappFees && !isEstimateRequiredForDappFees)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to preserve complete dapp-supplied fees when fee estimation and the fallback RPC both fail.

Enabling replaceUnderpricedDappGasFees makes transactions with complete fees enter the estimation path at the bottom of the file:

  const gasPriceHex = (await rpcRequest({
    messenger,
    networkClientId,
    method: 'eth_gasPrice',
  })) as Hex | undefined;

An estimation failure falls through to the unguarded eth_gasPrice request; if that request rejects, transaction creation now fails instead of retaining the original complete fees. Catch the fallback failure or return empty suggestions on this comparison-only path so the feature continues to fail open.

In summary:

  • Flag disabled: no regression; the old early-return behavior remains.
  • Flag enabled, estimate succeeds: intended new behavior.
  • Flag enabled, estimate fails but eth_gasPrice succeeds: likely still fails open and retains the dapp fees.
  • Flag enabled, both estimate and eth_gasPrice fail: new failure mode; transaction creation rejects where it previously retained the complete dapp fees.

@cloudonshore cloudonshore Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 7f37616. The comparison-only path now returns empty suggestions after a flow failure instead of falling through to the unguarded eth_gasPrice request, so both-failing can no longer reject transaction creation and the dapp fees are retained.

@pedronfigueiredo pedronfigueiredo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking good just two small nits

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