1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-21 17:37:01 +01:00

Replace IncomingTransactionsController with helper (#20378)

Remove the IncomingTransactionController and replace it with an internal helper class.
Move incoming transactions into the central transactions object.
Create a new RemoteTransactionSource interface to decouple incoming transaction support from Etherscan.
Split the incoming transaction logic into multiple files for easier maintenance.
This commit is contained in:
Matthew Walsh 2023-08-22 10:17:07 +01:00 committed by GitHub
parent 8ca0b762ad
commit 37209a7d2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2491 additions and 1917 deletions

View File

@ -1,320 +0,0 @@
import { ObservableStore } from '@metamask/obs-store';
import log from 'loglevel';
import BN from 'bn.js';
import createId from '../../../shared/modules/random-id';
import { previousValueComparator } from '../lib/util';
import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout';
import {
TransactionType,
TransactionStatus,
} from '../../../shared/constants/transaction';
import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../shared/constants/network';
import { bnToHex } from '../../../shared/modules/conversion.utils';
const fetchWithTimeout = getFetchWithTimeout();
/**
* @typedef {import('../../../shared/constants/transaction').TransactionMeta} TransactionMeta
*/
/**
* A transaction object in the format returned by the Etherscan API.
*
* Note that this is not an exhaustive type definiton; only the properties we use are defined
*
* @typedef {object} EtherscanTransaction
* @property {string} blockNumber - The number of the block this transaction was found in, in decimal
* @property {string} from - The hex-prefixed address of the sender
* @property {string} gas - The gas limit, in decimal GWEI
* @property {string} [gasPrice] - The gas price, in decimal WEI
* @property {string} [maxFeePerGas] - The maximum fee per gas, inclusive of tip, in decimal WEI
* @property {string} [maxPriorityFeePerGas] - The maximum tip per gas in decimal WEI
* @property {string} hash - The hex-prefixed transaction hash
* @property {string} isError - Whether the transaction was confirmed or failed (0 for confirmed, 1 for failed)
* @property {string} nonce - The transaction nonce, in decimal
* @property {string} timeStamp - The timestamp for the transaction, in seconds
* @property {string} to - The hex-prefixed address of the recipient
* @property {string} value - The amount of ETH sent in this transaction, in decimal WEI
*/
/**
* This controller is responsible for retrieving incoming transactions. Etherscan is polled once every block to check
* for new incoming transactions for the current selected account on the current network
*
* Note that only Etherscan-compatible networks are supported. We will not attempt to retrieve incoming transactions
* on non-compatible custom RPC endpoints.
*/
export default class IncomingTransactionsController {
constructor(opts = {}) {
const {
blockTracker,
onNetworkDidChange,
getCurrentChainId,
preferencesController,
onboardingController,
} = opts;
this.blockTracker = blockTracker;
this.getCurrentChainId = getCurrentChainId;
this.preferencesController = preferencesController;
this.onboardingController = onboardingController;
this._onLatestBlock = async (newBlockNumberHex) => {
const selectedAddress = this.preferencesController.getSelectedAddress();
const newBlockNumberDec = parseInt(newBlockNumberHex, 16);
await this._update(selectedAddress, newBlockNumberDec);
};
const incomingTxLastFetchedBlockByChainId = Object.keys(
ETHERSCAN_SUPPORTED_NETWORKS,
).reduce((network, chainId) => {
network[chainId] = null;
return network;
}, {});
const initState = {
incomingTransactions: {},
incomingTxLastFetchedBlockByChainId,
...opts.initState,
};
this.store = new ObservableStore(initState);
this.preferencesController.store.subscribe(
previousValueComparator((prevState, currState) => {
const {
featureFlags: {
showIncomingTransactions: prevShowIncomingTransactions,
} = {},
} = prevState;
const {
featureFlags: {
showIncomingTransactions: currShowIncomingTransactions,
} = {},
} = currState;
if (currShowIncomingTransactions === prevShowIncomingTransactions) {
return;
}
if (prevShowIncomingTransactions && !currShowIncomingTransactions) {
this.stop();
return;
}
this.start();
}, this.preferencesController.store.getState()),
);
this.preferencesController.store.subscribe(
previousValueComparator(async (prevState, currState) => {
const { selectedAddress: prevSelectedAddress } = prevState;
const { selectedAddress: currSelectedAddress } = currState;
if (currSelectedAddress === prevSelectedAddress) {
return;
}
await this._update(currSelectedAddress);
}, this.preferencesController.store.getState()),
);
this.onboardingController.store.subscribe(
previousValueComparator(async (prevState, currState) => {
const { completedOnboarding: prevCompletedOnboarding } = prevState;
const { completedOnboarding: currCompletedOnboarding } = currState;
if (!prevCompletedOnboarding && currCompletedOnboarding) {
const address = this.preferencesController.getSelectedAddress();
await this._update(address);
}
}, this.onboardingController.store.getState()),
);
onNetworkDidChange(async () => {
const address = this.preferencesController.getSelectedAddress();
await this._update(address);
});
}
start() {
const chainId = this.getCurrentChainId();
if (this._allowedToMakeFetchIncomingTx(chainId)) {
this.blockTracker.removeListener('latest', this._onLatestBlock);
this.blockTracker.addListener('latest', this._onLatestBlock);
}
}
stop() {
this.blockTracker.removeListener('latest', this._onLatestBlock);
}
/**
* Determines the correct block number to begin looking for new transactions
* from, fetches the transactions and then saves them and the next block
* number to begin fetching from in state. Block numbers and transactions are
* stored per chainId.
*
* @private
* @param {string} address - address to lookup transactions for
* @param {number} [newBlockNumberDec] - block number to begin fetching from
*/
async _update(address, newBlockNumberDec) {
const chainId = this.getCurrentChainId();
if (!address || !this._allowedToMakeFetchIncomingTx(chainId)) {
return;
}
try {
const currentState = this.store.getState();
const currentBlock = parseInt(this.blockTracker.getCurrentBlock(), 16);
const mostRecentlyFetchedBlock =
currentState.incomingTxLastFetchedBlockByChainId[chainId];
const blockToFetchFrom =
mostRecentlyFetchedBlock ?? newBlockNumberDec ?? currentBlock;
const newIncomingTxs = await this._getNewIncomingTransactions(
address,
blockToFetchFrom,
chainId,
);
let newMostRecentlyFetchedBlock = blockToFetchFrom;
newIncomingTxs.forEach((tx) => {
if (
tx.blockNumber &&
parseInt(newMostRecentlyFetchedBlock, 10) <
parseInt(tx.blockNumber, 10)
) {
newMostRecentlyFetchedBlock = parseInt(tx.blockNumber, 10);
}
});
this.store.updateState({
incomingTxLastFetchedBlockByChainId: {
...currentState.incomingTxLastFetchedBlockByChainId,
[chainId]: newMostRecentlyFetchedBlock + 1,
},
incomingTransactions: newIncomingTxs.reduce(
(transactions, tx) => {
transactions[tx.hash] = tx;
return transactions;
},
{
...currentState.incomingTransactions,
},
),
});
} catch (err) {
log.error(err);
}
}
/**
* fetches transactions for the given address and chain, via etherscan, then
* processes the data into the necessary shape for usage in this controller.
*
* @private
* @param {string} [address] - Address to fetch transactions for
* @param {number} [fromBlock] - Block to look for transactions at
* @param {string} [chainId] - The chainId for the current network
* @returns {TransactionMeta[]}
*/
async _getNewIncomingTransactions(address, fromBlock, chainId) {
const etherscanDomain = ETHERSCAN_SUPPORTED_NETWORKS[chainId].domain;
const etherscanSubdomain = ETHERSCAN_SUPPORTED_NETWORKS[chainId].subdomain;
const apiUrl = `https://${etherscanSubdomain}.${etherscanDomain}`;
let url = `${apiUrl}/api?module=account&action=txlist&address=${address}&tag=latest&page=1`;
if (fromBlock) {
url += `&startBlock=${parseInt(fromBlock, 10)}`;
}
const response = await fetchWithTimeout(url);
const { status, result } = await response.json();
let newIncomingTxs = [];
if (status === '1' && Array.isArray(result) && result.length > 0) {
const remoteTxList = {};
const remoteTxs = [];
result.forEach((tx) => {
if (!remoteTxList[tx.hash]) {
remoteTxs.push(this._normalizeTxFromEtherscan(tx, chainId));
remoteTxList[tx.hash] = 1;
}
});
newIncomingTxs = remoteTxs.filter(
(tx) => tx.txParams?.to?.toLowerCase() === address.toLowerCase(),
);
newIncomingTxs.sort((a, b) => (a.time < b.time ? -1 : 1));
}
return newIncomingTxs;
}
/**
* Transmutes a EtherscanTransaction into a TransactionMeta
*
* @param {EtherscanTransaction} etherscanTransaction - the transaction to normalize
* @param {string} chainId - The chainId of the current network
* @returns {TransactionMeta}
*/
_normalizeTxFromEtherscan(etherscanTransaction, chainId) {
const time = parseInt(etherscanTransaction.timeStamp, 10) * 1000;
const status =
etherscanTransaction.isError === '0'
? TransactionStatus.confirmed
: TransactionStatus.failed;
const txParams = {
from: etherscanTransaction.from,
gas: bnToHex(new BN(etherscanTransaction.gas)),
nonce: bnToHex(new BN(etherscanTransaction.nonce)),
to: etherscanTransaction.to,
value: bnToHex(new BN(etherscanTransaction.value)),
};
if (etherscanTransaction.gasPrice) {
txParams.gasPrice = bnToHex(new BN(etherscanTransaction.gasPrice));
} else if (etherscanTransaction.maxFeePerGas) {
txParams.maxFeePerGas = bnToHex(
new BN(etherscanTransaction.maxFeePerGas),
);
txParams.maxPriorityFeePerGas = bnToHex(
new BN(etherscanTransaction.maxPriorityFeePerGas),
);
}
return {
blockNumber: etherscanTransaction.blockNumber,
id: createId(),
chainId,
metamaskNetworkId: ETHERSCAN_SUPPORTED_NETWORKS[chainId].networkId,
status,
time,
txParams,
hash: etherscanTransaction.hash,
type: TransactionType.incoming,
};
}
/**
* @param chainId - {string} The chainId of the current network
* @returns {boolean} Whether or not the user has consented to show incoming transactions
*/
_allowedToMakeFetchIncomingTx(chainId) {
const { featureFlags = {} } = this.preferencesController.store.getState();
const { completedOnboarding } = this.onboardingController.store.getState();
const hasIncomingTransactionsFeatureEnabled = Boolean(
featureFlags.showIncomingTransactions,
);
const isEtherscanSupportedNetwork = Boolean(
ETHERSCAN_SUPPORTED_NETWORKS[chainId],
);
return (
completedOnboarding &&
isEtherscanSupportedNetwork &&
hasIncomingTransactionsFeatureEnabled
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,219 @@
import { CHAIN_IDS } from '../../../../shared/constants/network';
import {
TransactionStatus,
TransactionType,
} from '../../../../shared/constants/transaction';
import createRandomId from '../../../../shared/modules/random-id';
import type {
EtherscanTokenTransactionMeta,
EtherscanTransactionMeta,
EtherscanTransactionMetaBase,
EtherscanTransactionResponse,
} from './etherscan';
import {
fetchEtherscanTokenTransactions,
fetchEtherscanTransactions,
} from './etherscan';
import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource';
jest.mock('./etherscan', () => ({
fetchEtherscanTransactions: jest.fn(),
fetchEtherscanTokenTransactions: jest.fn(),
}));
jest.mock('../../../../shared/modules/random-id');
const ID_MOCK = 123;
const ETHERSCAN_TRANSACTION_BASE_MOCK: EtherscanTransactionMetaBase = {
blockNumber: '4535105',
confirmations: '4',
contractAddress: '',
cumulativeGasUsed: '693910',
from: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207',
gas: '335208',
gasPrice: '20000000000',
gasUsed: '21000',
hash: '0x342e9d73e10004af41d04973339fc7219dbadcbb5629730cfe65e9f9cb15ff91',
nonce: '1',
timeStamp: '1543596356',
transactionIndex: '13',
value: '50000000000000000',
blockHash: '0x0000000001',
to: '0x6bf137f335ea1b8f193b8f6ea92561a60d23a207',
};
const ETHERSCAN_TRANSACTION_SUCCESS_MOCK: EtherscanTransactionMeta = {
...ETHERSCAN_TRANSACTION_BASE_MOCK,
functionName: 'testFunction',
input: '0x',
isError: '0',
methodId: 'testId',
txreceipt_status: '1',
};
const ETHERSCAN_TRANSACTION_ERROR_MOCK: EtherscanTransactionMeta = {
...ETHERSCAN_TRANSACTION_SUCCESS_MOCK,
isError: '1',
};
const ETHERSCAN_TOKEN_TRANSACTION_MOCK: EtherscanTokenTransactionMeta = {
...ETHERSCAN_TRANSACTION_BASE_MOCK,
tokenDecimal: '456',
tokenName: 'TestToken',
tokenSymbol: 'ABC',
};
const ETHERSCAN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse<EtherscanTransactionMeta> =
{
result: [
ETHERSCAN_TRANSACTION_SUCCESS_MOCK,
ETHERSCAN_TRANSACTION_ERROR_MOCK,
],
};
const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK: EtherscanTransactionResponse<EtherscanTokenTransactionMeta> =
{
result: [
ETHERSCAN_TOKEN_TRANSACTION_MOCK,
ETHERSCAN_TOKEN_TRANSACTION_MOCK,
],
};
const ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse<EtherscanTransactionMeta> =
{
result: [],
};
const ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK: EtherscanTransactionResponse<EtherscanTokenTransactionMeta> =
ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK as any;
const EXPECTED_NORMALISED_TRANSACTION_BASE = {
blockNumber: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.blockNumber,
chainId: undefined,
hash: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.hash,
id: ID_MOCK,
metamaskNetworkId: undefined,
status: TransactionStatus.confirmed,
time: 1543596356000,
txParams: {
from: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.from,
gas: '0x51d68',
gasPrice: '0x4a817c800',
nonce: '0x1',
to: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.to,
value: '0xb1a2bc2ec50000',
},
type: TransactionType.incoming,
};
const EXPECTED_NORMALISED_TRANSACTION_SUCCESS = {
...EXPECTED_NORMALISED_TRANSACTION_BASE,
txParams: {
...EXPECTED_NORMALISED_TRANSACTION_BASE.txParams,
data: ETHERSCAN_TRANSACTION_SUCCESS_MOCK.input,
},
};
const EXPECTED_NORMALISED_TRANSACTION_ERROR = {
...EXPECTED_NORMALISED_TRANSACTION_SUCCESS,
status: TransactionStatus.failed,
};
const EXPECTED_NORMALISED_TOKEN_TRANSACTION = {
...EXPECTED_NORMALISED_TRANSACTION_BASE,
};
describe('EtherscanRemoteTransactionSource', () => {
const fetchEtherscanTransactionsMock =
fetchEtherscanTransactions as jest.MockedFn<
typeof fetchEtherscanTransactions
>;
const fetchEtherscanTokenTransactionsMock =
fetchEtherscanTokenTransactions as jest.MockedFn<
typeof fetchEtherscanTokenTransactions
>;
const createIdMock = createRandomId as jest.MockedFn<typeof createRandomId>;
beforeEach(() => {
jest.resetAllMocks();
fetchEtherscanTransactionsMock.mockResolvedValue(
ETHERSCAN_TRANSACTION_RESPONSE_EMPTY_MOCK,
);
fetchEtherscanTokenTransactionsMock.mockResolvedValue(
ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_EMPTY_MOCK,
);
createIdMock.mockReturnValue(ID_MOCK);
});
describe('isSupportedNetwork', () => {
it('returns true if chain ID in constant', () => {
expect(
new EtherscanRemoteTransactionSource().isSupportedNetwork(
CHAIN_IDS.MAINNET,
'1',
),
).toBe(true);
});
it('returns false if chain ID not in constant', () => {
expect(
new EtherscanRemoteTransactionSource().isSupportedNetwork(
CHAIN_IDS.LOCALHOST,
'1',
),
).toBe(false);
});
});
describe('fetchTransactions', () => {
it('returns normalized transactions fetched from Etherscan', async () => {
fetchEtherscanTransactionsMock.mockResolvedValueOnce(
ETHERSCAN_TRANSACTION_RESPONSE_MOCK,
);
const transactions =
await new EtherscanRemoteTransactionSource().fetchTransactions(
{} as any,
);
expect(transactions).toStrictEqual([
EXPECTED_NORMALISED_TRANSACTION_SUCCESS,
EXPECTED_NORMALISED_TRANSACTION_ERROR,
]);
});
it('returns normalized token transactions fetched from Etherscan', async () => {
fetchEtherscanTokenTransactionsMock.mockResolvedValueOnce(
ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK,
);
const transactions =
await new EtherscanRemoteTransactionSource().fetchTransactions(
{} as any,
);
expect(transactions).toStrictEqual([
EXPECTED_NORMALISED_TOKEN_TRANSACTION,
EXPECTED_NORMALISED_TOKEN_TRANSACTION,
]);
});
it('returns no normalized token transactions if flag disabled', async () => {
fetchEtherscanTokenTransactionsMock.mockResolvedValueOnce(
ETHERSCAN_TOKEN_TRANSACTION_RESPONSE_MOCK,
);
const transactions = await new EtherscanRemoteTransactionSource({
includeTokenTransfers: false,
}).fetchTransactions({} as any);
expect(transactions).toStrictEqual([]);
});
});
});

View File

@ -0,0 +1,156 @@
import { BNToHex } from '@metamask/controller-utils';
import type { Hex } from '@metamask/utils';
import { BN } from 'ethereumjs-util';
import createId from '../../../../shared/modules/random-id';
import {
TransactionMeta,
TransactionStatus,
TransactionType,
} from '../../../../shared/constants/transaction';
import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../../shared/constants/network';
import type {
EtherscanTokenTransactionMeta,
EtherscanTransactionMeta,
EtherscanTransactionMetaBase,
EtherscanTransactionRequest,
EtherscanTransactionResponse,
} from './etherscan';
import {
fetchEtherscanTokenTransactions,
fetchEtherscanTransactions,
} from './etherscan';
import {
RemoteTransactionSource,
RemoteTransactionSourceRequest,
} from './types';
/**
* A RemoteTransactionSource that fetches transaction data from Etherscan.
*/
export class EtherscanRemoteTransactionSource
implements RemoteTransactionSource
{
#apiKey?: string;
#includeTokenTransfers: boolean;
constructor({
apiKey,
includeTokenTransfers,
}: { apiKey?: string; includeTokenTransfers?: boolean } = {}) {
this.#apiKey = apiKey;
this.#includeTokenTransfers = includeTokenTransfers ?? true;
}
isSupportedNetwork(chainId: Hex, _networkId: string): boolean {
return Object.keys(ETHERSCAN_SUPPORTED_NETWORKS).includes(chainId);
}
async fetchTransactions(
request: RemoteTransactionSourceRequest,
): Promise<TransactionMeta[]> {
const etherscanRequest: EtherscanTransactionRequest = {
...request,
apiKey: this.#apiKey,
chainId: request.currentChainId,
};
const transactionPromise = fetchEtherscanTransactions(etherscanRequest);
const tokenTransactionPromise = this.#includeTokenTransfers
? fetchEtherscanTokenTransactions(etherscanRequest)
: Promise.resolve({
result: [] as EtherscanTokenTransactionMeta[],
} as EtherscanTransactionResponse<EtherscanTokenTransactionMeta>);
const [etherscanTransactions, etherscanTokenTransactions] =
await Promise.all([transactionPromise, tokenTransactionPromise]);
const transactions = etherscanTransactions.result.map((tx) =>
this.#normalizeTransaction(
tx,
request.currentNetworkId,
request.currentChainId,
),
);
const tokenTransactions = etherscanTokenTransactions.result.map((tx) =>
this.#normalizeTokenTransaction(
tx,
request.currentNetworkId,
request.currentChainId,
),
);
return [...transactions, ...tokenTransactions];
}
#normalizeTransaction(
txMeta: EtherscanTransactionMeta,
currentNetworkId: string,
currentChainId: Hex,
): TransactionMeta {
const base = this.#normalizeTransactionBase(
txMeta,
currentNetworkId,
currentChainId,
);
return {
...base,
txParams: {
...base.txParams,
data: txMeta.input,
},
...(txMeta.isError === '0'
? { status: TransactionStatus.confirmed }
: {
status: TransactionStatus.failed,
}),
};
}
#normalizeTokenTransaction(
txMeta: EtherscanTokenTransactionMeta,
currentNetworkId: string,
currentChainId: Hex,
): TransactionMeta {
const base = this.#normalizeTransactionBase(
txMeta,
currentNetworkId,
currentChainId,
);
return {
...base,
};
}
#normalizeTransactionBase(
txMeta: EtherscanTransactionMetaBase,
currentNetworkId: string,
currentChainId: Hex,
): TransactionMeta {
const time = parseInt(txMeta.timeStamp, 10) * 1000;
return {
blockNumber: txMeta.blockNumber,
chainId: currentChainId,
hash: txMeta.hash,
id: createId(),
metamaskNetworkId: currentNetworkId,
status: TransactionStatus.confirmed,
time,
txParams: {
from: txMeta.from,
gas: BNToHex(new BN(txMeta.gas)),
gasPrice: BNToHex(new BN(txMeta.gasPrice)),
nonce: BNToHex(new BN(txMeta.nonce)),
to: txMeta.to,
value: BNToHex(new BN(txMeta.value)),
},
type: TransactionType.incoming,
} as TransactionMeta;
}
}

