Skip to content

Wire GasFeeController into @metamask/wallet default initialization #9510

Description

@sirtimid

Summary

Wire GasFeeController into @metamask/wallet's default initialization set, as its own InitializationConfiguration instance under src/initialization/instances/gas-fee-controller/, following the pattern of the most recently merged instances (e.g. transaction-controller, network-controller).

@metamask/wallet is the shared controller-integration layer that metamask-extension, metamask-mobile, and @metamask/wallet-cli all adopt, so any value that differs between clients must be an injectable instanceOptions slot with a platform-agnostic default — never hardcoded to one client's value.

Why

TransactionController is already wired into @metamask/wallet and its messenger delegates GasFeeController:fetchGasFeeEstimates. Delegation registers a lazy handler, so today the wallet boots fine — but the moment a transaction flow needs gas estimates, the call throws A handler for GasFeeController:fetchGasFeeEstimates has not been registered. Wiring GasFeeController is the missing piece that lets a wired TransactionController actually estimate gas, and it also unblocks UserOperationController later.

Current status — unblocked

  • Only controller dependency is NetworkController, which is already wired on main. Nothing upstream blocks this.
  • GasFeeController is not yet in src/initialization/instances/index.ts.
  • Neither client has a @metamask/wallet wallet-init instance-options file for it yet, so there is no existing reference to port from — this PR establishes the pattern (and the client-verification bottleneck applies).

Messenger surface

Exposes (already consumed by TransactionController): GasFeeController:fetchGasFeeEstimates (plus getGasFeeEstimatesAndStartPolling, getTimeEstimate, disableGasFeeEstimates/enableGasFeeEstimates, resetPolling, stopPolling, and :getState / :stateChange).

getMessenger must delegate from the root, per the controller's AllowedActions/AllowedEvents — all NetworkController, all already wired:

  • Actions: NetworkController:getState, NetworkController:getNetworkClientById, NetworkController:getEIP1559Compatibility
  • Events: NetworkController:networkDidChange

Keep the allowlist tight — delegate only these.

Constructor requirements

GasFeeController takes direct callbacks, not pure messenger delegation. The instance init({ messenger, state, options }) builds them from the wired NetworkController messenger actions:

Constructor option Required How the instance supplies it
messenger, state yes from the init args
getProvider: () => ProviderProxy yes NetworkController:getStateselectedNetworkClientId, then NetworkController:getNetworkClientById(id).provider
getCurrentNetworkEIP1559Compatibility: () => Promise<boolean> yes () => messenger.call('NetworkController:getEIP1559Compatibility') ?? false
getCurrentNetworkLegacyGasAPICompatibility: () => boolean yes see options table (client-varying → injectable, with default)
EIP1559APIEndpoint: string yes, no default injectable option (see table)
legacyAPIEndpoint, interval, clientId, getCurrentAccountEIP1559Compatibility no injectable / default
onNetworkDidChange, getChainId no can be omitted — the constructor already has a messenger-based network-tracking fallback (subscribes NetworkController:networkDidChange and reads getState/getNetworkClientById itself) when both are absent

GasFeeController extends StaticIntervalPollingController (a mixin) — nothing extra to wire for that.

Injectable options + per-environment table

Add a GasFeeControllerInstanceOptions in types.ts (typed via indexed access from the upstream options), wire it into InstanceSpecificOptions.gasFeeController, and give each a platform-agnostic default.

Option Extension Mobile Default (wallet-cli / package)
EIP1559APIEndpoint https://gas.api.cx.metamask.io/networks/<chain_id>/suggestedGasFees (dev override → gas.uat-api.cx.metamask.io) https://gas.api.cx.metamask.io/networks/<chain_id>/suggestedGasFees prod URL as default; injectable
legacyAPIEndpoint ${GAS_API_BASE_URL}/networks/<chain_id>/gasPrices https://gas.api.cx.metamask.io/networks/<chain_id>/gasPrices controller default is already this host; injectable
clientId 'extension' 'mobile' 'cli' (sent as X-Client-Id); injectable
interval 10_000 default 15_000 controller default 15_000; injectable
getCurrentNetworkLegacyGasAPICompatibility chainId === BSC mainnet || BSC || POLYGON reasonable EVM default (e.g. () => false); consider injectable
getCurrentAccountEIP1559Compatibility () => true omitted () => true default

