Skip to content

Commit 289ea6b

Browse files
fix: hide gas sponsorship banner for hardware wallets cp-7.77.0 (#29898)
Prevent swap quote sponsorship UI from rendering for hardware wallets, including sponsored-quote and insufficient-balance fallback paths, to align MON/SEI behavior with hardware wallet constraints. <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** This PR has done following change to fix #29870 - hide the gas sponsorship banner in bridge/swap quote details when the source account is a hardware wallet - keep existing sponsorship behavior for non-hardware wallets, including same-chain insufficient-balance fallback logic - add regression tests covering hardware-wallet paths where sponsorship previously rendered incorrectly <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Prevent swap quote sponsorship UI from rendering for hardware wallets ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Moderate risk because it changes gating logic for gas sponsorship/gasless support based on hardware-wallet detection and adjusts E2E mocking URLs/network flags, which could affect transaction/confirmation flows if misclassified. > > **Overview** > Prevents gas sponsorship UI/eligibility from applying to hardware wallets by gating `useShouldRenderGasSponsoredBanner` behind `useIsHardwareWalletForBridge`, covering both sponsored-quote and insufficient-balance fallback paths. > > Adds regression coverage ensuring `useIsGaslessSupported` reports `isSupported: false` for hardware-wallet senders (both Smart Transactions and EIP-7702 relay paths). > > Stabilizes gasless/EIP-7702 E2E mocks by normalizing Sentinel localhost URLs (no trailing slash), adding a `1337` network entry with relay flags, and aligning the sponsored simulation request body/ignore-fields and assertions in `gas-fee-tokens-eip-7702-sponsored.spec.ts`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 270c6b8. 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: Cursor <cursoragent@cursor.com>
1 parent 521dac3 commit 289ea6b

7 files changed

Lines changed: 182 additions & 22 deletions

File tree

app/components/UI/Bridge/hooks/useShouldRenderGasSponsoredBanner/index.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { renderHook } from '@testing-library/react-hooks';
22
import { useShouldRenderGasSponsoredBanner } from './index';
33
import { useIsNetworkGasSponsored } from '../useIsNetworkGasSponsored';
4+
import { useIsHardwareWalletForBridge } from '../useIsHardwareWalletForBridge';
45
import { useSelector } from 'react-redux';
56
import {
67
selectSourceToken,
78
selectDestToken,
89
} from '../../../../../core/redux/slices/bridge';
910

1011
jest.mock('../useIsNetworkGasSponsored');
12+
jest.mock('../useIsHardwareWalletForBridge');
1113
jest.mock('react-redux', () => ({
1214
useSelector: jest.fn(),
1315
}));
@@ -16,6 +18,10 @@ const mockUseIsNetworkGasSponsored =
1618
useIsNetworkGasSponsored as jest.MockedFunction<
1719
typeof useIsNetworkGasSponsored
1820
>;
21+
const mockUseIsHardwareWalletForBridge =
22+
useIsHardwareWalletForBridge as jest.MockedFunction<
23+
typeof useIsHardwareWalletForBridge
24+
>;
1925
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
2026

2127
const SOURCE_CHAIN_ID = '0x1';
@@ -51,6 +57,7 @@ describe('useShouldRenderGasSponsoredBanner', () => {
5157
jest.clearAllMocks();
5258
mockTokens({});
5359
mockUseIsNetworkGasSponsored.mockReturnValue(false);
60+
mockUseIsHardwareWalletForBridge.mockReturnValue(false);
5461
});
5562

5663
describe('returns true when quoteGasSponsored is true', () => {
@@ -146,6 +153,43 @@ describe('useShouldRenderGasSponsoredBanner', () => {
146153
});
147154

148155
describe('returns false', () => {
156+
it('returns false when quote is sponsored but source account is a hardware wallet', () => {
157+
// Arrange
158+
mockUseIsHardwareWalletForBridge.mockReturnValue(true);
159+
160+
// Act
161+
const { result } = renderHook(() =>
162+
useShouldRenderGasSponsoredBanner({
163+
quoteGasSponsored: true,
164+
hasInsufficientBalance: false,
165+
}),
166+
);
167+
168+
// Assert
169+
expect(result.current).toBe(false);
170+
});
171+
172+
it('returns false when insufficient balance and network is sponsored but source account is a hardware wallet', () => {
173+
// Arrange
174+
mockTokens({
175+
sourceChainId: SOURCE_CHAIN_ID,
176+
destChainId: SAME_CHAIN_DEST_CHAIN_ID,
177+
});
178+
mockUseIsNetworkGasSponsored.mockReturnValue(true);
179+
mockUseIsHardwareWalletForBridge.mockReturnValue(true);
180+
181+
// Act
182+
const { result } = renderHook(() =>
183+
useShouldRenderGasSponsoredBanner({
184+
quoteGasSponsored: false,
185+
hasInsufficientBalance: true,
186+
}),
187+
);
188+
189+
// Assert
190+
expect(result.current).toBe(false);
191+
});
192+
149193
it('returns false when quoteGasSponsored is false and balance is sufficient', () => {
150194
// Arrange
151195
mockUseIsNetworkGasSponsored.mockReturnValue(false);

app/components/UI/Bridge/hooks/useShouldRenderGasSponsoredBanner/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
selectSourceToken,
55
selectDestToken,
66
} from '../../../../../core/redux/slices/bridge';
7+
import { useIsHardwareWalletForBridge } from '../useIsHardwareWalletForBridge';
78

89
interface Props {
910
quoteGasSponsored?: boolean;
@@ -16,6 +17,7 @@ export const useShouldRenderGasSponsoredBanner = ({
1617
}: Props) => {
1718
const sourceToken = useSelector(selectSourceToken);
1819
const destToken = useSelector(selectDestToken);
20+
const isHardwareWallet = useIsHardwareWalletForBridge();
1921
const isNetworkGasSponsored = useIsNetworkGasSponsored(sourceToken?.chainId);
2022

2123
// Sponsorship only applies to same-chain (swap) flows; cross-chain bridges
@@ -27,8 +29,9 @@ export const useShouldRenderGasSponsoredBanner = ({
2729
);
2830

2931
const shouldShowGasSponsored =
30-
quoteGasSponsored ||
31-
(hasInsufficientBalance && isNetworkGasSponsored && isSameChain);
32+
!isHardwareWallet &&
33+
(quoteGasSponsored ||
34+
(hasInsufficientBalance && isNetworkGasSponsored && isSameChain));
3235

3336
return shouldShowGasSponsored;
3437
};

app/components/Views/confirmations/hooks/gas/useIsGaslessSupported.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,17 @@ import { transferTransactionStateMock } from '../../__mocks__/transfer-transacti
88
import { useTransactionMetadataRequest } from '../transactions/useTransactionMetadataRequest';
99
import { useIsGaslessSupported } from './useIsGaslessSupported';
1010
import { useGaslessSupportedSmartTransactions } from './useGaslessSupportedSmartTransactions';
11+
import { isHardwareAccount } from '../../../../../util/address';
1112

1213
jest.mock('../../../../../util/transactions/sentinel-api');
1314
jest.mock('../../../../../util/transaction-controller');
1415
jest.mock('../../../../../util/transactions/transaction-relay');
1516
jest.mock('../transactions/useTransactionMetadataRequest');
1617
jest.mock('./useGaslessSupportedSmartTransactions');
18+
jest.mock('../../../../../util/address', () => ({
19+
...jest.requireActual('../../../../../util/address'),
20+
isHardwareAccount: jest.fn(),
21+
}));
1722

1823
const SMART_TRANSACTIONS_ENABLED_STATE = {
1924
swaps: {
@@ -54,6 +59,7 @@ describe('useIsGaslessSupported', () => {
5459
const useGaslessSupportedSmartTransactionsMock = jest.mocked(
5560
useGaslessSupportedSmartTransactions,
5661
);
62+
const isHardwareAccountMock = jest.mocked(isHardwareAccount);
5763

5864
beforeEach(() => {
5965
mockUseTransactionMetadataRequest.mockReturnValue({
@@ -66,6 +72,7 @@ describe('useIsGaslessSupported', () => {
6672
isSupported: false,
6773
pending: false,
6874
});
75+
isHardwareAccountMock.mockReturnValue(false);
6976
});
7077

7178
describe('Gasless Smart Transactions', () => {
@@ -94,6 +101,32 @@ describe('useIsGaslessSupported', () => {
94101
);
95102
});
96103

104+
it('returns isSupported false when smart transactions are enabled but sender is a hardware wallet', async () => {
105+
const stateWithSmartTransactionEnabled = merge(
106+
{},
107+
transferConfirmationState,
108+
SMART_TRANSACTIONS_ENABLED_STATE,
109+
);
110+
useGaslessSupportedSmartTransactionsMock.mockReturnValue({
111+
isSmartTransaction: true,
112+
isSupported: true,
113+
pending: false,
114+
});
115+
isHardwareAccountMock.mockReturnValue(true);
116+
117+
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
118+
state: stateWithSmartTransactionEnabled,
119+
});
120+
121+
await waitFor(() =>
122+
expect(result.current).toEqual({
123+
isSupported: false,
124+
isSmartTransaction: true,
125+
pending: false,
126+
}),
127+
);
128+
});
129+
97130
it('returns isSupported and isSmartTransaction as false when smart transactions are disabled', async () => {
98131
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
99132
state: transferTransactionStateMock,
@@ -153,6 +186,24 @@ describe('useIsGaslessSupported', () => {
153186
});
154187
});
155188

189+
it('returns isSupported false when the sender is a hardware wallet even if relay is supported', async () => {
190+
isRelaySupportedMock.mockResolvedValue(true);
191+
isHardwareAccountMock.mockReturnValue(true);
192+
193+
const state = merge({}, transferTransactionStateMock);
194+
const { result } = renderHookWithProvider(() => useIsGaslessSupported(), {
195+
state,
196+
});
197+
198+
await waitFor(() => {
199+
expect(result.current).toEqual({
200+
isSupported: false,
201+
isSmartTransaction: false,
202+
pending: false,
203+
});
204+
});
205+
});
206+
156207
it('returns isSupported false and isSmartTransaction: false when relay not supported', async () => {
157208
isRelaySupportedMock.mockResolvedValue(false);
158209

tests/api-mocking/mock-responses/simulations.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ const RECIPIENT_ADDRESS_MOCK = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb';
66
const SENTINEL_URL = 'https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io';
77
const LOCALHOST_SENTINEL_URL =
88
device.getPlatform() === 'android'
9-
? 'https://tx-sentinel-127.0.0.1.api.cx.metamask.io/'
10-
: 'https://tx-sentinel-localhost.api.cx.metamask.io/';
9+
? 'https://tx-sentinel-127.0.0.1.api.cx.metamask.io'
10+
: 'https://tx-sentinel-localhost.api.cx.metamask.io';
1111

1212
export const SEND_ETH_TRANSACTION_MOCK = {
1313
data: '0x',

tests/api-mocking/mock-responses/transaction-relay-mocks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ const TRANSACTION_HASH =
1111
const SENTINEL_URL = 'https://tx-sentinel-ethereum-mainnet.api.cx.metamask.io';
1212
const LOCALHOST_SENTINEL_URL =
1313
device.getPlatform() === 'android'
14-
? 'https://tx-sentinel-127.0.0.1.api.cx.metamask.io/'
15-
: 'https://tx-sentinel-localhost.api.cx.metamask.io/';
14+
? 'https://tx-sentinel-127.0.0.1.api.cx.metamask.io'
15+
: 'https://tx-sentinel-localhost.api.cx.metamask.io';
1616

1717
export const SEND_ETH_TRANSACTION_MOCK = {
1818
data: '0x',

tests/api-mocking/mock-responses/tx-sentinel-networks-map.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,28 @@ export const TX_SENTINEL_NETWORKS_MAP = {
7171
hidden: false,
7272
sendBundle: false,
7373
},
74+
/**
75+
* Local Anvil / fixture chain (0x539). Required for EIP-7702 relay + gasless
76+
* confirmation E2E (`gas-fee-tokens-eip-7702-sponsored.spec.ts`): `isRelaySupported`
77+
* reads Sentinel `/networks` via `getSentinelNetworkFlags`.
78+
*/
79+
'1337': {
80+
name: 'Localhost',
81+
group: 'ethereum',
82+
chainID: 1337,
83+
nativeCurrency: {
84+
name: 'ETH',
85+
symbol: 'ETH',
86+
decimals: 18,
87+
},
88+
network: 'localhost',
89+
explorer: 'http://localhost:8545/explorer',
90+
confirmations: true,
91+
smartTransactions: true,
92+
relayTransactions: true,
93+
hidden: false,
94+
sendBundle: false,
95+
},
7496
'137': {
7597
name: 'Polygon Mainnet',
7698
group: 'polygon',

tests/smoke/confirmations/transactions/gas-fee-tokens-eip-7702-sponsored.spec.ts

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ import { RelayStatus } from '../../../../app/util/transactions/transaction-relay
3535
const TRANSACTION_UUID_MOCK = '1234-5678';
3636
const SENDER_ADDRESS_MOCK = '0x76cf1cdd1fcc252442b50d6e97207228aa4aefc3';
3737
const RECIPIENT_ADDRESS_MOCK = '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb';
38-
const SENTINEL_URL = 'https://tx-sentinel-localhost.api.cx.metamask.io';
38+
/** Match {@link gas-fee-tokens-eip-7702.spec.ts} and {@link transaction-relay-mocks} for proxy URL matching. */
39+
const LOCALHOST_SENTINEL_URL =
40+
device.getPlatform() === 'android'
41+
? 'https://tx-sentinel-127.0.0.1.api.cx.metamask.io'
42+
: 'https://tx-sentinel-localhost.api.cx.metamask.io';
3943

4044
const SEND_ETH_TRANSACTION_MOCK = {
4145
data: '0x',
@@ -51,10 +55,39 @@ const SIMULATION_ENABLED_NETWORKS_WITH_RELAY = {
5155
1337: {
5256
...SIMULATION_ENABLED_NETWORKS_MOCK.response[1337],
5357
relayTransactions: true,
58+
sendBundle: true,
59+
},
60+
1: {
61+
network: 'ethereum-mainnet',
62+
confirmations: true,
63+
relayTransactions: true,
64+
sendBundle: true,
5465
},
5566
},
5667
};
5768

69+
const SIMULATION_SPONSORED_REQUEST_BODY = {
70+
jsonrpc: '2.0',
71+
method: 'infura_simulateTransactions',
72+
params: [
73+
{
74+
transactions: [SEND_ETH_TRANSACTION_MOCK],
75+
suggestFees: {
76+
withFeeTransfer: true,
77+
withTransfer: true,
78+
with7702: true,
79+
},
80+
},
81+
],
82+
};
83+
84+
const SIMULATION_SPONSORED_IGNORE_FIELDS = [
85+
'params.0.blockOverrides',
86+
'id',
87+
'params.0.transactions',
88+
'params.0.suggestFees',
89+
];
90+
5891
const SIMULATION_RESPONSE = {
5992
jsonrpc: '2.0',
6093
result: {
@@ -98,24 +131,22 @@ const setupCommonMocks = async (mockServer: Mockttp) => {
98131
1000,
99132
);
100133

101-
// Mock infura_simulateTransactions
134+
await setupMockRequest(mockServer, {
135+
requestMethod: 'GET',
136+
url: `${LOCALHOST_SENTINEL_URL}/network`,
137+
response: SIMULATION_ENABLED_NETWORKS_WITH_RELAY.response,
138+
responseCode: 200,
139+
});
140+
141+
// Mock infura_simulateTransactions (align body + ignoreFields with gas-fee-tokens-eip-7702.spec.ts)
102142
await setupMockPostRequest(
103143
mockServer,
104-
SENTINEL_URL,
105-
{
106-
jsonrpc: '2.0',
107-
method: 'infura_simulateTransactions',
108-
params: [
109-
{
110-
transactions: [SEND_ETH_TRANSACTION_MOCK],
111-
suggestFees: { withFeeTransfer: true, withTransfer: true },
112-
},
113-
],
114-
},
144+
LOCALHOST_SENTINEL_URL,
145+
SIMULATION_SPONSORED_REQUEST_BODY,
115146
SIMULATION_RESPONSE,
116147
{
117148
statusCode: 200,
118-
ignoreFields: ['id', 'params'],
149+
ignoreFields: SIMULATION_SPONSORED_IGNORE_FIELDS,
119150
priority: 1000,
120151
},
121152
);
@@ -184,8 +215,17 @@ const performSendTransaction = async () => {
184215
await SendView.pressContinueButton();
185216
await SendView.inputRecipientAddress(RECIPIENT_ADDRESS_MOCK);
186217
await SendView.pressReviewButton();
218+
await Assertions.expectElementToBeVisible(RowComponents.GasFeesDetails, {
219+
description: 'gas fees row is present on review screen',
220+
timeout: 30000,
221+
});
187222
await Assertions.expectElementToBeVisible(
188223
RowComponents.NetworkFeePaidByMetaMask,
224+
{
225+
description:
226+
'network fee shows MetaMask-sponsored gas after relay + simulation settle',
227+
timeout: 60000,
228+
},
189229
);
190230
await Utilities.waitForElementToBeVisible(FooterActions.confirmButton);
191231
await Utilities.waitForElementToStopMoving(FooterActions.confirmButton, {
@@ -217,7 +257,7 @@ describe.skip(
217257
// Mock eth_sendRelayTransaction
218258
await setupMockPostRequest(
219259
mockServer,
220-
SENTINEL_URL,
260+
LOCALHOST_SENTINEL_URL,
221261
{
222262
jsonrpc: '2.0',
223263
method: 'eth_sendRelayTransaction',
@@ -233,7 +273,7 @@ describe.skip(
233273
// Status check mock
234274
await setupMockRequest(mockServer, {
235275
requestMethod: 'GET',
236-
url: `${SENTINEL_URL}/smart-transactions/${TRANSACTION_UUID_MOCK}`,
276+
url: `${LOCALHOST_SENTINEL_URL}/smart-transactions/${TRANSACTION_UUID_MOCK}`,
237277
response: {
238278
transactions: [
239279
{

0 commit comments

Comments
 (0)