View File

@ -0,0 +1,585 @@
import { NetworkType } from '@metamask/controller-utils';
import type { BlockTracker, NetworkState } from '@metamask/network-controller';
import {
TransactionMeta,
TransactionStatus,
} from '../../../../shared/constants/transaction';
import { IncomingTransactionHelper } from './IncomingTransactionHelper';
import { RemoteTransactionSource } from './types';
jest.mock('@metamask/controller-utils', () => ({
...jest.requireActual('@metamask/controller-utils'),
isSmartContractCode: jest.fn(),
query: () => Promise.resolve({}),
}));
const NETWORK_STATE_MOCK: NetworkState = {
providerConfig: {
chainId: '0x1',
type: NetworkType.mainnet,
},
networkId: '1',
} as unknown as NetworkState;
const ADDERSS_MOCK = '0x1';
const FROM_BLOCK_HEX_MOCK = '0x20';
const FROM_BLOCK_DECIMAL_MOCK = 32;
const BLOCK_TRACKER_MOCK = {
addListener: jest.fn(),
removeListener: jest.fn(),
getLatestBlock: jest.fn(() => FROM_BLOCK_HEX_MOCK),
} as unknown as jest.Mocked<BlockTracker>;
const CONTROLLER_ARGS_MOCK = {
blockTracker: BLOCK_TRACKER_MOCK,
getCurrentAccount: () => ADDERSS_MOCK,
getNetworkState: () => NETWORK_STATE_MOCK,
remoteTransactionSource: {} as RemoteTransactionSource,
transactionLimit: 1,
};
const TRANSACTION_MOCK: TransactionMeta = {
blockNumber: '123',
chainId: '0x1',
status: TransactionStatus.submitted,
time: 0,
txParams: { to: '0x1' },
} as unknown as TransactionMeta;
const TRANSACTION_MOCK_2: TransactionMeta = {
blockNumber: '234',
chainId: '0x1',
hash: '0x2',
time: 1,
txParams: { to: '0x1' },
} as unknown as TransactionMeta;
const createRemoteTransactionSourceMock = (
remoteTransactions: TransactionMeta[],
{
isSupportedNetwork,
error,
}: { isSupportedNetwork?: boolean; error?: boolean } = {},
): RemoteTransactionSource => ({
isSupportedNetwork: jest.fn(() => isSupportedNetwork ?? true),
fetchTransactions: jest.fn(() =>
error
? Promise.reject(new Error('Test Error'))
: Promise.resolve(remoteTransactions),
),
});
async function emitBlockTrackerLatestEvent(
helper: IncomingTransactionHelper,
{ start, error }: { start?: boolean; error?: boolean } = {},
) {
const transactionsListener = jest.fn();
const blockNumberListener = jest.fn();
if (error) {
transactionsListener.mockImplementation(() => {
throw new Error('Test Error');
});
}
helper.hub.addListener('transactions', transactionsListener);
helper.hub.addListener('updatedLastFetchedBlockNumbers', blockNumberListener);
if (start !== false) {
helper.start();
}
await BLOCK_TRACKER_MOCK.addListener.mock.calls[0]?.[1]?.(
FROM_BLOCK_HEX_MOCK,
);
return {
transactions: transactionsListener.mock.calls[0]?.[0],
lastFetchedBlockNumbers:
blockNumberListener.mock.calls[0]?.[0].lastFetchedBlockNumbers,
transactionsListener,
blockNumberListener,
};
}
describe('IncomingTransactionHelper', () => {
beforeEach(() => {
jest.resetAllMocks();
});
describe('on block tracker latest event', () => {
it('handles errors', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
});
await emitBlockTrackerLatestEvent(helper, { error: true });
});
describe('fetches remote transactions', () => {
it('using remote transaction source', async () => {
const remoteTransactionSource = createRemoteTransactionSourceMock([]);
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource,
});
await emitBlockTrackerLatestEvent(helper);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes(
1,
);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith({
address: ADDERSS_MOCK,
currentChainId: NETWORK_STATE_MOCK.providerConfig.chainId,
currentNetworkId: NETWORK_STATE_MOCK.networkId,
fromBlock: expect.any(Number),
limit: CONTROLLER_ARGS_MOCK.transactionLimit,
});
});
it('using from block as latest block minus ten if no last fetched data', async () => {
const remoteTransactionSource = createRemoteTransactionSourceMock([]);
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource,
});
await emitBlockTrackerLatestEvent(helper);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes(
1,
);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith(
expect.objectContaining({
fromBlock: FROM_BLOCK_DECIMAL_MOCK - 10,
}),
);
});
it('using from block as last fetched value plus one', async () => {
const remoteTransactionSource = createRemoteTransactionSourceMock([]);
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource,
lastFetchedBlockNumbers: {
[`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]:
FROM_BLOCK_DECIMAL_MOCK,
},
});
await emitBlockTrackerLatestEvent(helper);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledTimes(
1,
);
expect(remoteTransactionSource.fetchTransactions).toHaveBeenCalledWith(
expect.objectContaining({
fromBlock: FROM_BLOCK_DECIMAL_MOCK + 1,
}),
);
});
});
describe('emits transactions event', () => {
it('if new transaction fetched', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
});
const { transactions } = await emitBlockTrackerLatestEvent(helper);
expect(transactions).toStrictEqual({
added: [TRANSACTION_MOCK_2],
updated: [],
});
});
it('if new outgoing transaction fetched and update transactions enabled', async () => {
const outgoingTransaction = {
...TRANSACTION_MOCK_2,
txParams: {
...TRANSACTION_MOCK_2.txParams,
from: '0x1',
to: '0x2',
},
};
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
outgoingTransaction,
]),
updateTransactions: true,
});
const { transactions } = await emitBlockTrackerLatestEvent(helper);
expect(transactions).toStrictEqual({
added: [outgoingTransaction],
updated: [],
});
});
it('if existing transaction fetched with different status and update transactions enabled', async () => {
const updatedTransaction = {
...TRANSACTION_MOCK,
status: TransactionStatus.confirmed,
} as TransactionMeta;
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
updatedTransaction,
]),
getLocalTransactions: () => [TRANSACTION_MOCK],
updateTransactions: true,
});
const { transactions } = await emitBlockTrackerLatestEvent(helper);
expect(transactions).toStrictEqual({
added: [],
updated: [updatedTransaction],
});
});
it('sorted by time in ascending order', async () => {
const firstTransaction = { ...TRANSACTION_MOCK, time: 5 };
const secondTransaction = { ...TRANSACTION_MOCK, time: 6 };
const thirdTransaction = { ...TRANSACTION_MOCK, time: 7 };
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
firstTransaction,
thirdTransaction,
secondTransaction,
]),
});
const { transactions } = await emitBlockTrackerLatestEvent(helper);
expect(transactions).toStrictEqual({
added: [firstTransaction, secondTransaction, thirdTransaction],
updated: [],
});
});
it('does not if identical transaction fetched and update transactions enabled', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK,
]),
getLocalTransactions: () => [TRANSACTION_MOCK],
updateTransactions: true,
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if disabled', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK,
]),
isEnabled: jest
.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if current network is not supported by remote transaction source', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock(
[TRANSACTION_MOCK],
{ isSupportedNetwork: false },
),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if no remote transactions', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if update transactions disabled and no incoming transactions', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
{
...TRANSACTION_MOCK,
txParams: { to: '0x2' },
} as TransactionMeta,
{
...TRANSACTION_MOCK,
txParams: { to: undefined } as any,
} as TransactionMeta,
]),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if error fetching transactions', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock(
[TRANSACTION_MOCK],
{ error: true },
),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(transactionsListener).not.toHaveBeenCalled();
});
it('does not if not started', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK,
]),
});
const { transactionsListener } = await emitBlockTrackerLatestEvent(
helper,
{ start: false },
);
expect(transactionsListener).not.toHaveBeenCalled();
});
});
describe('emits updatedLastFetchedBlockNumbers event', () => {
it('if fetched transaction has higher block number', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
});
const { lastFetchedBlockNumbers } = await emitBlockTrackerLatestEvent(
helper,
);
expect(lastFetchedBlockNumbers).toStrictEqual({
[`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]:
parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10),
});
});
it('does not if no fetched transactions', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
const { blockNumberListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(blockNumberListener).not.toHaveBeenCalled();
});
it('does not if no block number on fetched transaction', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
{ ...TRANSACTION_MOCK_2, blockNumber: undefined },
]),
});
const { blockNumberListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(blockNumberListener).not.toHaveBeenCalled();
});
it('does not if fetch transaction not to current account', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
{
...TRANSACTION_MOCK_2,
txParams: { to: '0x2' },
} as TransactionMeta,
]),
});
const { blockNumberListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(blockNumberListener).not.toHaveBeenCalled();
});
it('does not if fetched transaction has same block number', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
lastFetchedBlockNumbers: {
[`${NETWORK_STATE_MOCK.providerConfig.chainId}#${ADDERSS_MOCK}`]:
parseInt(TRANSACTION_MOCK_2.blockNumber as string, 10),
},
});
const { blockNumberListener } = await emitBlockTrackerLatestEvent(
helper,
);
expect(blockNumberListener).not.toHaveBeenCalled();
});
});
});
describe('start', () => {
it('adds listener to block tracker', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
helper.start();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.addListener,
).toHaveBeenCalledTimes(1);
});
it('does nothing if already started', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
helper.start();
helper.start();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.addListener,
).toHaveBeenCalledTimes(1);
});
it('does nothing if disabled', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
isEnabled: () => false,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
helper.start();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.addListener,
).not.toHaveBeenCalled();
});
it('does nothing if network not supported by remote transaction source', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([], {
isSupportedNetwork: false,
}),
});
helper.start();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.addListener,
).not.toHaveBeenCalled();
});
});
describe('stop', () => {
it('removes listener from block tracker', async () => {
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([]),
});
helper.start();
helper.stop();
expect(
CONTROLLER_ARGS_MOCK.blockTracker.removeListener,
).toHaveBeenCalledTimes(1);
});
});
describe('update', () => {
it('emits transactions event', async () => {
const listener = jest.fn();
const helper = new IncomingTransactionHelper({
...CONTROLLER_ARGS_MOCK,
remoteTransactionSource: createRemoteTransactionSourceMock([
TRANSACTION_MOCK_2,
]),
});
helper.hub.on('transactions', listener);
await helper.update();
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({
added: [TRANSACTION_MOCK_2],
updated: [],
});
});
});
});

