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:getState → selectedNetworkClientId, 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 handleFetch → successfulFetch → 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)
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
Summary
Wire
GasFeeControllerinto@metamask/wallet's default initialization set, as its ownInitializationConfigurationinstance undersrc/initialization/instances/gas-fee-controller/, following the pattern of the most recently merged instances (e.g.transaction-controller,network-controller).@metamask/walletis the shared controller-integration layer thatmetamask-extension,metamask-mobile, and@metamask/wallet-cliall adopt, so any value that differs between clients must be an injectableinstanceOptionsslot with a platform-agnostic default — never hardcoded to one client's value.Why
TransactionControlleris already wired into@metamask/walletand its messenger delegatesGasFeeController:fetchGasFeeEstimates. Delegation registers a lazy handler, so today the wallet boots fine — but the moment a transaction flow needs gas estimates, the call throwsA handler for GasFeeController:fetchGasFeeEstimates has not been registered. WiringGasFeeControlleris the missing piece that lets a wiredTransactionControlleractually estimate gas, and it also unblocksUserOperationControllerlater.Current status — unblocked
NetworkController, which is already wired onmain. Nothing upstream blocks this.GasFeeControlleris not yet insrc/initialization/instances/index.ts.@metamask/walletwallet-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(plusgetGasFeeEstimatesAndStartPolling,getTimeEstimate,disableGasFeeEstimates/enableGasFeeEstimates,resetPolling,stopPolling, and:getState/:stateChange).getMessengermust delegate from the root, per the controller'sAllowedActions/AllowedEvents— allNetworkController, all already wired:NetworkController:getState,NetworkController:getNetworkClientById,NetworkController:getEIP1559CompatibilityNetworkController:networkDidChangeKeep the allowlist tight — delegate only these.
Constructor requirements
GasFeeControllertakes direct callbacks, not pure messenger delegation. The instanceinit({ messenger, state, options })builds them from the wiredNetworkControllermessenger actions:messenger,stategetProvider: () => ProviderProxyNetworkController:getState→selectedNetworkClientId, thenNetworkController:getNetworkClientById(id).providergetCurrentNetworkEIP1559Compatibility: () => Promise<boolean>() => messenger.call('NetworkController:getEIP1559Compatibility') ?? falsegetCurrentNetworkLegacyGasAPICompatibility: () => booleanEIP1559APIEndpoint: stringlegacyAPIEndpoint,interval,clientId,getCurrentAccountEIP1559CompatibilityonNetworkDidChange,getChainIdNetworkController:networkDidChangeand readsgetState/getNetworkClientByIditself) when both are absentGasFeeControllerextendsStaticIntervalPollingController(a mixin) — nothing extra to wire for that.Injectable options + per-environment table
Add a
GasFeeControllerInstanceOptionsintypes.ts(typed via indexed access from the upstream options), wire it intoInstanceSpecificOptions.gasFeeController, and give each a platform-agnostic default.EIP1559APIEndpointhttps://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>/suggestedGasFeeslegacyAPIEndpoint${GAS_API_BASE_URL}/networks/<chain_id>/gasPriceshttps://gas.api.cx.metamask.io/networks/<chain_id>/gasPricesclientId'extension''mobile''cli'(sent asX-Client-Id); injectableinterval10_00015_00015_000; injectablegetCurrentNetworkLegacyGasAPICompatibilitychainId === BSCmainnet || BSC || POLYGON() => false); consider injectablegetCurrentAccountEIP1559Compatibility() => true() => truedefaultOpen decision —
fetchinjection (please resolve before implementation)GasFeeControllerfetches gas estimates viahandleFetch→successfulFetch→ the globalfetch(packages/controller-utils/src/util.ts). It has no injectablefetchoption. This is in tension with the integration guide's rule that data-fetching controllers must injectfetch(the package must run in browser, React Native, and Node.js).Two paths:
fetch. No upstream change. Works on Node 18+ (wallet-cli daemon), modern browsers, and React Native (with itsfetchpolyfill). Note that the global-fetchusage lives inside the@metamask/gas-fee-controllerpackage, not inside@metamask/walletcode — the wallet instance never itself reaches for a global. Both clients already rely on this today.fetchoption to@metamask/gas-fee-controllerfirst (thread it throughgas-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.ts—const gasFeeController: InitializationConfiguration<...>withname: 'GasFeeController', tight-allowlistgetMessenger, andinitbuilding the callbacks above.src/initialization/instances/gas-fee-controller/types.ts—GasFeeControllerInstanceOptions(indexed-access types from the upstream options).gasFeeController?: GasFeeControllerInstanceOptionsintoInstanceSpecificOptionsinsrc/types.ts.src/initialization/instances/index.ts..github/CODEOWNERS— assign the new instance directory (trailing slash) to@MetaMask/confirmations(mirrors/packages/gas-fee-controllerownership); runyarn lint:teams.@metamask/gas-fee-controlleras a dependency; update tsconfig project references + root README dependency graph (yarn lint:fix).src/Wallet.test.tsif it participates; keep the Anvil integration test green.CHANGELOG.md—## [Unreleased]/### Added.yarn workspace @metamask/wallet run test+build,yarn lint:fix,yarn validate:changelog.mainreference links.Client adoption (follow-ups, one PR each)
After this lands and a
@metamask/walletrelease includes it:gas-fee-controller-init.ts+ its messenger factory, construct via the sharedWallet, pass values throughinstanceOptions.gasFeeController, resolve viawallet.getInstance('GasFeeController').gas-fee-controller-init.ts+ messenger factory.gasFeeController: { EIP1559APIEndpoint, clientId: 'cli' }slot to the daemon'sbuildInstanceOptions(globalfetchis fine on Node).References (live
main)packages/gas-fee-controller/src/GasFeeController.tspackages/wallet/src/initialization/instances/transaction-controller/app/scripts/messenger-client-init/confirmations/gas-fee-controller-init.ts· messenger:app/scripts/messenger-client-init/messengers/gas-fee-controller-messenger.tsapp/core/Engine/controllers/gas-fee-controller/gas-fee-controller-init.ts· messenger:app/core/Engine/messengers/gas-fee-controller-messenger/gas-fee-controller-messenger.tsAcceptance criteria
GasFeeControlleris in the wallet's default wired set; a constructedWalletexposesGasFeeController:fetchGasFeeEstimatesover the shared bus, resolving theTransactionControllerdelegation.instanceOptions.gasFeeControllerwith platform-agnostic defaults; nothing baked to one client.fetchdecision above is recorded.Owning team for the controller package:
@MetaMask/confirmations(with@MetaMask/core-platform).Related
wallet-cli: make the daemon transaction-capable — consumeGasFeeController+ headless auto-approval #9512 — consumed by the wallet-cli daemon (adds thegasFeeControllerslot once this lands + ships in a@metamask/walletrelease)wallet-cli: add themmsend-transaction command #9513 — themmsend command this ultimately unblocks (transitively, viawallet-cli: make the daemon transaction-capable — consumeGasFeeController+ headless auto-approval #9512)TransactionControllerslot, whoseGasFeeController:fetchGasFeeEstimatesdelegation this satisfieswallet-cli: makesendCommandretry idempotency-safe before mutating RPC ships #9511