Note: the API host is effectively the same production endpoint across clients; extension adds a dev-API toggle. Keeping the endpoint injectable (rather than hardcoded) is what lets the dev override and the CLI configuration work.

Open decision — fetch injection (please resolve before implementation)

GasFeeController fetches gas estimates via handleFetchsuccessfulFetch → the global fetch (packages/controller-utils/src/util.ts). It has no injectable fetch option. This is in tension with the integration guide's rule that data-fetching controllers must inject fetch (the package must run in browser, React Native, and Node.js).

Two paths:

  • (a) Accept the global fetch. No upstream change. Works on Node 18+ (wallet-cli daemon), modern browsers, and React Native (with its fetch polyfill). Note that the global-fetch usage lives inside the @metamask/gas-fee-controller package, not inside @metamask/wallet code — the wallet instance never itself reaches for a global. Both clients already rely on this today.
  • (b) Add an injectable fetch option to @metamask/gas-fee-controller first (thread it through gas-util), then wire and inject it here — aligns with the guide/convention. Larger scope; touches a @MetaMask/confirmations-owned package.

Recommendation: (a) to land the wire, with (b) tracked as a separate follow-up if the convention is to be enforced strictly.

Implementation checklist (per the controller-integration guide)

  • src/initialization/instances/gas-fee-controller/gas-fee-controller.tsconst gasFeeController: InitializationConfiguration<...> with name: 'GasFeeController', tight-allowlist getMessenger, and init building the callbacks above.
  • src/initialization/instances/gas-fee-controller/types.tsGasFeeControllerInstanceOptions (indexed-access types from the upstream options).
  • Wire gasFeeController?: GasFeeControllerInstanceOptions into InstanceSpecificOptions in src/types.ts.
  • Export from src/initialization/instances/index.ts.
  • .github/CODEOWNERS — assign the new instance directory (trailing slash) to @MetaMask/confirmations (mirrors /packages/gas-fee-controller ownership); run yarn lint:teams.
  • Add @metamask/gas-fee-controller as a dependency; update tsconfig project references + root README dependency graph (yarn lint:fix).
  • Tests (TDD, 100% coverage): colocated unit test for the instance; extend src/Wallet.test.ts if it participates; keep the Anvil integration test green.
  • CHANGELOG.md## [Unreleased] / ### Added.
  • Verify: yarn workspace @metamask/wallet run test + build, yarn lint:fix, yarn validate:changelog.
  • Open as a draft PR with the per-environment options table and live main reference links.

Client adoption (follow-ups, one PR each)

After this lands and a @metamask/wallet release includes it:

  • metamask-extension — delete gas-fee-controller-init.ts + its messenger factory, construct via the shared Wallet, pass values through instanceOptions.gasFeeController, resolve via wallet.getInstance('GasFeeController').
  • metamask-mobile — same, deleting its gas-fee-controller-init.ts + messenger factory.
  • @metamask/wallet-cli — add a gasFeeController: { EIP1559APIEndpoint, clientId: 'cli' } slot to the daemon's buildInstanceOptions (global fetch is fine on Node).

References (live main)

Acceptance criteria

  • GasFeeController is in the wallet's default wired set; a constructed Wallet exposes GasFeeController:fetchGasFeeEstimates over the shared bus, resolving the TransactionController delegation.
  • All client-varying values are injectable via instanceOptions.gasFeeController with platform-agnostic defaults; nothing baked to one client.
  • The fetch decision above is recorded.
  • Full verification suite passes; PR opened as draft with the options table + live reference links.

Owning team for the controller package: @MetaMask/confirmations (with @MetaMask/core-platform).

Related

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions