Skip to content

Commit 3bf82cf

Browse files
feat: support relay execute in metamask pay (#27430)
## **Description** Add support for entirely gasless Relay execute flow in MetaMask Pay. - Add `SourceHashSummaryLine` component for displaying outgoing transaction when no local transactions. - Add `TransactionPayController:getDelegationTransaction` permission. ## **Changelog** CHANGELOG entry: null ## **Related issues** ## **Manual testing steps** ## **Screenshots/Recordings** ### **Before** ### **After** ## **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. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] 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** > Adds conditional transaction-summary UI based on `metamaskPay.sourceHash` and expands messenger action permissions for `TransactionPayController`, which could affect transaction display and controller interactions if mis-gated. > > **Overview** > **MetaMask Pay relay execute support:** `TransactionDetailsSummary` now conditionally prepends a new `SourceHashSummaryLine` when `metamaskPay.sourceHash` is present and there are *no* deposit-related transactions (no `requiredTransactionIds` and no batch txs), enabling an outgoing tx reference even when nothing is stored locally. > > `SourceHashSummaryLine` is added to format a “bridge send” title using the source token/network and delegates rendering/navigation to `TransactionSummaryLine` with `txHash=sourceHash`; unit tests cover rendering and block-explorer navigation, and `TransactionDetailsSummary` tests cover the new gating behavior. > > **Engine wiring:** `TransactionControllerInit` messenger permissions are extended to allow `TransactionPayController:getDelegationTransaction`, and `@metamask/transaction-controller` / `@metamask/transaction-pay-controller` dependencies are bumped to `^62.22.0` and `^17.0.0` respectively. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit ee119fc. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 6556ecd commit 3bf82cf

7 files changed

Lines changed: 260 additions & 9 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React from 'react';
2+
import { fireEvent } from '@testing-library/react-native';
3+
import { TransactionMeta } from '@metamask/transaction-controller';
4+
import { Hex } from '@metamask/utils';
5+
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
6+
import { strings } from '../../../../../../../locales/i18n';
7+
import { useMultichainBlockExplorerTxUrl } from '../../../../../UI/Bridge/hooks/useMultichainBlockExplorerTxUrl';
8+
import Routes from '../../../../../../constants/navigation/Routes';
9+
import { useNetworkName } from '../../../hooks/useNetworkName';
10+
import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance';
11+
import { selectBridgeHistoryForAccount } from '../../../../../../selectors/bridgeStatusController';
12+
import { useBridgeTxHistoryData } from '../../../../../../util/bridge/hooks/useBridgeTxHistoryData';
13+
import { useTokenAmount } from '../../../hooks/useTokenAmount';
14+
import { useTransactionDetails } from '../../../hooks/activity/useTransactionDetails';
15+
import { SourceHashSummaryLine } from './source-hash-summary-line';
16+
17+
const mockNavigate = jest.fn();
18+
19+
jest.mock('../../../../../UI/Bridge/hooks/useMultichainBlockExplorerTxUrl');
20+
jest.mock('../../../hooks/useNetworkName');
21+
jest.mock('../../../hooks/tokens/useTokenWithBalance');
22+
jest.mock('../../../../../../selectors/bridgeStatusController');
23+
jest.mock('../../../../../../util/bridge/hooks/useBridgeTxHistoryData');
24+
jest.mock('../../../hooks/useTokenAmount');
25+
jest.mock('../../../hooks/activity/useTransactionDetails');
26+
27+
jest.mock('@react-navigation/native', () => ({
28+
...jest.requireActual('@react-navigation/native'),
29+
useNavigation: () => ({
30+
navigate: mockNavigate,
31+
}),
32+
}));
33+
34+
function render() {
35+
return renderWithProvider(
36+
<SourceHashSummaryLine
37+
parentTransaction={
38+
{
39+
id: 'parent-id',
40+
chainId: '0x1',
41+
submittedTime: 1755719285723,
42+
metamaskPay: {
43+
tokenAddress: '0x123',
44+
chainId: '0x1',
45+
},
46+
} as unknown as TransactionMeta
47+
}
48+
sourceHash={'0xabc' as Hex}
49+
/>,
50+
{
51+
state: {
52+
engine: {
53+
backgroundState: {
54+
TransactionController: { transactions: [] },
55+
},
56+
},
57+
},
58+
},
59+
);
60+
}
61+
62+
describe('SourceHashSummaryLine', () => {
63+
const useMultichainBlockExplorerTxUrlMock = jest.mocked(
64+
useMultichainBlockExplorerTxUrl,
65+
);
66+
const useNetworkNameMock = jest.mocked(useNetworkName);
67+
const useTokenWithBalanceMock = jest.mocked(useTokenWithBalance);
68+
69+
beforeEach(() => {
70+
jest.resetAllMocks();
71+
72+
useMultichainBlockExplorerTxUrlMock.mockReturnValue({
73+
explorerTxUrl: 'https://explorer.example',
74+
explorerName: 'Explorer',
75+
} as ReturnType<typeof useMultichainBlockExplorerTxUrl>);
76+
77+
useNetworkNameMock.mockReturnValue('Ethereum');
78+
useTokenWithBalanceMock.mockReturnValue({ symbol: 'USDC' } as ReturnType<
79+
typeof useTokenWithBalance
80+
>);
81+
82+
jest.mocked(selectBridgeHistoryForAccount).mockReturnValue({});
83+
jest.mocked(useBridgeTxHistoryData).mockReturnValue({
84+
bridgeTxHistoryItem: undefined,
85+
isBridgeComplete: null,
86+
});
87+
jest
88+
.mocked(useTokenAmount)
89+
.mockReturnValue({} as ReturnType<typeof useTokenAmount>);
90+
jest.mocked(useTransactionDetails).mockReturnValue({
91+
transactionMeta: {} as TransactionMeta,
92+
});
93+
});
94+
95+
it('renders send title', () => {
96+
const { getByText } = render();
97+
98+
expect(
99+
getByText(
100+
strings('transaction_details.summary_title.bridge_send', {
101+
sourceSymbol: 'USDC',
102+
sourceChain: 'Ethereum',
103+
}),
104+
),
105+
).toBeDefined();
106+
});
107+
108+
it('navigates to block explorer when button is pressed', () => {
109+
const { getByTestId } = render();
110+
111+
fireEvent.press(getByTestId('block-explorer-button'));
112+
113+
expect(mockNavigate).toHaveBeenCalledWith(Routes.WEBVIEW.MAIN, {
114+
screen: Routes.WEBVIEW.SIMPLE,
115+
params: {
116+
url: 'https://explorer.example',
117+
title: 'Explorer',
118+
},
119+
});
120+
});
121+
});
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import React from 'react';
2+
import { TransactionMeta } from '@metamask/transaction-controller';
3+
import { Hex } from '@metamask/utils';
4+
import { strings } from '../../../../../../../locales/i18n';
5+
import { useNetworkName } from '../../../hooks/useNetworkName';
6+
import { useTokenWithBalance } from '../../../hooks/tokens/useTokenWithBalance';
7+
import { TransactionSummaryLine } from './transaction-summary-line';
8+
9+
export function SourceHashSummaryLine({
10+
parentTransaction,
11+
sourceHash,
12+
}: {
13+
parentTransaction: TransactionMeta;
14+
sourceHash: Hex;
15+
}) {
16+
const tokenAddress = parentTransaction.metamaskPay?.tokenAddress;
17+
const tokenChainId = parentTransaction.metamaskPay?.chainId;
18+
19+
const sourceToken = useTokenWithBalance(
20+
tokenAddress ?? '0x0',
21+
tokenChainId ?? '0x0',
22+
);
23+
24+
const sourceNetworkName = useNetworkName(tokenChainId);
25+
const chainId = tokenChainId ?? parentTransaction.chainId;
26+
27+
const title =
28+
sourceToken?.symbol && sourceNetworkName
29+
? strings('transaction_details.summary_title.bridge_send', {
30+
sourceSymbol: sourceToken.symbol,
31+
sourceChain: sourceNetworkName,
32+
})
33+
: strings('transaction_details.summary_title.bridge_send_loading');
34+
35+
return (
36+
<TransactionSummaryLine
37+
title={title}
38+
transactionMeta={parentTransaction}
39+
chainId={chainId}
40+
txHash={sourceHash}
41+
/>
42+
);
43+
}

app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ jest.mock('./default-summary-line', () => ({
4343
},
4444
}));
4545

