diff --git a/jest.config.js b/jest.config.js index a1f4e7e..77098f3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,7 +2,7 @@ module.exports = { collectCoverage: true, // Ensures that we collect coverage from all source files, not just tested // ones. - collectCoverageFrom: ['./src/**.ts'], + collectCoverageFrom: ['./src/**.ts', '!./src/index.ts'], coverageReporters: ['text', 'html'], coverageThreshold: { global: { diff --git a/src/SmartTransactionsController.test.ts b/src/SmartTransactionsController.test.ts index 3f55dd2..18e7068 100644 --- a/src/SmartTransactionsController.test.ts +++ b/src/SmartTransactionsController.test.ts @@ -17,7 +17,6 @@ import { type NetworkState, } from '@metamask/network-controller'; import type { - TransactionControllerConfirmExternalTransactionAction, TransactionControllerGetNonceLockAction, TransactionControllerGetTransactionsAction, TransactionControllerUpdateTransactionAction, @@ -25,7 +24,6 @@ import type { import { type TransactionParams, TransactionStatus, - TransactionType, } from '@metamask/transaction-controller'; import nock from 'nock'; import * as sinon from 'sinon'; @@ -56,7 +54,7 @@ type RootMessenger = Messenger; jest.mock('@metamask/eth-query', () => { const EthQuery = jest.requireActual('@metamask/eth-query'); return class FakeEthQuery extends EthQuery { - sendAsync = jest.fn(({ method }, callback) => { + sendAsync = jest.fn(({ method, params }, callback) => { switch (method) { case 'eth_getBalance': { callback(null, '0x1000'); @@ -64,7 +62,13 @@ jest.mock('@metamask/eth-query', () => { } case 'eth_getTransactionReceipt': { - callback(null, { blockNumber: '123' }); + // Return null if txHash is empty/falsy + const txHash = params?.[0]; + if (txHash) { + callback(null, { blockNumber: '123' }); + } else { + callback(null, null); + } break; } @@ -308,37 +312,6 @@ const testHistory = [ }, ]; -const createTransactionMeta = ( - status: TransactionStatus = TransactionStatus.signed, -) => { - return { - hash: txHash, - status, - id: '1', - txParams: { - from: addressFrom, - to: '0x1678a085c290ebd122dc42cba69373b5953b831d', - gasPrice: '0x77359400', - gas: '0x7b0d', - nonce: '0x4b', - }, - type: TransactionType.simpleSend, - chainId: ChainId.mainnet, - time: 1624408066355, - defaultGasEstimates: { - gas: '0x7b0d', - gasPrice: '0x77359400', - }, - error: { - name: 'Error', - message: 'Details of the error', - }, - securityProviderResponse: { - flagAsDangerous: 0, - }, - }; -}; - const ethereumChainIdDec = parseInt(ChainId.mainnet, 16); const sepoliaChainIdDec = parseInt(ChainId.sepolia, 16); @@ -460,6 +433,7 @@ describe('SmartTransactionsController', () => { controller.timeoutHandle = setTimeout(() => ({})); + // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.poll(1000); expect(updateSmartTransactionsSpy).toHaveBeenCalled(); @@ -1225,301 +1199,6 @@ describe('SmartTransactionsController', () => { ); }); - it('confirms a smart transaction that has status success', async () => { - const { smartTransactionsState } = - getDefaultSmartTransactionsControllerState(); - const pendingStx = { - ...createStateAfterPending()[0], - history: testHistory, - }; - const confirmExternalTransactionSpy = jest.fn(); - const getRegularTransactionsSpy = jest.fn().mockImplementation(() => { - return [createTransactionMeta()]; - }); - await withController( - { - options: { - state: { - smartTransactionsState: { - ...smartTransactionsState, - smartTransactions: { - [ChainId.mainnet]: [pendingStx] as SmartTransaction[], - }, - }, - }, - }, - confirmExternalTransaction: confirmExternalTransactionSpy, - getTransactions: getRegularTransactionsSpy, - }, - async ({ controller }) => { - const updateTransaction = { - ...pendingStx, - statusMetadata: { - ...pendingStx.statusMetadata, - minedHash: txHash, - }, - status: SmartTransactionStatuses.SUCCESS, - }; - - controller.updateSmartTransaction( - updateTransaction as SmartTransaction, - { - networkClientId: NetworkType.mainnet, - }, - ); - await flushPromises(); - - expect(confirmExternalTransactionSpy).toHaveBeenCalledTimes(1); - expect( - controller.state.smartTransactionsState.smartTransactions[ - ChainId.mainnet - ], - ).toStrictEqual([ - { - ...updateTransaction, - confirmed: true, - }, - ]); - }, - ); - }); - - it('confirms a smart transaction that was not found in the list of regular transactions', async () => { - const { smartTransactionsState } = - getDefaultSmartTransactionsControllerState(); - const pendingStx = { - ...createStateAfterPending()[0], - history: testHistory, - }; - const confirmExternalTransactionSpy = jest.fn(); - const getRegularTransactionsSpy = jest.fn().mockImplementation(() => { - return []; - }); - await withController( - { - options: { - state: { - smartTransactionsState: { - ...smartTransactionsState, - smartTransactions: { - [ChainId.mainnet]: [pendingStx] as SmartTransaction[], - }, - }, - }, - }, - confirmExternalTransaction: confirmExternalTransactionSpy, - getTransactions: getRegularTransactionsSpy, - }, - async ({ controller }) => { - const updateTransaction = { - ...pendingStx, - statusMetadata: { - ...pendingStx.statusMetadata, - minedHash: txHash, - }, - status: SmartTransactionStatuses.SUCCESS, - }; - - controller.updateSmartTransaction( - updateTransaction as SmartTransaction, - { - networkClientId: NetworkType.mainnet, - }, - ); - await flushPromises(); - - expect(confirmExternalTransactionSpy).toHaveBeenCalledTimes(1); - expect( - controller.state.smartTransactionsState.smartTransactions[ - ChainId.mainnet - ], - ).toStrictEqual([ - { - ...updateTransaction, - confirmed: true, - }, - ]); - }, - ); - }); - - it('confirms a smart transaction that does not have a minedHash', async () => { - const { smartTransactionsState } = - getDefaultSmartTransactionsControllerState(); - const pendingStx = { - ...createStateAfterPending()[0], - history: testHistory, - }; - const confirmExternalTransactionSpy = jest.fn(); - const getRegularTransactionsSpy = jest.fn().mockImplementation(() => { - return [createTransactionMeta(TransactionStatus.confirmed)]; - }); - await withController( - { - options: { - state: { - smartTransactionsState: { - ...smartTransactionsState, - smartTransactions: { - [ChainId.mainnet]: [pendingStx] as SmartTransaction[], - }, - }, - }, - }, - confirmExternalTransaction: confirmExternalTransactionSpy, - getTransactions: getRegularTransactionsSpy, - }, - async ({ controller }) => { - const updateTransaction = { - ...pendingStx, - statusMetadata: { - ...pendingStx.statusMetadata, - minedHash: '', - }, - status: SmartTransactionStatuses.SUCCESS, - }; - - controller.updateSmartTransaction( - updateTransaction as SmartTransaction, - { - networkClientId: NetworkType.mainnet, - }, - ); - await flushPromises(); - - expect(confirmExternalTransactionSpy).toHaveBeenCalledTimes(1); - expect( - controller.state.smartTransactionsState.smartTransactions[ - ChainId.mainnet - ], - ).toStrictEqual([ - { - ...updateTransaction, - confirmed: true, - }, - ]); - }, - ); - }); - - it('does not call the "confirmExternalTransaction" fn if a tx is already confirmed', async () => { - const { smartTransactionsState } = - getDefaultSmartTransactionsControllerState(); - const pendingStx = { - ...createStateAfterPending()[0], - history: testHistory, - }; - const confirmExternalTransactionSpy = jest.fn(); - const getRegularTransactionsSpy = jest.fn().mockImplementation(() => { - return [createTransactionMeta(TransactionStatus.confirmed)]; - }); - await withController( - { - options: { - state: { - smartTransactionsState: { - ...smartTransactionsState, - smartTransactions: { - [ChainId.mainnet]: [pendingStx] as SmartTransaction[], - }, - }, - }, - }, - confirmExternalTransaction: confirmExternalTransactionSpy, - getTransactions: getRegularTransactionsSpy, - }, - async ({ controller }) => { - const updateTransaction = { - ...pendingStx, - status: SmartTransactionStatuses.SUCCESS, - statusMetadata: { - ...pendingStx.statusMetadata, - minedHash: txHash, - }, - }; - - controller.updateSmartTransaction( - updateTransaction as SmartTransaction, - { - networkClientId: NetworkType.mainnet, - }, - ); - await flushPromises(); - - expect(confirmExternalTransactionSpy).not.toHaveBeenCalled(); - expect( - controller.state.smartTransactionsState.smartTransactions[ - ChainId.mainnet - ], - ).toStrictEqual([ - { - ...updateTransaction, - confirmed: true, - }, - ]); - }, - ); - }); - - it('does not call the "confirmExternalTransaction" fn if a tx is already submitted', async () => { - const { smartTransactionsState } = - getDefaultSmartTransactionsControllerState(); - const pendingStx = { - ...createStateAfterPending()[0], - history: testHistory, - }; - const confirmExternalTransactionSpy = jest.fn(); - const getRegularTransactionsSpy = jest.fn().mockImplementation(() => { - return [createTransactionMeta(TransactionStatus.submitted)]; - }); - await withController( - { - options: { - state: { - smartTransactionsState: { - ...smartTransactionsState, - smartTransactions: { - [ChainId.mainnet]: [pendingStx] as SmartTransaction[], - }, - }, - }, - }, - confirmExternalTransaction: confirmExternalTransactionSpy, - getTransactions: getRegularTransactionsSpy, - }, - async ({ controller }) => { - const updateTransaction = { - ...pendingStx, - status: SmartTransactionStatuses.SUCCESS, - statusMetadata: { - ...pendingStx.statusMetadata, - minedHash: txHash, - }, - }; - - controller.updateSmartTransaction( - updateTransaction as SmartTransaction, - { - networkClientId: NetworkType.mainnet, - }, - ); - await flushPromises(); - - expect(confirmExternalTransactionSpy).not.toHaveBeenCalled(); - expect( - controller.state.smartTransactionsState.smartTransactions[ - ChainId.mainnet - ], - ).toStrictEqual([ - { - ...updateTransaction, - confirmed: true, - }, - ]); - }, - ); - }); - it('calls updateTransaction when smart transaction is cancelled and returnTxHashAsap is true', async () => { const mockUpdateTransaction = jest.fn(); const defaultState = getDefaultSmartTransactionsControllerState(); @@ -1691,6 +1370,147 @@ describe('SmartTransactionsController', () => { }, ); }); + + it('confirms a smart transaction with SUCCESS status', async () => { + const { smartTransactionsState } = + getDefaultSmartTransactionsControllerState(); + const pendingStx = { + ...createStateAfterPending()[0], + history: testHistory, + }; + await withController( + { + options: { + state: { + smartTransactionsState: { + ...smartTransactionsState, + smartTransactions: { + [ChainId.mainnet]: [pendingStx] as SmartTransaction[], + }, + }, + }, + }, + }, + async ({ controller }) => { + const updateTransaction = { + ...pendingStx, + statusMetadata: { + ...pendingStx.statusMetadata, + minedHash: txHash, + }, + status: SmartTransactionStatuses.SUCCESS, + }; + + controller.updateSmartTransaction( + updateTransaction as SmartTransaction, + { + networkClientId: NetworkType.mainnet, + }, + ); + await flushPromises(); + + expect( + controller.state.smartTransactionsState.smartTransactions[ + ChainId.mainnet + ][0].confirmed, + ).toBe(true); + }, + ); + }); + + it('confirms a smart transaction with REVERTED status', async () => { + const { smartTransactionsState } = + getDefaultSmartTransactionsControllerState(); + const pendingStx = { + ...createStateAfterPending()[0], + history: testHistory, + }; + await withController( + { + options: { + state: { + smartTransactionsState: { + ...smartTransactionsState, + smartTransactions: { + [ChainId.mainnet]: [pendingStx] as SmartTransaction[], + }, + }, + }, + }, + }, + async ({ controller }) => { + const updateTransaction = { + ...pendingStx, + statusMetadata: { + ...pendingStx.statusMetadata, + minedHash: txHash, + }, + status: SmartTransactionStatuses.REVERTED, + }; + + controller.updateSmartTransaction( + updateTransaction as SmartTransaction, + { + networkClientId: NetworkType.mainnet, + }, + ); + await flushPromises(); + + expect( + controller.state.smartTransactionsState.smartTransactions[ + ChainId.mainnet + ][0].confirmed, + ).toBe(true); + }, + ); + }); + + it('does not confirm a smart transaction that does not have a minedHash', async () => { + const { smartTransactionsState } = + getDefaultSmartTransactionsControllerState(); + const pendingStx = { + ...createStateAfterPending()[0], + history: testHistory, + }; + await withController( + { + options: { + state: { + smartTransactionsState: { + ...smartTransactionsState, + smartTransactions: { + [ChainId.mainnet]: [pendingStx] as SmartTransaction[], + }, + }, + }, + }, + }, + async ({ controller }) => { + const updateTransaction = { + ...pendingStx, + statusMetadata: { + ...pendingStx.statusMetadata, + minedHash: '', + }, + status: SmartTransactionStatuses.SUCCESS, + }; + + controller.updateSmartTransaction( + updateTransaction as SmartTransaction, + { + networkClientId: NetworkType.mainnet, + }, + ); + await flushPromises(); + + expect( + controller.state.smartTransactionsState.smartTransactions[ + ChainId.mainnet + ][0].confirmed, + ).toBeUndefined(); + }, + ); + }); }); describe('cancelSmartTransaction', () => { @@ -2691,7 +2511,6 @@ type WithControllerOptions = { ConstructorParameters[0] >; getNonceLock?: TransactionControllerGetNonceLockAction['handler']; - confirmExternalTransaction?: TransactionControllerConfirmExternalTransactionAction['handler']; getTransactions?: TransactionControllerGetTransactionsAction['handler']; updateTransaction?: TransactionControllerUpdateTransactionAction['handler']; }; @@ -2719,7 +2538,6 @@ async function withController( nextNonce: 42, releaseLock: jest.fn(), }), - confirmExternalTransaction = jest.fn(), getTransactions = jest.fn(), updateTransaction = jest.fn(), } = rest; @@ -2792,10 +2610,6 @@ async function withController( 'TransactionController:getNonceLock', getNonceLock, ); - rootMessenger.registerActionHandler( - 'TransactionController:confirmExternalTransaction', - confirmExternalTransaction, - ); rootMessenger.registerActionHandler( 'TransactionController:getTransactions', getTransactions, @@ -2820,7 +2634,6 @@ async function withController( 'NetworkController:getNetworkClientById', 'NetworkController:getState', 'TransactionController:getNonceLock', - 'TransactionController:confirmExternalTransaction', 'TransactionController:getTransactions', 'TransactionController:updateTransaction', ], @@ -2880,6 +2693,7 @@ async function withController( triggerNetworStateChange, }); } finally { + // eslint-disable-next-line @typescript-eslint/no-floating-promises controller.stop(); controller.stopAllPolling(); } diff --git a/src/SmartTransactionsController.ts b/src/SmartTransactionsController.ts index a9298ce..3c8b67a 100644 --- a/src/SmartTransactionsController.ts +++ b/src/SmartTransactionsController.ts @@ -21,14 +21,12 @@ import type { } from '@metamask/network-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { - TransactionControllerConfirmExternalTransactionAction, TransactionControllerGetNonceLockAction, TransactionControllerGetTransactionsAction, TransactionControllerUpdateTransactionAction, TransactionMeta, TransactionParams, } from '@metamask/transaction-controller'; -import { TransactionStatus } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; import cloneDeep from 'lodash/cloneDeep'; @@ -53,14 +51,11 @@ import type { import { APIType, SmartTransactionStatuses } from './types'; import { calculateStatus, - generateHistoryEntry, getAPIRequestURL, handleFetch, incrementNonceInHex, isSmartTransactionCancellable, isSmartTransactionPending, - replayHistory, - snapshotFromTxMeta, getTxHash, getSmartTransactionMetricsProperties, getSmartTransactionMetricsSensitiveProperties, @@ -154,7 +149,6 @@ type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetStateAction | TransactionControllerGetNonceLockAction - | TransactionControllerConfirmExternalTransactionAction | TransactionControllerGetTransactionsAction | TransactionControllerUpdateTransactionAction; @@ -560,8 +554,7 @@ export class SmartTransactionsController extends StaticIntervalPollingController ...smartTransaction, }; - // We have to emit this event here, because then a txHash is returned to the TransactionController once it's available - // and the #doesTransactionNeedConfirmation function will work properly, since it will find the txHash in the regular transactions list. + // We have to emit this event here, so a txHash is returned to the TransactionController once it's available. this.messenger.publish( `SmartTransactionsController:smartTransaction`, nextSmartTransaction, @@ -643,27 +636,6 @@ export class SmartTransactionsController extends StaticIntervalPollingController } } - #doesTransactionNeedConfirmation(txHash: string | undefined): boolean { - if (!txHash) { - return true; - } - const transactions = this.messenger.call( - 'TransactionController:getTransactions', - ); - const foundTransaction = transactions?.find((tx) => { - return tx.hash?.toLowerCase() === txHash.toLowerCase(); - }); - if (!foundTransaction) { - return true; - } - // If a found transaction is either confirmed or submitted, it doesn't need confirmation from the STX controller. - // When it's in the submitted state, the TransactionController checks its status and confirms it, - // so no need to confirm it again here. - return ![TransactionStatus.confirmed, TransactionStatus.submitted].includes( - foundTransaction.status, - ); - } - async #confirmSmartTransaction( smartTransaction: SmartTransaction, { @@ -680,65 +652,10 @@ export class SmartTransactionsController extends StaticIntervalPollingController const txHash = smartTransaction.statusMetadata?.minedHash; try { const transactionReceipt: { - maxFeePerGas?: string; - maxPriorityFeePerGas?: string; blockNumber: string; } | null = await query(ethQuery, 'getTransactionReceipt', [txHash]); - const transaction: { - maxFeePerGas?: string; - maxPriorityFeePerGas?: string; - } | null = await query(ethQuery, 'getTransactionByHash', [txHash]); - const maxFeePerGas = transaction?.maxFeePerGas; - const maxPriorityFeePerGas = transaction?.maxPriorityFeePerGas; if (transactionReceipt?.blockNumber) { - const blockData: { baseFeePerGas?: Hex } | null = await query( - ethQuery, - 'getBlockByNumber', - [transactionReceipt?.blockNumber, false], - ); - const baseFeePerGas = blockData?.baseFeePerGas; - const updatedTxParams = { - ...smartTransaction.txParams, - maxFeePerGas, - maxPriorityFeePerGas, - }; - // call confirmExternalTransaction - const originalTxMeta = { - ...smartTransaction, - id: smartTransaction.uuid, - status: TransactionStatus.confirmed, - hash: txHash, - txParams: updatedTxParams, - }; - // create txMeta snapshot for history - const snapshot = snapshotFromTxMeta(originalTxMeta); - // recover previous tx state obj - const previousState = replayHistory(originalTxMeta.history); - // generate history entry and add to history - const entry = generateHistoryEntry( - previousState, - snapshot, - 'txStateManager: setting status to confirmed', - ); - const txMeta = - entry.length > 0 - ? { - ...originalTxMeta, - history: originalTxMeta.history.concat(entry), - } - : originalTxMeta; - - if (this.#doesTransactionNeedConfirmation(txHash)) { - this.messenger.call( - 'TransactionController:confirmExternalTransaction', - // TODO: Replace 'as' assertion with correct typing for `txMeta` - txMeta as TransactionMeta, - transactionReceipt, - // TODO: Replace 'as' assertion with correct typing for `baseFeePerGas` - baseFeePerGas as Hex, - ); - } this.#trackMetaMetricsEvent({ event: MetaMetricsEventName.StxConfirmed, category: MetaMetricsEventCategory.Transactions, diff --git a/src/utils.ts b/src/utils.ts index 755a64e..8a0bf61 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,7 +8,6 @@ import type { } from '@metamask/transaction-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; -import jsonDiffer from 'fast-json-patch'; import _ from 'lodash'; // Ignoring TypeScript errors here because this import is disallowed for production builds, because @@ -114,57 +113,6 @@ export const calculateStatus = (stxStatus: SmartTransactionsStatus) => { return SmartTransactionStatuses.UNKNOWN; }; -/** - Generates an array of history objects sense the previous state. - The object has the keys - op (the operation performed), - path (the key and if a nested object then each key will be separated with a `/`) - value - with the first entry having the note and a timestamp when the change took place - @param previousState - the previous state of the object - @param newState - the update object - @param [note] - a optional note for the state change - @returns -*/ -export function generateHistoryEntry( - previousState: any, - newState: any, - note: string, -) { - const entry: any = jsonDiffer.compare(previousState, newState); - // Add a note to the first op, since it breaks if we append it to the entry - if (entry[0]) { - if (note) { - entry[0].note = note; - } - - entry[0].timestamp = Date.now(); - } - return entry; -} - -/** - Recovers previous txMeta state obj - @returns -*/ -export function replayHistory(_shortHistory: any) { - const shortHistory = _.cloneDeep(_shortHistory); - return shortHistory.reduce( - (val: any, entry: any) => jsonDiffer.applyPatch(val, entry).newDocument, - ); -} - -/** - * Snapshot {@code txMeta} - * @param txMeta - the tx metadata object - * @returns a deep clone without history - */ -export function snapshotFromTxMeta(txMeta: any) { - const shallow = { ...txMeta }; - delete shallow.history; - return _.cloneDeep(shallow); -} - /** * Returns processing time for an STX in seconds. * @param smartTransactionSubmittedtime