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:
parent
8ca0b762ad
commit
37209a7d2e
@ -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
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
153
app/scripts/controllers/transactions/etherscan.test.ts
Normal file
153
app/scripts/controllers/transactions/etherscan.test.ts
Normal 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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
205
app/scripts/controllers/transactions/etherscan.ts
Normal file
205
app/scripts/controllers/transactions/etherscan.ts
Normal 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;
|
||||
}
|
@ -72,6 +72,8 @@ import TransactionStateManager from './tx-state-manager';
|
||||
import TxGasUtil from './tx-gas-utils';
|
||||
import PendingTransactionTracker from './pending-tx-tracker';
|
||||
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 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 {Function} opts.getNetworkId - Get the current network ID.
|
||||
* @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 {object} opts.blockTracker - An instance of eth-blocktracker
|
||||
* @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 {Function} opts.signTransaction - ethTx signer that returns a rawTx
|
||||
* @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
|
||||
*/
|
||||
|
||||
@ -142,6 +146,7 @@ export default class TransactionController extends EventEmitter {
|
||||
super();
|
||||
this.getNetworkId = opts.getNetworkId;
|
||||
this.getNetworkStatus = opts.getNetworkStatus;
|
||||
this._getNetworkState = opts.getNetworkState;
|
||||
this._getCurrentChainId = opts.getCurrentChainId;
|
||||
this.getProviderConfig = opts.getProviderConfig;
|
||||
this._getCurrentNetworkEIP1559Compatibility =
|
||||
@ -166,6 +171,7 @@ export default class TransactionController extends EventEmitter {
|
||||
this.getTokenStandardAndDetails = opts.getTokenStandardAndDetails;
|
||||
this.securityProviderRequest = opts.securityProviderRequest;
|
||||
this.messagingSystem = opts.messenger;
|
||||
this._hasCompletedOnboarding = opts.hasCompletedOnboarding;
|
||||
|
||||
this.memStore = new ObservableStore({});
|
||||
|
||||
@ -216,6 +222,32 @@ export default class TransactionController extends EventEmitter {
|
||||
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.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
|
||||
//
|
||||
@ -2086,11 +2130,18 @@ export default class TransactionController extends EventEmitter {
|
||||
* Updates the memStore in transaction controller
|
||||
*/
|
||||
_updateMemstore() {
|
||||
const { transactions } = this.store.getState();
|
||||
const unapprovedTxs = this.txStateManager.getUnapprovedTxList();
|
||||
|
||||
const currentNetworkTxList = this.txStateManager.getTransactions({
|
||||
limit: MAX_MEMSTORE_TX_LIST_SIZE,
|
||||
});
|
||||
this.memStore.updateState({ unapprovedTxs, currentNetworkTxList });
|
||||
|
||||
this.memStore.updateState({
|
||||
unapprovedTxs,
|
||||
currentNetworkTxList,
|
||||
transactions,
|
||||
});
|
||||
}
|
||||
|
||||
_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
|
||||
|
||||
async _requestTransactionApproval(
|
||||
|
@ -38,6 +38,7 @@ import { ORIGIN_METAMASK } from '../../../../shared/constants/app';
|
||||
import { NetworkStatus } from '../../../../shared/constants/network';
|
||||
import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils';
|
||||
import TxGasUtil from './tx-gas-utils';
|
||||
import * as IncomingTransactionHelperClass from './IncomingTransactionHelper';
|
||||
import TransactionController from '.';
|
||||
|
||||
const noop = () => true;
|
||||
@ -51,6 +52,16 @@ const actionId = 'DUMMY_ACTION_ID';
|
||||
const VALID_ADDRESS = '0x0000000000000000000000000000000000000000';
|
||||
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() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
@ -65,7 +76,9 @@ describe('Transaction Controller', function () {
|
||||
getCurrentChainId,
|
||||
messengerMock,
|
||||
resultCallbacksMock,
|
||||
updateSpy;
|
||||
updateSpy,
|
||||
incomingTransactionHelperClassMock,
|
||||
incomingTransactionHelperEventMock;
|
||||
|
||||
beforeEach(function () {
|
||||
fragmentExists = false;
|
||||
@ -101,6 +114,16 @@ describe('Transaction Controller', function () {
|
||||
call: sinon.stub(),
|
||||
};
|
||||
|
||||
incomingTransactionHelperEventMock = sinon.spy();
|
||||
|
||||
incomingTransactionHelperClassMock = sinon
|
||||
.stub(IncomingTransactionHelperClass, 'IncomingTransactionHelper')
|
||||
.returns({
|
||||
hub: {
|
||||
on: incomingTransactionHelperEventMock,
|
||||
},
|
||||
});
|
||||
|
||||
txController = new TransactionController({
|
||||
provider,
|
||||
getGasPrice() {
|
||||
@ -148,6 +171,10 @@ describe('Transaction Controller', function () {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
incomingTransactionHelperClassMock.restore();
|
||||
});
|
||||
|
||||
function getLastTxMeta() {
|
||||
return updateSpy.lastCall.args[0];
|
||||
}
|
||||
@ -3374,4 +3401,78 @@ describe('Transaction Controller', function () {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
49
app/scripts/controllers/transactions/types.ts
Normal file
49
app/scripts/controllers/transactions/types.ts
Normal 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[]>;
|
||||
}
|
@ -58,9 +58,6 @@ export const SENTRY_BACKGROUND_STATE = {
|
||||
EncryptionPublicKeyController: {
|
||||
unapprovedEncryptionPublicKeyMsgCount: true,
|
||||
},
|
||||
IncomingTransactionsController: {
|
||||
incomingTxLastFetchedBlockByChainId: true,
|
||||
},
|
||||
KeyringController: {
|
||||
isUnlocked: true,
|
||||
},
|
||||
|
@ -190,7 +190,6 @@ import CachedBalancesController from './controllers/cached-balances';
|
||||
import AlertController from './controllers/alert';
|
||||
import OnboardingController from './controllers/onboarding';
|
||||
import Backup from './lib/backup';
|
||||
import IncomingTransactionsController from './controllers/incoming-transactions';
|
||||
import DecryptMessageController from './controllers/decrypt-message';
|
||||
import TransactionController from './controllers/transactions';
|
||||
import DetectTokensController from './controllers/detect-tokens';
|
||||
@ -418,10 +417,6 @@ export default class MetamaskController extends EventEmitter {
|
||||
provider: this.provider,
|
||||
});
|
||||
|
||||
this.preferencesController.store.subscribe(async ({ currentLocale }) => {
|
||||
await updateCurrentLocale(currentLocale);
|
||||
});
|
||||
|
||||
const tokensControllerMessenger = this.controllerMessenger.getRestricted({
|
||||
name: 'TokensController',
|
||||
allowedActions: ['ApprovalController:addRequest'],
|
||||
@ -744,19 +739,6 @@ export default class MetamaskController extends EventEmitter {
|
||||
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
|
||||
this.accountTracker = new AccountTracker({
|
||||
provider: this.provider,
|
||||
@ -1192,6 +1174,9 @@ export default class MetamaskController extends EventEmitter {
|
||||
this.networkController.state.networksMetadata?.[
|
||||
this.networkController.state.selectedNetworkClientId
|
||||
]?.status,
|
||||
getNetworkState: () => this.networkController.state,
|
||||
hasCompletedOnboarding: () =>
|
||||
this.onboardingController.store.getState().completedOnboarding,
|
||||
onNetworkStateChange: (listener) => {
|
||||
networkControllerMessenger.subscribe(
|
||||
'NetworkController:stateChange',
|
||||
@ -1660,7 +1645,6 @@ export default class MetamaskController extends EventEmitter {
|
||||
CachedBalancesController: this.cachedBalancesController.store,
|
||||
AlertController: this.alertController.store,
|
||||
OnboardingController: this.onboardingController.store,
|
||||
IncomingTransactionsController: this.incomingTransactionsController.store,
|
||||
PermissionController: this.permissionController,
|
||||
PermissionLogController: this.permissionLogController.store,
|
||||
SubjectMetadataController: this.subjectMetadataController,
|
||||
@ -1706,8 +1690,6 @@ export default class MetamaskController extends EventEmitter {
|
||||
CurrencyController: this.currencyRateController,
|
||||
AlertController: this.alertController.store,
|
||||
OnboardingController: this.onboardingController.store,
|
||||
IncomingTransactionsController:
|
||||
this.incomingTransactionsController.store,
|
||||
PermissionController: this.permissionController,
|
||||
PermissionLogController: this.permissionLogController.store,
|
||||
SubjectMetadataController: this.subjectMetadataController,
|
||||
@ -1803,7 +1785,7 @@ export default class MetamaskController extends EventEmitter {
|
||||
|
||||
triggerNetworkrequests() {
|
||||
this.accountTracker.start();
|
||||
this.incomingTransactionsController.start();
|
||||
this.txController.startIncomingTransactionPolling();
|
||||
if (this.preferencesController.store.getState().useCurrencyRateCheck) {
|
||||
this.currencyRateController.start();
|
||||
}
|
||||
@ -1814,7 +1796,7 @@ export default class MetamaskController extends EventEmitter {
|
||||
|
||||
stopNetworkRequests() {
|
||||
this.accountTracker.stop();
|
||||
this.incomingTransactionsController.stop();
|
||||
this.txController.stopIncomingTransactionPolling();
|
||||
if (this.preferencesController.store.getState().useCurrencyRateCheck) {
|
||||
this.currencyRateController.stop();
|
||||
}
|
||||
@ -1991,40 +1973,22 @@ export default class MetamaskController extends EventEmitter {
|
||||
* becomes unlocked are handled in MetaMaskController._onUnlock.
|
||||
*/
|
||||
setupControllerEventSubscriptions() {
|
||||
const handleAccountsChange = async (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),
|
||||
});
|
||||
let lastSelectedAddress;
|
||||
|
||||
this.preferencesController.store.subscribe(async (state) => {
|
||||
const { selectedAddress, currentLocale } = state;
|
||||
|
||||
await updateCurrentLocale(currentLocale);
|
||||
|
||||
if (state?.featureFlags?.showIncomingTransactions) {
|
||||
this.txController.startIncomingTransactionPolling();
|
||||
} else {
|
||||
this.txController.stopIncomingTransactionPolling();
|
||||
}
|
||||
|
||||
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) {
|
||||
lastSelectedAddress = selectedAddress;
|
||||
const permittedAccountsMap = getPermittedAccountsByOrigin(
|
||||
this.permissionController.state,
|
||||
);
|
||||
|
||||
for (const [origin, accounts] of permittedAccountsMap.entries()) {
|
||||
if (accounts.includes(selectedAddress)) {
|
||||
handleAccountsChange(origin, accounts);
|
||||
}
|
||||
}
|
||||
await this._onAccountChange(selectedAddress);
|
||||
}
|
||||
});
|
||||
|
||||
@ -2036,12 +2000,19 @@ export default class MetamaskController extends EventEmitter {
|
||||
const changedAccounts = getChangedAccounts(currentValue, previousValue);
|
||||
|
||||
for (const [origin, accounts] of changedAccounts.entries()) {
|
||||
handleAccountsChange(origin, accounts);
|
||||
this._notifyAccountsChange(origin, accounts);
|
||||
}
|
||||
},
|
||||
getPermittedAccountsByOrigin,
|
||||
);
|
||||
|
||||
this.controllerMessenger.subscribe(
|
||||
'NetworkController:networkDidChange',
|
||||
async () => {
|
||||
await this.txController.updateIncomingTransactions();
|
||||
},
|
||||
);
|
||||
|
||||
///: BEGIN:ONLY_INCLUDE_IN(snaps)
|
||||
// Record Snap metadata whenever a Snap is added to state.
|
||||
this.controllerMessenger.subscribe(
|
||||
@ -4819,4 +4790,38 @@ export default class MetamaskController extends EventEmitter {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
METAMASK_HOTLIST_DIFF_FILE,
|
||||
} from '@metamask/phishing-controller';
|
||||
import { NetworkType } from '@metamask/controller-utils';
|
||||
import { ControllerMessenger } from '@metamask/base-controller';
|
||||
import { TransactionStatus } from '../../shared/constants/transaction';
|
||||
import createTxMeta from '../../test/lib/createTxMeta';
|
||||
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 { KeyringType } from '../../shared/constants/keyring';
|
||||
import { deferredPromise } from './lib/util';
|
||||
import TransactionController from './controllers/transactions';
|
||||
import PreferencesController from './controllers/preferences';
|
||||
|
||||
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:
|
||||
// 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
|
||||
@ -91,6 +102,7 @@ function MockEthContract() {
|
||||
const MetaMaskController = proxyquire('./metamask-controller', {
|
||||
'./lib/createLoggerMiddleware': { default: createLoggerMiddlewareMock },
|
||||
'ethjs-contract': MockEthContract,
|
||||
'./controllers/preferences': { default: MockPreferencesController },
|
||||
}).default;
|
||||
|
||||
const MetaMaskControllerMV3 = proxyquire('./metamask-controller', {
|
||||
@ -279,6 +291,23 @@ describe('MetaMaskController', function () {
|
||||
beforeEach(function () {
|
||||
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({
|
||||
showUserConfirmation: noop,
|
||||
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 () {
|
||||
|
364
app/scripts/migrations/095.test.ts
Normal file
364
app/scripts/migrations/095.test.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
94
app/scripts/migrations/095.ts
Normal file
94
app/scripts/migrations/095.ts
Normal 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;
|
||||
}
|
@ -99,6 +99,7 @@ import * as m092 from './092';
|
||||
import * as m092point1 from './092.1';
|
||||
import * as m093 from './093';
|
||||
import * as m094 from './094';
|
||||
import * as m095 from './095';
|
||||
|
||||
const migrations = [
|
||||
m002,
|
||||
@ -195,5 +196,6 @@ const migrations = [
|
||||
m092point1,
|
||||
m093,
|
||||
m094,
|
||||
m095,
|
||||
];
|
||||
export default migrations;
|
||||
|
@ -4,6 +4,9 @@ module.exports = {
|
||||
'<rootDir>/app/scripts/controllers/permissions/**/*.js',
|
||||
'<rootDir>/app/scripts/controllers/sign.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/lib/**/*.js',
|
||||
'<rootDir>/app/scripts/lib/createRPCMethodTrackingMiddleware.js',
|
||||
@ -37,6 +40,9 @@ module.exports = {
|
||||
testMatch: [
|
||||
'<rootDir>/app/scripts/constants/error-utils.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/permissions/**/*.test.js',
|
||||
'<rootDir>/app/scripts/controllers/sign.test.ts',
|
||||
|
@ -269,7 +269,7 @@ export interface TxParams {
|
||||
/** The amount of wei, in hexadecimal, to send */
|
||||
value: string;
|
||||
/** The transaction count for the current account/network */
|
||||
nonce: number;
|
||||
nonce: string;
|
||||
/** The amount of gwei, in hexadecimal, per unit of gas */
|
||||
gasPrice?: string;
|
||||
/** The max amount of gwei, in hexadecimal, the user is willing to pay */
|
||||
@ -329,6 +329,7 @@ export interface TransactionMeta {
|
||||
* on incoming transactions!
|
||||
*/
|
||||
blockNumber?: string;
|
||||
chainId: string;
|
||||
/** An internally unique tx identifier. */
|
||||
id: number;
|
||||
/** Time the transaction was first suggested, in unix epoch time (ms). */
|
||||
|
@ -197,16 +197,6 @@ function defaultFixture() {
|
||||
gasEstimateType: 'none',
|
||||
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: {
|
||||
vault:
|
||||
'{"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;
|
||||
}
|
||||
|
||||
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) {
|
||||
merge(this.fixture.data.KeyringController, data);
|
||||
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() {
|
||||
this.fixture.meta = {
|
||||
version: 74,
|
||||
|
@ -21,8 +21,7 @@ describe('Clear account activity', function () {
|
||||
await withFixtures(
|
||||
{
|
||||
fixtures: new FixtureBuilder()
|
||||
.withTransactionControllerCompletedTransaction()
|
||||
.withIncomingTransactionsControllerOneTransaction()
|
||||
.withTransactionControllerCompletedAndIncomingTransaction()
|
||||
.build(),
|
||||
ganacheOptions,
|
||||
title: this.test.title,
|
||||
|
@ -57,16 +57,6 @@
|
||||
},
|
||||
"EnsController": "object",
|
||||
"GasFeeController": "object",
|
||||
"IncomingTransactionsController": {
|
||||
"incomingTransactions": "object",
|
||||
"incomingTxLastFetchedBlockByChainId": {
|
||||
"0x1": null,
|
||||
"0xe708": null,
|
||||
"0x5": null,
|
||||
"0xaa36a7": null,
|
||||
"0xe704": null
|
||||
}
|
||||
},
|
||||
"KeyringController": {
|
||||
"isUnlocked": false,
|
||||
"keyringTypes": "object",
|
||||
|
@ -100,6 +100,7 @@
|
||||
"metaMetricsId": "fake-metrics-id",
|
||||
"eventsBeforeMetricsOptIn": "object",
|
||||
"traits": "object",
|
||||
"transactions": "object",
|
||||
"fragments": "object",
|
||||
"segmentApiCalls": "object",
|
||||
"previousUserTraits": "object",
|
||||
@ -113,14 +114,6 @@
|
||||
"web3ShimUsageOrigins": "object",
|
||||
"seedPhraseBackedUp": true,
|
||||
"onboardingTabs": "object",
|
||||
"incomingTransactions": "object",
|
||||
"incomingTxLastFetchedBlockByChainId": {
|
||||
"0x1": null,
|
||||
"0xe708": null,
|
||||
"0x5": null,
|
||||
"0xaa36a7": null,
|
||||
"0xe704": null
|
||||
},
|
||||
"subjects": "object",
|
||||
"permissionHistory": "object",
|
||||
"permissionActivityLog": "object",
|
||||
|
@ -32,16 +32,6 @@
|
||||
"usdConversionRate": "number"
|
||||
},
|
||||
"GasFeeController": "object",
|
||||
"IncomingTransactionsController": {
|
||||
"incomingTransactions": "object",
|
||||
"incomingTxLastFetchedBlockByChainId": {
|
||||
"0x1": null,
|
||||
"0xe708": null,
|
||||
"0x5": null,
|
||||
"0xaa36a7": null,
|
||||
"0xe704": null
|
||||
}
|
||||
},
|
||||
"KeyringController": { "vault": "string" },
|
||||
"MetaMetricsController": {
|
||||
"eventsBeforeMetricsOptIn": "object",
|
||||
|
@ -32,16 +32,6 @@
|
||||
"usdConversionRate": "number"
|
||||
},
|
||||
"GasFeeController": "object",
|
||||
"IncomingTransactionsController": {
|
||||
"incomingTransactions": "object",
|
||||
"incomingTxLastFetchedBlockByChainId": {
|
||||
"0x1": null,
|
||||
"0xe708": null,
|
||||
"0x5": null,
|
||||
"0xaa36a7": null,
|
||||
"0xe704": null
|
||||
}
|
||||
},
|
||||
"KeyringController": { "vault": "string" },
|
||||
"MetaMetricsController": {
|
||||
"eventsBeforeMetricsOptIn": "object",
|
||||
|
@ -205,7 +205,6 @@ describe('Account Details Modal', () => {
|
||||
},
|
||||
},
|
||||
cachedBalances: {},
|
||||
incomingTransactions: {},
|
||||
selectedAddress: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
|
||||
accounts: {
|
||||
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': {
|
||||
|
@ -83,7 +83,7 @@ const getStateTree = ({
|
||||
featureFlags: {
|
||||
showIncomingTransactions: true,
|
||||
},
|
||||
incomingTransactions: [...incomingTxList],
|
||||
transactions: [...incomingTxList],
|
||||
currentNetworkTxList: [...txList],
|
||||
},
|
||||
});
|
||||
|
@ -27,6 +27,7 @@ const INVALID_INITIAL_TRANSACTION_TYPES = [
|
||||
|
||||
export const incomingTxListSelector = (state) => {
|
||||
const { showIncomingTransactions } = state.metamask.featureFlags;
|
||||
|
||||
if (!showIncomingTransactions) {
|
||||
return [];
|
||||
}
|
||||
@ -34,8 +35,10 @@ export const incomingTxListSelector = (state) => {
|
||||
const { networkId } = state.metamask;
|
||||
const { chainId } = getProviderConfig(state);
|
||||
const selectedAddress = getSelectedAddress(state);
|
||||
return Object.values(state.metamask.incomingTransactions).filter(
|
||||
|
||||
return Object.values(state.metamask.transactions || {}).filter(
|
||||
(tx) =>
|
||||
tx.type === TransactionType.incoming &&
|
||||
tx.txParams.to === selectedAddress &&
|
||||
transactionMatchesNetwork(tx, chainId, networkId),
|
||||
);
|
||||
|
@ -85,6 +85,7 @@ import {
|
||||
import { decimalToHex } from '../../shared/modules/conversion.utils';
|
||||
import { TxGasFees, PriorityLevels } from '../../shared/constants/gas';
|
||||
import {
|
||||
TransactionMeta,
|
||||
TransactionMetaMetricsEvent,
|
||||
TransactionType,
|
||||
} from '../../shared/constants/transaction';
|
||||
@ -94,10 +95,9 @@ import {
|
||||
isErrorWithMessage,
|
||||
logErrorWithMessage,
|
||||
} from '../../shared/modules/error';
|
||||
import { TransactionMeta } from '../../app/scripts/controllers/incoming-transactions';
|
||||
import { TxParams } from '../../app/scripts/controllers/transactions/tx-state-manager';
|
||||
import { CustomGasSettings } from '../../app/scripts/controllers/transactions';
|
||||
import { ThemeType } from '../../shared/constants/preferences';
|
||||
import { CustomGasSettings } from '../../app/scripts/controllers/transactions';
|
||||
import * as actionConstants from './actionConstants';
|
||||
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
|
||||
import { updateCustodyState } from './institutional/institution-actions';
|
||||
|
Loading…
Reference in New Issue
Block a user