46+
jest.mock('./source-hash-summary-line', () => ({
47+
SourceHashSummaryLine: () => {
48+
const ReactNative = require('react-native');
49+
50+
return <ReactNative.Text>SourceHashSummaryLine</ReactNative.Text>;
51+
},
52+
}));
53+
4654
function render({
4755
transactions,
4856
}: {
@@ -191,6 +199,68 @@ describe('TransactionDetailsSummary', () => {
191199
expect(getAllByText('DefaultSummaryLine')).toHaveLength(2);
192200
});
193201

202+
it('renders SourceHashSummaryLine when sourceHash exists and no deposit transactions', () => {
203+
useTransactionDetailsMock.mockReturnValue({
204+
transactionMeta: {
205+
id: transactionIdMock,
206+
chainId: '0x1',
207+
type: TransactionType.perpsDeposit,
208+
metamaskPay: {
209+
sourceHash: '0xabc',
210+
tokenAddress: '0x123',
211+
chainId: '0x1',
212+
},
213+
} as unknown as TransactionMeta,
214+
});
215+
216+
const { getByText } = render({
217+
transactions: [
218+
{
219+
id: transactionIdMock,
220+
chainId: '0x1',
221+
type: TransactionType.perpsDeposit,
222+
},
223+
],
224+
});
225+
226+
expect(getByText('SourceHashSummaryLine')).toBeDefined();
227+
});
228+
229+
it('does not render SourceHashSummaryLine when deposit transactions exist', () => {
230+
const depositId = 'deposit-id';
231+
232+
useTransactionDetailsMock.mockReturnValue({
233+
transactionMeta: {
234+
id: transactionIdMock,
235+
chainId: '0x1',
236+
type: TransactionType.perpsDeposit,
237+
requiredTransactionIds: [depositId],
238+
metamaskPay: {
239+
sourceHash: '0xabc',
240+
tokenAddress: '0x123',
241+
chainId: '0x1',
242+
},
243+
} as unknown as TransactionMeta,
244+
});
245+
246+
const { queryByText } = render({
247+
transactions: [
248+
{
249+
id: depositId,
250+
chainId: '0x1',
251+
type: TransactionType.relayDeposit,
252+
},
253+
{
254+
id: transactionIdMock,
255+
chainId: '0x1',
256+
type: TransactionType.perpsDeposit,
257+
},
258+
],
259+
});
260+
261+
expect(queryByText('SourceHashSummaryLine')).toBeNull();
262+
});
263+
194264
it('skips non-relay child transactions for mUSD conversion parent', () => {
195265
const sendId = 'send-id';
196266
const receiveId = 'receive-id';

app/components/Views/confirmations/components/activity/transaction-details-summary/transaction-details-summary.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { hasTransactionType } from '../../../utils/transaction';
1919
import { RELAY_DEPOSIT_TYPES } from '../../../constants/confirmations';
2020
import { ProgressList } from '../../progress-list';
21+
import { SourceHashSummaryLine } from './source-hash-summary-line';
2122
import { DepositSummaryLine } from './deposit-summary-line';
2223
import { ApprovalSummaryLine } from './approval-summary-line';
2324
import { ReceiveSummaryLine } from './receive-summary-line';
@@ -28,6 +29,7 @@ export function TransactionDetailsSummary() {
2829
const {
2930
batchId,
3031
id: transactionId,
32+
metamaskPay,
3133
requiredTransactionIds,
3234
} = transactionMeta;
3335

@@ -62,10 +64,21 @@ export function TransactionDetailsSummary() {
6264
transaction.id === transactionId,
6365
);
6466

67+
const hasDepositTransactions =
68+
(requiredTransactionIds?.length ?? 0) > 0 || batchTransactionIds.length > 0;
69+
70+
const { sourceHash } = metamaskPay ?? {};
71+
6572
return (
6673
<Box gap={12}>
6774
<Text color={TextColor.Alternative}>Summary</Text>
6875
<ProgressList>
76+
{!hasDepositTransactions && sourceHash ? (
77+
<SourceHashSummaryLine
78+
parentTransaction={transactionMeta}
79+
sourceHash={sourceHash}
80+
/>
81+
) : null}
6982
{transactions.map((transaction) => (
7083
<SummaryLine
7184
key={transaction.id}

app/core/Engine/messengers/transaction-controller-messenger/transaction-controller-messenger.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
CurrencyRateControllerActions,
4444
} from '@metamask/assets-controllers';
4545
import {
46+
TransactionPayControllerGetDelegationTransactionAction,
4647
TransactionPayControllerGetStateAction,
4748
TransactionPayControllerGetStrategyAction,
4849
} from '@metamask/transaction-pay-controller';
@@ -107,6 +108,7 @@ type InitMessengerActions =
107108
| TransactionControllerAddTransactionBatchAction
108109
| TransactionControllerGetStateAction
109110
| TransactionControllerUpdateTransactionAction
111+
| TransactionPayControllerGetDelegationTransactionAction
110112
| TransactionPayControllerGetStateAction
111113
| TransactionPayControllerGetStrategyAction
112114
| AnalyticsControllerActions;
@@ -163,6 +165,7 @@ export function getTransactionControllerInitMessenger(
163165
'TransactionController:addTransactionBatch',
164166
'TransactionController:getState',
165167
'TransactionController:updateTransaction',
168+
'TransactionPayController:getDelegationTransaction',
166169
'TransactionPayController:getState',
167170
'TransactionPayController:getStrategy',
168171
'AnalyticsController:trackEvent',

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,8 @@
304304
"@metamask/superstruct": "^3.2.1",
305305
"@metamask/swappable-obj-proxy": "^2.1.0",
306306
"@metamask/swaps-controller": "^15.0.0",
307-
"@metamask/transaction-controller": "^62.19.0",
308-
"@metamask/transaction-pay-controller": "^16.5.0",
307+
"@metamask/transaction-controller": "^62.22.0",
308+
"@metamask/transaction-pay-controller": "^17.0.0",
309309
"@metamask/tron-wallet-snap": "1.22.1",
310310
"@metamask/utils": "^11.8.1",
311311
"@myx-trade/sdk": "^0.1.265",

yarn.lock

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10079,13 +10079,14 @@ __metadata:
1007910079
languageName: node
1008010080
linkType: hard
1008110081

10082-
"@metamask/transaction-pay-controller@npm:^16.5.0":
10083-
version: 16.5.0
10084-
resolution: "@metamask/transaction-pay-controller@npm:16.5.0"
10082+
"@metamask/transaction-pay-controller@npm:^17.0.0":
10083+
version: 17.0.0
10084+
resolution: "@metamask/transaction-pay-controller@npm:17.0.0"
1008510085
dependencies:
1008610086
"@ethersproject/abi": "npm:^5.7.0"
1008710087
"@ethersproject/contracts": "npm:^5.7.0"
1008810088
"@ethersproject/providers": "npm:^5.7.0"
10089+
"@metamask/assets-controller": "npm:^2.3.0"
1008910090
"@metamask/assets-controllers": "npm:^100.2.1"
1009010091
"@metamask/base-controller": "npm:^9.0.0"
1009110092
"@metamask/bridge-controller": "npm:^69.1.0"
@@ -10096,13 +10097,13 @@ __metadata:
1009610097
"@metamask/metamask-eth-abis": "npm:^3.1.1"
1009710098
"@metamask/network-controller": "npm:^30.0.0"
1009810099
"@metamask/remote-feature-flag-controller": "npm:^4.1.0"
10099-
"@metamask/transaction-controller": "npm:^62.21.0"
10100+
"@metamask/transaction-controller": "npm:^62.22.0"
1010010101
"@metamask/utils": "npm:^11.9.0"
1010110102
bignumber.js: "npm:^9.1.2"
1010210103
bn.js: "npm:^5.2.1"
1010310104
immer: "npm:^9.0.6"
1010410105
lodash: "npm:^4.17.21"
10105-
checksum: 10/9a7ad32a78a0b0cfed9c45521527cee94f5209244688ad739c768a33da241adb0ce20ccc14d84180c86177daa7e2e2703da404b07e9d8a4795441672bb062c1e
10106+
checksum: 10/3563c46ae779c24b46cf245874504b94ea9be205015976092a275433f3600699993bbfbcf8b11181800c0f8f94dc222b7d55b403f61e3699588c1968258447b3
1010610107
languageName: node
1010710108
linkType: hard
1010810109

@@ -35467,8 +35468,8 @@ __metadata:
3546735468
"@metamask/test-dapp": "npm:9.5.0"
3546835469
"@metamask/test-dapp-multichain": "npm:^0.17.1"
3546935470
"@metamask/test-dapp-solana": "npm:^0.3.0"
35470-
"@metamask/transaction-controller": "npm:^62.19.0"
35471-
"@metamask/transaction-pay-controller": "npm:^16.5.0"
35471+
"@metamask/transaction-controller": "npm:^62.22.0"
35472+
"@metamask/transaction-pay-controller": "npm:^17.0.0"
3547235473
"@metamask/tron-wallet-snap": "npm:1.22.1"
3547335474
"@metamask/utils": "npm:^11.8.1"
3547435475
"@myx-trade/sdk": "npm:^0.1.265"

0 commit comments

Comments
 (0)