From eaaf5d27c7a07c315b6277797c0d3e17eb66e4f0 Mon Sep 17 00:00:00 2001 From: jpuri Date: Wed, 2 Sep 2026 10:27:51 +0530 Subject: [PATCH 1/4] fix(transaction-controller): harden gas fee token preflight Avoid treating native balance as sufficient when gas estimates are still pending, and do not leave isExternalSign enabled when the selected gas fee token fails preflight validation. --- packages/transaction-controller/CHANGELOG.md | 5 +++ .../src/utils/balance.test.ts | 35 +++++++++++++++++++ .../src/utils/balance.ts | 16 ++++++--- .../src/utils/gas-fee-tokens.test.ts | 10 ++++++ .../src/utils/gas-fee-tokens.ts | 24 +++++++------ 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index f8c38eebac4..69a508aab1f 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Prevent clearing a selected gas fee token when native gas estimates are still pending ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Avoid leaving `isExternalSign` enabled when gas fee token preflight validation fails ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + ## [69.7.0] ### Added diff --git a/packages/transaction-controller/src/utils/balance.test.ts b/packages/transaction-controller/src/utils/balance.test.ts index a230667a78b..e3b145ccbbe 100644 --- a/packages/transaction-controller/src/utils/balance.test.ts +++ b/packages/transaction-controller/src/utils/balance.test.ts @@ -75,5 +75,40 @@ describe('Balance Utils', () => { expect(result).toBe(false); }); + + it('returns false if gas estimate is missing', async () => { + const result = await isNativeBalanceSufficientForGas( + { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + gas: undefined, + }, + }, + MESSENGER_MOCK, + NETWORK_CLIENT_ID_MOCK, + ); + + expect(result).toBe(false); + expect(rpcRequestMock).not.toHaveBeenCalled(); + }); + + it('returns false if max fee per gas is missing', async () => { + const result = await isNativeBalanceSufficientForGas( + { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + maxFeePerGas: undefined, + gasPrice: undefined, + }, + }, + MESSENGER_MOCK, + NETWORK_CLIENT_ID_MOCK, + ); + + expect(result).toBe(false); + expect(rpcRequestMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/transaction-controller/src/utils/balance.ts b/packages/transaction-controller/src/utils/balance.ts index ae1a8cd10a1..e5c6ab67e35 100644 --- a/packages/transaction-controller/src/utils/balance.ts +++ b/packages/transaction-controller/src/utils/balance.ts @@ -52,12 +52,18 @@ export async function isNativeBalanceSufficientForGas( networkClientId: NetworkClientId, ): Promise { const from = transaction.txParams.from as Hex; + const gas = transaction.txParams.gas; + const maxFeePerGas = + transaction.txParams.maxFeePerGas ?? transaction.txParams.gasPrice; - const gasCostRawValue = new BigNumber( - transaction.txParams.gas ?? '0x0', - ).multipliedBy( - transaction.txParams.maxFeePerGas ?? transaction.txParams.gasPrice ?? '0x0', - ); + // Gas estimates can still be in flight (e.g. skipInitialGasEstimate). Treating + // missing fee fields as zero makes every balance look sufficient and clears a + // selected gas fee token before publish. + if (!gas || !maxFeePerGas) { + return false; + } + + const gasCostRawValue = new BigNumber(gas).multipliedBy(maxFeePerGas); const { balanceRaw } = await getNativeBalance( from, diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts index 6cad84fc4ec..9b769acd2da 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.test.ts @@ -411,10 +411,20 @@ describe('Gas Fee Tokens Utils', () => { request.transaction.isGasFeeTokenIgnoredIfBalance = true; request.transaction.selectedGasFeeToken = TOKEN_ADDRESS_1_MOCK; request.transaction.gasFeeTokens = []; + request.transaction.isExternalSign = true; + + jest.mocked(request.fetchGasFeeTokens).mockResolvedValueOnce([]); await expect(checkGasFeeTokenBeforePublish(request)).rejects.toThrow( 'Gas fee token not found and insufficient native balance', ); + + jest + .mocked(request.updateTransaction) + .mock.calls[0][1](request.transaction); + + expect(request.transaction.isExternalSign).toBe(false); + expect(request.transaction.gasFeeTokens).toStrictEqual([]); }); it('updates gas fee tokens', async () => { diff --git a/packages/transaction-controller/src/utils/gas-fee-tokens.ts b/packages/transaction-controller/src/utils/gas-fee-tokens.ts index c51dcb37ac0..849cf607098 100644 --- a/packages/transaction-controller/src/utils/gas-fee-tokens.ts +++ b/packages/transaction-controller/src/utils/gas-fee-tokens.ts @@ -187,6 +187,20 @@ export async function checkGasFeeTokenBeforePublish({ isExternalSign: true, }); + const isSelectedGasFeeTokenAvailable = gasFeeTokens?.some( + (token) => + token.tokenAddress.toLowerCase() === selectedGasFeeToken.toLowerCase(), + ); + + if (!isSelectedGasFeeTokenAvailable) { + updateTransaction(transaction.id, (tx) => { + tx.gasFeeTokens = gasFeeTokens; + tx.isExternalSign = false; + }); + + throw new Error('Gas fee token not found and insufficient native balance'); + } + updateTransaction(transaction.id, (tx) => { tx.gasFeeTokens = gasFeeTokens; tx.isExternalSign = true; @@ -194,16 +208,6 @@ export async function checkGasFeeTokenBeforePublish({ }); log('Updated gas fee tokens before publish', gasFeeTokens); - - if ( - !gasFeeTokens?.some( - (token) => - token.tokenAddress.toLowerCase() === selectedGasFeeToken.toLowerCase(), - ) - ) { - throw new Error('Gas fee token not found and insufficient native balance'); - } - log('Publishing with selected gas fee token', { selectedGasFeeToken }); } From d2ed12db0313007e0a7e1f714581dd274846650f Mon Sep 17 00:00:00 2001 From: jpuri Date: Wed, 2 Sep 2026 10:28:15 +0530 Subject: [PATCH 2/4] docs(transaction-controller): link changelog to #10071 --- packages/transaction-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 69a508aab1f..c40b4cc2a27 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Prevent clearing a selected gas fee token when native gas estimates are still pending ([#TBD](https://github.com/MetaMask/core/pull/TBD)) -- Avoid leaving `isExternalSign` enabled when gas fee token preflight validation fails ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Prevent clearing a selected gas fee token when native gas estimates are still pending ([#10071](https://github.com/MetaMask/core/pull/10071)) +- Avoid leaving `isExternalSign` enabled when gas fee token preflight validation fails ([#10071](https://github.com/MetaMask/core/pull/10071)) ## [69.7.0] From db916c8f556d2a569c6766266ac1abb1791d80bc Mon Sep 17 00:00:00 2001 From: jpuri Date: Wed, 2 Sep 2026 10:35:24 +0530 Subject: [PATCH 3/4] docs(transaction-controller): consolidate unreleased changelog entry --- packages/transaction-controller/CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index c40b4cc2a27..53c78b3b8b8 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -9,8 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Prevent clearing a selected gas fee token when native gas estimates are still pending ([#10071](https://github.com/MetaMask/core/pull/10071)) -- Avoid leaving `isExternalSign` enabled when gas fee token preflight validation fails ([#10071](https://github.com/MetaMask/core/pull/10071)) +- Harden gas fee token preflight by not treating pending gas estimates as zero-cost native gas, and by resetting `isExternalSign` when preflight validation fails ([#10071](https://github.com/MetaMask/core/pull/10071)) ## [69.7.0] From 447935cb0e0c0563f27eac4dbbc98d5e9ee4f1b0 Mon Sep 17 00:00:00 2001 From: jpuri Date: Wed, 2 Sep 2026 10:44:15 +0530 Subject: [PATCH 4/4] fix(transaction-controller): satisfy prefer-destructuring in balance util --- .../transaction-controller/src/utils/balance.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/transaction-controller/src/utils/balance.ts b/packages/transaction-controller/src/utils/balance.ts index e5c6ab67e35..3bb28be7c59 100644 --- a/packages/transaction-controller/src/utils/balance.ts +++ b/packages/transaction-controller/src/utils/balance.ts @@ -51,22 +51,22 @@ export async function isNativeBalanceSufficientForGas( messenger: TransactionControllerMessenger, networkClientId: NetworkClientId, ): Promise { - const from = transaction.txParams.from as Hex; - const gas = transaction.txParams.gas; - const maxFeePerGas = - transaction.txParams.maxFeePerGas ?? transaction.txParams.gasPrice; + const { + txParams: { from, gas, maxFeePerGas, gasPrice }, + } = transaction; + const maxFeePerGasValue = maxFeePerGas ?? gasPrice; // Gas estimates can still be in flight (e.g. skipInitialGasEstimate). Treating // missing fee fields as zero makes every balance look sufficient and clears a // selected gas fee token before publish. - if (!gas || !maxFeePerGas) { + if (!gas || !maxFeePerGasValue) { return false; } - const gasCostRawValue = new BigNumber(gas).multipliedBy(maxFeePerGas); + const gasCostRawValue = new BigNumber(gas).multipliedBy(maxFeePerGasValue); const { balanceRaw } = await getNativeBalance( - from, + from as Hex, messenger, networkClientId, );