View File

@ -0,0 +1,282 @@
import EventEmitter from 'events';
import type { BlockTracker, NetworkState } from '@metamask/network-controller';
import type { Hex } from '@metamask/utils';
import log from 'loglevel';
import { TransactionMeta } from '../../../../shared/constants/transaction';
import { RemoteTransactionSource } from './types';
const UPDATE_CHECKS: ((txMeta: TransactionMeta) => any)[] = [
(txMeta) => txMeta.status,
];
export class IncomingTransactionHelper {
hub: EventEmitter;
#blockTracker: BlockTracker;
#getCurrentAccount: () => string;
#getLocalTransactions: () => TransactionMeta[];
#getNetworkState: () => NetworkState;
#isEnabled: () => boolean;
#isRunning: boolean;
#isUpdating: boolean;
#lastFetchedBlockNumbers: Record<string, number>;
#onLatestBlock: (blockNumberHex: Hex) => Promise<void>;
#remoteTransactionSource: RemoteTransactionSource;
#transactionLimit?: number;
#updateTransactions: boolean;
constructor({
blockTracker,
getCurrentAccount,
getLocalTransactions,
getNetworkState,
isEnabled,
lastFetchedBlockNumbers,
remoteTransactionSource,
transactionLimit,
updateTransactions,
}: {
blockTracker: BlockTracker;
getCurrentAccount: () => string;
getNetworkState: () => NetworkState;
getLocalTransactions?: () => TransactionMeta[];
isEnabled?: () => boolean;
lastFetchedBlockNumbers?: Record<string, number>;
remoteTransactionSource: RemoteTransactionSource;
transactionLimit?: number;
updateTransactions?: boolean;
}) {
this.hub = new EventEmitter();
this.#blockTracker = blockTracker;
this.#getCurrentAccount = getCurrentAccount;
this.#getLocalTransactions = getLocalTransactions || (() => []);
this.#getNetworkState = getNetworkState;
this.#isEnabled = isEnabled ?? (() => true);
this.#isRunning = false;
this.#isUpdating = false;
this.#lastFetchedBlockNumbers = lastFetchedBlockNumbers ?? {};
this.#remoteTransactionSource = remoteTransactionSource;
this.#transactionLimit = transactionLimit;
this.#updateTransactions = updateTransactions ?? false;
// Using a property instead of a method to provide a listener reference
// with the correct scope that we can remove later if stopped.
this.#onLatestBlock = async (blockNumberHex: Hex) => {
await this.update(blockNumberHex);
};
}
start() {
if (this.#isRunning) {
return;
}
if (!this.#canStart()) {
return;
}
this.#blockTracker.addListener('latest', this.#onLatestBlock);
this.#isRunning = true;
}
stop() {
this.#blockTracker.removeListener('latest', this.#onLatestBlock);
this.#isRunning = false;
}
async update(latestBlockNumberHex?: Hex): Promise<void> {
if (this.#isUpdating) {
return;
}
this.#isUpdating = true;
try {
if (!this.#canStart()) {
return;
}
const latestBlockNumber = parseInt(
latestBlockNumberHex || (await this.#blockTracker.getLatestBlock()),
16,
);
const fromBlock = this.#getFromBlock(latestBlockNumber);
const address = this.#getCurrentAccount();
const currentChainId = this.#getCurrentChainId();
const currentNetworkId = this.#getCurrentNetworkId();
let remoteTransactions = [];
try {
remoteTransactions =
await this.#remoteTransactionSource.fetchTransactions({
address,
currentChainId,
currentNetworkId,
fromBlock,
limit: this.#transactionLimit,
});
} catch (error: any) {
return;
}
if (!this.#updateTransactions) {
remoteTransactions = remoteTransactions.filter(
(tx) => tx.txParams.to?.toLowerCase() === address.toLowerCase(),
);
}
const localTransactions = this.#updateTransactions
? this.#getLocalTransactions()
: [];
const newTransactions = this.#getNewTransactions(
remoteTransactions,
localTransactions,
);
const updatedTransactions = this.#getUpdatedTransactions(
remoteTransactions,
localTransactions,
);
if (newTransactions.length > 0 || updatedTransactions.length > 0) {
this.#sortTransactionsByTime(newTransactions);
this.#sortTransactionsByTime(updatedTransactions);
this.hub.emit('transactions', {
added: newTransactions,
updated: updatedTransactions,
});
}
this.#updateLastFetchedBlockNumber(remoteTransactions);
} catch (error) {
log.error('Error while checking incoming transactions', error);
} finally {
this.#isUpdating = false;
}
}
#sortTransactionsByTime(transactions: TransactionMeta[]) {
transactions.sort((a, b) => (a.time < b.time ? -1 : 1));
}
#getNewTransactions(
remoteTxs: TransactionMeta[],
localTxs: TransactionMeta[],
): TransactionMeta[] {
return remoteTxs.filter(
(tx) => !localTxs.some(({ hash }) => hash === tx.hash),
);
}
#getUpdatedTransactions(
remoteTxs: TransactionMeta[],
localTxs: TransactionMeta[],
): TransactionMeta[] {
return remoteTxs.filter((remoteTx) =>
localTxs.some(
(localTx) =>
remoteTx.hash === localTx.hash &&
this.#isTransactionOutdated(remoteTx, localTx),
),
);
}
#isTransactionOutdated(
remoteTx: TransactionMeta,
localTx: TransactionMeta,
): boolean {
return UPDATE_CHECKS.some(
(getValue) => getValue(remoteTx) !== getValue(localTx),
);
}
#getFromBlock(latestBlockNumber: number): number {
const lastFetchedKey = this.#getBlockNumberKey();
const lastFetchedBlockNumber =
this.#lastFetchedBlockNumbers[lastFetchedKey];
if (lastFetchedBlockNumber) {
return lastFetchedBlockNumber + 1;
}
// Avoid using latest block as remote transaction source
// may not have indexed it yet
return Math.max(latestBlockNumber - 10, 0);
}
#updateLastFetchedBlockNumber(remoteTxs: TransactionMeta[]) {
let lastFetchedBlockNumber = -1;
for (const tx of remoteTxs) {
const currentBlockNumberValue = tx.blockNumber
? parseInt(tx.blockNumber, 10)
: -1;
lastFetchedBlockNumber = Math.max(
lastFetchedBlockNumber,
currentBlockNumberValue,
);
}
if (lastFetchedBlockNumber === -1) {
return;
}
const lastFetchedKey = this.#getBlockNumberKey();
const previousValue = this.#lastFetchedBlockNumbers[lastFetchedKey];
if (previousValue === lastFetchedBlockNumber) {
return;
}
this.#lastFetchedBlockNumbers[lastFetchedKey] = lastFetchedBlockNumber;
this.hub.emit('updatedLastFetchedBlockNumbers', {
lastFetchedBlockNumbers: this.#lastFetchedBlockNumbers,
blockNumber: lastFetchedBlockNumber,
});
}
#getBlockNumberKey(): string {
return `${this.#getCurrentChainId()}#${this.#getCurrentAccount().toLowerCase()}`;
}
#canStart(): boolean {
const isEnabled = this.#isEnabled();
const currentChainId = this.#getCurrentChainId();
const currentNetworkId = this.#getCurrentNetworkId();
const isSupportedNetwork = this.#remoteTransactionSource.isSupportedNetwork(
currentChainId,
currentNetworkId,
);
return isEnabled && isSupportedNetwork;
}
#getCurrentChainId(): Hex {
return this.#getNetworkState().providerConfig.chainId;
}
#getCurrentNetworkId(): string {
return this.#getNetworkState().networkId as string;
}
}

