Skip to content

fix: ignore saved advanced gas fees for bridge transactions - #9401

Merged
matthewwalsh0 merged 4 commits into
mainfrom
fix/bridge-ignore-saved-gas-fees
Jul 7, 2026
Merged

fix: ignore saved advanced gas fees for bridge transactions#9401
matthewwalsh0 merged 4 commits into
mainfrom
fix/bridge-ignore-saved-gas-fees

Conversation

@cloudonshore

@cloudonshore cloudonshore commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Explanation

Bridge transactions apply the user's saved advanced gas fees, whereas swaps deliberately ignore them.

In updateGasFees, saved gas fees are skipped only for SWAP_TRANSACTION_TYPES (swap, swapAndSend, swapApproval). bridge / bridgeApproval are not in that list, so a user's saved advanced gas fees are applied to bridge transactions.

For a user who has saved a low max base fee (observed in production: maxBaseFee: "0.05" gwei, priorityFee: "0" for mainnet), every bridge is submitted underpriced — below the current base fee — causing it to fail (GAS_TOO_LOW on the relay) or get stuck as pending. This is exactly the failure swaps were already protected from.

Fix

Ignore saved gas fees for bridge transactions as well, via a dedicated list:

const SAVED_GAS_FEES_IGNORED_TRANSACTION_TYPES: TransactionType[] = [
  ...SWAP_TRANSACTION_TYPES,
  TransactionType.bridge,
  TransactionType.bridgeApproval,
];

I intentionally did not add the bridge types to SWAP_TRANSACTION_TYPES itself, because that constant also gates swap-specific behavior in updateSwapsTransaction (e.g. the simulationFails cancel-and-throw, and transactionNewSwap* events). Keeping a separate, purpose-named list decouples "ignore saved gas fees" from "is a swap".

References

  • Root-caused from production state logs where a bridge smart transaction was submitted with maxFeePerGas: 0x2faf080 (0.05 gwei) / maxPriorityFeePerGas: 0x0, sourced from advancedGasFee["0x1"] = { maxBaseFee: "0.05", priorityFee: "0" }.

Changelog

@metamask/transaction-controller

  • FIXED: bridge and bridgeApproval transactions now ignore user-saved (advanced) gas fees, matching swaps.

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc) for new or updated code as appropriate
  • I've highlighted breaking changes using the "BREAKING" category above as appropriate

Note

Medium Risk
Changes gas pricing for all internal transactions (bridges, swaps, etc.); behavior is intentional but affects submission paths where wrong fees caused production failures.

Overview
updateGasFees no longer applies user-saved (advanced) gas fees when txMeta.isInternal is true. Saved fees are only loaded for non-internal (dApp) transactions.

The previous logic skipped saved fees only for SWAP_TRANSACTION_TYPES, so bridge and other internal flows could inherit a low saved max base fee and submit underpriced txs. The swap-type check and SWAP_TRANSACTION_TYPES import were removed in favor of isInternal.

Tests cover applying saved fees for non-internal txs and ignoring them (without calling getSavedGasFees) for internal txs. The changelog documents the fix.

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

Bridge and bridge approval transactions applied the user's saved advanced
gas fees, which swaps already deliberately ignore. A user with a low saved
max base fee (e.g. 0.05 gwei on mainnet) would therefore submit underpriced
bridge transactions that fail or get stuck as pending.

