Skip to content

Commit 04b18d4

Browse files
committed
feat: use account API v4 transactions
1 parent 4e11714 commit 04b18d4

8 files changed

Lines changed: 533 additions & 179 deletions

File tree

app/components/Views/UnifiedTransactionsView/UnifiedTransactionsView.tsx

Lines changed: 121 additions & 178 deletions
Large diffs are not rendered by default.
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import {
2+
type V1TransactionByHashResponse,
3+
type V4MultiAccountTransactionsResponse,
4+
} from '@metamask/core-backend';
5+
import {
6+
TransactionStatus,
7+
TransactionType,
8+
} from '@metamask/transaction-controller';
9+
import { numberToHex, type CaipChainId } from '@metamask/utils';
10+
import type { InfiniteData } from '@tanstack/react-query';
11+
import { NATIVE_TOKEN_ADDRESS } from '../../confirmations/constants/tokens';
12+
import type { ConfirmedEvmTransaction } from './types';
13+
14+
export interface ActivityHistoryQueryParams {
15+
accountAddresses: string[];
16+
networks: CaipChainId[];
17+
}
18+
19+
const EXCLUDED_TRANSACTION_TYPES = ['SPAM_TOKEN_TRANSFER'];
20+
21+
const getPlainAddresses = (accountAddresses: string[]) =>
22+
[
23+
...new Set(
24+
accountAddresses
25+
.map((accountAddress) => accountAddress.split(':').pop()?.toLowerCase())
26+
.filter(Boolean),
27+
),
28+
] as string[];
29+
30+
const findMatchingAddress = (
31+
plainAddresses: Set<string>,
32+
transaction: V1TransactionByHashResponse,
33+
) => {
34+
const from = transaction.from?.toLowerCase();
35+
const to = transaction.to?.toLowerCase();
36+
37+
if (from && plainAddresses.has(from)) {
38+
return from;
39+
}
40+
41+
if (to && plainAddresses.has(to)) {
42+
return to;
43+
}
44+
45+
return undefined;
46+
};
47+
48+
const isIncomingTokenTransfer = (
49+
address: string,
50+
transaction: V1TransactionByHashResponse,
51+
) =>
52+
transaction.valueTransfers?.some(
53+
(transfer) =>
54+
Boolean(transfer.contractAddress) &&
55+
transfer.to?.toLowerCase() === address &&
56+
transaction.from?.toLowerCase() !== address,
57+
) ?? false;
58+
59+
const parseValueTransfers = (
60+
address: string,
61+
transaction: V1TransactionByHashResponse,
62+
) => {
63+
const normalizedAddress = address.toLowerCase();
64+
const result: {
65+
from?: { token: { address: string } };
66+
to?: { token: { address: string } };
67+
} = {};
68+
69+
for (const transfer of transaction.valueTransfers ?? []) {
70+
const tokenAddress =
71+
transfer.contractAddress?.toLowerCase() || NATIVE_TOKEN_ADDRESS;
72+
73+
if (!result.from && transfer.from?.toLowerCase() === normalizedAddress) {
74+
result.from = { token: { address: tokenAddress } };
75+
}
76+
77+
if (!result.to && transfer.to?.toLowerCase() === normalizedAddress) {
78+
result.to = { token: { address: tokenAddress } };
79+
}
80+
81+
if (result.from && result.to) {
82+
break;
83+
}
84+
}
85+
86+
return result;
87+
};
88+
89+
const getId = (tx: V1TransactionByHashResponse) =>
90+
`${numberToHex(tx.chainId)}-${tx.hash}`;
91+
92+
const getTime = (tx: V1TransactionByHashResponse) =>
93+
Date.parse(tx.timestamp) || 0;
94+
95+
const decimalToHex = (value: string | number | undefined | null) => {
96+
if (value === undefined || value === null) {
97+
return '0x0';
98+
}
99+
100+
try {
101+
return `0x${BigInt(value).toString(16)}`;
102+
} catch {
103+
return '0x0';
104+
}
105+
};
106+
107+
const getTransactionType = (tx: V1TransactionByHashResponse) =>
108+
tx.methodId && tx.methodId !== '0x'
109+
? TransactionType.contractInteraction
110+
: TransactionType.simpleSend;
111+
112+
const getTransferInformation = (
113+
address: string,
114+
tx: V1TransactionByHashResponse,
115+
) => {
116+
const normalizedAddress = address.toLowerCase();
117+
const transfer = tx.valueTransfers?.find(
118+
(valueTransfer) =>
119+
Boolean(valueTransfer.contractAddress) &&
120+
(valueTransfer.from?.toLowerCase() === normalizedAddress ||
121+
valueTransfer.to?.toLowerCase() === normalizedAddress),
122+
);
123+
124+
if (!transfer?.contractAddress) {
125+
return undefined;
126+
}
127+
128+
return {
129+
amount: transfer.amount,
130+
contractAddress: transfer.contractAddress,
131+
decimals: transfer.decimal,
132+
symbol: transfer.symbol,
133+
recipient: transfer.to,
134+
};
135+
};
136+
137+
const normalizeTransaction = (
138+
address: string,
139+
tx: V1TransactionByHashResponse,
140+
): ConfirmedEvmTransaction => {
141+
const txChainId = numberToHex(tx.chainId);
142+
const transferInformation = getTransferInformation(address, tx);
143+
144+
return {
145+
...tx,
146+
blockNumber: String(tx.blockNumber),
147+
chainId: txChainId,
148+
error: tx.isError ? new Error('Transaction failed') : undefined,
149+
hash: tx.hash,
150+
id: getId(tx),
151+
networkClientId: '',
152+
status: tx.isError ? TransactionStatus.failed : TransactionStatus.confirmed,
153+
time: getTime(tx),
154+
toSmartContract: false,
155+
transferInformation,
156+
txChainId,
157+
txParams: {
158+
chainId: txChainId,
159+
data: tx.methodId || '0x',
160+
from: tx.from,
161+
gas: decimalToHex(tx.gas),
162+
gasPrice: decimalToHex(tx.gasPrice),
163+
gasUsed: decimalToHex(tx.gasUsed),
164+
nonce: decimalToHex(tx.nonce),
165+
to: tx.to,
166+
value: decimalToHex(tx.value),
167+
},
168+
type: getTransactionType(tx),
169+
verifiedOnBlockchain: true,
170+
};
171+
};
172+
173+
export const normalizeTransactions = (
174+
address: string,
175+
transactions: V1TransactionByHashResponse[],
176+
) =>
177+
transactions
178+
.map((tx): ConfirmedEvmTransaction => normalizeTransaction(address, tx))
179+
.filter(
180+
(tx, index, self) =>
181+
index ===
182+
self.findIndex(
183+
(candidate) =>
184+
candidate.hash === tx.hash && candidate.txChainId === tx.txChainId,
185+
),
186+
);
187+
188+
export const selectConfirmedTransactions =
189+
({ accountAddresses }: { accountAddresses: string[] }) =>
190+
(data: InfiniteData<V4MultiAccountTransactionsResponse>) => {
191+
const plainAddresses = new Set(getPlainAddresses(accountAddresses));
192+
193+
return {
194+
...data,
195+
pages: data.pages.map((page) => ({
196+
...page,
197+
data: page.data
198+
.reduce<ConfirmedEvmTransaction[]>((result, raw) => {
199+
const matchedAddress = findMatchingAddress(plainAddresses, raw);
200+
201+
if (!matchedAddress) {
202+
return result;
203+
}
204+
205+
const rawFrom = raw.from?.toLowerCase();
206+
const rawTo = raw.to?.toLowerCase();
207+
208+
if (
209+
EXCLUDED_TRANSACTION_TYPES.includes(raw.transactionType ?? '')
210+
) {
211+
return result;
212+
}
213+
214+
if (
215+
rawFrom === matchedAddress &&
216+
rawTo === matchedAddress &&
217+
raw.value === '0' &&
218+
!raw.valueTransfers?.length &&
219+
(!raw.methodId || raw.methodId === '0x')
220+
) {
221+
return result;
222+
}
223+
224+
if (isIncomingTokenTransfer(matchedAddress, raw)) {
225+
return result;
226+
}
227+
228+
const amounts = parseValueTransfers(matchedAddress, raw);
229+
230+
if (
231+
rawFrom !== matchedAddress &&
232+
amounts.to?.token.address === NATIVE_TOKEN_ADDRESS &&
233+
!amounts.from
234+
) {
235+
return result;
236+
}
237+
238+
result.push(normalizeTransaction(matchedAddress, raw));
239+
240+
return result;
241+
}, [])
242+
.filter(
243+
(tx, index, self) =>
244+
index ===
245+
self.findIndex(
246+
(candidate) =>
247+
candidate.hash === tx.hash &&
248+
candidate.txChainId === tx.txChainId,
249+
),
250+
),
251+
})),
252+
};
253+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { V1TransactionByHashResponse } from '@metamask/core-backend';
2+
import type { TransactionMeta } from '@metamask/transaction-controller';
3+
4+
export type ConfirmedEvmTransaction = TransactionMeta &
5+
V1TransactionByHashResponse & {
6+
id: string;
7+
time: number;
8+
txChainId: string;
9+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { useInfiniteQuery } from '@tanstack/react-query';
2+
import { useMemo } from 'react';
3+
import { useSelector } from 'react-redux';
4+
import { getApiClient } from '../../../../core/apiClient';
5+
import { selectAccountGroupEvmAccountAddresses } from '../../../../selectors/multichainAccounts/accountTreeController';
6+
import { selectEvmEnabledCaipNetworks } from '../../../../selectors/networkEnablementController';
7+
import { selectConfirmedTransactions } from '../helpers/mappers';
8+
import { MINUTE } from '../../../../constants/time';
9+
10+
export const useTransactionsQuery = () => {
11+
const accountAddresses = useSelector(selectAccountGroupEvmAccountAddresses);
12+
const networks = useSelector(selectEvmEnabledCaipNetworks);
13+
const selectFn = useMemo(
14+
() =>
15+
selectConfirmedTransactions({
16+
accountAddresses,
17+
}),
18+
[accountAddresses],
19+
);
20+
21+
const queryOptions =
22+
getApiClient().accounts.getV4MultiAccountTransactionsInfiniteQueryOptions({
23+
accountAddresses,
24+
networks,
25+
includeTxMetadata: true,
26+
});
27+
28+
return useInfiniteQuery({
29+
...queryOptions,
30+
select: selectFn,
31+
enabled: accountAddresses.length > 0 && networks.length > 0,
32+
staleTime: 5 * MINUTE,
33+
});
34+
};

app/core/apiClient.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { createApiPlatformClient } from '@metamask/core-backend';
2+
import Engine from './Engine';
3+
import ReactQueryService from './ReactQueryService/ReactQueryService';
4+
5+
let apiClient: ReturnType<typeof createApiPlatformClient> | undefined;
6+
7+
export const getApiClient = () => {
8+
if (!apiClient) {
9+
apiClient = createApiPlatformClient({
10+
clientProduct: 'metamask-mobile',
11+
getBearerToken: async () => {
12+
try {
13+
return await Engine.context.AuthenticationController.getBearerToken();
14+
} catch {
15+
return undefined;
16+
}
17+
},
18+
queryClient: ReactQueryService.queryClient,
19+
});
20+
}
21+
22+
return apiClient;
23+
};

app/selectors/multichainAccounts/accountTreeController.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createSelector } from 'reselect';
22
import { RootState } from '../../reducers';
33
import {
44
selectInternalAccounts,
5+
selectSelectedInternalAccount,
56
selectSelectedInternalAccountId,
67
} from '../../selectors/accountsController';
78
import {
@@ -16,10 +17,12 @@ import {
1617
AccountGroupObject,
1718
AccountTreeControllerState,
1819
} from '@metamask/account-tree-controller';
19-
import { CaipChainId } from '@metamask/utils';
20+
import { CaipAccountId, CaipChainId } from '@metamask/utils';
2021
import { InternalAccount } from '@metamask/keyring-internal-api';
22+
import { EthScope } from '@metamask/keyring-api';
2123
import { AccountGroupWithInternalAccounts } from './accounts.type';
2224
import { getFormattedAddressFromInternalAccount } from '../../core/Multichain/utils';
25+
import { selectEvmEnabledCaipNetworks } from '../networkEnablementController';
2326

2427
// Stable empty references to prevent unnecessary re-renders
2528
const EMPTY_ARR: readonly never[] = Object.freeze([]);
@@ -419,6 +422,41 @@ export const selectSelectedAccountGroupInternalAccounts = createSelector(
419422
},
420423
);
421424

425+
export const selectAccountGroupEvmInternalAccounts = createSelector(
426+
[selectSelectedAccountGroupInternalAccounts],
427+
(accounts) =>
428+
accounts.filter((account) =>
429+
account.scopes.some((scope) => scope.startsWith(`${EthScope.Eoa}:`)),
430+
),
431+
);
432+
433+
export const selectAccountGroupEvmAccountAddresses = createSelector(
434+
[selectSelectedInternalAccount, selectEvmEnabledCaipNetworks],
435+
(selectedInternalAccount, networks) => {
436+
if (!selectedInternalAccount || !networks.length) {
437+
return EMPTY_ARR as readonly CaipAccountId[];
438+
}
439+
440+
const accountScopes = selectedInternalAccount.scopes.filter((scope) =>
441+
scope.startsWith(`${EthScope.Eoa}:`),
442+
);
443+
if (!accountScopes.length) {
444+
return EMPTY_ARR as readonly CaipAccountId[];
445+
}
446+
447+
return networks
448+
.filter(
449+
(network) =>
450+
accountScopes.includes(EthScope.Eoa as CaipChainId) ||
451+
accountScopes.includes(network),
452+
)
453+
.map(
454+
(network) =>
455+
`${network}:${selectedInternalAccount.address}` as CaipAccountId,
456+
);
457+
},
458+
);
459+
422460
/**
423461
* Returns an array with the formatted addresses of the internal accounts
424462
* of the selected account group.

0 commit comments

Comments
 (0)