View File

@ -0,0 +1,153 @@
import { handleFetch } from '@metamask/controller-utils';
import {
CHAIN_IDS,
ETHERSCAN_SUPPORTED_NETWORKS,
} from '../../../../shared/constants/network';
import type {
EtherscanTransactionMeta,
EtherscanTransactionRequest,
EtherscanTransactionResponse,
} from './etherscan';
import * as Etherscan from './etherscan';
jest.mock('@metamask/controller-utils', () => ({
...jest.requireActual('@metamask/controller-utils'),
handleFetch: jest.fn(),
}));
const ADDERSS_MOCK = '0x2A2D72308838A6A46a0B5FDA3055FE915b5D99eD';
const REQUEST_MOCK: EtherscanTransactionRequest = {
address: ADDERSS_MOCK,
chainId: CHAIN_IDS.GOERLI,
limit: 3,
fromBlock: 2,
apiKey: 'testApiKey',
};
const RESPONSE_MOCK: EtherscanTransactionResponse<EtherscanTransactionMeta> = {
result: [
{ from: ADDERSS_MOCK, nonce: '0x1' } as EtherscanTransactionMeta,
{ from: ADDERSS_MOCK, nonce: '0x2' } as EtherscanTransactionMeta,
],
};
describe('Etherscan', () => {
const handleFetchMock = handleFetch as jest.MockedFunction<
typeof handleFetch
>;
beforeEach(() => {
jest.resetAllMocks();
});
describe.each([
['fetchEtherscanTransactions', 'txlist'],
['fetchEtherscanTokenTransactions', 'tokentx'],
])('%s', (method, action) => {
it('returns fetched response', async () => {
handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK);
const result = await (Etherscan as any)[method](REQUEST_MOCK);
expect(result).toStrictEqual(RESPONSE_MOCK);
});
it('fetches from Etherscan URL', async () => {
handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK);
await (Etherscan as any)[method](REQUEST_MOCK);
expect(handleFetchMock).toHaveBeenCalledTimes(1);
expect(handleFetchMock).toHaveBeenCalledWith(
`https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].subdomain}.${
ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].domain
}/api?` +
`module=account` +
`&address=${REQUEST_MOCK.address}` +
`&startBlock=${REQUEST_MOCK.fromBlock}` +
`&apikey=${REQUEST_MOCK.apiKey}` +
`&offset=${REQUEST_MOCK.limit}` +
`&order=desc` +
`&action=${action}` +
`&tag=latest` +
`&page=1`,
);
});
it('supports alternate networks', async () => {
handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK);
await (Etherscan as any)[method]({
...REQUEST_MOCK,
chainId: CHAIN_IDS.MAINNET,
});
expect(handleFetchMock).toHaveBeenCalledTimes(1);
expect(handleFetchMock).toHaveBeenCalledWith(
`https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.MAINNET].subdomain}.${
ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.MAINNET].domain
}/api?` +
`module=account` +
`&address=${REQUEST_MOCK.address}` +
`&startBlock=${REQUEST_MOCK.fromBlock}` +
`&apikey=${REQUEST_MOCK.apiKey}` +
`&offset=${REQUEST_MOCK.limit}` +
`&order=desc` +
`&action=${action}` +
`&tag=latest` +
`&page=1`,
);
});
it('throws if message is not ok', async () => {
handleFetchMock.mockResolvedValueOnce({
status: '0',
message: 'NOTOK',
result: 'test error',
});
await expect((Etherscan as any)[method](REQUEST_MOCK)).rejects.toThrow(
'Etherscan request failed - test error',
);
});
it('throws if chain is not supported', async () => {
const unsupportedChainId = '0x11111111111111111111';
await expect(
(Etherscan as any)[method]({
...REQUEST_MOCK,
chainId: unsupportedChainId,
}),
).rejects.toThrow(
`Etherscan does not support chain with ID: ${unsupportedChainId}`,
);
});
it('does not include empty values in fetched URL', async () => {
handleFetchMock.mockResolvedValueOnce(RESPONSE_MOCK);
await (Etherscan as any)[method]({
...REQUEST_MOCK,
fromBlock: undefined,
apiKey: undefined,
});
expect(handleFetchMock).toHaveBeenCalledTimes(1);
expect(handleFetchMock).toHaveBeenCalledWith(
`https://${ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].subdomain}.${
ETHERSCAN_SUPPORTED_NETWORKS[CHAIN_IDS.GOERLI].domain
}/api?` +
`module=account` +
`&address=${REQUEST_MOCK.address}` +
`&offset=${REQUEST_MOCK.limit}` +
`&order=desc` +
`&action=${action}` +
`&tag=latest` +
`&page=1`,
);
});
});
});

View File

@ -0,0 +1,205 @@
import { handleFetch } from '@metamask/controller-utils';
import { Hex } from '@metamask/utils';
import { ETHERSCAN_SUPPORTED_NETWORKS } from '../../../../shared/constants/network';
export interface EtherscanTransactionMetaBase {
blockNumber: string;
blockHash: string;
confirmations: string;
contractAddress: string;
cumulativeGasUsed: string;
from: string;
gas: string;
gasPrice: string;
gasUsed: string;
hash: string;
nonce: string;
timeStamp: string;
to: string;
transactionIndex: string;
value: string;
}
export interface EtherscanTransactionMeta extends EtherscanTransactionMetaBase {
functionName: string;
input: string;
isError: string;
methodId: string;
txreceipt_status: string;
}
export interface EtherscanTokenTransactionMeta
extends EtherscanTransactionMetaBase {
tokenDecimal: string;
tokenName: string;
tokenSymbol: string;
}
export interface EtherscanTransactionResponse<
T extends EtherscanTransactionMetaBase,
> {
result: T[];
}
export interface EtherscanTransactionRequest {
address: string;
apiKey?: string;
chainId: Hex;
fromBlock?: number;
limit?: number;
}
interface RawEtherscanResponse<T extends EtherscanTransactionMetaBase> {
status: '0' | '1';
message: string;
result: string | T[];
}
/**
* Retrieves transaction data from Etherscan.
*
* @param request - Configuration required to fetch transactions.
* @param request.address - Address to retrieve transactions for.
* @param request.apiKey - Etherscan API key.
* @param request.chainId - Current chain ID used to determine subdomain and domain.
* @param request.fromBlock - Block number to start fetching transactions from.
* @param request.limit - Number of transactions to retrieve.
* @returns An Etherscan response object containing the request status and an array of token transaction data.
*/
export async function fetchEtherscanTransactions({
address,
apiKey,
chainId,
fromBlock,
limit,
}: EtherscanTransactionRequest): Promise<
EtherscanTransactionResponse<EtherscanTransactionMeta>
> {
return await fetchTransactions('txlist', {
address,
apiKey,
chainId,
fromBlock,
limit,
});
}
/**
* Retrieves token transaction data from Etherscan.
*
* @param request - Configuration required to fetch token transactions.
* @param request.address - Address to retrieve token transactions for.
* @param request.apiKey - Etherscan API key.
* @param request.chainId - Current chain ID used to determine subdomain and domain.
* @param request.fromBlock - Block number to start fetching token transactions from.
* @param request.limit - Number of token transactions to retrieve.
* @returns An Etherscan response object containing the request status and an array of token transaction data.
*/
export async function fetchEtherscanTokenTransactions({
address,
apiKey,
chainId,
fromBlock,
limit,
}: EtherscanTransactionRequest): Promise<
EtherscanTransactionResponse<EtherscanTokenTransactionMeta>
> {
return await fetchTransactions('tokentx', {
address,
apiKey,
chainId,
fromBlock,
limit,
});
}
/**
* Retrieves transaction data from Etherscan from a specific endpoint.
*
* @param action - The Etherscan endpoint to use.
* @param options - Options bag.
* @param options.address - Address to retrieve transactions for.
* @param options.apiKey - Etherscan API key.
* @param options.chainId - Current chain ID used to determine subdomain and domain.
* @param options.fromBlock - Block number to start fetching transactions from.
* @param options.limit - Number of transactions to retrieve.
* @returns An object containing the request status and an array of transaction data.
*/
async function fetchTransactions<T extends EtherscanTransactionMetaBase>(
action: string,
{
address,
apiKey,
chainId,
fromBlock,
limit,
}: {
address: string;
apiKey?: string;
chainId: Hex;
fromBlock?: number;
limit?: number;
},
): Promise<EtherscanTransactionResponse<T>> {
const urlParams = {
module: 'account',
address,
startBlock: fromBlock?.toString(),
apikey: apiKey,
offset: limit?.toString(),
order: 'desc',
};
const etherscanTxUrl = getEtherscanApiUrl(chainId, {
...urlParams,
action,
});
const response = (await handleFetch(
etherscanTxUrl,
)) as RawEtherscanResponse<T>;
if (response.status === '0' && response.message === 'NOTOK') {
throw new Error(`Etherscan request failed - ${response.result}`);
}
return { result: response.result as T[] };
}
/**
* Return a URL that can be used to fetch data from Etherscan.
*
* @param chainId - Current chain ID used to determine subdomain and domain.
* @param urlParams - The parameters used to construct the URL.
* @returns URL to access Etherscan data.
*/
function getEtherscanApiUrl(
chainId: Hex,
urlParams: Record<string, string | undefined>,
): string {
type SupportedChainId = keyof typeof ETHERSCAN_SUPPORTED_NETWORKS;
const networkInfo = ETHERSCAN_SUPPORTED_NETWORKS[chainId as SupportedChainId];
if (!networkInfo) {
throw new Error(`Etherscan does not support chain with ID: ${chainId}`);
}
const apiUrl = `https://${networkInfo.subdomain}.${networkInfo.domain}`;
let url = `${apiUrl}/api?`;
// eslint-disable-next-line guard-for-in
for (const paramKey in urlParams) {
const value = urlParams[paramKey];
if (!value) {
continue;
}
url += `${paramKey}=${value}&`;
}
url += 'tag=latest&page=1';
return url;
}

View File

