Skip to content

Commit f91b7f7

Browse files
feat: add incoming transaction types (#5987)
## Explanation Determine the specific `TransactionType` for outgoing transactions retrieved from accounts API. ## References ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [x] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes
1 parent 33b3810 commit f91b7f7

File tree

7 files changed

+106
-47
lines changed

7 files changed

+106
-47
lines changed

packages/transaction-controller/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add specific transaction types to outgoing transactions retrieved from accounts API ([#5987](https://github.com/MetaMask/core/pull/5987))
13+
- Add optional `amount` property to `transferInformation` object in `TransactionMeta` type.
14+
1015
### Changed
1116

1217
- Remove incoming transactions when calling `wipeTransactions` ([#5986](https://github.com/MetaMask/core/pull/5986))

packages/transaction-controller/src/api/accounts-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export type TransactionResponse = {
5959
effectiveGasPrice: string;
6060
nonce: number;
6161
cumulativeGasUsed: number;
62-
methodId: null;
62+
methodId?: Hex;
6363
value: string;
6464
to: string;
6565
from: string;

packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.test.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import {
22
AccountsApiRemoteTransactionSource,
33
SUPPORTED_CHAIN_IDS,
44
} from './AccountsApiRemoteTransactionSource';
5+
import { determineTransactionType } from '..';
56
import type {
67
GetAccountTransactionsResponse,
78
TransactionResponse,
89
} from '../api/accounts-api';
910
import { getAccountTransactions } from '../api/accounts-api';
10-
import type { RemoteTransactionSourceRequest } from '../types';
11+
import { TransactionType, type RemoteTransactionSourceRequest } from '../types';
1112

1213
jest.mock('../api/accounts-api');
14+
jest.mock('../utils/transaction-type');
1315

1416
jest.useFakeTimers();
1517

@@ -41,7 +43,7 @@ const RESPONSE_STANDARD_MOCK: TransactionResponse = {
4143
effectiveGasPrice: '1',
4244
nonce: 1,
4345
cumulativeGasUsed: 1,
44-
methodId: null,
46+
methodId: '0x12345678',
4547
value: '1',
4648
to: ADDRESS_MOCK,
4749
from: '0x2',
@@ -78,6 +80,7 @@ const TRANSACTION_STANDARD_MOCK = {
7880
transferInformation: undefined,
7981
txParams: {
8082
chainId: '0x1',
83+
data: '0x12345678',
8184
from: '0x2',
8285
gas: '0x1',
8386
gasPrice: '0x1',
@@ -86,14 +89,15 @@ const TRANSACTION_STANDARD_MOCK = {
8689
to: '0x123',
8790
value: '0x1',
8891
},
89-
type: 'incoming',
92+
type: TransactionType.incoming,
9093
verifiedOnBlockchain: false,
9194
};
9295

9396
const TRANSACTION_TOKEN_TRANSFER_MOCK = {
9497
...TRANSACTION_STANDARD_MOCK,
9598
isTransfer: true,
9699
transferInformation: {
100+
amount: '1',
97101
contractAddress: '0x123',
98102
decimals: 18,
99103
symbol: 'ABC',
@@ -102,6 +106,7 @@ const TRANSACTION_TOKEN_TRANSFER_MOCK = {
102106

103107
describe('AccountsApiRemoteTransactionSource', () => {
104108
const getAccountTransactionsMock = jest.mocked(getAccountTransactions);
109+
const determineTransactionTypeMock = jest.mocked(determineTransactionType);
105110

106111
beforeEach(() => {
107112
jest.resetAllMocks();
@@ -110,6 +115,11 @@ describe('AccountsApiRemoteTransactionSource', () => {
110115
getAccountTransactionsMock.mockResolvedValue(
111116
{} as GetAccountTransactionsResponse,
112117
);
118+
119+
determineTransactionTypeMock.mockResolvedValue({
120+
type: TransactionType.tokenMethodTransfer,
121+
getCodeResponse: undefined,
122+
});
113123
});
114124

115125
describe('getSupportedChains', () => {
@@ -352,5 +362,19 @@ describe('AccountsApiRemoteTransactionSource', () => {
352362

353363
expect(transactions).toStrictEqual([]);
354364
});
365+
366+
it('determines transaction type if outgoing', async () => {
367+
getAccountTransactionsMock.mockResolvedValue({
368+
data: [{ ...RESPONSE_TOKEN_TRANSFER_MOCK, from: ADDRESS_MOCK }],
369+
pageInfo: { hasNextPage: false, count: 1 },
370+
});
371+
372+
const transactions =
373+
await new AccountsApiRemoteTransactionSource().fetchTransactions(
374+
REQUEST_MOCK,
375+
);
376+
377+
expect(transactions[0].type).toBe(TransactionType.tokenMethodTransfer);
378+
});
355379
});
356380
});

packages/transaction-controller/src/helpers/AccountsApiRemoteTransactionSource.ts

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Hex } from '@metamask/utils';
33
import BN from 'bn.js';
44
import { v1 as random } from 'uuid';
55

6+
import { determineTransactionType } from '..';
67
import type {
78
GetAccountTransactionsResponse,
89
TransactionResponse,
@@ -60,8 +61,8 @@ export class AccountsApiRemoteTransactionSource
6061
responseTransactions,
6162
);
6263

63-
const normalizedTransactions = responseTransactions.map((tx) =>
64-
this.#normalizeTransaction(address, tx),
64+
const normalizedTransactions = await Promise.all(
65+
responseTransactions.map((tx) => this.#normalizeTransaction(address, tx)),
6566
);
6667

6768
log('Normalized transactions', normalizedTransactions);
@@ -188,10 +189,10 @@ export class AccountsApiRemoteTransactionSource
188189
return filteredTransactions;
189190
}
190191

191-
#normalizeTransaction(
192+
async #normalizeTransaction(
192193
address: Hex,
193194
responseTransaction: GetAccountTransactionsResponse['data'][0],
194-
): TransactionMeta {
195+
): Promise<TransactionMeta> {
195196
const blockNumber = String(responseTransaction.blockNumber);
196197
const chainId = `0x${responseTransaction.chainId.toString(16)}` as Hex;
197198
const { hash } = responseTransaction;
@@ -202,6 +203,7 @@ export class AccountsApiRemoteTransactionSource
202203
const gasPrice = BNToHex(new BN(responseTransaction.gasPrice));
203204
const gasUsed = BNToHex(new BN(responseTransaction.gasUsed));
204205
const nonce = BNToHex(new BN(responseTransaction.nonce));
206+
const data = responseTransaction.methodId;
205207
const type = TransactionType.incoming;
206208
const verifiedOnBlockchain = false;
207209

@@ -211,40 +213,52 @@ export class AccountsApiRemoteTransactionSource
211213

212214
const valueTransfer = responseTransaction.valueTransfers.find(
213215
(vt) =>
214-
vt.to.toLowerCase() === address.toLowerCase() && vt.contractAddress,
216+
(vt.to.toLowerCase() === address.toLowerCase() ||
217+
vt.from.toLowerCase() === address.toLowerCase()) &&
218+
vt.contractAddress,
215219
);
216220

217-
const isTransfer = Boolean(valueTransfer);
221+
const isIncomingTokenTransfer =
222+
valueTransfer?.to.toLowerCase() === address.toLowerCase() &&
223+
from.toLowerCase() !== address.toLowerCase();
224+
225+
const isOutgoing = from.toLowerCase() === address.toLowerCase();
226+
const amount = valueTransfer?.amount;
218227
const contractAddress = valueTransfer?.contractAddress as string;
219228
const decimals = valueTransfer?.decimal as number;
220229
const symbol = valueTransfer?.symbol as string;
221230

222231
const value = BNToHex(
223-
new BN(valueTransfer?.amount ?? responseTransaction.value),
232+
new BN(
233+
isIncomingTokenTransfer
234+
? (valueTransfer?.amount ?? responseTransaction.value)
235+
: responseTransaction.value,
236+
),
224237
);
225238

226-
const to = valueTransfer ? address : responseTransaction.to;
239+
const to = isIncomingTokenTransfer ? address : responseTransaction.to;
227240

228241
const error =
229242
status === TransactionStatus.failed
230243
? new Error('Transaction failed')
231244
: (undefined as unknown as TransactionError);
232245

233-
const transferInformation = isTransfer
246+
const transferInformation = valueTransfer
234247
? {
248+
amount,
235249
contractAddress,
236250
decimals,
237251
symbol,
238252
}
239253
: undefined;
240254

241-
return {
255+
const meta: TransactionMeta = {
242256
blockNumber,
243257
chainId,
244258
error,
245259
hash,
246260
id,
247-
isTransfer,
261+
isTransfer: isIncomingTokenTransfer,
248262
// Populated by TransactionController when added to state
249263
networkClientId: '',
250264
status,
@@ -253,6 +267,7 @@ export class AccountsApiRemoteTransactionSource
253267
transferInformation,
254268
txParams: {
255269
chainId,
270+
data,
256271
from,
257272
gas,
258273
gasPrice,
@@ -264,6 +279,12 @@ export class AccountsApiRemoteTransactionSource
264279
type,
265280
verifiedOnBlockchain,
266281
};
282+
283+
if (isOutgoing) {
284+
meta.type = (await determineTransactionType(meta.txParams)).type;
285+
}
286+
287+
return meta;
267288
}
268289

269290
#updateCache({

packages/transaction-controller/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export type TransactionMeta = {
228228
isExternalSign?: boolean;
229229

230230
/**
231-
* Whether the transaction is a transfer.
231+
* Whether the transaction is an incoming token transfer.
232232
*/
233233
isTransfer?: boolean;
234234

@@ -442,6 +442,7 @@ export type TransactionMeta = {
442442
* Additional transfer information.
443443
*/
444444
transferInformation?: {
445+
amount?: string;
445446
contractAddress: string;
446447
decimals: number;
447448
symbol: string;

packages/transaction-controller/src/utils/transaction-type.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,4 +277,17 @@ describe('determineTransactionType', () => {
277277
getCodeResponse: '0xa',
278278
});
279279
});
280+
281+
it('returns contractInteraction if data and no eth query provided', async () => {
282+
const result = await determineTransactionType({
283+
...txParams,
284+
value: '0x5af3107a4000',
285+
data: '0x095ea7b30000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C9700000000000000000000000000000000000000000000000000000000000000005',
286+
});
287+
288+
expect(result).toMatchObject({
289+
type: TransactionType.contractInteraction,
290+
getCodeResponse: undefined,
291+
});
292+
});
280293
});

packages/transaction-controller/src/utils/transaction-type.ts

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { TransactionDescription } from '@ethersproject/abi';
21
import { Interface } from '@ethersproject/abi';
32
import { query } from '@metamask/controller-utils';
43
import type EthQuery from '@metamask/eth-query';
@@ -31,16 +30,23 @@ const USDCInterface = new Interface(abiFiatTokenV2);
3130
*/
3231
export async function determineTransactionType(
3332
txParams: TransactionParams,
34-
ethQuery: EthQuery,
33+
ethQuery?: EthQuery,
3534
): Promise<InferTransactionTypeResult> {
3635
const { data, to } = txParams;
3736

3837
if (data && !to) {
3938
return { type: TransactionType.deployContract, getCodeResponse: undefined };
4039
}
4140

42-
const { contractCode: getCodeResponse, isContractAddress } =
43-
await readAddressAsContract(ethQuery, to);
41+
let getCodeResponse;
42+
let isContractAddress = Boolean(data?.length);
43+
44+
if (ethQuery) {
45+
const response = await readAddressAsContract(ethQuery, to);
46+
47+
getCodeResponse = response.contractCode;
48+
isContractAddress = response.isContractAddress;
49+
}
4450

4551
if (!isContractAddress) {
4652
return { type: TransactionType.simpleSend, getCodeResponse };
@@ -57,7 +63,7 @@ export async function determineTransactionType(
5763
return contractInteractionResult;
5864
}
5965

60-
const name = parseStandardTokenTransactionData(data)?.name;
66+
const name = getMethodName(data);
6167

6268
if (!name) {
6369
return contractInteractionResult;
@@ -89,35 +95,24 @@ export async function determineTransactionType(
8995
* @param data - Encoded transaction data.
9096
* @returns A representation of an ethereum contract call.
9197
*/
92-
function parseStandardTokenTransactionData(
93-
data?: string,
94-
): TransactionDescription | undefined {
95-
if (!data) {
98+
function getMethodName(data?: string): string | undefined {
99+
if (!data || data.length < 10) {
96100
return undefined;
97101
}
98102

99-
try {
100-
return ERC20Interface.parseTransaction({ data });
101-
} catch {
102-
// ignore and next try to parse with erc721 ABI
103-
}
104-
105-
try {
106-
return ERC721Interface.parseTransaction({ data });
107-
} catch {
108-
// ignore and next try to parse with erc1155 ABI
109-
}
110-
111-
try {
112-
return ERC1155Interface.parseTransaction({ data });
113-
} catch {
114-
// ignore and return undefined
115-
}
116-
117-
try {
118-
return USDCInterface.parseTransaction({ data });
119-
} catch {
120-
// ignore and return undefined
103+
const fourByte = data.substring(0, 10);
104+
105+
for (const interfaceInstance of [
106+
ERC20Interface,
107+
ERC721Interface,
108+
ERC1155Interface,
109+
USDCInterface,
110+
]) {
111+
try {
112+
return interfaceInstance.getFunction(fourByte).name;
113+
} catch {
114+
// Intentionally empty
115+
}
121116
}
122117

123118
return undefined;

0 commit comments

Comments
 (0)