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 TxGasUtil from './tx-gas-utils';
|
||||||
import PendingTransactionTracker from './pending-tx-tracker';
|
import PendingTransactionTracker from './pending-tx-tracker';
|
||||||
import * as txUtils from './lib/util';
|
import * as txUtils from './lib/util';
|
||||||
|
import { IncomingTransactionHelper } from './IncomingTransactionHelper';
|
||||||
|
import { EtherscanRemoteTransactionSource } from './EtherscanRemoteTransactionSource';
|
||||||
|
|
||||||
const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory
|
const MAX_MEMSTORE_TX_LIST_SIZE = 100; // Number of transactions (by unique nonces) to keep in memory
|
||||||
const UPDATE_POST_TX_BALANCE_TIMEOUT = 5000;
|
const UPDATE_POST_TX_BALANCE_TIMEOUT = 5000;
|
||||||
@ -127,6 +129,7 @@ const METRICS_STATUS_FAILED = 'failed on-chain';
|
|||||||
* @param {object} opts.initState - initial transaction list default is an empty array
|
* @param {object} opts.initState - initial transaction list default is an empty array
|
||||||
* @param {Function} opts.getNetworkId - Get the current network ID.
|
* @param {Function} opts.getNetworkId - Get the current network ID.
|
||||||
* @param {Function} opts.getNetworkStatus - Get the current network status.
|
* @param {Function} opts.getNetworkStatus - Get the current network status.
|
||||||
|
* @param {Function} opts.getNetworkState - Get the network state.
|
||||||
* @param {Function} opts.onNetworkStateChange - Subscribe to network state change events.
|
* @param {Function} opts.onNetworkStateChange - Subscribe to network state change events.
|
||||||
* @param {object} opts.blockTracker - An instance of eth-blocktracker
|
* @param {object} opts.blockTracker - An instance of eth-blocktracker
|
||||||
* @param {object} opts.provider - A network provider.
|
* @param {object} opts.provider - A network provider.
|
||||||
@ -134,6 +137,7 @@ const METRICS_STATUS_FAILED = 'failed on-chain';
|
|||||||
* @param {object} opts.getPermittedAccounts - get accounts that an origin has permissions for
|
* @param {object} opts.getPermittedAccounts - get accounts that an origin has permissions for
|
||||||
* @param {Function} opts.signTransaction - ethTx signer that returns a rawTx
|
* @param {Function} opts.signTransaction - ethTx signer that returns a rawTx
|
||||||
* @param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state
|
* @param {number} [opts.txHistoryLimit] - number *optional* for limiting how many transactions are in state
|
||||||
|
* @param {Function} opts.hasCompletedOnboarding - Returns whether or not the user has completed the onboarding flow
|
||||||
* @param {object} opts.preferencesStore
|
* @param {object} opts.preferencesStore
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -142,6 +146,7 @@ export default class TransactionController extends EventEmitter {
|
|||||||
super();
|
super();
|
||||||
this.getNetworkId = opts.getNetworkId;
|
this.getNetworkId = opts.getNetworkId;
|
||||||
this.getNetworkStatus = opts.getNetworkStatus;
|
this.getNetworkStatus = opts.getNetworkStatus;
|
||||||
|
this._getNetworkState = opts.getNetworkState;
|
||||||
this._getCurrentChainId = opts.getCurrentChainId;
|
this._getCurrentChainId = opts.getCurrentChainId;
|
||||||
this.getProviderConfig = opts.getProviderConfig;
|
this.getProviderConfig = opts.getProviderConfig;
|
||||||
this._getCurrentNetworkEIP1559Compatibility =
|
this._getCurrentNetworkEIP1559Compatibility =
|
||||||
@ -166,6 +171,7 @@ export default class TransactionController extends EventEmitter {
|
|||||||
this.getTokenStandardAndDetails = opts.getTokenStandardAndDetails;
|
this.getTokenStandardAndDetails = opts.getTokenStandardAndDetails;
|
||||||
this.securityProviderRequest = opts.securityProviderRequest;
|
this.securityProviderRequest = opts.securityProviderRequest;
|
||||||
this.messagingSystem = opts.messenger;
|
this.messagingSystem = opts.messenger;
|
||||||
|
this._hasCompletedOnboarding = opts.hasCompletedOnboarding;
|
||||||
|
|
||||||
this.memStore = new ObservableStore({});
|
this.memStore = new ObservableStore({});
|
||||||
|
|
||||||
@ -216,6 +222,32 @@ export default class TransactionController extends EventEmitter {
|
|||||||
this.txStateManager.getConfirmedTransactions.bind(this.txStateManager),
|
this.txStateManager.getConfirmedTransactions.bind(this.txStateManager),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.incomingTransactionHelper = new IncomingTransactionHelper({
|
||||||
|
blockTracker: this.blockTracker,
|
||||||
|
getCurrentAccount: () => this.getSelectedAddress(),
|
||||||
|
getNetworkState: () => this._getNetworkState(),
|
||||||
|
isEnabled: () =>
|
||||||
|
Boolean(
|
||||||
|
this.preferencesStore.getState().featureFlags
|
||||||
|
?.showIncomingTransactions && this._hasCompletedOnboarding(),
|
||||||
|
),
|
||||||
|
lastFetchedBlockNumbers: opts.initState?.lastFetchedBlockNumbers || {},
|
||||||
|
remoteTransactionSource: new EtherscanRemoteTransactionSource({
|
||||||
|
includeTokenTransfers: false,
|
||||||
|
}),
|
||||||
|
updateTransactions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.incomingTransactionHelper.hub.on(
|
||||||
|
'transactions',
|
||||||
|
this._onIncomingTransactions.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.incomingTransactionHelper.hub.on(
|
||||||
|
'updatedLastFetchedBlockNumbers',
|
||||||
|
this._onUpdatedLastFetchedBlockNumbers.bind(this),
|
||||||
|
);
|
||||||
|
|
||||||
this.txStateManager.store.subscribe(() =>
|
this.txStateManager.store.subscribe(() =>
|
||||||
this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE),
|
this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE),
|
||||||
);
|
);
|
||||||
@ -759,6 +791,18 @@ export default class TransactionController extends EventEmitter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startIncomingTransactionPolling() {
|
||||||
|
this.incomingTransactionHelper.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopIncomingTransactionPolling() {
|
||||||
|
this.incomingTransactionHelper.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateIncomingTransactions() {
|
||||||
|
await this.incomingTransactionHelper.update();
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// PRIVATE METHODS
|
// PRIVATE METHODS
|
||||||
//
|
//
|
||||||
@ -2086,11 +2130,18 @@ export default class TransactionController extends EventEmitter {
|
|||||||
* Updates the memStore in transaction controller
|
* Updates the memStore in transaction controller
|
||||||
*/
|
*/
|
||||||
_updateMemstore() {
|
_updateMemstore() {
|
||||||
|
const { transactions } = this.store.getState();
|
||||||
const unapprovedTxs = this.txStateManager.getUnapprovedTxList();
|
const unapprovedTxs = this.txStateManager.getUnapprovedTxList();
|
||||||
|
|
||||||
const currentNetworkTxList = this.txStateManager.getTransactions({
|
const currentNetworkTxList = this.txStateManager.getTransactions({
|
||||||
limit: MAX_MEMSTORE_TX_LIST_SIZE,
|
limit: MAX_MEMSTORE_TX_LIST_SIZE,
|
||||||
});
|
});
|
||||||
this.memStore.updateState({ unapprovedTxs, currentNetworkTxList });
|
|
||||||
|
this.memStore.updateState({
|
||||||
|
unapprovedTxs,
|
||||||
|
currentNetworkTxList,
|
||||||
|
transactions,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_calculateTransactionsCost(txMeta, approvalTxMeta) {
|
_calculateTransactionsCost(txMeta, approvalTxMeta) {
|
||||||
@ -2734,6 +2785,34 @@ export default class TransactionController extends EventEmitter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onIncomingTransactions({ added: transactions }) {
|
||||||
|
log.debug('Detected new incoming transactions', transactions);
|
||||||
|
|
||||||
|
const currentTransactions = this.store.getState().transactions || {};
|
||||||
|
|
||||||
|
const incomingTransactions = transactions
|
||||||
|
.filter((tx) => !this._hasTransactionHash(tx.hash, currentTransactions))
|
||||||
|
.reduce((result, tx) => {
|
||||||
|
result[tx.id] = tx;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const updatedTransactions = {
|
||||||
|
...currentTransactions,
|
||||||
|
...incomingTransactions,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.store.updateState({ transactions: updatedTransactions });
|
||||||
|
}
|
||||||
|
|
||||||
|
_onUpdatedLastFetchedBlockNumbers({ lastFetchedBlockNumbers }) {
|
||||||
|
this.store.updateState({ lastFetchedBlockNumbers });
|
||||||
|
}
|
||||||
|
|
||||||
|
_hasTransactionHash(hash, transactions) {
|
||||||
|
return Object.values(transactions).some((tx) => tx.hash === hash);
|
||||||
|
}
|
||||||
|
|
||||||
// Approvals
|
// Approvals
|
||||||
|
|
||||||
async _requestTransactionApproval(
|
async _requestTransactionApproval(
|
||||||
|
@ -38,6 +38,7 @@ import { ORIGIN_METAMASK } from '../../../../shared/constants/app';
|
|||||||
import { NetworkStatus } from '../../../../shared/constants/network';
|
import { NetworkStatus } from '../../../../shared/constants/network';
|
||||||
import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils';
|
import { TRANSACTION_ENVELOPE_TYPE_NAMES } from '../../../../shared/lib/transactions-controller-utils';
|
||||||
import TxGasUtil from './tx-gas-utils';
|
import TxGasUtil from './tx-gas-utils';
|
||||||
|
import * as IncomingTransactionHelperClass from './IncomingTransactionHelper';
|
||||||
import TransactionController from '.';
|
import TransactionController from '.';
|
||||||
|
|
||||||
const noop = () => true;
|
const noop = () => true;
|
||||||
@ -51,6 +52,16 @@ const actionId = 'DUMMY_ACTION_ID';
|
|||||||
const VALID_ADDRESS = '0x0000000000000000000000000000000000000000';
|
const VALID_ADDRESS = '0x0000000000000000000000000000000000000000';
|
||||||
const VALID_ADDRESS_TWO = '0x0000000000000000000000000000000000000001';
|
const VALID_ADDRESS_TWO = '0x0000000000000000000000000000000000000001';
|
||||||
|
|
||||||
|
const TRANSACTION_META_MOCK = {
|
||||||
|
hash: '0x1',
|
||||||
|
id: 1,
|
||||||
|
status: TransactionStatus.confirmed,
|
||||||
|
transaction: {
|
||||||
|
from: VALID_ADDRESS,
|
||||||
|
},
|
||||||
|
time: 123456789,
|
||||||
|
};
|
||||||
|
|
||||||
async function flushPromises() {
|
async function flushPromises() {
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
}
|
}
|
||||||
@ -65,7 +76,9 @@ describe('Transaction Controller', function () {
|
|||||||
getCurrentChainId,
|
getCurrentChainId,
|
||||||
messengerMock,
|
messengerMock,
|
||||||
resultCallbacksMock,
|
resultCallbacksMock,
|
||||||
updateSpy;
|
updateSpy,
|
||||||
|
incomingTransactionHelperClassMock,
|
||||||
|
incomingTransactionHelperEventMock;
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
fragmentExists = false;
|
fragmentExists = false;
|
||||||
@ -101,6 +114,16 @@ describe('Transaction Controller', function () {
|
|||||||
call: sinon.stub(),
|
call: sinon.stub(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
incomingTransactionHelperEventMock = sinon.spy();
|
||||||
|
|
||||||
|
incomingTransactionHelperClassMock = sinon
|
||||||
|
.stub(IncomingTransactionHelperClass, 'IncomingTransactionHelper')
|
||||||
|
.returns({
|
||||||
|
hub: {
|
||||||
|
on: incomingTransactionHelperEventMock,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
txController = new TransactionController({
|
txController = new TransactionController({
|
||||||
provider,
|
provider,
|
||||||
getGasPrice() {
|
getGasPrice() {
|
||||||
@ -148,6 +171,10 @@ describe('Transaction Controller', function () {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
incomingTransactionHelperClassMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
function getLastTxMeta() {
|
function getLastTxMeta() {
|
||||||
return updateSpy.lastCall.args[0];
|
return updateSpy.lastCall.args[0];
|
||||||
}
|
}
|
||||||
@ -3374,4 +3401,78 @@ describe('Transaction Controller', function () {
|
|||||||
assert.deepEqual(transaction1, transaction2);
|
assert.deepEqual(transaction1, transaction2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('on incoming transaction helper transactions event', function () {
|
||||||
|
it('adds new transactions to state', async function () {
|
||||||
|
const existingTransaction = TRANSACTION_META_MOCK;
|
||||||
|
|
||||||
|
const incomingTransaction1 = {
|
||||||
|
...TRANSACTION_META_MOCK,
|
||||||
|
id: 2,
|
||||||
|
hash: '0x2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const incomingTransaction2 = {
|
||||||
|
...TRANSACTION_META_MOCK,
|
||||||
|
id: 3,
|
||||||
|
hash: '0x3',
|
||||||
|
};
|
||||||
|
|
||||||
|
txController.store.getState().transactions = {
|
||||||
|
[existingTransaction.id]: existingTransaction,
|
||||||
|
};
|
||||||
|
|
||||||
|
await incomingTransactionHelperEventMock.firstCall.args[1]({
|
||||||
|
added: [incomingTransaction1, incomingTransaction2],
|
||||||
|
updated: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(txController.store.getState().transactions, {
|
||||||
|
[existingTransaction.id]: existingTransaction,
|
||||||
|
[incomingTransaction1.id]: incomingTransaction1,
|
||||||
|
[incomingTransaction2.id]: incomingTransaction2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores new transactions if hash matches existing transaction', async function () {
|
||||||
|
const existingTransaction = TRANSACTION_META_MOCK;
|
||||||
|
const incomingTransaction1 = { ...TRANSACTION_META_MOCK, id: 2 };
|
||||||
|
const incomingTransaction2 = { ...TRANSACTION_META_MOCK, id: 3 };
|
||||||
|
|
||||||
|
txController.store.getState().transactions = {
|
||||||
|
[existingTransaction.id]: existingTransaction,
|
||||||
|
};
|
||||||
|
|
||||||
|
await incomingTransactionHelperEventMock.firstCall.args[1]({
|
||||||
|
added: [incomingTransaction1, incomingTransaction2],
|
||||||
|
updated: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(txController.store.getState().transactions, {
|
||||||
|
[existingTransaction.id]: existingTransaction,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('on incoming transaction helper updatedLastFetchedBlockNumbers event', function () {
|
||||||
|
it('updates state', async function () {
|
||||||
|
const lastFetchedBlockNumbers = {
|
||||||
|
key: 234,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
txController.store.getState().lastFetchedBlockNumbers,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await incomingTransactionHelperEventMock.secondCall.args[1]({
|
||||||
|
lastFetchedBlockNumbers,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
txController.store.getState().lastFetchedBlockNumbers,
|
||||||
|
lastFetchedBlockNumbers,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
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: {
|
EncryptionPublicKeyController: {
|
||||||
unapprovedEncryptionPublicKeyMsgCount: true,
|
unapprovedEncryptionPublicKeyMsgCount: true,
|
||||||
},
|
},
|
||||||
IncomingTransactionsController: {
|
|
||||||
incomingTxLastFetchedBlockByChainId: true,
|
|
||||||
},
|
|
||||||
KeyringController: {
|
KeyringController: {
|
||||||
isUnlocked: true,
|
isUnlocked: true,
|
||||||
},
|
},
|
||||||
|
@ -190,7 +190,6 @@ import CachedBalancesController from './controllers/cached-balances';
|
|||||||
import AlertController from './controllers/alert';
|
import AlertController from './controllers/alert';
|
||||||
import OnboardingController from './controllers/onboarding';
|
import OnboardingController from './controllers/onboarding';
|
||||||
import Backup from './lib/backup';
|
import Backup from './lib/backup';
|
||||||
import IncomingTransactionsController from './controllers/incoming-transactions';
|
|
||||||
import DecryptMessageController from './controllers/decrypt-message';
|
import DecryptMessageController from './controllers/decrypt-message';
|
||||||
import TransactionController from './controllers/transactions';
|
import TransactionController from './controllers/transactions';
|
||||||
import DetectTokensController from './controllers/detect-tokens';
|
import DetectTokensController from './controllers/detect-tokens';
|
||||||
@ -418,10 +417,6 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.preferencesController.store.subscribe(async ({ currentLocale }) => {
|
|
||||||
await updateCurrentLocale(currentLocale);
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokensControllerMessenger = this.controllerMessenger.getRestricted({
|
const tokensControllerMessenger = this.controllerMessenger.getRestricted({
|
||||||
name: 'TokensController',
|
name: 'TokensController',
|
||||||
allowedActions: ['ApprovalController:addRequest'],
|
allowedActions: ['ApprovalController:addRequest'],
|
||||||
@ -744,19 +739,6 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
initState: initState.OnboardingController,
|
initState: initState.OnboardingController,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.incomingTransactionsController = new IncomingTransactionsController({
|
|
||||||
blockTracker: this.blockTracker,
|
|
||||||
onNetworkDidChange: networkControllerMessenger.subscribe.bind(
|
|
||||||
networkControllerMessenger,
|
|
||||||
'NetworkController:networkDidChange',
|
|
||||||
),
|
|
||||||
getCurrentChainId: () =>
|
|
||||||
this.networkController.state.providerConfig.chainId,
|
|
||||||
preferencesController: this.preferencesController,
|
|
||||||
onboardingController: this.onboardingController,
|
|
||||||
initState: initState.IncomingTransactionsController,
|
|
||||||
});
|
|
||||||
|
|
||||||
// account tracker watches balances, nonces, and any code at their address
|
// account tracker watches balances, nonces, and any code at their address
|
||||||
this.accountTracker = new AccountTracker({
|
this.accountTracker = new AccountTracker({
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
@ -1192,6 +1174,9 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
this.networkController.state.networksMetadata?.[
|
this.networkController.state.networksMetadata?.[
|
||||||
this.networkController.state.selectedNetworkClientId
|
this.networkController.state.selectedNetworkClientId
|
||||||
]?.status,
|
]?.status,
|
||||||
|
getNetworkState: () => this.networkController.state,
|
||||||
|
hasCompletedOnboarding: () =>
|
||||||
|
this.onboardingController.store.getState().completedOnboarding,
|
||||||
onNetworkStateChange: (listener) => {
|
onNetworkStateChange: (listener) => {
|
||||||
networkControllerMessenger.subscribe(
|
networkControllerMessenger.subscribe(
|
||||||
'NetworkController:stateChange',
|
'NetworkController:stateChange',
|
||||||
@ -1660,7 +1645,6 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
CachedBalancesController: this.cachedBalancesController.store,
|
CachedBalancesController: this.cachedBalancesController.store,
|
||||||
AlertController: this.alertController.store,
|
AlertController: this.alertController.store,
|
||||||
OnboardingController: this.onboardingController.store,
|
OnboardingController: this.onboardingController.store,
|
||||||
IncomingTransactionsController: this.incomingTransactionsController.store,
|
|
||||||
PermissionController: this.permissionController,
|
PermissionController: this.permissionController,
|
||||||
PermissionLogController: this.permissionLogController.store,
|
PermissionLogController: this.permissionLogController.store,
|
||||||
SubjectMetadataController: this.subjectMetadataController,
|
SubjectMetadataController: this.subjectMetadataController,
|
||||||
@ -1706,8 +1690,6 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
CurrencyController: this.currencyRateController,
|
CurrencyController: this.currencyRateController,
|
||||||
AlertController: this.alertController.store,
|
AlertController: this.alertController.store,
|
||||||
OnboardingController: this.onboardingController.store,
|
OnboardingController: this.onboardingController.store,
|
||||||
IncomingTransactionsController:
|
|
||||||
this.incomingTransactionsController.store,
|
|
||||||
PermissionController: this.permissionController,
|
PermissionController: this.permissionController,
|
||||||
PermissionLogController: this.permissionLogController.store,
|
PermissionLogController: this.permissionLogController.store,
|
||||||
SubjectMetadataController: this.subjectMetadataController,
|
SubjectMetadataController: this.subjectMetadataController,
|
||||||
@ -1803,7 +1785,7 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
|
|
||||||
triggerNetworkrequests() {
|
triggerNetworkrequests() {
|
||||||
this.accountTracker.start();
|
this.accountTracker.start();
|
||||||
this.incomingTransactionsController.start();
|
this.txController.startIncomingTransactionPolling();
|
||||||
if (this.preferencesController.store.getState().useCurrencyRateCheck) {
|
if (this.preferencesController.store.getState().useCurrencyRateCheck) {
|
||||||
this.currencyRateController.start();
|
this.currencyRateController.start();
|
||||||
}
|
}
|
||||||
@ -1814,7 +1796,7 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
|
|
||||||
stopNetworkRequests() {
|
stopNetworkRequests() {
|
||||||
this.accountTracker.stop();
|
this.accountTracker.stop();
|
||||||
this.incomingTransactionsController.stop();
|
this.txController.stopIncomingTransactionPolling();
|
||||||
if (this.preferencesController.store.getState().useCurrencyRateCheck) {
|
if (this.preferencesController.store.getState().useCurrencyRateCheck) {
|
||||||
this.currencyRateController.stop();
|
this.currencyRateController.stop();
|
||||||
}
|
}
|
||||||
@ -1991,40 +1973,22 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
* becomes unlocked are handled in MetaMaskController._onUnlock.
|
* becomes unlocked are handled in MetaMaskController._onUnlock.
|
||||||
*/
|
*/
|
||||||
setupControllerEventSubscriptions() {
|
setupControllerEventSubscriptions() {
|
||||||
const handleAccountsChange = async (origin, newAccounts) => {
|
let lastSelectedAddress;
|
||||||
if (this.isUnlocked()) {
|
|
||||||
this.notifyConnections(origin, {
|
this.preferencesController.store.subscribe(async (state) => {
|
||||||
method: NOTIFICATION_NAMES.accountsChanged,
|
const { selectedAddress, currentLocale } = state;
|
||||||
// This should be the same as the return value of `eth_accounts`,
|
|
||||||
// namely an array of the current / most recently selected Ethereum
|
await updateCurrentLocale(currentLocale);
|
||||||
// account.
|
|
||||||
params:
|
if (state?.featureFlags?.showIncomingTransactions) {
|
||||||
newAccounts.length < 2
|
this.txController.startIncomingTransactionPolling();
|
||||||
? // If the length is 1 or 0, the accounts are sorted by definition.
|
} else {
|
||||||
newAccounts
|
this.txController.stopIncomingTransactionPolling();
|
||||||
: // If the length is 2 or greater, we have to execute
|
|
||||||
// `eth_accounts` vi this method.
|
|
||||||
await this.getPermittedAccounts(origin),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.permissionLogController.updateAccountsHistory(origin, newAccounts);
|
|
||||||
};
|
|
||||||
|
|
||||||
// This handles account changes whenever the selected address changes.
|
|
||||||
let lastSelectedAddress;
|
|
||||||
this.preferencesController.store.subscribe(async ({ selectedAddress }) => {
|
|
||||||
if (selectedAddress && selectedAddress !== lastSelectedAddress) {
|
if (selectedAddress && selectedAddress !== lastSelectedAddress) {
|
||||||
lastSelectedAddress = selectedAddress;
|
lastSelectedAddress = selectedAddress;
|
||||||
const permittedAccountsMap = getPermittedAccountsByOrigin(
|
await this._onAccountChange(selectedAddress);
|
||||||
this.permissionController.state,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [origin, accounts] of permittedAccountsMap.entries()) {
|
|
||||||
if (accounts.includes(selectedAddress)) {
|
|
||||||
handleAccountsChange(origin, accounts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2036,12 +2000,19 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
const changedAccounts = getChangedAccounts(currentValue, previousValue);
|
const changedAccounts = getChangedAccounts(currentValue, previousValue);
|
||||||
|
|
||||||
for (const [origin, accounts] of changedAccounts.entries()) {
|
for (const [origin, accounts] of changedAccounts.entries()) {
|
||||||
handleAccountsChange(origin, accounts);
|
this._notifyAccountsChange(origin, accounts);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getPermittedAccountsByOrigin,
|
getPermittedAccountsByOrigin,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.controllerMessenger.subscribe(
|
||||||
|
'NetworkController:networkDidChange',
|
||||||
|
async () => {
|
||||||
|
await this.txController.updateIncomingTransactions();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(snaps)
|
///: BEGIN:ONLY_INCLUDE_IN(snaps)
|
||||||
// Record Snap metadata whenever a Snap is added to state.
|
// Record Snap metadata whenever a Snap is added to state.
|
||||||
this.controllerMessenger.subscribe(
|
this.controllerMessenger.subscribe(
|
||||||
@ -4819,4 +4790,38 @@ export default class MetamaskController extends EventEmitter {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _onAccountChange(newAddress) {
|
||||||
|
const permittedAccountsMap = getPermittedAccountsByOrigin(
|
||||||
|
this.permissionController.state,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [origin, accounts] of permittedAccountsMap.entries()) {
|
||||||
|
if (accounts.includes(newAddress)) {
|
||||||
|
this._notifyAccountsChange(origin, accounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.txController.updateIncomingTransactions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _notifyAccountsChange(origin, newAccounts) {
|
||||||
|
if (this.isUnlocked()) {
|
||||||
|
this.notifyConnections(origin, {
|
||||||
|
method: NOTIFICATION_NAMES.accountsChanged,
|
||||||
|
// This should be the same as the return value of `eth_accounts`,
|
||||||
|
// namely an array of the current / most recently selected Ethereum
|
||||||
|
// account.
|
||||||
|
params:
|
||||||
|
newAccounts.length < 2
|
||||||
|
? // If the length is 1 or 0, the accounts are sorted by definition.
|
||||||
|
newAccounts
|
||||||
|
: // If the length is 2 or greater, we have to execute
|
||||||
|
// `eth_accounts` vi this method.
|
||||||
|
await this.getPermittedAccounts(origin),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.permissionLogController.updateAccountsHistory(origin, newAccounts);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
METAMASK_HOTLIST_DIFF_FILE,
|
METAMASK_HOTLIST_DIFF_FILE,
|
||||||
} from '@metamask/phishing-controller';
|
} from '@metamask/phishing-controller';
|
||||||
import { NetworkType } from '@metamask/controller-utils';
|
import { NetworkType } from '@metamask/controller-utils';
|
||||||
|
import { ControllerMessenger } from '@metamask/base-controller';
|
||||||
import { TransactionStatus } from '../../shared/constants/transaction';
|
import { TransactionStatus } from '../../shared/constants/transaction';
|
||||||
import createTxMeta from '../../test/lib/createTxMeta';
|
import createTxMeta from '../../test/lib/createTxMeta';
|
||||||
import { NETWORK_TYPES } from '../../shared/constants/network';
|
import { NETWORK_TYPES } from '../../shared/constants/network';
|
||||||
@ -23,6 +24,8 @@ import { createTestProviderTools } from '../../test/stub/provider';
|
|||||||
import { HardwareDeviceNames } from '../../shared/constants/hardware-wallets';
|
import { HardwareDeviceNames } from '../../shared/constants/hardware-wallets';
|
||||||
import { KeyringType } from '../../shared/constants/keyring';
|
import { KeyringType } from '../../shared/constants/keyring';
|
||||||
import { deferredPromise } from './lib/util';
|
import { deferredPromise } from './lib/util';
|
||||||
|
import TransactionController from './controllers/transactions';
|
||||||
|
import PreferencesController from './controllers/preferences';
|
||||||
|
|
||||||
const Ganache = require('../../test/e2e/ganache');
|
const Ganache = require('../../test/e2e/ganache');
|
||||||
|
|
||||||
@ -83,6 +86,14 @@ function MockEthContract() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MockPreferencesController(...args) {
|
||||||
|
const controller = new PreferencesController(...args);
|
||||||
|
|
||||||
|
sinon.stub(controller.store, 'subscribe');
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO, Feb 24, 2023:
|
// TODO, Feb 24, 2023:
|
||||||
// ethjs-contract is being added to proxyquire, but we might want to discontinue proxyquire
|
// ethjs-contract is being added to proxyquire, but we might want to discontinue proxyquire
|
||||||
// this is for expediency as we resolve a bug for v10.26.0. The proper solution here would have
|
// this is for expediency as we resolve a bug for v10.26.0. The proper solution here would have
|
||||||
@ -91,6 +102,7 @@ function MockEthContract() {
|
|||||||
const MetaMaskController = proxyquire('./metamask-controller', {
|
const MetaMaskController = proxyquire('./metamask-controller', {
|
||||||
'./lib/createLoggerMiddleware': { default: createLoggerMiddlewareMock },
|
'./lib/createLoggerMiddleware': { default: createLoggerMiddlewareMock },
|
||||||
'ethjs-contract': MockEthContract,
|
'ethjs-contract': MockEthContract,
|
||||||
|
'./controllers/preferences': { default: MockPreferencesController },
|
||||||
}).default;
|
}).default;
|
||||||
|
|
||||||
const MetaMaskControllerMV3 = proxyquire('./metamask-controller', {
|
const MetaMaskControllerMV3 = proxyquire('./metamask-controller', {
|
||||||
@ -279,6 +291,23 @@ describe('MetaMaskController', function () {
|
|||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
sandbox.spy(MetaMaskController.prototype, 'resetStates');
|
sandbox.spy(MetaMaskController.prototype, 'resetStates');
|
||||||
|
|
||||||
|
sandbox.stub(
|
||||||
|
TransactionController.prototype,
|
||||||
|
'updateIncomingTransactions',
|
||||||
|
);
|
||||||
|
|
||||||
|
sandbox.stub(
|
||||||
|
TransactionController.prototype,
|
||||||
|
'startIncomingTransactionPolling',
|
||||||
|
);
|
||||||
|
|
||||||
|
sandbox.stub(
|
||||||
|
TransactionController.prototype,
|
||||||
|
'stopIncomingTransactionPolling',
|
||||||
|
);
|
||||||
|
|
||||||
|
sandbox.spy(ControllerMessenger.prototype, 'subscribe');
|
||||||
|
|
||||||
metamaskController = new MetaMaskController({
|
metamaskController = new MetaMaskController({
|
||||||
showUserConfirmation: noop,
|
showUserConfirmation: noop,
|
||||||
encryptor: {
|
encryptor: {
|
||||||
@ -1647,6 +1676,60 @@ describe('MetaMaskController', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('incoming transactions', function () {
|
||||||
|
let txControllerStub, preferencesControllerSpy, controllerMessengerSpy;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
txControllerStub = TransactionController.prototype;
|
||||||
|
preferencesControllerSpy = metamaskController.preferencesController;
|
||||||
|
controllerMessengerSpy = ControllerMessenger.prototype;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts incoming transaction polling if show incoming transactions enabled', async function () {
|
||||||
|
assert(txControllerStub.startIncomingTransactionPolling.notCalled);
|
||||||
|
|
||||||
|
await preferencesControllerSpy.store.subscribe.lastCall.args[0]({
|
||||||
|
featureFlags: {
|
||||||
|
showIncomingTransactions: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(txControllerStub.startIncomingTransactionPolling.calledOnce);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops incoming transaction polling if show incoming transactions disabled', async function () {
|
||||||
|
assert(txControllerStub.stopIncomingTransactionPolling.notCalled);
|
||||||
|
|
||||||
|
await preferencesControllerSpy.store.subscribe.lastCall.args[0]({
|
||||||
|
featureFlags: {
|
||||||
|
showIncomingTransactions: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(txControllerStub.stopIncomingTransactionPolling.calledOnce);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates incoming transactions when changing account', async function () {
|
||||||
|
assert(txControllerStub.updateIncomingTransactions.notCalled);
|
||||||
|
|
||||||
|
await preferencesControllerSpy.store.subscribe.lastCall.args[0]({
|
||||||
|
selectedAddress: 'foo',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert(txControllerStub.updateIncomingTransactions.calledOnce);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates incoming transactions when changing network', async function () {
|
||||||
|
assert(txControllerStub.updateIncomingTransactions.notCalled);
|
||||||
|
|
||||||
|
await controllerMessengerSpy.subscribe.args
|
||||||
|
.filter((args) => args[0] === 'NetworkController:networkDidChange')
|
||||||
|
.slice(-1)[0][1]();
|
||||||
|
|
||||||
|
assert(txControllerStub.updateIncomingTransactions.calledOnce);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MV3 Specific behaviour', function () {
|
describe('MV3 Specific behaviour', function () {
|
||||||
|
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 m092point1 from './092.1';
|
||||||
import * as m093 from './093';
|
import * as m093 from './093';
|
||||||
import * as m094 from './094';
|
import * as m094 from './094';
|
||||||
|
import * as m095 from './095';
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
m002,
|
m002,
|
||||||
@ -195,5 +196,6 @@ const migrations = [
|
|||||||
m092point1,
|
m092point1,
|
||||||
m093,
|
m093,
|
||||||
m094,
|
m094,
|
||||||
|
m095,
|
||||||
];
|
];
|
||||||
export default migrations;
|
export default migrations;
|
||||||
|
@ -4,6 +4,9 @@ module.exports = {
|
|||||||
'<rootDir>/app/scripts/controllers/permissions/**/*.js',
|
'<rootDir>/app/scripts/controllers/permissions/**/*.js',
|
||||||
'<rootDir>/app/scripts/controllers/sign.ts',
|
'<rootDir>/app/scripts/controllers/sign.ts',
|
||||||
'<rootDir>/app/scripts/controllers/decrypt-message.ts',
|
'<rootDir>/app/scripts/controllers/decrypt-message.ts',
|
||||||
|
'<rootDir>/app/scripts/controllers/transactions/etherscan.ts',
|
||||||
|
'<rootDir>/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.ts',
|
||||||
|
'<rootDir>/app/scripts/controllers/transactions/IncomingTransactionHelper.ts',
|
||||||
'<rootDir>/app/scripts/flask/**/*.js',
|
'<rootDir>/app/scripts/flask/**/*.js',
|
||||||
'<rootDir>/app/scripts/lib/**/*.js',
|
'<rootDir>/app/scripts/lib/**/*.js',
|
||||||
'<rootDir>/app/scripts/lib/createRPCMethodTrackingMiddleware.js',
|
'<rootDir>/app/scripts/lib/createRPCMethodTrackingMiddleware.js',
|
||||||
@ -37,6 +40,9 @@ module.exports = {
|
|||||||
testMatch: [
|
testMatch: [
|
||||||
'<rootDir>/app/scripts/constants/error-utils.test.js',
|
'<rootDir>/app/scripts/constants/error-utils.test.js',
|
||||||
'<rootDir>/app/scripts/controllers/app-state.test.js',
|
'<rootDir>/app/scripts/controllers/app-state.test.js',
|
||||||
|
'<rootDir>/app/scripts/controllers/transactions/etherscan.test.ts',
|
||||||
|
'<rootDir>/app/scripts/controllers/transactions/EtherscanRemoteTransactionSource.test.ts',
|
||||||
|
'<rootDir>/app/scripts/controllers/transactions/IncomingTransactionHelper.test.ts',
|
||||||
'<rootDir>/app/scripts/controllers/mmi-controller.test.js',
|
'<rootDir>/app/scripts/controllers/mmi-controller.test.js',
|
||||||
'<rootDir>/app/scripts/controllers/permissions/**/*.test.js',
|
'<rootDir>/app/scripts/controllers/permissions/**/*.test.js',
|
||||||
'<rootDir>/app/scripts/controllers/sign.test.ts',
|
'<rootDir>/app/scripts/controllers/sign.test.ts',
|
||||||
|
@ -269,7 +269,7 @@ export interface TxParams {
|
|||||||
/** The amount of wei, in hexadecimal, to send */
|
/** The amount of wei, in hexadecimal, to send */
|
||||||
value: string;
|
value: string;
|
||||||
/** The transaction count for the current account/network */
|
/** The transaction count for the current account/network */
|
||||||
nonce: number;
|
nonce: string;
|
||||||
/** The amount of gwei, in hexadecimal, per unit of gas */
|
/** The amount of gwei, in hexadecimal, per unit of gas */
|
||||||
gasPrice?: string;
|
gasPrice?: string;
|
||||||
/** The max amount of gwei, in hexadecimal, the user is willing to pay */
|
/** The max amount of gwei, in hexadecimal, the user is willing to pay */
|
||||||
@ -329,6 +329,7 @@ export interface TransactionMeta {
|
|||||||
* on incoming transactions!
|
* on incoming transactions!
|
||||||
*/
|
*/
|
||||||
blockNumber?: string;
|
blockNumber?: string;
|
||||||
|
chainId: string;
|
||||||
/** An internally unique tx identifier. */
|
/** An internally unique tx identifier. */
|
||||||
id: number;
|
id: number;
|
||||||
/** Time the transaction was first suggested, in unix epoch time (ms). */
|
/** Time the transaction was first suggested, in unix epoch time (ms). */
|
||||||
|
@ -197,16 +197,6 @@ function defaultFixture() {
|
|||||||
gasEstimateType: 'none',
|
gasEstimateType: 'none',
|
||||||
gasFeeEstimates: {},
|
gasFeeEstimates: {},
|
||||||
},
|
},
|
||||||
IncomingTransactionsController: {
|
|
||||||
incomingTransactions: {},
|
|
||||||
incomingTxLastFetchedBlockByChainId: {
|
|
||||||
[CHAIN_IDS.MAINNET]: null,
|
|
||||||
[CHAIN_IDS.LINEA_MAINNET]: null,
|
|
||||||
[CHAIN_IDS.GOERLI]: null,
|
|
||||||
[CHAIN_IDS.SEPOLIA]: null,
|
|
||||||
[CHAIN_IDS.LINEA_GOERLI]: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
KeyringController: {
|
KeyringController: {
|
||||||
vault:
|
vault:
|
||||||
'{"data":"s6TpYjlUNsn7ifhEFTkuDGBUM1GyOlPrim7JSjtfIxgTt8/6MiXgiR/CtFfR4dWW2xhq85/NGIBYEeWrZThGdKGarBzeIqBfLFhw9n509jprzJ0zc2Rf+9HVFGLw+xxC4xPxgCS0IIWeAJQ+XtGcHmn0UZXriXm8Ja4kdlow6SWinB7sr/WM3R0+frYs4WgllkwggDf2/Tv6VHygvLnhtzp6hIJFyTjh+l/KnyJTyZW1TkZhDaNDzX3SCOHT","iv":"FbeHDAW5afeWNORfNJBR0Q==","salt":"TxZ+WbCW6891C9LK/hbMAoUsSEW1E8pyGLVBU6x5KR8="}',
|
'{"data":"s6TpYjlUNsn7ifhEFTkuDGBUM1GyOlPrim7JSjtfIxgTt8/6MiXgiR/CtFfR4dWW2xhq85/NGIBYEeWrZThGdKGarBzeIqBfLFhw9n509jprzJ0zc2Rf+9HVFGLw+xxC4xPxgCS0IIWeAJQ+XtGcHmn0UZXriXm8Ja4kdlow6SWinB7sr/WM3R0+frYs4WgllkwggDf2/Tv6VHygvLnhtzp6hIJFyTjh+l/KnyJTyZW1TkZhDaNDzX3SCOHT","iv":"FbeHDAW5afeWNORfNJBR0Q==","salt":"TxZ+WbCW6891C9LK/hbMAoUsSEW1E8pyGLVBU6x5KR8="}',
|
||||||
@ -487,40 +477,6 @@ class FixtureBuilder {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
withIncomingTransactionsController(data) {
|
|
||||||
merge(
|
|
||||||
this.fixture.data.IncomingTransactionsController
|
|
||||||
? this.fixture.data.IncomingTransactionsController
|
|
||||||
: (this.fixture.data.IncomingTransactionsController = {}),
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
withIncomingTransactionsControllerOneTransaction() {
|
|
||||||
return this.withIncomingTransactionsController({
|
|
||||||
incomingTransactions: {
|
|
||||||
'0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab': {
|
|
||||||
blockNumber: '1',
|
|
||||||
chainId: CHAIN_IDS.LOCALHOST,
|
|
||||||
hash: '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab',
|
|
||||||
id: 5748272735958807,
|
|
||||||
metamaskNetworkId: '1337',
|
|
||||||
status: 'confirmed',
|
|
||||||
time: 1671635520000,
|
|
||||||
txParams: {
|
|
||||||
from: '0xc87261ba337be737fa744f50e7aaf4a920bdfcd6',
|
|
||||||
gas: '0x5208',
|
|
||||||
gasPrice: '0x329af9707',
|
|
||||||
to: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1',
|
|
||||||
value: '0xDE0B6B3A7640000',
|
|
||||||
},
|
|
||||||
type: 'incoming',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
withKeyringController(data) {
|
withKeyringController(data) {
|
||||||
merge(this.fixture.data.KeyringController, data);
|
merge(this.fixture.data.KeyringController, data);
|
||||||
return this;
|
return this;
|
||||||
@ -1488,6 +1444,47 @@ class FixtureBuilder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withTransactionControllerIncomingTransaction() {
|
||||||
|
return this.withTransactionController({
|
||||||
|
transactions: {
|
||||||
|
5748272735958807: {
|
||||||
|
blockNumber: '1',
|
||||||
|
chainId: CHAIN_IDS.LOCALHOST,
|
||||||
|
hash: '0xf1af8286e4fa47578c2aec5f08c108290643df978ebc766d72d88476eee90bab',
|
||||||
|
id: 5748272735958807,
|
||||||
|
metamaskNetworkId: '1337',
|
||||||
|
status: 'confirmed',
|
||||||
|
time: 1671635520000,
|
||||||
|
txParams: {
|
||||||
|
from: '0xc87261ba337be737fa744f50e7aaf4a920bdfcd6',
|
||||||
|
gas: '0x5208',
|
||||||
|
gasPrice: '0x329af9707',
|
||||||
|
to: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1',
|
||||||
|
value: '0xDE0B6B3A7640000',
|
||||||
|
},
|
||||||
|
type: 'incoming',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
withTransactionControllerCompletedAndIncomingTransaction() {
|
||||||
|
const completedTransaction =
|
||||||
|
this.withTransactionControllerCompletedTransaction().fixture.data
|
||||||
|
.TransactionController.transactions;
|
||||||
|
|
||||||
|
const incomingTransaction =
|
||||||
|
this.withTransactionControllerIncomingTransaction().fixture.data
|
||||||
|
.TransactionController.transactions;
|
||||||
|
|
||||||
|
return this.withTransactionController({
|
||||||
|
transactions: {
|
||||||
|
...completedTransaction,
|
||||||
|
...incomingTransaction,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
this.fixture.meta = {
|
this.fixture.meta = {
|
||||||
version: 74,
|
version: 74,
|
||||||
|
@ -21,8 +21,7 @@ describe('Clear account activity', function () {
|
|||||||
await withFixtures(
|
await withFixtures(
|
||||||
{
|
{
|
||||||
fixtures: new FixtureBuilder()
|
fixtures: new FixtureBuilder()
|
||||||
.withTransactionControllerCompletedTransaction()
|
.withTransactionControllerCompletedAndIncomingTransaction()
|
||||||
.withIncomingTransactionsControllerOneTransaction()
|
|
||||||
.build(),
|
.build(),
|
||||||
ganacheOptions,
|
ganacheOptions,
|
||||||
title: this.test.title,
|
title: this.test.title,
|
||||||
|
@ -57,16 +57,6 @@
|
|||||||
},
|
},
|
||||||
"EnsController": "object",
|
"EnsController": "object",
|
||||||
"GasFeeController": "object",
|
"GasFeeController": "object",
|
||||||
"IncomingTransactionsController": {
|
|
||||||
"incomingTransactions": "object",
|
|
||||||
"incomingTxLastFetchedBlockByChainId": {
|
|
||||||
"0x1": null,
|
|
||||||
"0xe708": null,
|
|
||||||
"0x5": null,
|
|
||||||
"0xaa36a7": null,
|
|
||||||
"0xe704": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"KeyringController": {
|
"KeyringController": {
|
||||||
"isUnlocked": false,
|
"isUnlocked": false,
|
||||||
"keyringTypes": "object",
|
"keyringTypes": "object",
|
||||||
|
@ -100,6 +100,7 @@
|
|||||||
"metaMetricsId": "fake-metrics-id",
|
"metaMetricsId": "fake-metrics-id",
|
||||||
"eventsBeforeMetricsOptIn": "object",
|
"eventsBeforeMetricsOptIn": "object",
|
||||||
"traits": "object",
|
"traits": "object",
|
||||||
|
"transactions": "object",
|
||||||
"fragments": "object",
|
"fragments": "object",
|
||||||
"segmentApiCalls": "object",
|
"segmentApiCalls": "object",
|
||||||
"previousUserTraits": "object",
|
"previousUserTraits": "object",
|
||||||
@ -113,14 +114,6 @@
|
|||||||
"web3ShimUsageOrigins": "object",
|
"web3ShimUsageOrigins": "object",
|
||||||
"seedPhraseBackedUp": true,
|
"seedPhraseBackedUp": true,
|
||||||
"onboardingTabs": "object",
|
"onboardingTabs": "object",
|
||||||
"incomingTransactions": "object",
|
|
||||||
"incomingTxLastFetchedBlockByChainId": {
|
|
||||||
"0x1": null,
|
|
||||||
"0xe708": null,
|
|
||||||
"0x5": null,
|
|
||||||
"0xaa36a7": null,
|
|
||||||
"0xe704": null
|
|
||||||
},
|
|
||||||
"subjects": "object",
|
"subjects": "object",
|
||||||
"permissionHistory": "object",
|
"permissionHistory": "object",
|
||||||
"permissionActivityLog": "object",
|
"permissionActivityLog": "object",
|
||||||
|
@ -32,16 +32,6 @@
|
|||||||
"usdConversionRate": "number"
|
"usdConversionRate": "number"
|
||||||
},
|
},
|
||||||
"GasFeeController": "object",
|
"GasFeeController": "object",
|
||||||
"IncomingTransactionsController": {
|
|
||||||
"incomingTransactions": "object",
|
|
||||||
"incomingTxLastFetchedBlockByChainId": {
|
|
||||||
"0x1": null,
|
|
||||||
"0xe708": null,
|
|
||||||
"0x5": null,
|
|
||||||
"0xaa36a7": null,
|
|
||||||
"0xe704": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"KeyringController": { "vault": "string" },
|
"KeyringController": { "vault": "string" },
|
||||||
"MetaMetricsController": {
|
"MetaMetricsController": {
|
||||||
"eventsBeforeMetricsOptIn": "object",
|
"eventsBeforeMetricsOptIn": "object",
|
||||||
|
@ -32,16 +32,6 @@
|
|||||||
"usdConversionRate": "number"
|
"usdConversionRate": "number"
|
||||||
},
|
},
|
||||||
"GasFeeController": "object",
|
"GasFeeController": "object",
|
||||||
"IncomingTransactionsController": {
|
|
||||||
"incomingTransactions": "object",
|
|
||||||
"incomingTxLastFetchedBlockByChainId": {
|
|
||||||
"0x1": null,
|
|
||||||
"0xe708": null,
|
|
||||||
"0x5": null,
|
|
||||||
"0xaa36a7": null,
|
|
||||||
"0xe704": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"KeyringController": { "vault": "string" },
|
"KeyringController": { "vault": "string" },
|
||||||
"MetaMetricsController": {
|
"MetaMetricsController": {
|
||||||
"eventsBeforeMetricsOptIn": "object",
|
"eventsBeforeMetricsOptIn": "object",
|
||||||
|
@ -205,7 +205,6 @@ describe('Account Details Modal', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
cachedBalances: {},
|
cachedBalances: {},
|
||||||
incomingTransactions: {},
|
|
||||||
selectedAddress: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
|
selectedAddress: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
|
||||||
accounts: {
|
accounts: {
|
||||||
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': {
|
'0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc': {
|
||||||
|
@ -83,7 +83,7 @@ const getStateTree = ({
|
|||||||
featureFlags: {
|
featureFlags: {
|
||||||
showIncomingTransactions: true,
|
showIncomingTransactions: true,
|
||||||
},
|
},
|
||||||
incomingTransactions: [...incomingTxList],
|
transactions: [...incomingTxList],
|
||||||
currentNetworkTxList: [...txList],
|
currentNetworkTxList: [...txList],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -27,6 +27,7 @@ const INVALID_INITIAL_TRANSACTION_TYPES = [
|
|||||||
|
|
||||||
export const incomingTxListSelector = (state) => {
|
export const incomingTxListSelector = (state) => {
|
||||||
const { showIncomingTransactions } = state.metamask.featureFlags;
|
const { showIncomingTransactions } = state.metamask.featureFlags;
|
||||||
|
|
||||||
if (!showIncomingTransactions) {
|
if (!showIncomingTransactions) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -34,8 +35,10 @@ export const incomingTxListSelector = (state) => {
|
|||||||
const { networkId } = state.metamask;
|
const { networkId } = state.metamask;
|
||||||
const { chainId } = getProviderConfig(state);
|
const { chainId } = getProviderConfig(state);
|
||||||
const selectedAddress = getSelectedAddress(state);
|
const selectedAddress = getSelectedAddress(state);
|
||||||
return Object.values(state.metamask.incomingTransactions).filter(
|
|
||||||
|
return Object.values(state.metamask.transactions || {}).filter(
|
||||||
(tx) =>
|
(tx) =>
|
||||||
|
tx.type === TransactionType.incoming &&
|
||||||
tx.txParams.to === selectedAddress &&
|
tx.txParams.to === selectedAddress &&
|
||||||
transactionMatchesNetwork(tx, chainId, networkId),
|
transactionMatchesNetwork(tx, chainId, networkId),
|
||||||
);
|
);
|
||||||
|
@ -85,6 +85,7 @@ import {
|
|||||||
import { decimalToHex } from '../../shared/modules/conversion.utils';
|
import { decimalToHex } from '../../shared/modules/conversion.utils';
|
||||||
import { TxGasFees, PriorityLevels } from '../../shared/constants/gas';
|
import { TxGasFees, PriorityLevels } from '../../shared/constants/gas';
|
||||||
import {
|
import {
|
||||||
|
TransactionMeta,
|
||||||
TransactionMetaMetricsEvent,
|
TransactionMetaMetricsEvent,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
} from '../../shared/constants/transaction';
|
} from '../../shared/constants/transaction';
|
||||||
@ -94,10 +95,9 @@ import {
|
|||||||
isErrorWithMessage,
|
isErrorWithMessage,
|
||||||
logErrorWithMessage,
|
logErrorWithMessage,
|
||||||
} from '../../shared/modules/error';
|
} from '../../shared/modules/error';
|
||||||
import { TransactionMeta } from '../../app/scripts/controllers/incoming-transactions';
|
|
||||||
import { TxParams } from '../../app/scripts/controllers/transactions/tx-state-manager';
|
import { TxParams } from '../../app/scripts/controllers/transactions/tx-state-manager';
|
||||||
import { CustomGasSettings } from '../../app/scripts/controllers/transactions';
|
|
||||||
import { ThemeType } from '../../shared/constants/preferences';
|
import { ThemeType } from '../../shared/constants/preferences';
|
||||||
|
import { CustomGasSettings } from '../../app/scripts/controllers/transactions';
|
||||||
import * as actionConstants from './actionConstants';
|
import * as actionConstants from './actionConstants';
|
||||||
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
|
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
|
||||||
import { updateCustodyState } from './institutional/institution-actions';
|
import { updateCustodyState } from './institutional/institution-actions';
|
||||||
|
Loading…
Reference in New Issue
Block a user