Skip to content

Commit fa79ebb

Browse files
authored
fix: clear gas sponsorship flag for hardware wallet transactions cp-7.77.0 (#29262)
<!-- 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** Hardware wallet (Ledger/QR) transactions on gas-sponsored networks (MON, SEI) incorrectly showed "Paid by MetaMask" in the activity transaction details. The root cause was twofold: 1. **Confirm callback gap**: The `isGasFeeSponsored` override in `useTransactionConfirm` only ran inside `handleSmartTransaction` and `handleGasless7702`, both of which exit early when no `selectedGasFeeToken` is present — which is always the case for HW wallets since gasless is not supported. The flag was never cleared on the persisted transaction metadata. 2. **Missing UI guard**: The activity list `TransactionDetails` component passed `isGasFeeSponsored` from stored transaction data without checking if the account is a hardware wallet. **Fix**: Moved the `isGasFeeSponsored` override to the top of `onConfirm` so it runs unconditionally for every transaction, and added an `isHardwareAccount` guard in the activity list UI (matching the pattern already used in Bridge `TransactionDetails`). ## **Changelog** CHANGELOG entry: Fixed "Paid by MetaMask" incorrectly showing for hardware wallet transactions on gas-sponsored networks ## **Related issues** Fixes: #29241 ## **Manual testing steps** ```gherkin Feature: Gas sponsorship display for hardware wallets Background: Given I am logged into MetaMask Mobile And I have a Ledger or QR hardware wallet connected Scenario: HW wallet send on MON does not show "Paid by MetaMask" Given I am connected to the Monad network with a hardware wallet When user sends MON to another address And user confirms the transaction on the hardware device And user navigates to the activity log And user selects the completed transaction Then the network fee field should show the actual gas fee amount And "Paid by MetaMask" should not appear Scenario: HW wallet send on SEI does not show "Paid by MetaMask" Given I am connected to the Sei network with a hardware wallet When user sends SEI to another address And user confirms the transaction on the hardware device And user navigates to the activity log And user selects the completed transaction Then the network fee field should show the actual gas fee amount And "Paid by MetaMask" should not appear Scenario: Non-HW wallet send on sponsored network still shows "Paid by MetaMask" Given I am connected to a gas-sponsored network with a software wallet And the transaction is eligible for gas sponsorship When user sends a transaction And user navigates to the activity log And user selects the completed transaction Then "Paid by MetaMask" should appear in the network fee field ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [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** - [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. <!-- Generated with the help of the pr-description AI skill --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches transaction confirmation metadata and the activity transaction-details UI; incorrect gating could hide or show sponsorship inappropriately on supported networks, but the change is narrowly scoped and covered by tests. > > **Overview** > Fixes incorrect "Paid by MetaMask" labeling for hardware-wallet transactions by **clearing `isGasFeeSponsored` in `useTransactionConfirm` whenever gasless isn’t supported**, even when no `selectedGasFeeToken` is present. > > Adds a UI guard in `TransactionDetails` to **suppress sponsored-fee display for hardware accounts** (based on `txParams.from` + `isHardwareAccount`), and extends unit tests to cover both the confirmation override behavior and the hardware-wallet UI suppression. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 60007da. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b9f3ee5 commit fa79ebb

4 files changed

Lines changed: 129 additions & 27 deletions

File tree

app/components/UI/TransactionElement/TransactionDetails/index.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { AvatarAccountType } from '../../../../component-library/components/Avat
5858
import { WalletViewSelectorsIDs } from '../../../Views/Wallet/WalletView.testIds';
5959
import { TransactionType } from '@metamask/transaction-controller';
6060
import TagBase from '../../../../component-library/base-components/TagBase';
61+
import { isHardwareAccount } from '../../../../util/address';
6162

6263
const createStyles = (colors) =>
6364
StyleSheet.create({
@@ -333,6 +334,10 @@ class TransactionDetails extends PureComponent {
333334
);
334335
const { updatedTransactionDetails } = this.state;
335336
const styles = this.getStyles();
337+
const fromAddress = txParams?.from;
338+
const isHardwareWallet = Boolean(
339+
fromAddress && isHardwareAccount(fromAddress),
340+
);
336341
const isBridgeTransaction =
337342
transactionObject?.type === TransactionType.bridge;
338343
const renderTxActions =
@@ -474,7 +479,9 @@ class TransactionDetails extends PureComponent {
474479
gasEstimationReady
475480
transactionType={updatedTransactionDetails.transactionType}
476481
chainId={chainId}
477-
isGasFeeSponsored={transactionObject.isGasFeeSponsored}
482+
isGasFeeSponsored={
483+
transactionObject.isGasFeeSponsored && !isHardwareWallet
484+
}
478485
/>
479486
</View>
480487
{updatedTransactionDetails.hash &&

app/components/UI/TransactionElement/TransactionDetails/index.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import renderWithProvider from '../../../../util/test/renderWithProvider';
88
import { createStackNavigator } from '@react-navigation/stack';
99
import { mockNetworkState } from '../../../../util/test/network';
1010
import type { NetworkState } from '@metamask/network-controller';
11+
import { isHardwareAccount } from '../../../../util/address';
1112

1213
const Stack = createStackNavigator();
1314
const mockEthQuery = {
@@ -71,6 +72,11 @@ jest.mock('../../../../util/networks/global-network', () => ({
7172
getGlobalEthQuery: jest.fn(() => mockEthQuery),
7273
}));
7374

75+
jest.mock('../../../../util/address', () => ({
76+
...jest.requireActual('../../../../util/address'),
77+
isHardwareAccount: jest.fn(),
78+
}));
79+
7480
jest.mock('@metamask/controller-utils', () => ({
7581
...jest.requireActual('@metamask/controller-utils'),
7682
query: jest.fn(),
@@ -511,4 +517,19 @@ describe('TransactionDetails', () => {
511517

512518
expect(screen.queryByText('Paid by MetaMask')).not.toBeOnTheScreen();
513519
});
520+
521+
it('does not show "Paid by MetaMask" for hardware wallet even when isGasFeeSponsored is true', () => {
522+
jest.mocked(isHardwareAccount).mockReturnValue(true);
523+
524+
renderComponent({
525+
state: initialState,
526+
transactionObj: {
527+
isGasFeeSponsored: true,
528+
txParams: { from: '0xHardwareAddress' },
529+
},
530+
});
531+
532+
expect(screen.queryByTestId('paid-by-metamask')).not.toBeOnTheScreen();
533+
expect(screen.queryByText('Paid by MetaMask')).not.toBeOnTheScreen();
534+
});
514535
});

app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,96 @@ describe('useTransactionConfirm', () => {
396396
);
397397
});
398398

399+
describe('isGasFeeSponsored override', () => {
400+
it('clears isGasFeeSponsored when gasless is not supported', async () => {
401+
useIsGaslessSupportedMock.mockReturnValue({
402+
isSmartTransaction: false,
403+
isSupported: false,
404+
pending: false,
405+
});
406+
407+
useTransactionMetadataRequestMock.mockReturnValue({
408+
id: transactionIdMock,
409+
chainId: CHAIN_ID_MOCK,
410+
origin: ORIGIN_METAMASK,
411+
txParams: {},
412+
isGasFeeSponsored: true,
413+
} as unknown as TransactionMeta);
414+
415+
const { result } = renderHook();
416+
417+
await act(async () => {
418+
await result.current.onConfirm();
419+
});
420+
421+
expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
422+
txMeta: expect.objectContaining({
423+
isGasFeeSponsored: false,
424+
}),
425+
});
426+
});
427+
428+
it('preserves isGasFeeSponsored when gasless is supported', async () => {
429+
useIsGaslessSupportedMock.mockReturnValue({
430+
isSmartTransaction: true,
431+
isSupported: true,
432+
pending: false,
433+
});
434+
435+
useTransactionMetadataRequestMock.mockReturnValue({
436+
id: transactionIdMock,
437+
chainId: CHAIN_ID_MOCK,
438+
origin: ORIGIN_METAMASK,
439+
txParams: {},
440+
isGasFeeSponsored: true,
441+
} as unknown as TransactionMeta);
442+
443+
const { result } = renderHook();
444+
445+
await act(async () => {
446+
await result.current.onConfirm();
447+
});
448+
449+
expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
450+
txMeta: expect.objectContaining({
451+
isGasFeeSponsored: true,
452+
}),
453+
});
454+
});
455+
456+
it('clears isGasFeeSponsored even without selectedGasFeeToken', async () => {
457+
useIsGaslessSupportedMock.mockReturnValue({
458+
isSmartTransaction: false,
459+
isSupported: false,
460+
pending: false,
461+
});
462+
463+
useSelectedGasFeeTokenMock.mockReturnValue(
464+
undefined as unknown as ReturnType<typeof useSelectedGasFeeToken>,
465+
);
466+
467+
useTransactionMetadataRequestMock.mockReturnValue({
468+
id: transactionIdMock,
469+
chainId: CHAIN_ID_MOCK,
470+
origin: ORIGIN_METAMASK,
471+
txParams: {},
472+
isGasFeeSponsored: true,
473+
} as unknown as TransactionMeta);
474+
475+
const { result } = renderHook();
476+
477+
await act(async () => {
478+
await result.current.onConfirm();
479+
});
480+
481+
expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
482+
txMeta: expect.objectContaining({
483+
isGasFeeSponsored: false,
484+
}),
485+
});
486+
});
487+
});
488+
399489
describe('handleSmartTransaction', () => {
400490
beforeEach(() => {
401491
useGaslessSupportedSmartTransactionsMock.mockReturnValue({

app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -72,24 +72,8 @@ export function useTransactionConfirm() {
7272
updatedMetadata.txParams.maxFeePerGas = selectedGasFeeToken.maxFeePerGas;
7373
updatedMetadata.txParams.maxPriorityFeePerGas =
7474
selectedGasFeeToken.maxPriorityFeePerGas;
75-
76-
// If the gasless flow is not supported (e.g. stx is disabled by the user,
77-
// or 7702 is not supported in the chain), we override the
78-
// `isGasFeeSponsored` flag to `false` so the transaction meta object in
79-
// state has the correct value for the transaction details on the activity
80-
// list to not show as sponsored. One limitation on the activity list will
81-
// be that pre-populated transactions on fresh installs will not show as
82-
// sponsored even if they were because this is not easily observable onchain
83-
// for all cases.
84-
updatedMetadata.isGasFeeSponsored =
85-
isGaslessSupported && transactionMetadata?.isGasFeeSponsored;
8675
},
87-
[
88-
selectedGasFeeToken,
89-
isGasFeeTokenIgnoredIfBalance,
90-
isGaslessSupported,
91-
transactionMetadata?.isGasFeeSponsored,
92-
],
76+
[selectedGasFeeToken, isGasFeeTokenIgnoredIfBalance],
9377
);
9478

9579
const handleGasless7702 = useCallback(
@@ -99,15 +83,8 @@ export function useTransactionConfirm() {
9983
}
10084

10185
updatedMetadata.isExternalSign = true;
102-
updatedMetadata.isGasFeeSponsored =
103-
isGaslessSupported && transactionMetadata?.isGasFeeSponsored;
10486
},
105-
[
106-
isGasFeeTokenIgnoredIfBalance,
107-
isGaslessSupported,
108-
selectedGasFeeToken,
109-
transactionMetadata?.isGasFeeSponsored,
110-
],
87+
[isGasFeeTokenIgnoredIfBalance, selectedGasFeeToken],
11188
);
11289

11390
const onConfirm = useCallback(
@@ -121,7 +98,13 @@ export function useTransactionConfirm() {
12198

12299
const updatedMetadata = cloneDeep(transactionMetadata);
123100

124-
if (isGaslessSupportedSTX && !isHardwareWallet) {
101+
// Ensure the persisted `isGasFeeSponsored` flag reflects whether gasless
102+
// is actually supported (e.g. HW wallets don't support gasless, so the
103+
// flag must be cleared so the activity list does not show "Paid by MetaMask").
104+
updatedMetadata.isGasFeeSponsored =
105+
isGaslessSupported && transactionMetadata?.isGasFeeSponsored;
106+
107+
if (isGaslessSupportedSTX) {
125108
handleSmartTransaction(updatedMetadata);
126109
} else if (selectedGasFeeToken && !isHardwareWallet) {
127110
handleGasless7702(updatedMetadata);
@@ -169,6 +152,7 @@ export function useTransactionConfirm() {
169152
handleGasless7702,
170153
handleSmartTransaction,
171154
isFullScreenConfirmation,
155+
isGaslessSupported,
172156
isGaslessSupportedSTX,
173157
navigation,
174158
musdConversionNavigateOnConfirm,

0 commit comments

Comments
 (0)