Introduce a dedicated SAVED_GAS_FEES_IGNORED_TRANSACTION_TYPES list
(swap + bridge types) to decide whether saved gas fees apply, keeping the
"ignore saved gas fees" concern decoupled from SWAP_TRANSACTION_TYPES, which
also gates swap-specific behavior.
@cloudonshore
cloudonshore requested review from a team as code owners July 7, 2026 04:07
chaitanyapotti
chaitanyapotti previously approved these changes Jul 7, 2026
@matthewwalsh0
matthewwalsh0 requested a review from dan437 July 7, 2026 11:00
* fees are dictated by the swap/bridge aggregator or relay; applying saved gas
* fees could underprice the transaction and cause it to fail or get stuck.
*/
const SAVED_GAS_FEES_IGNORED_TRANSACTION_TYPES: TransactionType[] = [

@matthewwalsh0 matthewwalsh0 Jul 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Rather than doing this per type, should we just exclude based on isInternal so it only applies to dApp transactions as was the intent?

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.

Sounds good, even simpler! I made the change

@dan437
dan437 removed their request for review July 7, 2026 11:34
Per review feedback, skip user-saved (advanced) gas fees for all internal
transactions rather than maintaining a list of swap/bridge types. Saved gas
fees are only intended for dApp transactions; internal transactions (swaps,
bridges, etc.) have their fees dictated by the aggregator or relay.
@matthewwalsh0
matthewwalsh0 added this pull request to the merge queue Jul 7, 2026
Merged via the queue into main with commit 9b2a8b1 Jul 7, 2026
417 checks passed
@matthewwalsh0
matthewwalsh0 deleted the fix/bridge-ignore-saved-gas-fees branch July 7, 2026 18:35
@cloudonshore cloudonshore mentioned this pull request Jul 7, 2026
3 tasks
pull Bot pushed a commit to dmrazzy/core that referenced this pull request Jul 7, 2026
## Explanation

Releases two packages needed to fix stuck/underpriced bridge smart
transactions in the clients (extension + mobile).

### `@metamask/transaction-controller` — `68.2.2` → `68.3.0` (minor)
- **Added:** `failTransaction` method +
`TransactionController:failTransaction` messenger action
([MetaMask#9400](MetaMask#9400)) — fails a tx
through the standard path, emitting
`transactionFailed`/`transactionStatusUpdated`/`transactionFinished`.
- **Fixed:** Only apply user-saved (advanced) gas fees to dApp
transactions; ignored for internal (`isInternal`) txs such as swaps and
bridges ([MetaMask#9401](MetaMask#9401)).
- **Changed:** Bump `@metamask/messenger` `^1.2.0` → `^2.0.0`
([MetaMask#9392](MetaMask#9392)).

### `@metamask/smart-transactions-controller` — `24.2.4` → `25.0.0`
(major)
- **Changed (BREAKING):** Fail the associated regular transaction via
`TransactionController:failTransaction` instead of `updateTransaction`
when a smart transaction is cancelled
([MetaMask#9400](MetaMask#9400)). Consumers must
now grant the STX messenger access to
`TransactionController:failTransaction`.
- **Changed:** Bump `@metamask/messenger` `^1.2.0` → `^2.0.0`
([MetaMask#9392](MetaMask#9392)).

All other changed packages are `intentionally-skip` (owned by other
teams); dependent version ranges were updated automatically.

## References

- Fixes stuck-pending bridge STX (root cause in MetaMask#9400) and underpriced
bridge gas (MetaMask#9401).

## Checklist

- [x] I've followed the [release
process](https://github.com/MetaMask/core/blob/main/docs/processes/releasing.md)
- [x] Changelogs reviewed and categorized (no `Uncategorized`; breaking
change marked)
- [x] Version bumps follow SemVer (STX major for the breaking messenger
change)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches transaction failure signaling and gas on internal bridge
flows; STX **25.0.0** is a breaking messenger permission change for
extension/mobile integrators.
> 
> **Overview**
> **Monorepo release `1102.0.0`** that publishes
**`@metamask/transaction-controller` `68.3.0`** and
**`@metamask/smart-transactions-controller` `25.0.0`**, and rolls
**`@metamask/transaction-controller` `^68.2.2` → `^68.3.0`** through
dependent packages and **`yarn.lock`**.
> 
> **`transaction-controller` `68.3.0`** (documented in its changelog for
this release): adds **`failTransaction`** /
**`TransactionController:failTransaction`** so out-of-band failures emit
normal lifecycle events; fixes **advanced gas fees** so they apply only
to dApp txs, not **`isInternal`** bridge/swap txs.
> 
> **`smart-transactions-controller` `25.0.0` (breaking):** on smart-tx
cancel, calls **`failTransaction`** instead of **`updateTransaction`**;
hosts must delegate **`TransactionController:failTransaction`** on the
STX messenger.
> 
> Other touched packages in the diff are mainly **version/changelog
dependency bumps** tied to this release, not new feature code in those
files.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
c423a08. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
pull Bot pushed a commit to Reality2byte/metamask-mobile that referenced this pull request Jul 21, 2026
…ask#33175)

## **Description**

Integrates the published core fix for stuck bridge smart transactions
into mobile.

Changes:
- Bump `@metamask/smart-transactions-controller` `^24.2.2` → `25.0.0`
- Bump `@metamask/transaction-controller` to `68.4.0` (dependency
`^68.3.0` + resolution `68.4.0`)
- Update the smart transactions controller messenger to delegate
`TransactionController:failTransaction` instead of
`TransactionController:updateTransaction`

**Why:** When the relay cancelled a smart transaction, the STX
controller previously called `updateTransaction`, which only patches
state and does not emit transaction lifecycle events. Consumers that
react to `transactionFailed`/`transactionStatusUpdated` (the bridge
status controller and metrics) were never notified, so cancelled bridge
smart transactions stayed **stuck pending indefinitely**. The core fix
([MetaMask/core#9400](MetaMask/core#9400)) adds
a `failTransaction` action that fails the tx through the standard path
and emits those events; this PR wires mobile's STX messenger to use it.

**Note on versions:** `smart-transactions-controller` is pinned to
`25.0.0` (and the `transaction-controller` resolution to `68.4.0`) to
keep `transaction-controller` within `68.x`. `stx@25.0.1` requires
`transaction-controller@^69.0.0`, a larger major bump out of scope for
this fix.

## **Changelog**

CHANGELOG entry: Fixed bridge smart transactions that could remain stuck
as pending after being cancelled by the relay

## **Related issues**

Refs:
- [MetaMask/core#9400](MetaMask/core#9400) —
fail cancelled smart transactions through the standard path
- [MetaMask/core#9401](MetaMask/core#9401) —
ignore saved gas fees for internal transactions
- Released in
[MetaMask/core#9421](MetaMask/core#9421)

## **Manual testing steps**

1. Build the branch and set up a wallet with a bridge route on a
supported chain.
2. Initiate a bridge that is submitted as a smart transaction.
3. Observe a relay-side cancellation of the smart transaction.
4. Confirm the transaction transitions to **failed** in the activity
list (instead of remaining pending) and the bridge status updates
accordingly.

## **Screenshots/Recordings**

N/A — dependency bump + messenger wiring (no UI changes).

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches transaction lifecycle integration for smart transactions;
incorrect wiring could affect how cancelled STX are surfaced, but scope
is limited to messenger delegation and a controlled dependency bump.
> 
> **Overview**
> **Integrates the core fix for bridge smart transactions that stayed
pending after relay cancellation.**
> 
> Bumps `@metamask/smart-transactions-controller` from `^24.2.2` to
`25.0.0` and updates the Smart Transactions controller messenger (and
related test harnesses) to delegate
**`TransactionController:failTransaction`** instead of
**`TransactionController:updateTransaction`**. The upgraded STX
controller uses the fail path when a relay cancels a smart transaction
so **`transactionFailed` / `transactionStatusUpdated`** fire and bridge
status and activity UI can leave the pending state.
> 
> No app-layer transaction logic changes beyond messenger wiring and
dependency lock updates.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
02840f1. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
pull Bot pushed a commit to Reality2byte/metamask-extension that referenced this pull request Jul 21, 2026
…ask#44372)

## **Description**

Integrates the published core fix for stuck bridge smart transactions
into the extension.

Changes:
- Bump `@metamask/smart-transactions-controller` `^24.2.2` → `25.0.0`
- Bump `@metamask/transaction-controller` `^68.2.2` → `^68.3.0`
(resolves to `68.4.0`)
- Update the smart transactions controller messenger to delegate
`TransactionController:failTransaction` instead of
`TransactionController:updateTransaction`

**Why:** When the relay cancelled a smart transaction, the STX
controller previously called `updateTransaction`, which only patches
state and does not emit transaction lifecycle events. Consumers that
react to `transactionFailed`/`transactionStatusUpdated` (the bridge
status controller and metrics) were never notified, so cancelled bridge
smart transactions stayed **stuck pending indefinitely**. The core fix
([MetaMask/core#9400](MetaMask/core#9400)) adds
a `failTransaction` action that fails the tx through the standard path
and emits those events; this PR wires the extension's STX messenger to
use it.

**Note on versions:** `smart-transactions-controller` is pinned to
`25.0.0` (not a caret range) to keep `transaction-controller` within
`68.x`. `stx@25.0.1` requires `transaction-controller@^69.0.0`, a larger
major bump out of scope for this fix.

## **Changelog**

CHANGELOG entry: Fixed bridge smart transactions that could remain stuck
as pending after being cancelled by the relay

## **Related issues**

Integrates the published core fix:
- [MetaMask/core#9400](MetaMask/core#9400) —
fail cancelled smart transactions through the standard path
- [MetaMask/core#9401](MetaMask/core#9401) —
ignore saved gas fees for internal transactions
- Released in
[MetaMask/core#9421](MetaMask/core#9421)

## **Manual testing steps**

1. Build the branch and set up a wallet with a bridge route on a
supported chain.
2. Initiate a bridge that is submitted as a smart transaction.
3. Observe a relay-side cancellation of the smart transaction.
4. Confirm the transaction transitions to **failed** in the activity
list (instead of remaining pending) and the bridge status updates
accordingly.

## **Screenshots/Recordings**

N/A — dependency bump + messenger wiring (no UI changes).

### **Before**

Cancelled bridge smart transactions stayed pending indefinitely.

### **After**

Cancelled bridge smart transactions transition to failed and notify the
bridge status controller / metrics.

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches smart-transaction and transaction lifecycle integration;
behavior change when relays cancel STXs, but scoped to messenger
delegation and a targeted dependency bump.
> 
> **Overview**
> Integrates the core fix for **bridge smart transactions stuck
pending** after relay cancellation by bumping
`@metamask/smart-transactions-controller` to **25.0.0** (pinned) and
aligning the lockfile.
> 
> The extension wires the smart transactions controller messenger to
delegate **`TransactionController:failTransaction`** instead of
**`TransactionController:updateTransaction`**, in both production init
and unit test setup, so cancelled STXs go through the standard failure
path and emit **`transactionFailed`** / **`transactionStatusUpdated`**
for bridge status and metrics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3985dc9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
seaona pushed a commit to MetaMask/metamask-extension that referenced this pull request Jul 22, 2026
## **Description**

Integrates the published core fix for stuck bridge smart transactions
into the extension.

Changes:
- Bump `@metamask/smart-transactions-controller` `^24.2.2` → `25.0.0`
- Bump `@metamask/transaction-controller` `^68.2.2` → `^68.3.0`
(resolves to `68.4.0`)
- Update the smart transactions controller messenger to delegate
`TransactionController:failTransaction` instead of
`TransactionController:updateTransaction`

**Why:** When the relay cancelled a smart transaction, the STX
controller previously called `updateTransaction`, which only patches
state and does not emit transaction lifecycle events. Consumers that
react to `transactionFailed`/`transactionStatusUpdated` (the bridge
status controller and metrics) were never notified, so cancelled bridge
smart transactions stayed **stuck pending indefinitely**. The core fix
([MetaMask/core#9400](MetaMask/core#9400)) adds
a `failTransaction` action that fails the tx through the standard path
and emits those events; this PR wires the extension's STX messenger to
use it.

**Note on versions:** `smart-transactions-controller` is pinned to
`25.0.0` (not a caret range) to keep `transaction-controller` within
`68.x`. `stx@25.0.1` requires `transaction-controller@^69.0.0`, a larger
major bump out of scope for this fix.

## **Changelog**

CHANGELOG entry: Fixed bridge smart transactions that could remain stuck
as pending after being cancelled by the relay

## **Related issues**

Integrates the published core fix:
- [MetaMask/core#9400](MetaMask/core#9400) —
fail cancelled smart transactions through the standard path
- [MetaMask/core#9401](MetaMask/core#9401) —
ignore saved gas fees for internal transactions
- Released in
[MetaMask/core#9421](MetaMask/core#9421)

## **Manual testing steps**

1. Build the branch and set up a wallet with a bridge route on a
supported chain.
2. Initiate a bridge that is submitted as a smart transaction.
3. Observe a relay-side cancellation of the smart transaction.
4. Confirm the transaction transitions to **failed** in the activity
list (instead of remaining pending) and the bridge status updates
accordingly.

## **Screenshots/Recordings**

N/A — dependency bump + messenger wiring (no UI changes).

### **Before**

Cancelled bridge smart transactions stayed pending indefinitely.

### **After**

Cancelled bridge smart transactions transition to failed and notify the
bridge status controller / metrics.

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches smart-transaction and transaction lifecycle integration;
behavior change when relays cancel STXs, but scoped to messenger
delegation and a targeted dependency bump.
> 
> **Overview**
> Integrates the core fix for **bridge smart transactions stuck
pending** after relay cancellation by bumping
`@metamask/smart-transactions-controller` to **25.0.0** (pinned) and
aligning the lockfile.
> 
> The extension wires the smart transactions controller messenger to
delegate **`TransactionController:failTransaction`** instead of
**`TransactionController:updateTransaction`**, in both production init
and unit test setup, so cancelled STXs go through the standard failure
path and emit **`transactionFailed`** / **`transactionStatusUpdated`**
for bridge status and metrics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
3985dc9. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
pull Bot pushed a commit to Reality2byte/core that referenced this pull request Jul 29, 2026
…bridges (MetaMask#9682)

## Summary

Saved gas preferences were excluded from every transaction with
`isInternal: true`. This also excluded wallet-initiated transfers, which
are marked internal by the extension API even though they should use
saved gas settings.

This changes the exclusion to a dedicated transaction-type list
containing swaps and bridge transactions. Wallet transfers can now apply
saved gas preferences while swaps and bridges remain protected from
underpriced saved fees.

## Related PRs

- MetaMask#9401
- MetaMask/metamask-extension#43317

## Testing

- `yarn jest --config packages/transaction-controller/jest.config.js
--runInBand packages/transaction-controller/src/utils/gas-fees.test.ts
--coverage=false`
- Added coverage for internal wallet transactions and swap/bridge
exclusions.

## Changelog

Added an Unreleased changelog entry for
`@metamask/transaction-controller`.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes which transactions receive saved gas fees at submit time;
wallet sends gain user prefs while swap/bridge behavior stays guarded,
but any misclassified `type` could get the wrong fee path.
> 
> **Overview**
> **Saved gas preferences** were skipped for every transaction with
`isInternal: true`, which incorrectly blocked wallet-initiated transfers
(often marked internal) from using the user’s advanced gas settings.
> 
> `updateGasFees` now ignores saved gas only for **swap and bridge**
transaction types (`SWAP_TRANSACTION_TYPES`, `bridge`, `bridgeApproval`)
instead of all internal transactions. Internal `simpleSend` transfers
can apply saved fees again; aggregator/relay-driven swaps and bridges
still avoid user-saved fees that could underprice them.
> 
> Tests cover internal wallet sends applying saved gas and parameterized
cases for swap/bridge types still ignoring them.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
61d4074. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
pull Bot pushed a commit to dmrazzy/core that referenced this pull request Aug 4, 2026
…sted estimates (MetaMask#9704)

## 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 MetaMask#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`, MetaMask#9401/MetaMask#9682) — explicitly
because a saved fee could underprice aggregator-priced transactions —
and already yields them to explicit fee params
(`hasInitialGasFeeParams`, MetaMask#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

- Prior art for saved-fee exclusions: MetaMask#9401, MetaMask#9682, MetaMask#8993
- Known edge case (fee bump vs. dapp max-spend arithmetic) and proposed
balance-clamp refinement: see PR comment below — it applies to both
gates.

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've communicated my changes to consumers by [updating changelogs
for packages I've
changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md)
- [ ] I've introduced [breaking
changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md)
in this PR and have prepared draft pull requests for clients and
consumer packages to resolve them

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes pre-sign gas selection for dapp and saved-fee paths when flags
are enabled; behavior is gated and fail-open, but wrong thresholds or
rollout could surprise users or sites.
> 
> **Overview**
> Adds **opt-in, per-chain** gates in `updateGasFees` so transactions
are less likely to be priced below current network conditions before
approval.
> 
> When **`replaceUnderpricedDappGasFees`** is enabled, dapp-suggested
EIP-1559 fees whose effective `maxFeePerGas` (including legacy
`gasPrice` used as max fee) is **below the wallet’s low estimate** are
replaced with **suggested medium** `maxFeePerGas` /
`maxPriorityFeePerGas`. **`userFeeLevel`** becomes **`medium`** instead
of **`dappSuggested`**, so fees can keep updating while unapproved;
originals stay on **`dappSuggestedGasFees`**. Complete dapp fee params
no longer skip estimate fetch when this flag is on (needed for the
comparison; fail-open if estimates are missing).
> 
> When **`replaceUnderpricedSavedGasFees`** is enabled, saved **custom**
`maxBaseFee` below the low estimate is **dropped** for that tx so medium
suggestions apply; **level-based** saved prefs are unchanged.
**`userFeeLevel`** becomes **`medium`** instead of **`custom`**.
> 
> New remote feature-flag helpers and types mirror existing per-chain
`default` / `perChainConfig` patterns; both flags default to
**disabled**.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
95e622d. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
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.

3 participants