@ -72,6 +72,8 @@ import TransactionStateManager from './tx-state-manager';
import TxGasUtil from './tx-gas-utils'; import TxGasUtil from './tx-gas-utils';
import PendingTransactionTracker from './pending-tx-tracker'; import PendingTransactionTracker from './pending-tx-tracker';
import * as txUtils from './lib/util'; import * as txUtils from './lib/util';
import { IncomingTransactionHelper } from './IncomingTransactionHelper';
import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource';
const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory
const UPDATE_POST_TX_BALANCE_TIMEOUT = 5000; const UPDATE_POST_TX_BALANCE_TIMEOUT = 5000;
@ -127,6 +129,7 @@ const METRICS_STATUS_FAILED = 'failed on-chain';
* @param {object} opts.initState - initial transaction list default is an empty array * @param {object} opts.initState - initial transaction list default is an empty array
* @param {Function} opts.getNetworkId - Get the current network ID. * @param {Function} opts.getNetworkId - Get the current network ID.
* @param {Function} opts.getNetworkStatus - Get the current network status. * @param {Function} opts.getNetworkStatus - Get the current network status.
* @param {Function} opts.getNetworkState - Get the network state.
* @param {Function} opts.onNetworkStateChange - Subscribe to network state change events. * @param {Function} opts.onNetworkStateChange - Subscribe to network state change events.
* @param {object} opts.blockTracker - An instance of eth-blocktracker * @param {object} opts.blockTracker - An instance of eth-blocktracker
* @param {object} opts.provider - A network provider. * @param {object} opts.provider - A network provider.
@ -134,6 +137,7 @@ const METRICS_STATUS_FAILED = 'failed on-chain';
* @param {object} opts.getPermittedAccounts - get accounts that an origin has permissions for * @param {object} opts.getPermittedAccounts - get accounts that an origin has permissions for
* @param {Function} opts.signTransaction - ethTx signer that returns a rawTx * @param {Function} opts.signTransaction - ethTx signer that returns a rawTx
* @param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state * @param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state
* @param {Function} opts.hasCompletedOnboarding - Returns whether or not the user has completed the onboarding flow
* @param {object} opts.preferencesStore * @param {object} opts.preferencesStore
*/ */
@ -142,6 +146,7 @@ export default class TransactionController extends EventEmitter {
super(); super();
this.getNetworkId = opts.getNetworkId; this.getNetworkId = opts.getNetworkId;
this.getNetworkStatus = opts.getNetworkStatus; this.getNetworkStatus = opts.getNetworkStatus;
this._getNetworkState = opts.getNetworkState;
this._getCurrentChainId = opts.getCurrentChainId; this._getCurrentChainId = opts.getCurrentChainId;
this.getProviderConfig = opts.getProviderConfig; this.getProviderConfig = opts.getProviderConfig;
this._getCurrentNetworkEIP1559Compatibility = this._getCurrentNetworkEIP1559Compatibility =
@ -166,6 +171,7 @@ export default class TransactionController extends EventEmitter {
this.getTokenStandardAndDetails = opts.getTokenStandardAndDetails; this.getTokenStandardAndDetails = opts.getTokenStandardAndDetails;
this.securityProviderRequest = opts.securityProviderRequest; this.securityProviderRequest = opts.securityProviderRequest;
this.messagingSystem = opts.messenger; this.messagingSystem = opts.messenger;
this._hasCompletedOnboarding = opts.hasCompletedOnboarding;
this.memStore = new ObservableStore({}); this.memStore = new ObservableStore({});
@ -216,6 +222,32 @@ export default class TransactionController extends EventEmitter {
this.txStateManager.getConfirmedTransactions.bind(this.txStateManager), this.txStateManager.getConfirmedTransactions.bind(this.txStateManager),
}); });
this.incomingTransactionHelper = new IncomingTransactionHelper({
blockTracker: this.blockTracker,
getCurrentAccount: () => this.getSelectedAddress(),
getNetworkState: () => this._getNetworkState(),
isEnabled: () =>
Boolean(
this.preferencesStore.getState().featureFlags
?.showIncomingTransactions && this._hasCompletedOnboarding(),
),
lastFetchedBlockNumbers: opts.initState?.lastFetchedBlockNumbers || {},
remoteTransactionSource: new EtherscanRemoteTransactionSource({
includeTokenTransfers: false,
}),
updateTransactions: false,
});
this.incomingTransactionHelper.hub.on(
'transactions',
this._onIncomingTransactions.bind(this),
);
this.incomingTransactionHelper.hub.on(
'updatedLastFetchedBlockNumbers',
this._onUpdatedLastFetchedBlockNumbers.bind(this),
);
this.txStateManager.store.subscribe(() => this.txStateManager.store.subscribe(() =>
this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE), this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE),
); );
@ -759,6 +791,18 @@ export default class TransactionController extends EventEmitter {
); );
} }
startIncomingTransactionPolling() {
this.incomingTransactionHelper.start();
}
stopIncomingTransactionPolling() {
this.incomingTransactionHelper.stop();
}
async updateIncomingTransactions() {
await this.incomingTransactionHelper.update();
}
// //
// PRIVATE METHODS // PRIVATE METHODS
// //
@ -2086,11 +2130,18 @@ export default class TransactionController extends EventEmitter {
* Updates the memStore in transaction controller * Updates the memStore in transaction controller
*/ */
_updateMemstore() { _updateMemstore() {
const { transactions } = this.store.getState();
const unapprovedTxs = this.txStateManager.getUnapprovedTxList(); const unapprovedTxs = this.txStateManager.getUnapprovedTxList();
const currentNetworkTxList = this.txStateManager.getTransactions({ const currentNetworkTxList = this.txStateManager.getTransactions({
limit: MAX_MEMSTORE_TX_LIST_SIZE, limit: MAX_MEMSTORE_TX_LIST_SIZE,
}); });
this.memStore.updateState({ unapprovedTxs, currentNetworkTxList });
this.memStore.updateState({
unapprovedTxs,
currentNetworkTxList,
transactions,
});
} }
_calculateTransactionsCost(txMeta, approvalTxMeta) { _calculateTransactionsCost(txMeta, approvalTxMeta) {
@ -2734,6 +2785,34 @@ export default class TransactionController extends EventEmitter {
); );
} }
_onIncomingTransactions({ added: transactions }) {
log.debug('Detected new incoming transactions', transactions);
const currentTransactions = this.store.getState().transactions || {};
const incomingTransactions = transactions
.filter((tx) => !this._hasTransactionHash(tx.hash, currentTransactions))
.reduce((result, tx) => {
result[tx.id] = tx;
return result;
}, {});
const updatedTransactions = {
...currentTransactions,
...incomingTransactions,
};
this.store.updateState({ transactions: updatedTransactions });
}
_onUpdatedLastFetchedBlockNumbers({ lastFetchedBlockNumbers }) {
this.store.updateState({ lastFetchedBlockNumbers });
}
_hasTransactionHash(hash, transactions) {
return Object.values(transactions).some((tx) => tx.hash === hash);
}
// Approvals // Approvals
async _requestTransactionApproval( async _requestTransactionApproval(

View File

@ -38,6 +38,7 @@ import { ORIGIN_METAMASK } from '../../../../shared/constants/app';
import { NetworkStatus } from '../../../../shared/constants/network'; import { NetworkStatus } from '../../../../shared/constants/network';
import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils'; import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils';
import TxGasUtil from './tx-gas-utils'; import TxGasUtil from './tx-gas-utils';
import * as IncomingTransactionHelperClass from './IncomingTransactionHelper';
import TransactionController from '.'; import TransactionController from '.';
const noop = () => true; const noop = () => true;
@ -51,6 +52,16 @@ const actionId = 'DUMMY_ACTION_ID';
const VALID_ADDRESS = '0x0000000000000000000000000000000000000000'; const VALID_ADDRESS = '0x0000000000000000000000000000000000000000';
const VALID_ADDRESS_TWO = '0x0000000000000000000000000000000000000001'; const VALID_ADDRESS_TWO = '0x0000000000000000000000000000000000000001';
const TRANSACTION_META_MOCK = {
hash: '0x1',
id: 1,
status: TransactionStatus.confirmed,
transaction: {
from: VALID_ADDRESS,
},
time: 123456789,
};
async function flushPromises() { async function flushPromises() {
await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve));
} }
@ -65,7 +76,9 @@ describe('Transaction Controller', function () {
getCurrentChainId, getCurrentChainId,
messengerMock, messengerMock,
resultCallbacksMock, resultCallbacksMock,
updateSpy; updateSpy,
incomingTransactionHelperClassMock,
incomingTransactionHelperEventMock;
beforeEach(function () { beforeEach(function () {
fragmentExists = false; fragmentExists = false;
@ -101,6 +114,16 @@ describe('Transaction Controller', function () {
call: sinon.stub(), call: sinon.stub(),
}; };
incomingTransactionHelperEventMock = sinon.spy();
incomingTransactionHelperClassMock = sinon
.stub(IncomingTransactionHelperClass, 'IncomingTransactionHelper')
.returns({
hub: {
on: incomingTransactionHelperEventMock,
},
});
txController = new TransactionController({ txController = new TransactionController({
provider, provider,
getGasPrice() { getGasPrice() {
@ -148,6 +171,10 @@ describe('Transaction Controller', function () {
); );
}); });
afterEach(function () {
incomingTransactionHelperClassMock.restore();
});
function getLastTxMeta() { function getLastTxMeta() {
return updateSpy.lastCall.args[0]; return updateSpy.lastCall.args[0];
} }
@ -3374,4 +3401,78 @@ describe('Transaction Controller', function () {
assert.deepEqual(transaction1, transaction2); assert.deepEqual(transaction1, transaction2);
}); });
}); });
describe('on incoming transaction helper transactions event', function () {
it('adds new transactions to state', async function () {
const existingTransaction = TRANSACTION_META_MOCK;
const incomingTransaction1 = {
...TRANSACTION_META_MOCK,
id: 2,
hash: '0x2',
};
const incomingTransaction2 = {
...TRANSACTION_META_MOCK,
id: 3,
hash: '0x3',
};
txController.store.getState().transactions = {
[existingTransaction.id]: existingTransaction,
};
await incomingTransactionHelperEventMock.firstCall.args[1]({
added: [incomingTransaction1, incomingTransaction2],
updated: [],
});
assert.deepEqual(txController.store.getState().transactions, {
[existingTransaction.id]: existingTransaction,
[incomingTransaction1.id]: incomingTransaction1,
[incomingTransaction2.id]: incomingTransaction2,
});
});
it('ignores new transactions if hash matches existing transaction', async function () {
const existingTransaction = TRANSACTION_META_MOCK;
const incomingTransaction1 = { ...TRANSACTION_META_MOCK, id: 2 };
const incomingTransaction2 = { ...TRANSACTION_META_MOCK, id: 3 };
txController.store.getState().transactions = {
[existingTransaction.id]: existingTransaction,
};
await incomingTransactionHelperEventMock.firstCall.args[1]({
added: [incomingTransaction1, incomingTransaction2],
updated: [],
});
assert.deepEqual(txController.store.getState().transactions, {
[existingTransaction.id]: existingTransaction,
});
});
});
describe('on incoming transaction helper updatedLastFetchedBlockNumbers event', function () {
it('updates state', async function () {
const lastFetchedBlockNumbers = {
key: 234,
};
assert.deepEqual(
txController.store.getState().lastFetchedBlockNumbers,
undefined,
);
await incomingTransactionHelperEventMock.secondCall.args[1]({
lastFetchedBlockNumbers,
});
assert.deepEqual(
txController.store.getState().lastFetchedBlockNumbers,
lastFetchedBlockNumbers,
);
});
});
}); });

View File

@ -0,0 +1,49 @@
import { Hex } from '@metamask/utils';
import { TransactionMeta } from '../../../../shared/constants/transaction';
/**
* The configuration required to fetch transaction data from a RemoteTransactionSource.
*/
export interface RemoteTransactionSourceRequest {
/**
* The address of the account to fetch transactions for.
*/
address: string;
/**
* API key if required by the remote source.
*/
apiKey?: string;
/**
* The chainId of the current network.
*/
currentChainId: Hex;
/**
* The networkId of the current network.
*/
currentNetworkId: string;
/**
* Block number to start fetching transactions from.
*/
fromBlock?: number;
/**
* Maximum number of transactions to retrieve.
*/
limit?: number;
}
/**
* An object capable of fetching transaction data from a remote source.
* Used by the IncomingTransactionHelper to retrieve remote transaction data.
*/
export interface RemoteTransactionSource {
isSupportedNetwork: (chainId: Hex, networkId: string) => boolean;
fetchTransactions: (
request: RemoteTransactionSourceRequest,
) => Promise<TransactionMeta[]>;
}

View File

