diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 5f882a5f75..58058e0fd9 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fix MAX native token swap failing with "insufficient gas" when STX is off by using quote's `txFee` instead of re-estimating gas when `gasIncluded` is true ([#7306](https://github.com/MetaMask/core/pull/7306)) + ## [64.0.0] ### Changed diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 43b1c08b9c..f8e2980b1a 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -2803,6 +2803,25 @@ Array [ ] `; +exports[`BridgeStatusController submitTx: EVM swap should estimate gas when gasIncluded is false and STX is off 1`] = ` +Object { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 1`] = ` Object { "batchId": "batchId1", @@ -3348,6 +3367,173 @@ Array [ ] `; +exports[`BridgeStatusController submitTx: EVM swap should use batch path when gasIncluded7702 is true regardless of STX setting 1`] = ` +Object { + "batchId": "batchId1", + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 1`] = ` +Object { + "chainId": "0xa4b1", + "hash": "0xevmTxHash", + "id": "test-tx-id", + "status": "unapproved", + "time": 1234567890, + "txParams": Object { + "chainId": "0xa4b1", + "data": "0xdata", + "from": "0xaccount1", + "gasLimit": "0x5208", + "to": "0xbridgeContract", + "value": "0x0", + }, + "type": "swap", +} +`; + +exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 2`] = ` +Object { + "account": "0xaccount1", + "approvalTxId": undefined, + "batchId": undefined, + "estimatedProcessingTimeInSeconds": 0, + "featureId": undefined, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "isStxEnabled": false, + "pricingData": Object { + "amountSent": "1.234", + "amountSentInUsd": "1.01", + "quotedGasAmount": ".00055", + "quotedGasInUsd": "2.5778", + "quotedReturnInUsd": "0.134214", + }, + "quote": Object { + "bridgeId": "lifi", + "bridges": Array [ + "across", + ], + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 42161, + "destTokenAmount": "990654755978612", + "feeData": Object { + "metabridge": Object { + "amount": "8750000000000", + "asset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + "txFee": Object { + "maxFeePerGas": "1395348", + "maxPriorityFeePerGas": "1000001", + }, + }, + "gasIncluded": true, + "gasIncluded7702": false, + "minDestTokenAmount": "941000000000000", + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": Array [ + Object { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:10/slip44:60", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": Object { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "assetId": "eip155:42161/slip44:60", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "slippagePercentage": 0, + "startTime": 1234567890, + "status": Object { + "srcChain": Object { + "chainId": 42161, + "txHash": "0xevmTxHash", + }, + "status": "PENDING", + }, + "targetContractAddress": undefined, + "txMetaId": "test-tx-id", +} +`; + exports[`BridgeStatusController submitTx: Solana bridge should handle snap controller errors 1`] = ` Array [ Array [ diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 3c3e0d8cfd..94d0f85231 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -3319,6 +3319,136 @@ describe('BridgeStatusController', () => { expect(mockMessengerCall.mock.calls).toMatchSnapshot(); }); + it('should use quote txFee when gasIncluded is true and STX is off (Max native token swap)', async () => { + setupEventTrackingMocks(mockMessengerCall); + // Setup for single tx path - no gas estimation needed since gasIncluded=true + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + // Skip GasFeeController mock since we use quote's txFee directly + addTransactionFn.mockResolvedValueOnce({ + transactionMeta: mockEvmTxMeta, + result: Promise.resolve('0xevmTxHash'), + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [mockEvmTxMeta], + }); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx( + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: false, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + maxFeePerGas: '1395348', // Decimal string from quote + maxPriorityFeePerGas: '1000001', + }, + }, + }, + } as never, + false, // isStxEnabledOnClient = FALSE (key for this test) + ); + controller.stopAllPolling(); + + // Should use single tx path (addTransactionFn), NOT batch path + expect(addTransactionFn).toHaveBeenCalledTimes(1); + expect(addTransactionBatchFn).not.toHaveBeenCalled(); + + // Should NOT estimate gas (uses quote's txFee instead) + expect(estimateGasFeeFn).not.toHaveBeenCalled(); + + // Verify the tx params have hex-converted gas fees from quote + const txParams = addTransactionFn.mock.calls[0][0]; + expect(txParams.maxFeePerGas).toBe('0x154a94'); // toHex(1395348) + expect(txParams.maxPriorityFeePerGas).toBe('0xf4241'); // toHex(1000001) + + expect(result).toMatchSnapshot(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(controller.state.txHistory[result.id]).toMatchSnapshot(); + }); + + it('should estimate gas when gasIncluded is false and STX is off', async () => { + setupEventTrackingMocks(mockMessengerCall); + setupBridgeMocks(); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx( + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: false, + gasIncluded7702: false, + }, + }, + false, // STX off + ); + controller.stopAllPolling(); + + // Should estimate gas since gasIncluded is false + expect(estimateGasFeeFn).toHaveBeenCalledTimes(1); + expect(addTransactionFn).toHaveBeenCalledTimes(1); + expect(addTransactionBatchFn).not.toHaveBeenCalled(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); + }); + + it('should use batch path when gasIncluded7702 is true regardless of STX setting', async () => { + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); + mockMessengerCall.mockReturnValueOnce('arbitrum'); + addTransactionBatchFn.mockResolvedValueOnce({ + batchId: 'batchId1', + }); + mockMessengerCall.mockReturnValueOnce({ + transactions: [{ ...mockEvmTxMeta, batchId: 'batchId1' }], + }); + + const { controller, startPollingForBridgeTxStatusSpy } = + getController(mockMessengerCall); + + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + const result = await controller.submitTx( + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: true, // 7702 takes precedence → batch path + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + maxFeePerGas: '1395348', + maxPriorityFeePerGas: '1000001', + }, + }, + }, + } as never, + false, // STX off, but gasIncluded7702 = true forces batch path + ); + controller.stopAllPolling(); + + // Should use batch path because gasIncluded7702 = true + expect(addTransactionBatchFn).toHaveBeenCalledTimes(1); + expect(addTransactionFn).not.toHaveBeenCalled(); + expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); + expect(result).toMatchSnapshot(); + }); + it('should handle smart transactions', async () => { setupEventTrackingMocks(mockMessengerCall); mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 6b971d9c75..0a3611fa32 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -869,16 +869,21 @@ export class BridgeStatusController extends StaticIntervalPollingController => { const actionId = generateActionId().toString(); @@ -918,6 +923,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + const maxGasLimit = toHex(transactionParams.gas ?? 0); + + // If txFee is provided (gasIncluded case), use the quote's gas fees + // Convert to hex since txFee values from the quote are decimal strings + if (txFee) { + return { + maxFeePerGas: toHex(txFee.maxFeePerGas ?? 0), + maxPriorityFeePerGas: toHex(txFee.maxPriorityFeePerGas ?? 0), + gas: maxGasLimit, + }; + } + const { gasFeeEstimates } = this.messenger.call( 'GasFeeController:getState', ); @@ -955,7 +974,6 @@ export class BridgeStatusController extends StaticIntervalPollingController