Skip to content

Commit b35b7f4

Browse files
chore(runway): cherry-pick fix: cp-7.77.0 cp-7.78.0 missing metamask pay transactions in activity (#30217)
- fix: cp-7.77.0 cp-7.78.0 missing metamask pay transactions in activity (#30145) ## **Description** The Activity tab had several bugs causing MetaMask Pay transactions to be missing, duplicated, or unreachable from the source chain. This PR addresses four root causes in production code plus a test alignment for the bridge smoke E2E: 1. **Source-chain visibility.** Submitted EVM transactions were filtered strictly by `tx.chainId`, so a MetaMask Pay parent was only visible on its destination chain. The source chain is recorded on `metamaskPay.chainId` (for gasless flows) or on linked child transactions via `requiredTransactionIds` (for non-gasless flows). A new `selectRelatedChainIdsByTransactionId` selector returns the full set of chain IDs a transaction relates to, and the Activity list now matches against that set. 2. **Dedupe fallback collapsed internal MetaMask Pay transactions.** When a transaction had no nonce, `selectLocalTransactions` fell back to `txParams.actionId` as the dedupe key. `actionId` is a top-level field on `TransactionMeta`, not on `txParams`, so for MetaMask Pay internal transactions (which have no nonce) every entry collapsed onto the same `undefined` key and all but one were dropped. The fallback now uses the top-level `id`, which is always present. 3. **Local transactions were scoped to the wrong account.** `selectLocalTransactions` gated on `selectEvmAddress` — the EVM address of the **currently selected internal account**. When the user picked a non-EVM account (e.g. Solana), this was `undefined` and the selector returned an empty list. Switching to "All popular networks" did not restore the address because that toggle changes enabled networks, not the selected account. It now uses `selectSelectedAccountGroupEvmInternalAccount`, the same source already used by the Activity tab's API query. 4. **Incoming-transaction duplicates.** The `TransactionController` incoming-transactions feature stores incoming transfers as separate `TransactionMeta` entries marked with `isTransfer !== undefined`. The accounts API also returns these transactions in its confirmed history, producing duplicate rows in the Activity tab. The dedupe step now skips entries with `isTransfer !== undefined`, leaving the accounts-API row as the canonical source. 5. **Bridge smoke E2E row alignment.** The Activity list merges pending smart transactions in alongside the real `TransactionMeta` row, producing a stale shell entry that lands at row 0. `bridge-action-smoke` was asserting on row 0 and timing out. The test now asserts on row 1, with a TODO to remove the STX-state merge from the Activity selectors and restore row 0. ## **Changelog** CHANGELOG entry: Fixed MetaMask Pay transactions appearing duplicated or missing from the Activity tab, including on the source chain and when the selected account is non-EVM. ## **Related issues** Fixes: [#30066](#30066) ## **Manual testing steps** ```gherkin Feature: MetaMask Pay Activity visibility Scenario: User views Activity on the chain that funded a MetaMask Pay transaction Given the user has completed a MetaMask Pay transaction funded by a token on chain X with destination chain Y And both chains X and Y are enabled networks When the user opens the Activity tab with chain X selected Then the MetaMask Pay transaction is visible in the list When the user opens the Activity tab with chain Y selected Then the MetaMask Pay transaction is also visible in the list Scenario: User views Activity after switching to a non-EVM account Given the user has pending MetaMask Pay transactions visible in the Activity tab When the user switches to a non-EVM account in the same account group And switches back to "All popular networks" Then the pending MetaMask Pay transactions remain visible Scenario: User views a single on-chain MetaMask Pay transaction Given the user has completed a single-chain MetaMask Pay transaction (for example an mUSD conversion) When the user opens the Activity tab Then the transaction appears exactly once ``` ## **Screenshots/Recordings** ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **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) - [ ] I've tested on Android - [ ] I've tested with a power user scenario - [ ] I've instrumented key operations with Sentry traces for production performance metrics ## **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] > <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a summary for commit f45d17e. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [ff95f16](ff95f16) Co-authored-by: Matthew Walsh <matthew.walsh@consensys.net>
1 parent ec70592 commit b35b7f4

4 files changed

Lines changed: 198 additions & 35 deletions

File tree

app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ import {
2828
selectEVMEnabledNetworks,
2929
selectNonEVMEnabledNetworks,
3030
} from '../../../selectors/networkEnablementController';
31-
import { selectLocalTransactions } from '../../../selectors/transactionController';
31+
import {
32+
selectLocalTransactions,
33+
selectRelatedChainIdsByTransactionId,
34+
} from '../../../selectors/transactionController';
3235
import { baseStyles } from '../../../styles/common';
3336
import { areAddressesEqual, isHardwareAccount } from '../../../util/address';
3437
import { getBlockExplorerAddressUrl } from '../../../util/networks';
@@ -167,6 +170,10 @@ const UnifiedTransactionsView = ({
167170
[enabledNonEVMNetworks],
168171
);
169172

173+
const relatedChainIdsByTransactionId = useSelector(
174+
selectRelatedChainIdsByTransactionId,
175+
);
176+
170177
/** Drop confirmed rows not on currently enabled EVM chains (guards stale query pages). */
171178
const allConfirmedForEnabledChains = useMemo<TransactionViewModel[]>(() => {
172179
const chains = enabledEVMChainIds ?? [];
@@ -207,12 +214,18 @@ const UnifiedTransactionsView = ({
207214
}
208215

209216
const { chainId: _chainId, txParams } = tx;
217+
210218
if (!enabledEvmSet.size) {
211219
return false;
212220
}
213-
if (!_chainId || !enabledEvmSet.has(String(_chainId).toLowerCase())) {
221+
222+
const relatedChainIds = relatedChainIdsByTransactionId.get(tx.id) ?? [
223+
String(_chainId ?? '').toLowerCase(),
224+
];
225+
if (!relatedChainIds.some((id) => enabledEvmSet.has(id))) {
214226
return false;
215227
}
228+
216229
const isBridgeTransaction = isBridgeHistoryForEvmTransaction(
217230
tx,
218231
bridgeHistoryValues,
@@ -283,6 +296,7 @@ const UnifiedTransactionsView = ({
283296
enabledEVMChainIds,
284297
enabledNonEVMChainIds,
285298
bridgeHistory,
299+
relatedChainIdsByTransactionId,
286300
]);
287301

288302
const { data, nonEvmTransactionsForSelectedChain } = useMemo<{

app/selectors/transactionController.test.ts

Lines changed: 136 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
selectLastWithdrawTokenByType,
77
selectLocalTransactions,
88
selectNonReplacedTransactions,
9+
selectRelatedChainIdsByTransactionId,
910
selectRequiredTransactionIds,
1011
selectRequiredTransactionHashes,
1112
selectRequiredTransactions,
@@ -27,6 +28,17 @@ jest.mock('./smartTransactionsController', () => ({
2728
}) => state.pendingSmartTransactionsForGroup || [],
2829
}));
2930

31+
jest.mock('./multichainAccounts/accountTreeController', () => ({
32+
selectSelectedAccountGroupEvmInternalAccount: (state: {
33+
groupEvmAccount?: { address: string } | null;
34+
}) => state.groupEvmAccount ?? null,
35+
}));
36+
37+
jest.mock('./accountsController', () => ({
38+
selectEvmAddress: (state: { fallbackEvmAddress?: string }) =>
39+
state.fallbackEvmAddress,
40+
}));
41+
3042
describe('TransactionController Selectors', () => {
3143
describe('selectTransactions', () => {
3244
it('returns transactions from TransactionController state', () => {
@@ -181,58 +193,156 @@ describe('TransactionController Selectors', () => {
181193
});
182194
});
183195

196+
describe('selectRelatedChainIdsByTransactionId', () => {
197+
const buildState = (transactions: unknown[]) =>
198+
({
199+
engine: {
200+
backgroundState: {
201+
TransactionController: { transactions },
202+
},
203+
},
204+
}) as unknown as RootState;
205+
206+
it('returns the transaction own chain id, lower-cased', () => {
207+
const state = buildState([{ id: 'lone', chainId: '0xA4B1' }]);
208+
209+
expect(selectRelatedChainIdsByTransactionId(state).get('lone')).toEqual([
210+
'0xa4b1',
211+
]);
212+
});
213+
214+
it('includes metamaskPay.chainId for gasless MetaMask Pay parents', () => {
215+
const state = buildState([
216+
{
217+
id: 'pay-parent',
218+
chainId: '0xA4B1',
219+
metamaskPay: { chainId: '0x1' },
220+
},
221+
]);
222+
223+
expect(
224+
selectRelatedChainIdsByTransactionId(state).get('pay-parent')?.sort(),
225+
).toEqual(['0x1', '0xa4b1']);
226+
});
227+
228+
it('includes chain ids of required (child) transactions', () => {
229+
const state = buildState([
230+
{
231+
id: 'parent',
232+
chainId: '0xA4B1',
233+
requiredTransactionIds: ['child-1', 'child-2'],
234+
},
235+
{ id: 'child-1', chainId: '0x1' },
236+
{ id: 'child-2', chainId: '0xA' },
237+
]);
238+
239+
expect(
240+
selectRelatedChainIdsByTransactionId(state).get('parent')?.sort(),
241+
).toEqual(['0x1', '0xa', '0xa4b1']);
242+
});
243+
244+
it('dedupes overlapping chain ids', () => {
245+
const state = buildState([
246+
{
247+
id: 'parent',
248+
chainId: '0x1',
249+
metamaskPay: { chainId: '0x1' },
250+
requiredTransactionIds: ['child'],
251+
},
252+
{ id: 'child', chainId: '0x1' },
253+
]);
254+
255+
expect(selectRelatedChainIdsByTransactionId(state).get('parent')).toEqual(
256+
['0x1'],
257+
);
258+
});
259+
260+
it('ignores required ids that point to missing children', () => {
261+
const state = buildState([
262+
{ id: 'parent', chainId: '0x1', requiredTransactionIds: ['ghost'] },
263+
]);
264+
265+
expect(selectRelatedChainIdsByTransactionId(state).get('parent')).toEqual(
266+
['0x1'],
267+
);
268+
});
269+
});
270+
184271
describe('selectLocalTransactions', () => {
185-
it('filters required child transactions before nonce dedupe', () => {
186-
const activeEvmAddress = '0x0000000000000000000000000000000000000001';
187-
const state = {
272+
const evmAddress = '0x0000000000000000000000000000000000000001';
273+
274+
const buildLocalTxState = ({
275+
groupEvmAccount = { address: evmAddress },
276+
transactions,
277+
}: {
278+
groupEvmAccount?: { address: string } | null;
279+
transactions?: unknown[];
280+
} = {}) =>
281+
({
188282
engine: {
189283
backgroundState: {
190-
AccountsController: {
191-
internalAccounts: {
192-
selectedAccount: 'account-1',
193-
accounts: {
194-
'account-1': {
195-
id: 'account-1',
196-
address: activeEvmAddress,
197-
type: 'eip155:eoa',
198-
},
199-
},
200-
},
201-
},
202284
TransactionController: {
203-
transactions: [
285+
transactions: transactions ?? [
204286
{
205287
id: 'child',
206288
hash: '0xCHILD',
207289
chainId: '0x1',
208290
time: 200,
209-
txParams: {
210-
from: activeEvmAddress,
211-
nonce: '0x1',
212-
},
291+
txParams: { from: evmAddress, nonce: '0x1' },
213292
},
214293
{
215294
id: 'parent',
216295
chainId: '0x1',
217296
requiredTransactionIds: ['child'],
218297
time: 100,
219298
type: TransactionType.predictDeposit,
220-
txParams: {
221-
from: activeEvmAddress,
222-
nonce: '0x1',
223-
},
299+
txParams: { from: evmAddress, nonce: '0x1' },
224300
},
225301
],
226302
},
227303
},
228304
},
305+
groupEvmAccount,
229306
pendingSmartTransactionsForGroup: [],
230-
} as unknown as RootState;
307+
}) as unknown as RootState;
231308

232-
expect(selectLocalTransactions(state)).toStrictEqual([
309+
it('filters required child transactions before nonce dedupe', () => {
310+
expect(selectLocalTransactions(buildLocalTxState())).toStrictEqual([
233311
expect.objectContaining({ id: 'parent' }),
234312
]);
235313
});
314+
315+
it('returns no transactions when the selected group has no EVM account', () => {
316+
expect(
317+
selectLocalTransactions(buildLocalTxState({ groupEvmAccount: null })),
318+
).toStrictEqual([]);
319+
});
320+
321+
it('excludes incoming transactions populated by the TransactionController incoming-transactions feature', () => {
322+
const state = buildLocalTxState({
323+
transactions: [
324+
{
325+
id: 'outgoing',
326+
hash: '0xOUTGOING',
327+
chainId: '0x1',
328+
time: 200,
329+
txParams: { from: evmAddress, nonce: '0x1' },
330+
},
331+
{
332+
id: 'incoming-duplicate',
333+
hash: '0xOUTGOING',
334+
chainId: '0x1',
335+
time: 100,
336+
isTransfer: true,
337+
txParams: { from: evmAddress },
338+
},
339+
],
340+
});
341+
342+
expect(selectLocalTransactions(state)).toStrictEqual([
343+
expect.objectContaining({ id: 'outgoing' }),
344+
]);
345+
});
236346
});
237347

238348
describe('selectTransactionMetadataById', () => {

app/selectors/transactionController.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
selectPendingSmartTransactionsForSelectedAccountGroup,
77
} from './smartTransactionsController';
88
import { selectEvmAddress } from './accountsController';
9+
import { selectSelectedAccountGroupEvmInternalAccount } from './multichainAccounts/accountTreeController';
910
import {
1011
TransactionMeta,
1112
TransactionType,
@@ -26,13 +27,14 @@ function dedupeTransactions(transactions: LocalTransaction[]) {
2627
const seenTransactions = new Set<string>();
2728

2829
return transactions.filter((transaction) => {
29-
const { chainId, txParams } = transaction;
30-
const { from, nonce, actionId } = txParams || {};
30+
const { chainId, txParams, id, isTransfer } =
31+
transaction as TransactionMeta;
32+
const { from, nonce } = txParams || {};
3133
const hash = 'hash' in transaction ? transaction.hash : undefined;
3234
const isBridgeTransaction = transaction.type === TransactionType.bridge;
3335
const hasNonce = nonce !== undefined && nonce !== null;
3436

35-
if (!from) {
37+
if (!from || isTransfer !== undefined) {
3638
return false;
3739
}
3840

@@ -42,7 +44,7 @@ function dedupeTransactions(transactions: LocalTransaction[]) {
4244
? `${dedupeKeyPrefix}-bridge-${hash.toLowerCase()}`
4345
: hasNonce
4446
? `${dedupeKeyPrefix}-${nonce}`
45-
: `${dedupeKeyPrefix}-${actionId}`;
47+
: `${dedupeKeyPrefix}-${id}`;
4648

4749
// Keep only the first local transaction for each dedupe key
4850
if (seenTransactions.has(dedupeKey)) {
@@ -115,6 +117,35 @@ export const selectRequiredTransactionHashes = createSelector(
115117
),
116118
);
117119

120+
export const selectRelatedChainIdsByTransactionId = createSelector(
121+
selectTransactionsStrict,
122+
(transactions) => {
123+
const transactionsById = new Map<string, TransactionMeta>(
124+
transactions.map((tx) => [tx.id, tx]),
125+
);
126+
127+
return new Map<string, string[]>(
128+
transactions
129+
.map((tx) => {
130+
const childChainIds = (tx.requiredTransactionIds ?? []).map(
131+
(childId) => transactionsById.get(childId)?.chainId,
132+
);
133+
134+
const chainIds = [
135+
tx.chainId,
136+
tx.metamaskPay?.chainId,
137+
...childChainIds,
138+
]
139+
.filter((chainId): chainId is Hex => Boolean(chainId))
140+
.map((chainId) => chainId.toLowerCase());
141+
142+
return [tx.id, [...new Set(chainIds)]] satisfies [string, string[]];
143+
})
144+
.filter(([, chainIds]) => chainIds.length > 0),
145+
);
146+
},
147+
);
148+
118149
export const selectTransactions = createDeepEqualSelector(
119150
selectTransactionsStrict,
120151
(transactions) => transactions,
@@ -189,15 +220,19 @@ export const selectLocalTransactions = createDeepEqualSelector(
189220
[
190221
selectNonReplacedTransactions,
191222
selectPendingSmartTransactionsForSelectedAccountGroup,
223+
selectSelectedAccountGroupEvmInternalAccount,
192224
selectEvmAddress,
193225
selectRequiredTransactionIds,
194226
],
195227
(
196228
nonReplacedTransactions,
197229
pendingSmartTransactions,
198-
activeEvmAddress,
230+
groupEvmAccount,
231+
fallbackEvmAddress,
199232
requiredTransactionIds,
200233
) => {
234+
const activeEvmAddress = groupEvmAccount?.address ?? fallbackEvmAddress;
235+
201236
const transactions = nonReplacedTransactions.filter((transaction) => {
202237
if (requiredTransactionIds.has(transaction.id)) {
203238
return false;

tests/smoke/swap/bridge-action-smoke.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ describe(SmokeSwap('Bridge functionality'), () => {
2626
const sourceSymbol: string = 'ETH';
2727
const chainId = '0x1';
2828
const destChainId = '0x2105';
29-
const FIRST_ROW: number = 0;
29+
30+
// Row 0 is a stale STX-shaped entry; the confirmed bridge tx is on row 1.
31+
// TODO: stop merging SmartTransactionsController state into
32+
// selectLocalTransactions / selectSortedTransactions, then assert row 0.
33+
const BRIDGE_ROW: number = 1;
3034

3135
await withFixtures(
3236
{
@@ -103,7 +107,7 @@ describe(SmokeSwap('Bridge functionality'), () => {
103107
);
104108

105109
await Assertions.expectElementToHaveText(
106-
ActivitiesView.transactionStatus(FIRST_ROW),
110+
ActivitiesView.transactionStatus(BRIDGE_ROW),
107111
ActivitiesViewSelectorsText.CONFIRM_TEXT,
108112
{
109113
timeout: 120000,

0 commit comments

Comments
 (0)