@ -58,9 +58,6 @@ export const SENTRY_BACKGROUND_STATE = {
EncryptionPublicKeyController: { EncryptionPublicKeyController: {
unapprovedEncryptionPublicKeyMsgCount: true, unapprovedEncryptionPublicKeyMsgCount: true,
}, },
IncomingTransactionsController: {
incomingTxLastFetchedBlockByChainId: true,
},
KeyringController: { KeyringController: {
isUnlocked: true, isUnlocked: true,
}, },

View File

@ -190,7 +190,6 @@ import CachedBalancesController from './controllers/cached-balances';
import AlertController from './controllers/alert'; import AlertController from './controllers/alert';
import OnboardingController from './controllers/onboarding'; import OnboardingController from './controllers/onboarding';
import Backup from './lib/backup'; import Backup from './lib/backup';
import IncomingTransactionsController from './controllers/incoming-transactions';
import DecryptMessageController from './controllers/decrypt-message'; import DecryptMessageController from './controllers/decrypt-message';
import TransactionController from './controllers/transactions'; import TransactionController from './controllers/transactions';
import DetectTokensController from './controllers/detect-tokens'; import DetectTokensController from './controllers/detect-tokens';
@ -418,10 +417,6 @@ export default class MetamaskController extends EventEmitter {
provider: this.provider, provider: this.provider,
}); });
this.preferencesController.store.subscribe(async ({ currentLocale }) => {
await updateCurrentLocale(currentLocale);
});
const tokensControllerMessenger = this.controllerMessenger.getRestricted({ const tokensControllerMessenger = this.controllerMessenger.getRestricted({
name: 'TokensController', name: 'TokensController',
allowedActions: ['ApprovalController:addRequest'], allowedActions: ['ApprovalController:addRequest'],
@ -744,19 +739,6 @@ export default class MetamaskController extends EventEmitter {
initState: initState.OnboardingController, initState: initState.OnboardingController,
}); });
this.incomingTransactionsController = new IncomingTransactionsController({
blockTracker: this.blockTracker,
onNetworkDidChange: networkControllerMessenger.subscribe.bind(
networkControllerMessenger,
'NetworkController:networkDidChange',
),
getCurrentChainId: () =>
this.networkController.state.providerConfig.chainId,
preferencesController: this.preferencesController,
onboardingController: this.onboardingController,
initState: initState.IncomingTransactionsController,
});
// account tracker watches balances, nonces, and any code at their address // account tracker watches balances, nonces, and any code at their address
this.accountTracker = new AccountTracker({ this.accountTracker = new AccountTracker({
provider: this.provider, provider: this.provider,
@ -1192,6 +1174,9 @@ export default class MetamaskController extends EventEmitter {
this.networkController.state.networksMetadata?.[ this.networkController.state.networksMetadata?.[
this.networkController.state.selectedNetworkClientId this.networkController.state.selectedNetworkClientId
]?.status, ]?.status,
getNetworkState: () => this.networkController.state,
hasCompletedOnboarding: () =>
this.onboardingController.store.getState().completedOnboarding,
onNetworkStateChange: (listener) => { onNetworkStateChange: (listener) => {
networkControllerMessenger.subscribe( networkControllerMessenger.subscribe(
'NetworkController:stateChange', 'NetworkController:stateChange',
@ -1660,7 +1645,6 @@ export default class MetamaskController extends EventEmitter {
CachedBalancesController: this.cachedBalancesController.store, CachedBalancesController: this.cachedBalancesController.store,
AlertController: this.alertController.store, AlertController: this.alertController.store,
OnboardingController: this.onboardingController.store, OnboardingController: this.onboardingController.store,
IncomingTransactionsController: this.incomingTransactionsController.store,
PermissionController: this.permissionController, PermissionController: this.permissionController,
PermissionLogController: this.permissionLogController.store, PermissionLogController: this.permissionLogController.store,
SubjectMetadataController: this.subjectMetadataController, SubjectMetadataController: this.subjectMetadataController,
@ -1706,8 +1690,6 @@ export default class MetamaskController extends EventEmitter {
CurrencyController: this.currencyRateController, CurrencyController: this.currencyRateController,
AlertController: this.alertController.store, AlertController: this.alertController.store,
OnboardingController: this.onboardingController.store, OnboardingController: this.onboardingController.store,
IncomingTransactionsController:
this.incomingTransactionsController.store,
PermissionController: this.permissionController, PermissionController: this.permissionController,
PermissionLogController: this.permissionLogController.store, PermissionLogController: this.permissionLogController.store,
SubjectMetadataController: this.subjectMetadataController, SubjectMetadataController: this.subjectMetadataController,
@ -1803,7 +1785,7 @@ export default class MetamaskController extends EventEmitter {
triggerNetworkrequests() { triggerNetworkrequests() {
this.accountTracker.start(); this.accountTracker.start();
this.incomingTransactionsController.start(); this.txController.startIncomingTransactionPolling();
if (this.preferencesController.store.getState().useCurrencyRateCheck) { if (this.preferencesController.store.getState().useCurrencyRateCheck) {
this.currencyRateController.start(); this.currencyRateController.start();
} }
@ -1814,7 +1796,7 @@ export default class MetamaskController extends EventEmitter {
stopNetworkRequests() { stopNetworkRequests() {
this.accountTracker.stop(); this.accountTracker.stop();
this.incomingTransactionsController.stop(); this.txController.stopIncomingTransactionPolling();
if (this.preferencesController.store.getState().useCurrencyRateCheck) { if (this.preferencesController.store.getState().useCurrencyRateCheck) {
this.currencyRateController.stop(); this.currencyRateController.stop();
} }
@ -1991,40 +1973,22 @@ export default class MetamaskController extends EventEmitter {
* becomes unlocked are handled in MetaMaskController._onUnlock. * becomes unlocked are handled in MetaMaskController._onUnlock.
*/ */
setupControllerEventSubscriptions() { setupControllerEventSubscriptions() {
const handleAccountsChange = async (origin, newAccounts) => { let lastSelectedAddress;
if (this.isUnlocked()) {
this.notifyConnections(origin, { this.preferencesController.store.subscribe(async (state) => {
method: NOTIFICATION_NAMES.accountsChanged, const { selectedAddress, currentLocale } = state;
// This should be the same as the return value of `eth_accounts`,
// namely an array of the current / most recently selected Ethereum await updateCurrentLocale(currentLocale);
// account.
params: if (state?.featureFlags?.showIncomingTransactions) {
newAccounts.length < 2 this.txController.startIncomingTransactionPolling();
? // If the length is 1 or 0, the accounts are sorted by definition. } else {
newAccounts this.txController.stopIncomingTransactionPolling();
: // If the length is 2 or greater, we have to execute
// `eth_accounts` vi this method.
await this.getPermittedAccounts(origin),
});
} }
this.permissionLogController.updateAccountsHistory(origin, newAccounts);
};
// This handles account changes whenever the selected address changes.
let lastSelectedAddress;
this.preferencesController.store.subscribe(async ({ selectedAddress }) => {
if (selectedAddress && selectedAddress !== lastSelectedAddress) { if (selectedAddress && selectedAddress !== lastSelectedAddress) {
lastSelectedAddress = selectedAddress; lastSelectedAddress = selectedAddress;
const permittedAccountsMap = getPermittedAccountsByOrigin( await this._onAccountChange(selectedAddress);
this.permissionController.state,
);
for (const [origin, accounts] of permittedAccountsMap.entries()) {
if (accounts.includes(selectedAddress)) {
handleAccountsChange(origin, accounts);
}
}
} }
}); });
@ -2036,12 +2000,19 @@ export default class MetamaskController extends EventEmitter {
const changedAccounts = getChangedAccounts(currentValue, previousValue); const changedAccounts = getChangedAccounts(currentValue, previousValue);
for (const [origin, accounts] of changedAccounts.entries()) { for (const [origin, accounts] of changedAccounts.entries()) {
handleAccountsChange(origin, accounts); this._notifyAccountsChange(origin, accounts);
} }
}, },
getPermittedAccountsByOrigin, getPermittedAccountsByOrigin,
); );
this.controllerMessenger.subscribe(
'NetworkController:networkDidChange',
async () => {
await this.txController.updateIncomingTransactions();
},
);
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
// Record Snap metadata whenever a Snap is added to state. // Record Snap metadata whenever a Snap is added to state.
this.controllerMessenger.subscribe( this.controllerMessenger.subscribe(
@ -4819,4 +4790,38 @@ export default class MetamaskController extends EventEmitter {
return null; return null;
} }
async _onAccountChange(newAddress) {
const permittedAccountsMap = getPermittedAccountsByOrigin(
this.permissionController.state,
);
for (const [origin, accounts] of permittedAccountsMap.entries()) {
if (accounts.includes(newAddress)) {
this._notifyAccountsChange(origin, accounts);
}
}
await this.txController.updateIncomingTransactions();
}
async _notifyAccountsChange(origin, newAccounts) {
if (this.isUnlocked()) {
this.notifyConnections(origin, {
method: NOTIFICATION_NAMES.accountsChanged,
// This should be the same as the return value of `eth_accounts`,
// namely an array of the current / most recently selected Ethereum
// account.
params:
newAccounts.length < 2
? // If the length is 1 or 0, the accounts are sorted by definition.
newAccounts
: // If the length is 2 or greater, we have to execute
// `eth_accounts` vi this method.
await this.getPermittedAccounts(origin),
});
}
this.permissionLogController.updateAccountsHistory(origin, newAccounts);
}
} }

View File

@ -16,6 +16,7 @@ import {
METAMASK_HOTLIST_DIFF_FILE, METAMASK_HOTLIST_DIFF_FILE,
} from '@metamask/phishing-controller'; } from '@metamask/phishing-controller';
import { NetworkType } from '@metamask/controller-utils'; import { NetworkType } from '@metamask/controller-utils';
import { ControllerMessenger } from '@metamask/base-controller';
import { TransactionStatus } from '../../shared/constants/transaction'; import { TransactionStatus } from '../../shared/constants/transaction';
import createTxMeta from '../../test/lib/createTxMeta'; import createTxMeta from '../../test/lib/createTxMeta';
import { NETWORK_TYPES } from '../../shared/constants/network'; import { NETWORK_TYPES } from '../../shared/constants/network';
@ -23,6 +24,8 @@ import { createTestProviderTools } from '../../test/stub/provider';
import { HardwareDeviceNames } from '../../shared/constants/hardware-wallets'; import { HardwareDeviceNames } from '../../shared/constants/hardware-wallets';
import { KeyringType } from '../../shared/constants/keyring'; import { KeyringType } from '../../shared/constants/keyring';
import { deferredPromise } from './lib/util'; import { deferredPromise } from './lib/util';
import TransactionController from './controllers/transactions';
import PreferencesController from './controllers/preferences';
const Ganache = require('../../test/e2e/ganache'); const Ganache = require('../../test/e2e/ganache');
@ -83,6 +86,14 @@ function MockEthContract() {
}; };
} }
function MockPreferencesController(...args) {
const controller = new PreferencesController(...args);
sinon.stub(controller.store, 'subscribe');
return controller;
}
// TODO, Feb 24, 2023: // TODO, Feb 24, 2023:
// ethjs-contract is being added to proxyquire, but we might want to discontinue proxyquire // ethjs-contract is being added to proxyquire, but we might want to discontinue proxyquire
// this is for expediency as we resolve a bug for v10.26.0. The proper solution here would have // this is for expediency as we resolve a bug for v10.26.0. The proper solution here would have
@ -91,6 +102,7 @@ function MockEthContract() {
const MetaMaskController = proxyquire('./metamask-controller', { const MetaMaskController = proxyquire('./metamask-controller', {
'./lib/createLoggerMiddleware': { default: createLoggerMiddlewareMock }, './lib/createLoggerMiddleware': { default: createLoggerMiddlewareMock },
'ethjs-contract': MockEthContract, 'ethjs-contract': MockEthContract,
'./controllers/preferences': { default: MockPreferencesController },
}).default; }).default;
const MetaMaskControllerMV3 = proxyquire('./metamask-controller', { const MetaMaskControllerMV3 = proxyquire('./metamask-controller', {
@ -279,6 +291,23 @@ describe('MetaMaskController', function () {
beforeEach(function () { beforeEach(function () {
sandbox.spy(MetaMaskController.prototype, 'resetStates'); sandbox.spy(MetaMaskController.prototype, 'resetStates');
sandbox.stub(
TransactionController.prototype,
'updateIncomingTransactions',
);
sandbox.stub(
TransactionController.prototype,
'startIncomingTransactionPolling',
);
sandbox.stub(
TransactionController.prototype,
'stopIncomingTransactionPolling',
);
sandbox.spy(ControllerMessenger.prototype, 'subscribe');
metamaskController = new MetaMaskController({ metamaskController = new MetaMaskController({
showUserConfirmation: noop, showUserConfirmation: noop,
encryptor: { encryptor: {
@ -1647,6 +1676,60 @@ describe('MetaMaskController', function () {
}); });
}); });
}); });
describe('incoming transactions', function () {
let txControllerStub, preferencesControllerSpy, controllerMessengerSpy;
beforeEach(function () {
txControllerStub = TransactionController.prototype;
preferencesControllerSpy = metamaskController.preferencesController;
controllerMessengerSpy = ControllerMessenger.prototype;
});
it('starts incoming transaction polling if show incoming transactions enabled', async function () {
assert(txControllerStub.startIncomingTransactionPolling.notCalled);
await preferencesControllerSpy.store.subscribe.lastCall.args[0]({
featureFlags: {
showIncomingTransactions: true,
},
});
assert(txControllerStub.startIncomingTransactionPolling.calledOnce);
});
it('stops incoming transaction polling if show incoming transactions disabled', async function () {
assert(txControllerStub.stopIncomingTransactionPolling.notCalled);
await preferencesControllerSpy.store.subscribe.lastCall.args[0]({
featureFlags: {
showIncomingTransactions: false,
},
});
assert(txControllerStub.stopIncomingTransactionPolling.calledOnce);
});
it('updates incoming transactions when changing account', async function () {
assert(txControllerStub.updateIncomingTransactions.notCalled);
await preferencesControllerSpy.store.subscribe.lastCall.args[0]({
selectedAddress: 'foo',
});
assert(txControllerStub.updateIncomingTransactions.calledOnce);
});
it('updates incoming transactions when changing network', async function () {
assert(txControllerStub.updateIncomingTransactions.notCalled);
await controllerMessengerSpy.subscribe.args
.filter((args) => args[0] === 'NetworkController:networkDidChange')
.slice(-1)[0][1]();
assert(txControllerStub.updateIncomingTransactions.calledOnce);
});
});
}); });
describe('MV3 Specific behaviour', function () { describe('MV3 Specific behaviour', function () {

View File

@ -0,0 +1,364 @@
import { migrate } from './095';
const INCOMING_TRANSACTION_MOCK = {
blockNumber: '1',
chainId: '0x539',
hash: '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab',
id: 1,
metamaskNetworkId: '1337',
status: 'confirmed',
time: 1671635520000,
txParams: {
from: '0xc87261ba337be737fa744f50e7aaf4a920bdfcd6',
gas: '0x5208',
gasPrice: '0x329af9707',
to: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1',
value: '0xDE0B6B3A7640000',
},
type: 'incoming',
};
const INCOMING_TRANSACTION_2_MOCK = {
...INCOMING_TRANSACTION_MOCK,
blockNumber: '2',
id: 2,
chainId: '0x540',
txParams: {
...INCOMING_TRANSACTION_MOCK.txParams,
to: '0x2',
},
};
const TRANSACTION_MOCK = {
...INCOMING_TRANSACTION_MOCK,
blockNumber: '3',
id: 3,
type: 'contractInteraction',
};
describe('migration #95', () => {
it('updates the version metadata', async () => {
const oldStorage = {
meta: { version: 94 },
data: {},
};
const newStorage = await migrate(oldStorage);
expect(newStorage.meta).toStrictEqual({ version: 95 });
});
it('does nothing if no IncomingTransactionsController state', async () => {
const oldData = {
some: 'data',
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual(oldData);
});
it('removes IncomingTransactionsController state', async () => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
},
incomingTxLastFetchedBlockByChainId: {
'0x5': 1234,
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: expect.any(Object),
});
});
describe('moves incoming transactions', () => {
it('if no TransactionController state', async () => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK,
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: {
transactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK,
},
lastFetchedBlockNumbers: expect.any(Object),
},
});
});
it('if existing TransactionController state', async () => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK,
},
},
TransactionController: {
transactions: {
[TRANSACTION_MOCK.id]: TRANSACTION_MOCK,
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: {
transactions: {
...oldData.TransactionController.transactions,
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK,
},
lastFetchedBlockNumbers: expect.any(Object),
},
});
});
it.each([
['undefined', undefined],
['empty', {}],
])(
'does nothing if incoming transactions %s',
async (_title, incomingTransactions) => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions,
},
TransactionController: {
transactions: {
[TRANSACTION_MOCK.id]: TRANSACTION_MOCK,
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: oldData.TransactionController,
});
},
);
});
describe('generates last fetched block numbers', () => {
it('if incoming transactions have chain ID, block number, and to address', async () => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: INCOMING_TRANSACTION_2_MOCK,
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: {
transactions: expect.any(Object),
lastFetchedBlockNumbers: {
[`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]:
parseInt(INCOMING_TRANSACTION_MOCK.blockNumber, 10),
[`${INCOMING_TRANSACTION_2_MOCK.chainId}#${INCOMING_TRANSACTION_2_MOCK.txParams.to}`]:
parseInt(INCOMING_TRANSACTION_2_MOCK.blockNumber, 10),
},
},
});
});
it('using highest block number for each chain ID and to address', async () => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: {
...INCOMING_TRANSACTION_2_MOCK,
chainId: INCOMING_TRANSACTION_MOCK.chainId,
txParams: {
...INCOMING_TRANSACTION_2_MOCK.txParams,
to: INCOMING_TRANSACTION_MOCK.txParams.to,
},
},
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: {
transactions: expect.any(Object),
lastFetchedBlockNumbers: {
[`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]:
parseInt(INCOMING_TRANSACTION_2_MOCK.blockNumber, 10),
},
},
});
});
it('ignoring incoming transactions with no chain ID', async () => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: {
...INCOMING_TRANSACTION_2_MOCK,
chainId: undefined,
},
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: {
transactions: expect.any(Object),
lastFetchedBlockNumbers: {
[`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]:
parseInt(INCOMING_TRANSACTION_MOCK.blockNumber, 10),
},
},
});
});
it('ignoring incoming transactions with no block number', async () => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: {
...INCOMING_TRANSACTION_2_MOCK,
blockNumber: undefined,
},
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: {
transactions: expect.any(Object),
lastFetchedBlockNumbers: {
[`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]:
parseInt(INCOMING_TRANSACTION_MOCK.blockNumber, 10),
},
},
});
});
it('ignoring incoming transactions with no to address', async () => {
const oldData = {
some: 'data',
IncomingTransactionsController: {
incomingTransactions: {
[INCOMING_TRANSACTION_MOCK.id]: INCOMING_TRANSACTION_MOCK,
[INCOMING_TRANSACTION_2_MOCK.id]: {
...INCOMING_TRANSACTION_2_MOCK,
txParams: {
...INCOMING_TRANSACTION_2_MOCK.txParams,
to: undefined,
},
},
},
},
};
const oldStorage = {
meta: { version: 94 },
data: oldData,
};
const newStorage = await migrate(oldStorage);
expect(newStorage.data).toStrictEqual({
some: oldData.some,
TransactionController: {
transactions: expect.any(Object),
lastFetchedBlockNumbers: {
[`${INCOMING_TRANSACTION_MOCK.chainId}#${INCOMING_TRANSACTION_MOCK.txParams.to}`]:
parseInt(INCOMING_TRANSACTION_MOCK.blockNumber, 10),
},
},
});
});
});
});

View File

@ -0,0 +1,94 @@
import { cloneDeep } from 'lodash';
type VersionedData = {
meta: { version: number };
data: Record<string, unknown>;
};
export const version = 95;
/**
* This migration does the following:
*
* - Moves any incoming transactions from the IncomingTransactionsController to the TransactionController state.
* - Generates the new lastFetchedBlockNumbers object in the TransactionController using any existing incoming transactions.
* - Removes the IncomingTransactionsController state.
*
* @param originalVersionedData - Versioned MetaMask extension state, exactly what we persist to dist.
* @param originalVersionedData.meta - State metadata.
* @param originalVersionedData.meta.version - The current state version.
* @param originalVersionedData.data - The persisted MetaMask state, keyed by controller.
* @returns Updated versioned MetaMask extension state.
*/
export async function migrate(
originalVersionedData: VersionedData,
): Promise<VersionedData> {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
migrateData(versionedData.data);
return versionedData;
}
function migrateData(state: Record<string, unknown>): void {
moveIncomingTransactions(state);
generateLastFetchedBlockNumbers(state);
removeIncomingTransactionsControllerState(state);
}
function moveIncomingTransactions(state: Record<string, any>) {
const incomingTransactions: Record<string, any> =
state.IncomingTransactionsController?.incomingTransactions || {};
if (Object.keys(incomingTransactions).length === 0) {
return;
}
const transactions = state.TransactionController?.transactions || {};
const updatedTransactions = Object.values(incomingTransactions).reduce(
(result: Record<string, any>, tx: any) => {
result[tx.id] = tx;
return result;
},
transactions,
);
state.TransactionController = {
...(state.TransactionController || {}),
transactions: updatedTransactions,
};
}
function generateLastFetchedBlockNumbers(state: Record<string, any>) {
const incomingTransactions: Record<string, any> =
state.IncomingTransactionsController?.incomingTransactions || {};
if (Object.keys(incomingTransactions).length === 0) {
return;
}
const lastFetchedBlockNumbers: Record<string, number> = {};
for (const tx of Object.values(incomingTransactions)) {
if (!tx.blockNumber || !tx.chainId || !tx.txParams.to) {
continue;
}
const txBlockNumber = parseInt(tx.blockNumber, 10);
const key = `${tx.chainId}#${tx.txParams.to.toLowerCase()}`;
const highestBlockNumber = lastFetchedBlockNumbers[key] || -1;
lastFetchedBlockNumbers[key] = Math.max(highestBlockNumber, txBlockNumber);
}
state.TransactionController = {
...state.TransactionController,
lastFetchedBlockNumbers,
};
}
function removeIncomingTransactionsControllerState(
state: Record<string, unknown>,
) {
delete state.IncomingTransactionsController;
}

View File

@ -99,6 +99,7 @@ import * as m092 from './092';
import * as m092point1 from './092.1'; import * as m092point1 from './092.1';
import * as m093 from './093'; import * as m093 from './093';
import * as m094 from './094'; import * as m094 from './094';
import * as m095 from './095';
const migrations = [ const migrations = [
m002, m002,
@ -195,5 +196,6 @@ const migrations = [
m092point1, m092point1,
m093, m093,
m094, m094,
m095,
]; ];
export default migrations; export default migrations;

View File

@ -4,6 +4,9 @@ module.exports = {
'<rootDir>/app/scripts/controllers/permissions/**/*.js', '<rootDir>/app/scripts/controllers/permissions/**/*.js',
'<rootDir>/app/scripts/controllers/sign.ts', '<rootDir>/app/scripts/controllers/sign.ts',
'<rootDir>/app/scripts/controllers/decrypt-message.ts', '<rootDir>/app/scripts/controllers/decrypt-message.ts',
'<rootDir>/app/scripts/controllers/transactions/etherscan.ts',
'<rootDir>/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.ts',
'<rootDir>/app/scripts/controllers/transactions/IncomingTransactionHelper.ts',
'<rootDir>/app/scripts/flask/**/*.js', '<rootDir>/app/scripts/flask/**/*.js',
'<rootDir>/app/scripts/lib/**/*.js', '<rootDir>/app/scripts/lib/**/*.js',
'<rootDir>/app/scripts/lib/createRPCMethodTrackingMiddleware.js', '<rootDir>/app/scripts/lib/createRPCMethodTrackingMiddleware.js',
@ -37,6 +40,9 @@ module.exports = {
testMatch: [ testMatch: [
'<rootDir>/app/scripts/constants/error-utils.test.js', '<rootDir>/app/scripts/constants/error-utils.test.js',
'<rootDir>/app/scripts/controllers/app-state.test.js', '<rootDir>/app/scripts/controllers/app-state.test.js',
'<rootDir>/app/scripts/controllers/transactions/etherscan.test.ts',
'<rootDir>/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.test.ts',
'<rootDir>/app/scripts/controllers/transactions/IncomingTransactionHelper.test.ts',
'<rootDir>/app/scripts/controllers/mmi-controller.test.js', '<rootDir>/app/scripts/controllers/mmi-controller.test.js',
'<rootDir>/app/scripts/controllers/permissions/**/*.test.js', '<rootDir>/app/scripts/controllers/permissions/**/*.test.js',
'<rootDir>/app/scripts/controllers/sign.test.ts', '<rootDir>/app/scripts/controllers/sign.test.ts',

View File

@ -269,7 +269,7 @@ export interface TxParams {
/** The amount of wei, in hexadecimal, to send */ /** The amount of wei, in hexadecimal, to send */
value: string; value: string;
/** The transaction count for the current account/network */ /** The transaction count for the current account/network */
nonce: number; nonce: string;
/** The amount of gwei, in hexadecimal, per unit of gas */ /** The amount of gwei, in hexadecimal, per unit of gas */
gasPrice?: string; gasPrice?: string;
/** The max amount of gwei, in hexadecimal, the user is willing to pay */ /** The max amount of gwei, in hexadecimal, the user is willing to pay */
@ -329,6 +329,7 @@ export interface TransactionMeta {
* on incoming transactions! * on incoming transactions!
*/ */
blockNumber?: string; blockNumber?: string;
chainId: string;
/** An internally unique tx identifier. */ /** An internally unique tx identifier. */
id: number; id: number;
/** Time the transaction was first suggested, in unix epoch time (ms). */ /** Time the transaction was first suggested, in unix epoch time (ms). */

View File

@ -197,16 +197,6 @@ function defaultFixture() {
gasEstimateType: 'none', gasEstimateType: 'none',
gasFeeEstimates: {}, gasFeeEstimates: {},
}, },
IncomingTransactionsController: {
incomingTransactions: {},
incomingTxLastFetchedBlockByChainId: {
[CHAIN_IDS.MAINNET]: null,
[CHAIN_IDS.LINEA_MAINNET]: null,
[CHAIN_IDS.GOERLI]: null,
[CHAIN_IDS.SEPOLIA]: null,
[CHAIN_IDS.LINEA_GOERLI]: null,
},
},
KeyringController: { KeyringController: {
vault: vault:
'{"data":"s6TpYjlUNsn7ifhEFTkuDGBUM1GyOlPrim7JSjtfIxgTt8/6MiXgiR/CtFfR4dWW2xhq85/NGIBYEeWrZThGdKGarBzeIqBfLFhw9n509jprzJ0zc2Rf+9HVFGLw+xxC4xPxgCS0IIWeAJQ+XtGcHmn0UZXriXm8Ja4kdlow6SWinB7sr/WM3R0+frYs4WgllkwggDf2/Tv6VHygvLnhtzp6hIJFyTjh+l/KnyJTyZW1TkZhDaNDzX3SCOHT","iv":"FbeHDAW5afeWNORfNJBR0Q==","salt":"TxZ+WbCW6891C9LK/hbMAoUsSEW1E8pyGLVBU6x5KR8="}', '{"data":"s6TpYjlUNsn7ifhEFTkuDGBUM1GyOlPrim7JSjtfIxgTt8/6MiXgiR/CtFfR4dWW2xhq85/NGIBYEeWrZThGdKGarBzeIqBfLFhw9n509jprzJ0zc2Rf+9HVFGLw+xxC4xPxgCS0IIWeAJQ+XtGcHmn0UZXriXm8Ja4kdlow6SWinB7sr/WM3R0+frYs4WgllkwggDf2/Tv6VHygvLnhtzp6hIJFyTjh+l/KnyJTyZW1TkZhDaNDzX3SCOHT","iv":"FbeHDAW5afeWNORfNJBR0Q==","salt":"TxZ+WbCW6891C9LK/hbMAoUsSEW1E8pyGLVBU6x5KR8="}',
@ -487,40 +477,6 @@ class FixtureBuilder {
return this; return this;
} }
withIncomingTransactionsController(data) {
merge(
this.fixture.data.IncomingTransactionsController
? this.fixture.data.IncomingTransactionsController
: (this.fixture.data.IncomingTransactionsController = {}),
data,
);
return this;
}
withIncomingTransactionsControllerOneTransaction() {
return this.withIncomingTransactionsController({
incomingTransactions: {
'0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab': {
blockNumber: '1',
chainId: CHAIN_IDS.LOCALHOST,
hash: '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab',
id: 5748272735958807,
metamaskNetworkId: '1337',
status: 'confirmed',
time: 1671635520000,
txParams: {
from: '0xc87261ba337be737fa744f50e7aaf4a920bdfcd6',
gas: '0x5208',
gasPrice: '0x329af9707',
to: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1',
value: '0xDE0B6B3A7640000',
},
type: 'incoming',
},
},
});
}
withKeyringController(data) { withKeyringController(data) {
merge(this.fixture.data.KeyringController, data); merge(this.fixture.data.KeyringController, data);
return this; return this;
@ -1488,6 +1444,47 @@ class FixtureBuilder {
}); });
} }
withTransactionControllerIncomingTransaction() {
return this.withTransactionController({
transactions: {
5748272735958807: {
blockNumber: '1',
chainId: CHAIN_IDS.LOCALHOST,
hash: '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab',
id: 5748272735958807,
metamaskNetworkId: '1337',
status: 'confirmed',
time: 1671635520000,
txParams: {
from: '0xc87261ba337be737fa744f50e7aaf4a920bdfcd6',
gas: '0x5208',
gasPrice: '0x329af9707',
to: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1',
value: '0xDE0B6B3A7640000',
},
type: 'incoming',
},
},
});
}
withTransactionControllerCompletedAndIncomingTransaction() {
const completedTransaction =
this.withTransactionControllerCompletedTransaction().fixture.data
.TransactionController.transactions;
const incomingTransaction =
this.withTransactionControllerIncomingTransaction().fixture.data
.TransactionController.transactions;
return this.withTransactionController({
transactions: {
...completedTransaction,
...incomingTransaction,
},
});
}
build() { build() {
this.fixture.meta = { this.fixture.meta = {
version: 74, version: 74,

View File

@ -21,8 +21,7 @@ describe('Clear account activity', function () {
await withFixtures( await withFixtures(
{ {
fixtures: new FixtureBuilder() fixtures: new FixtureBuilder()
.withTransactionControllerCompletedTransaction() .withTransactionControllerCompletedAndIncomingTransaction()
.withIncomingTransactionsControllerOneTransaction()
.build(), .build(),
ganacheOptions, ganacheOptions,
title: this.test.title, title: this.test.title,

View File

@ -57,16 +57,6 @@
}, },
"EnsController": "object", "EnsController": "object",
"GasFeeController": "object", "GasFeeController": "object",
"IncomingTransactionsController": {
"incomingTransactions": "object",
"incomingTxLastFetchedBlockByChainId": {
"0x1": null,
"0xe708": null,
"0x5": null,
"0xaa36a7": null,
"0xe704": null
}
},
"KeyringController": { "KeyringController": {
"isUnlocked": false, "isUnlocked": false,
"keyringTypes": "object", "keyringTypes": "object",

View File

@ -100,6 +100,7 @@
"metaMetricsId": "fake-metrics-id", "metaMetricsId": "fake-metrics-id",
"eventsBeforeMetricsOptIn": "object", "eventsBeforeMetricsOptIn": "object",
"traits": "object", "traits": "object",
"transactions": "object",
"fragments": "object", "fragments": "object",
"segmentApiCalls": "object", "segmentApiCalls": "object",
"previousUserTraits": "object", "previousUserTraits": "object",
@ -113,14 +114,6 @@
"web3ShimUsageOrigins": "object", "web3ShimUsageOrigins": "object",
"seedPhraseBackedUp": true, "seedPhraseBackedUp": true,
"onboardingTabs": "object", "onboardingTabs": "object",
"incomingTransactions": "object",
"incomingTxLastFetchedBlockByChainId": {
"0x1": null,
"0xe708": null,
"0x5": null,
"0xaa36a7": null,
"0xe704": null
},
"subjects": "object", "subjects": "object",
"permissionHistory": "object", "permissionHistory": "object",
"permissionActivityLog": "object", "permissionActivityLog": "object",

View File

@ -32,16 +32,6 @@
"usdConversionRate": "number" "usdConversionRate": "number"
}, },
"GasFeeController": "object", "GasFeeController": "object",
"IncomingTransactionsController": {
"incomingTransactions": "object",
"incomingTxLastFetchedBlockByChainId": {
"0x1": null,
"0xe708": null,
"0x5": null,
"0xaa36a7": null,
"0xe704": null
}
},
"KeyringController": { "vault": "string" }, "KeyringController": { "vault": "string" },
"MetaMetricsController": { "MetaMetricsController": {
"eventsBeforeMetricsOptIn": "object", "eventsBeforeMetricsOptIn": "object",

View File

@ -32,16 +32,6 @@
"usdConversionRate": "number" "usdConversionRate": "number"
}, },
"GasFeeController": "object", "GasFeeController": "object",
"IncomingTransactionsController": {
"incomingTransactions": "object",
"incomingTxLastFetchedBlockByChainId": {
"0x1": null,
"0xe708": null,
"0x5": null,
"0xaa36a7": null,
"0xe704": null
}
},
"KeyringController": { "vault": "string" }, "KeyringController": { "vault": "string" },
"MetaMetricsController": { "MetaMetricsController": {
"eventsBeforeMetricsOptIn": "object", "eventsBeforeMetricsOptIn": "object",

View File

@ -205,7 +205,6 @@ describe('Account Details Modal', () => {
}, },
}, },
cachedBalances: {}, cachedBalances: {},
incomingTransactions: {},
selectedAddress: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', selectedAddress: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
accounts: { accounts: {
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': { '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': {

View File

@ -83,7 +83,7 @@ const getStateTree = ({
featureFlags: { featureFlags: {
showIncomingTransactions: true, showIncomingTransactions: true,
}, },
incomingTransactions: [...incomingTxList], transactions: [...incomingTxList],
currentNetworkTxList: [...txList], currentNetworkTxList: [...txList],
}, },
}); });

View File

@ -27,6 +27,7 @@ const INVALID_INITIAL_TRANSACTION_TYPES = [
export const incomingTxListSelector = (state) => { export const incomingTxListSelector = (state) => {
const { showIncomingTransactions } = state.metamask.featureFlags; const { showIncomingTransactions } = state.metamask.featureFlags;
if (!showIncomingTransactions) { if (!showIncomingTransactions) {
return []; return [];
} }
@ -34,8 +35,10 @@ export const incomingTxListSelector = (state) => {
const { networkId } = state.metamask; const { networkId } = state.metamask;
const { chainId } = getProviderConfig(state); const { chainId } = getProviderConfig(state);
const selectedAddress = getSelectedAddress(state); const selectedAddress = getSelectedAddress(state);
return Object.values(state.metamask.incomingTransactions).filter(
return Object.values(state.metamask.transactions || {}).filter(
(tx) => (tx) =>
tx.type === TransactionType.incoming &&
tx.txParams.to === selectedAddress && tx.txParams.to === selectedAddress &&
transactionMatchesNetwork(tx, chainId, networkId), transactionMatchesNetwork(tx, chainId, networkId),
); );

View File

@ -85,6 +85,7 @@ import {
import { decimalToHex } from '../../shared/modules/conversion.utils'; import { decimalToHex } from '../../shared/modules/conversion.utils';
import { TxGasFees, PriorityLevels } from '../../shared/constants/gas'; import { TxGasFees, PriorityLevels } from '../../shared/constants/gas';
import { import {
TransactionMeta,
TransactionMetaMetricsEvent, TransactionMetaMetricsEvent,
TransactionType, TransactionType,
} from '../../shared/constants/transaction'; } from '../../shared/constants/transaction';
@ -94,10 +95,9 @@ import {
isErrorWithMessage, isErrorWithMessage,
logErrorWithMessage, logErrorWithMessage,
} from '../../shared/modules/error'; } from '../../shared/modules/error';
import { TransactionMeta } from '../../app/scripts/controllers/incoming-transactions';
import { TxParams } from '../../app/scripts/controllers/transactions/tx-state-manager'; import { TxParams } from '../../app/scripts/controllers/transactions/tx-state-manager';
import { CustomGasSettings } from '../../app/scripts/controllers/transactions';
import { ThemeType } from '../../shared/constants/preferences'; import { ThemeType } from '../../shared/constants/preferences';
import { CustomGasSettings } from '../../app/scripts/controllers/transactions';
import * as actionConstants from './actionConstants'; import * as actionConstants from './actionConstants';
///: BEGIN:ONLY_INCLUDE_IN(build-mmi) ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
import { updateCustodyState } from './institutional/institution-actions'; import { updateCustodyState } from './institutional/institution-actions';