diff --git a/package.json b/package.json index d2dffec24..a4b033acd 100644 --- a/package.json +++ b/package.json @@ -26,16 +26,16 @@ "@avalabs/avalanchejs": "4.0.5", "@avalabs/bitcoin-module": "0.1.4", "@avalabs/bridge-unified": "2.1.0", - "@avalabs/core-bridge-sdk": "3.1.0-alpha.3", - "@avalabs/core-chains-sdk": "3.1.0-alpha.3", - "@avalabs/core-coingecko-sdk": "3.1.0-alpha.3", - "@avalabs/core-covalent-sdk": "3.1.0-alpha.3", - "@avalabs/core-etherscan-sdk": "3.1.0-alpha.3", - "@avalabs/core-snowtrace-sdk": "3.1.0-alpha.3", - "@avalabs/core-token-prices-sdk": "3.1.0-alpha.3", - "@avalabs/core-utils-sdk": "3.1.0-alpha.3", - "@avalabs/core-wallets-sdk": "3.1.0-alpha.3", - "@avalabs/glacier-sdk": "3.1.0-alpha.3", + "@avalabs/core-bridge-sdk": "3.1.0-alpha.4", + "@avalabs/core-chains-sdk": "3.1.0-alpha.4", + "@avalabs/core-coingecko-sdk": "3.1.0-alpha.4", + "@avalabs/core-covalent-sdk": "3.1.0-alpha.4", + "@avalabs/core-etherscan-sdk": "3.1.0-alpha.4", + "@avalabs/core-snowtrace-sdk": "3.1.0-alpha.4", + "@avalabs/core-token-prices-sdk": "3.1.0-alpha.4", + "@avalabs/core-utils-sdk": "3.1.0-alpha.4", + "@avalabs/core-wallets-sdk": "3.1.0-alpha.4", + "@avalabs/glacier-sdk": "3.1.0-alpha.4", "@avalabs/hw-app-avalanche": "0.14.1", "@avalabs/core-k2-components": "4.18.0-alpha.47", "@avalabs/types": "3.1.0-alpha.3", diff --git a/src/background/services/actions/ActionsService.test.ts b/src/background/services/actions/ActionsService.test.ts index 54f3d499b..522dfb180 100644 --- a/src/background/services/actions/ActionsService.test.ts +++ b/src/background/services/actions/ActionsService.test.ts @@ -49,6 +49,9 @@ describe('background/services/actions/ActionsService.ts', () => { actionId: 'uuid', } as any; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { displayData, ...mockActionWithoutDisplaydata } = mockAction; + beforeEach(() => { jest.resetAllMocks(); // jest is having issues mocking non static getters @@ -277,7 +280,10 @@ describe('background/services/actions/ActionsService.ts', () => { expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ type: ActionCompletedEventType.ERROR, - action: action, + action: { + ...mockActionWithoutDisplaydata, + method: 'method-with-no-handler', + }, result: ethErrors.rpc.internal('Request handler not found'), }); expect(storageService.save).toHaveBeenCalledTimes(1); @@ -340,7 +346,7 @@ describe('background/services/actions/ActionsService.ts', () => { expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ type: ActionCompletedEventType.ERROR, - action: mockAction, + action: mockActionWithoutDisplaydata, result: new Error('someError'), }); expect(storageService.save).toHaveBeenCalledTimes(1); @@ -375,7 +381,7 @@ describe('background/services/actions/ActionsService.ts', () => { expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ type: ActionCompletedEventType.COMPLETED, - action: mockAction, + action: mockActionWithoutDisplaydata, result: ['ADDRESS'], }); expect(storageService.save).toHaveBeenCalledTimes(1); @@ -406,7 +412,7 @@ describe('background/services/actions/ActionsService.ts', () => { expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ type: ActionCompletedEventType.ERROR, - action: mockAction, + action: mockActionWithoutDisplaydata, result: ethErrors.provider.userRejectedRequest(), }); expect(storageService.save).toHaveBeenCalledTimes(1); @@ -438,7 +444,7 @@ describe('background/services/actions/ActionsService.ts', () => { expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ type: ActionCompletedEventType.ERROR, - action: mockAction, + action: mockActionWithoutDisplaydata, result: ethErrors.rpc.internal(new Error('very big error')), }); expect(storageService.save).toHaveBeenCalledTimes(1); @@ -470,7 +476,7 @@ describe('background/services/actions/ActionsService.ts', () => { expect(eventListener).toHaveBeenCalledTimes(1); expect(eventListener).toHaveBeenCalledWith({ type: ActionCompletedEventType.COMPLETED, - action: mockAction, + action: mockActionWithoutDisplaydata, result: ['ADDRESS'], }); expect(storageService.save).toHaveBeenCalledTimes(1); diff --git a/src/background/services/actions/ActionsService.ts b/src/background/services/actions/ActionsService.ts index eb3954e0e..11a75027b 100644 --- a/src/background/services/actions/ActionsService.ts +++ b/src/background/services/actions/ActionsService.ts @@ -104,14 +104,16 @@ export class ActionsService implements OnStorageReady { ) { await this.removeAction(id); - // We dont want display data to be emitted. Sometimes it can not be serialized. - delete action.displayData; + // We dont want display data to be emitted. Sometimes it can not be serialized and it's content is internal to Core + // Make sure not to modify the original action object + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { displayData, ...actionWithoutDisplayData } = action; this.eventEmitter.emit(ActionsEvent.ACTION_COMPLETED, { type: isSuccess ? ActionCompletedEventType.COMPLETED : ActionCompletedEventType.ERROR, - action, + action: actionWithoutDisplayData, result, }); } diff --git a/src/background/services/wallet/handlers/avalanche_sendTransaction.test.ts b/src/background/services/wallet/handlers/avalanche_sendTransaction.test.ts index 92841bb4a..4492f5f40 100644 --- a/src/background/services/wallet/handlers/avalanche_sendTransaction.test.ts +++ b/src/background/services/wallet/handlers/avalanche_sendTransaction.test.ts @@ -16,12 +16,22 @@ import { ChainId } from '@avalabs/core-chains-sdk'; import { encryptAnalyticsData } from '../../analytics/utils/encryptAnalyticsData'; import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; import { buildRpcCall } from '@src/tests/test-utils'; +import { measureDuration } from '@src/utils/measureDuration'; jest.mock('@avalabs/avalanchejs'); jest.mock('@avalabs/core-wallets-sdk'); jest.mock('../utils/getProvidedUtxos'); jest.mock('../../analytics/utils/encryptAnalyticsData'); jest.mock('@src/background/runtime/openApprovalWindow'); +jest.mock('@src/utils/measureDuration', () => { + const measureDurationMock = { + start: jest.fn(), + end: jest.fn(), + }; + return { + measureDuration: () => measureDurationMock, + }; +}); describe('src/background/services/wallet/handlers/avalanche_sendTransaction.ts', () => { const env = process.env; @@ -88,6 +98,7 @@ describe('src/background/services/wallet/handlers/avalanche_sendTransaction.ts', }; const providerMock = { issueTxHex: issueTxHexMock, + waitForTransaction: jest.fn(), }; const utxosMock = [{ utxoId: '1' }, { utxoId: '2' }]; @@ -117,7 +128,7 @@ describe('src/background/services/wallet/handlers/avalanche_sendTransaction.ts', (UnsignedTx.fromJSON as jest.Mock).mockReturnValue(unsignedTxMock); (EVMUnsignedTx.fromJSON as jest.Mock).mockReturnValue(unsignedTxMock); signMock.mockReturnValue({ signedTx: 'baz' }); - getAvalancheNetworkXPMock.mockReturnValue({}); + getAvalancheNetworkXPMock.mockReturnValue({ rpcUrl: 'RPCURL' }); issueTxHexMock.mockResolvedValue({ txID: 1 }); getAvalanceProviderXPMock.mockResolvedValue(providerMock); getAddressesMock.mockReturnValue([]); @@ -417,6 +428,9 @@ describe('src/background/services/wallet/handlers/avalanche_sendTransaction.ts', unsignedTxJson, }, params: {}, + site: { + domain: 'core.app', + }, } as Action; it('returns error when there are multiple addresses without indices', async () => { @@ -532,7 +546,7 @@ describe('src/background/services/wallet/handlers/avalanche_sendTransaction.ts', externalIndices: undefined, internalIndices: undefined, }, - {}, + { rpcUrl: 'RPCURL' }, frontendTabId, 'avalanche_sendTransaction' ); @@ -577,7 +591,7 @@ describe('src/background/services/wallet/handlers/avalanche_sendTransaction.ts', externalIndices: undefined, internalIndices: undefined, }, - {}, + { rpcUrl: 'RPCURL' }, frontendTabId, 'avalanche_sendTransaction' ); @@ -627,7 +641,7 @@ describe('src/background/services/wallet/handlers/avalanche_sendTransaction.ts', externalIndices: [0, 1], internalIndices: [2, 3], }, - {}, + { rpcUrl: 'RPCURL' }, frontendTabId, 'avalanche_sendTransaction' ); @@ -652,5 +666,106 @@ describe('src/background/services/wallet/handlers/avalanche_sendTransaction.ts', }) ); }); + + it('measures and reports time to confirmation', async () => { + const signedTxHex = '0x000142'; + + let resolveWaitforTransaction; + providerMock.waitForTransaction.mockReturnValue( + new Promise((resolve) => { + resolveWaitforTransaction = resolve; + }) + ); + const measurement = measureDuration(); + jest.mocked(measurement.end).mockReturnValue(1000); + hasAllSignaturesMock.mockReturnValueOnce(true); + (Avalanche.signedTxToHex as jest.Mock).mockReturnValueOnce(signedTxHex); + + await handler.onActionApproved( + pendingActionMock, + {}, + onSuccessMock, + onErrorMock, + frontendTabId + ); + + expect(measurement.start).toHaveBeenCalled(); + expect(providerMock.waitForTransaction).toHaveBeenCalledTimes(1); + expect(providerMock.waitForTransaction).toHaveBeenCalledWith( + 1, + 'AVM', + 60000 + ); + expect( + analyticsServicePosthogMock.captureEncryptedEvent + ).toHaveBeenCalledTimes(1); + + resolveWaitforTransaction(undefined); + + await new Promise(process.nextTick); + + expect(measurement.end).toHaveBeenCalled(); + + expect( + analyticsServicePosthogMock.captureEncryptedEvent + ).toHaveBeenCalledTimes(2); + expect( + analyticsServicePosthogMock.captureEncryptedEvent + ).toHaveBeenNthCalledWith(2, { + name: 'TransactionTimeToConfirmation', + properties: { + chainId: 4503599627370468, + duration: 1000, + rpcUrl: 'RPCURL', + site: 'core.app', + txType: undefined, + }, + windowId: undefined, + }); + }); + + it('ends measurement if waiting for confirmation fails', async () => { + const signedTxHex = '0x000142'; + + let rejectWaitforTransaction; + providerMock.waitForTransaction.mockReturnValue( + new Promise((_, reject) => { + rejectWaitforTransaction = reject; + }) + ); + const measurement = measureDuration(); + jest.mocked(measurement.end).mockReturnValue(1000); + hasAllSignaturesMock.mockReturnValueOnce(true); + (Avalanche.signedTxToHex as jest.Mock).mockReturnValueOnce(signedTxHex); + + await handler.onActionApproved( + pendingActionMock, + {}, + onSuccessMock, + onErrorMock, + frontendTabId + ); + + expect(measurement.start).toHaveBeenCalled(); + expect(providerMock.waitForTransaction).toHaveBeenCalledTimes(1); + expect(providerMock.waitForTransaction).toHaveBeenCalledWith( + 1, + 'AVM', + 60000 + ); + expect( + analyticsServicePosthogMock.captureEncryptedEvent + ).toHaveBeenCalledTimes(1); + + rejectWaitforTransaction(new Error('some error')); + + await new Promise(process.nextTick); + + expect(measurement.end).toHaveBeenCalled(); + + expect( + analyticsServicePosthogMock.captureEncryptedEvent + ).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/src/background/services/wallet/handlers/avalanche_sendTransaction.ts b/src/background/services/wallet/handlers/avalanche_sendTransaction.ts index 1c4d87030..d9715ec13 100644 --- a/src/background/services/wallet/handlers/avalanche_sendTransaction.ts +++ b/src/background/services/wallet/handlers/avalanche_sendTransaction.ts @@ -24,6 +24,7 @@ import getProvidedUtxos from '../utils/getProvidedUtxos'; import { AnalyticsServicePosthog } from '../../analytics/AnalyticsServicePosthog'; import { ChainId } from '@avalabs/core-chains-sdk'; import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; +import { measureDuration } from '@src/utils/measureDuration'; type TxParams = { transactionHex: string; @@ -229,8 +230,9 @@ export class AvalancheSendTransactionHandler extends DAppRequestHandler< const usedAddress = this.#getAddressForVM(vm); const usedNetwork = this.#getChainIdForVM(vm); - + const measurement = measureDuration(); try { + measurement.start(); // Parse the json into a tx object const unsignedTx = vm === EVM @@ -250,18 +252,23 @@ export class AvalancheSendTransactionHandler extends DAppRequestHandler< ); } + const network = this.networkService.getAvalancheNetworkXP(); + const prov = await this.networkService.getAvalanceProviderXP(); const { txHash, signedTx } = await this.walletService.sign( { tx: unsignedTx, externalIndices, internalIndices, }, - this.networkService.getAvalancheNetworkXP(), + network, frontendTabId, DAppProviderRequest.AVALANCHE_SEND_TRANSACTION ); + let transactionHash: string; if (typeof txHash === 'string') { + transactionHash = txHash; + this.analyticsServicePosthog.captureEncryptedEvent({ name: 'avalanche_sendTransaction_success', windowId: crypto.randomUUID(), @@ -290,7 +297,6 @@ export class AvalancheSendTransactionHandler extends DAppRequestHandler< ); // Submit the transaction and return the tx id - const prov = await this.networkService.getAvalanceProviderXP(); const res = await prov.issueTxHex(signedTransactionHex, vm); this.analyticsServicePosthog.captureEncryptedEvent({ @@ -303,9 +309,38 @@ export class AvalancheSendTransactionHandler extends DAppRequestHandler< }, }); + transactionHash = res.txID; + onSuccess(res.txID); + } else { + onError(new Error('Signing error, invalid result')); + return; } + + prov + .waitForTransaction(transactionHash, vm, 60000) + .then(() => { + const duration = measurement.end(); + this.analyticsServicePosthog.captureEncryptedEvent({ + name: 'TransactionTimeToConfirmation', + windowId: crypto.randomUUID(), + properties: { + duration, + txType: unsignedTx.getTx()._type, + chainId: usedNetwork, + rpcUrl: network.rpcUrl, + site: pendingAction.site?.domain, + }, + }); + }) + .catch(() => { + // clean up pending measurement + measurement.end(); + }); } catch (e) { + // clean up pending measurement + measurement.end(); + this.analyticsServicePosthog.captureEncryptedEvent({ name: 'avalanche_sendTransaction_failed', windowId: crypto.randomUUID(), diff --git a/src/background/services/wallet/handlers/bitcoin_sendTransaction.test.ts b/src/background/services/wallet/handlers/bitcoin_sendTransaction.test.ts index f938a7e6b..c88ad0469 100644 --- a/src/background/services/wallet/handlers/bitcoin_sendTransaction.test.ts +++ b/src/background/services/wallet/handlers/bitcoin_sendTransaction.test.ts @@ -11,11 +11,21 @@ import { createTransferTx } from '@avalabs/core-wallets-sdk'; import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; import { buildRpcCall } from '@src/tests/test-utils'; import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork'; +import { measureDuration } from '@src/utils/measureDuration'; jest.mock('@avalabs/core-wallets-sdk'); jest.mock('@src/utils/isBtcAddressInNetwork'); jest.mock('@src/background/runtime/openApprovalWindow'); jest.mock('@src/utils/network/getProviderForNetwork'); +jest.mock('@src/utils/measureDuration', () => { + const measureDurationMock = { + start: jest.fn(), + end: jest.fn(), + }; + return { + measureDuration: () => measureDurationMock, + }; +}); describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', () => { const request = { @@ -32,6 +42,7 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( const signMock = jest.fn(); const sendTransactionMock = jest.fn(); const getBalancesForNetworksMock = jest.fn(); + const captureEventMock = jest.fn(); const getBitcoinNetworkMock = jest.fn(); const activeAccountMock = { @@ -51,6 +62,9 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( const balanceAggregatorServiceMock = { getBalancesForNetworks: getBalancesForNetworksMock, }; + const analyticsServiceMock = { + captureEncryptedEvent: captureEventMock, + }; beforeEach(() => { jest.resetAllMocks(); @@ -62,6 +76,7 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( jest.mocked(getProviderForNetwork).mockReturnValue({ getScriptsForUtxos: jest.fn().mockResolvedValue([]), getNetwork: jest.fn(), + waitForTx: jest.fn().mockRejectedValue(new Error()), } as any); jest .mocked(createTransferTx) @@ -69,6 +84,7 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( jest.mocked(openApprovalWindow).mockResolvedValue(undefined); getBitcoinNetworkMock.mockResolvedValue({ vmName: NetworkVMType.BITCOIN, + rpcUrl: 'RPCURL', }); getBalancesForNetworksMock.mockResolvedValue({ [ChainId.BITCOIN_TESTNET]: { @@ -91,6 +107,7 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( {} as any, {} as any, {} as any, + {} as any, {} as any ); const result = await handler.handleUnauthenticated(buildRpcCall(request)); @@ -113,7 +130,8 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( type: AccountType.PRIMARY, }, } as any, - balanceAggregatorServiceMock as any + balanceAggregatorServiceMock as any, + analyticsServiceMock as any ); it('returns error if the active account is imported via WalletConnect', async () => { @@ -128,7 +146,8 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( type: AccountType.WALLET_CONNECT, }, } as any, - balanceAggregatorServiceMock as any + balanceAggregatorServiceMock as any, + analyticsServiceMock as any ); const result = await sendHandler.handleAuthenticated( @@ -153,7 +172,8 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( addressC: 'abcd1234', }, } as any, - balanceAggregatorServiceMock as any + balanceAggregatorServiceMock as any, + analyticsServiceMock as any ); const result = await sendHandler.handleAuthenticated( @@ -230,7 +250,8 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( walletServiceMock as any, networkServiceMock as any, {} as any, - balanceAggregatorServiceMock as any + balanceAggregatorServiceMock as any, + analyticsServiceMock as any ); const result = await sendHandler.handleAuthenticated({ request } as any); expect(result).toEqual({ @@ -260,7 +281,8 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( walletServiceMock as any, networkServiceMock as any, accountsServiceMock as any, - balanceAggregatorServiceMock as any + balanceAggregatorServiceMock as any, + analyticsServiceMock as any ); const result = await sendHandler.handleAuthenticated( @@ -298,7 +320,8 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( }, } as any, accountsServiceMock as any, - balanceAggregatorServiceMock as any + balanceAggregatorServiceMock as any, + analyticsServiceMock as any ); const result = await sendHandler.handleAuthenticated( @@ -322,6 +345,9 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( from: 'btc1', sendFee: 5, }, + site: { + domain: 'core.app', + }, } as unknown as Action; it('returns error when signing fails', async () => { @@ -329,7 +355,8 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( walletServiceMock as any, networkServiceMock as any, accountsServiceMock as any, - balanceAggregatorServiceMock as any + balanceAggregatorServiceMock as any, + analyticsServiceMock as any ); getBitcoinNetworkMock.mockResolvedValue({ @@ -362,7 +389,8 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( walletServiceMock as any, networkServiceMock as any, accountsServiceMock as any, - balanceAggregatorServiceMock as any + balanceAggregatorServiceMock as any, + analyticsServiceMock as any ); getBitcoinNetworkMock.mockResolvedValue({ @@ -395,5 +423,56 @@ describe('src/background/services/wallet/handlers/bitcoin_sendTransaction.ts', ( ); expect(onSuccessMock).toHaveBeenCalledWith('resultHash'); }); + + it('reports time to confirmation event', async () => { + const handler = new BitcoinSendTransactionHandler( + walletServiceMock as any, + networkServiceMock as any, + accountsServiceMock as any, + balanceAggregatorServiceMock as any, + analyticsServiceMock as any + ); + const durationMock = measureDuration(); + jest.mocked(durationMock.end).mockReturnValue(1000); + jest.mocked(getProviderForNetwork).mockReturnValue({ + getScriptsForUtxos: jest.fn().mockResolvedValue([]), + getNetwork: jest.fn(), + waitForTx: jest.fn().mockResolvedValue({}), + } as any); + + getBitcoinNetworkMock.mockResolvedValue({ + chainId: ChainId.BITCOIN_TESTNET, + vmName: NetworkVMType.BITCOIN, + rpcUrl: 'RPCURL', + }); + + const onSuccessMock = jest.fn(); + const onErrorMock = jest.fn(); + + signMock.mockResolvedValue({ signedTx: 'resultHash' }); + sendTransactionMock.mockReturnValueOnce('resultHash'); + await handler.onActionApproved( + pendingActionMock, + {}, + onSuccessMock, + onErrorMock, + frontendTabId + ); + + expect(analyticsServiceMock.captureEncryptedEvent).toHaveBeenCalledTimes( + 1 + ); + expect(analyticsServiceMock.captureEncryptedEvent).toHaveBeenCalledWith({ + name: 'TransactionTimeToConfirmation', + properties: { + chainId: 4503599627370475, + duration: 1000, + site: 'core.app', + txType: 'send', + rpcUrl: 'RPCURL', + }, + windowId: undefined, + }); + }); }); }); diff --git a/src/background/services/wallet/handlers/bitcoin_sendTransaction.ts b/src/background/services/wallet/handlers/bitcoin_sendTransaction.ts index c675b42f6..cf8c7bd3c 100644 --- a/src/background/services/wallet/handlers/bitcoin_sendTransaction.ts +++ b/src/background/services/wallet/handlers/bitcoin_sendTransaction.ts @@ -8,6 +8,7 @@ import { DAppRequestHandler } from '@src/background/connections/dAppConnection/D import { Action } from '../../actions/models'; import { DEFERRED_RESPONSE } from '@src/background/connections/middlewares/models'; import { NetworkService } from '@src/background/services/network/NetworkService'; +import { AnalyticsServicePosthog } from '@src/background/services/analytics/AnalyticsServicePosthog'; import { ethErrors } from 'eth-rpc-errors'; import { DisplayData_BitcoinSendTx, @@ -33,6 +34,8 @@ import { resolve } from '@avalabs/core-utils-sdk'; import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; import { runtime } from 'webextension-polyfill'; +import { measureDuration } from '@src/utils/measureDuration'; +import { noop } from '@src/utils/noop'; type BitcoinTxParams = [ address: string, @@ -52,7 +55,8 @@ export class BitcoinSendTransactionHandler extends DAppRequestHandler< private walletService: WalletService, private networkService: NetworkService, private accountService: AccountsService, - private balanceAggregatorService: BalanceAggregatorService + private balanceAggregatorService: BalanceAggregatorService, + private analyticsServicePosthog: AnalyticsServicePosthog ) { super(); } @@ -255,9 +259,14 @@ export class BitcoinSendTransactionHandler extends DAppRequestHandler< onError, frontendTabId?: number ) => { + const measurement = measureDuration(); + measurement.start(); try { const { address, amount, from, feeRate, balance } = pendingAction.displayData; + const btcChainID = this.networkService.isMainnet() + ? ChainId.BITCOIN + : ChainId.BITCOIN_TESTNET; const [network, networkError] = await resolve( this.networkService.getBitcoinNetwork() @@ -266,16 +275,14 @@ export class BitcoinSendTransactionHandler extends DAppRequestHandler< throw new Error('Bitcoin network not found'); } - const { inputs, outputs } = await buildBtcTx( - from, - getProviderForNetwork(network) as BitcoinProvider, - { - amount, - address, - token: balance, - feeRate, - } - ); + const provider = getProviderForNetwork(network) as BitcoinProvider; + + const { inputs, outputs } = await buildBtcTx(from, provider, { + amount, + address, + token: balance, + feeRate, + }); if (!inputs || !outputs) { throw new Error('Unable to create transaction'); @@ -293,8 +300,29 @@ export class BitcoinSendTransactionHandler extends DAppRequestHandler< if (this.#isSupportedAccount(this.accountService.activeAccount)) { this.#getBalance(this.accountService.activeAccount); } + onSuccess(hash); + + provider + .waitForTx(hash) + .then(() => { + const duration = measurement.end(); + this.analyticsServicePosthog.captureEncryptedEvent({ + name: 'TransactionTimeToConfirmation', + windowId: crypto.randomUUID(), + properties: { + duration, + txType: 'send', + chainId: btcChainID, + site: pendingAction.site?.domain, + rpcUrl: network.rpcUrl, + }, + }); + }) + .catch(noop); } catch (e) { + // clean up pending measurement + measurement.end(); onError(e); } }; diff --git a/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.test.ts b/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.test.ts index 75ee796b3..6dae038b4 100644 --- a/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.test.ts +++ b/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.test.ts @@ -3,7 +3,6 @@ import { EthSendTransactionHandler } from './eth_sendTransaction'; import { NetworkFeeService } from '@src/background/services/networkFee/NetworkFeeService'; import { BalanceAggregatorService } from '@src/background/services/balances/BalanceAggregatorService'; import { AccountsService } from '@src/background/services/accounts/AccountsService'; -import { FeatureFlagService } from '@src/background/services/featureFlags/FeatureFlagService'; import { TokenManagerService } from '@src/background/services/tokens/TokenManagerService'; import { AnalyticsServicePosthog } from '@src/background/services/analytics/AnalyticsServicePosthog'; import { WalletService } from '@src/background/services/wallet/WalletService'; @@ -35,6 +34,8 @@ import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; import { buildRpcCall } from '@src/tests/test-utils'; import { BlockaidService } from '@src/background/services/blockaid/BlockaidService'; import { caipToChainId } from '@src/utils/caipConversion'; +import { measureDuration } from '@src/utils/measureDuration'; +import { LockService } from '@src/background/services/lock/LockService'; jest.mock('@src/utils/caipConversion'); jest.mock('@src/background/runtime/openApprovalWindow'); @@ -46,12 +47,22 @@ jest.mock('@src/background/services/network/NetworkService'); jest.mock('@src/background/services/balances/BalanceAggregatorService'); jest.mock('@src/background/services/featureFlags/FeatureFlagService'); jest.mock('@src/background/services/wallet/WalletService'); +jest.mock('@src/background/services/lock/LockService'); jest.mock('./utils/getTargetNetworkForTx'); jest.mock('@src/background/services/network/utils/isBitcoinNetwork'); jest.mock('@src/background/services/analytics/utils/encryptAnalyticsData'); jest.mock('./contracts/contractParsers/parseWithERC20Abi'); jest.mock('./utils/getTxDescription'); jest.mock('./contracts/contractParsers/utils/parseBasicDisplayValues'); +jest.mock('@src/utils/measureDuration', () => { + const measureDurationMock = { + start: jest.fn(), + end: jest.fn(), + }; + return { + measureDuration: () => measureDurationMock, + }; +}); jest.mock('./contracts/contractParsers/contractParserMap', () => ({ contractParserMap: new Map([['function', jest.fn()]]), })); @@ -153,11 +164,6 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa {} as any, {} as any ); - const featureFlagService = new FeatureFlagService( - {} as any, - {} as any, - {} as any - ); const tokenManagerService = new TokenManagerService({} as any, {} as any); const walletService = new WalletService( @@ -174,6 +180,7 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa {} as any ); const blockaidService = new BlockaidService({} as any); + const lockService = new LockService({} as any, {} as any); const accountMock = { type: AccountType.PRIMARY, @@ -206,17 +213,21 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa keyID: 'testKeyId', }; let handler: EthSendTransactionHandler; + let provider: JsonRpcBatchInternal; beforeEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); + jest.useFakeTimers(); jest.mocked(caipToChainId).mockReturnValue(43114); + jest.mocked(browser.notifications.clear).mockResolvedValue(true); jest .spyOn(accountsService, 'activeAccount', 'get') .mockReturnValue(accountMock); jest.mocked(getTargetNetworkForTx).mockResolvedValue(networkMock); - const provider = new JsonRpcBatchInternal(123); + provider = new JsonRpcBatchInternal(123); jest.spyOn(provider, 'getCode').mockResolvedValue('0x'); jest.spyOn(provider, 'getTransactionCount').mockResolvedValue(3); // dummy nonce jest.spyOn(provider, 'estimateGas').mockResolvedValue(21000n); // dummy gas limit + jest.spyOn(provider, 'waitForTransaction').mockRejectedValue(new Error()); jest.mocked(getProviderForNetwork).mockReturnValue(provider); jest.mocked(networkFeeService).getNetworkFee.mockResolvedValue(mockedFees); jest.mocked(networkFeeService).estimateGasLimit.mockResolvedValue(1234); @@ -241,14 +252,19 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa networkService, networkFeeService, accountsService, - featureFlagService, balanceAggregatorService, tokenManagerService, walletService, analyticsServicePosthog, - blockaidService + blockaidService, + lockService ); }); + + afterEach(() => { + jest.clearAllTimers(); + }); + describe('handleUnauthenticated', () => { it('returns error', async () => { const request = { @@ -872,7 +888,6 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa jest .mocked(getTargetNetworkForTx) .mockRejectedValue(new Error('Network not found')); - jest.mocked(crypto.randomUUID).mockReturnValue('a-b-c-d-e-f'); await handler.onActionApproved( mockAction, @@ -894,7 +909,7 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa method: 'eth_sendTransaction', txHash: undefined, }, - windowId: 'a-b-c-d-e-f', + windowId: '00000000-0000-0000-0000-000000000000', }); }); @@ -914,7 +929,6 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa }); it('captures encrypted analytics event on success', async () => { - jest.mocked(crypto.randomUUID).mockReturnValue('a-b-c-d-e-f'); jest.mocked(networkService.sendTransaction).mockResolvedValue('0x0123'); await handler.onActionApproved( @@ -937,12 +951,11 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa method: 'eth_sendTransaction', txHash: '0x0123', }, - windowId: 'a-b-c-d-e-f', + windowId: '00000000-0000-0000-0000-000000000000', }); }); - it('opens explorer link on notification click', async () => { - jest.spyOn(Date, 'now').mockReturnValue(999); + it('opens explorer link on pending notification click', async () => { jest.mocked(networkService.sendTransaction).mockResolvedValue('0x0123'); jest .mocked(getExplorerAddressByNetwork) @@ -956,13 +969,16 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa ); expect(browser.notifications.create).toHaveBeenCalledTimes(1); - expect(browser.notifications.create).toHaveBeenCalledWith('999', { - type: 'basic', - iconUrl: '../../../../images/icon-32.png', - title: 'Confirmed transaction', - message: `Transaction confirmed! View on the explorer.`, - priority: 2, - }); + expect(browser.notifications.create).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000', + { + type: 'basic', + iconUrl: '../../../../images/icon-32.png', + title: 'Pending transaction', + message: `Transaction pending! View on the explorer.`, + priority: 2, + } + ); expect(browser.notifications.onClicked.addListener).toHaveBeenCalledTimes( 1 @@ -978,7 +994,7 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa jest .mocked(browser.notifications.onClicked.addListener) - .mock.calls[0]?.[0]?.('999'); + .mock.calls[0]?.[0]?.('00000000-0000-0000-0000-000000000000'); expect(getExplorerAddressByNetwork).toHaveBeenCalledWith( networkMock, @@ -990,8 +1006,8 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa url: 'https://explorer.example.com', }); }); - it('unsubcribes from clicks when browser notification closed', async () => { - jest.spyOn(Date, 'now').mockReturnValue(999); + + it('unsubcribes from clicks when pending browser notification closed', async () => { jest.mocked(networkService.sendTransaction).mockResolvedValue('0x0123'); jest .mocked(getExplorerAddressByNetwork) @@ -1005,13 +1021,16 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa ); expect(browser.notifications.create).toHaveBeenCalledTimes(1); - expect(browser.notifications.create).toHaveBeenCalledWith('999', { - type: 'basic', - iconUrl: '../../../../images/icon-32.png', - title: 'Confirmed transaction', - message: `Transaction confirmed! View on the explorer.`, - priority: 2, - }); + expect(browser.notifications.create).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000', + { + type: 'basic', + iconUrl: '../../../../images/icon-32.png', + title: 'Pending transaction', + message: `Transaction pending! View on the explorer.`, + priority: 2, + } + ); expect(browser.notifications.onClosed.addListener).toHaveBeenCalledTimes( 1 @@ -1033,7 +1052,7 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa ( jest.mocked(browser.notifications.onClosed.addListener).mock .calls[0]?.[0] as any - )?.('999'); + )?.('00000000-0000-0000-0000-000000000000'); expect( browser.notifications.onClicked.removeListener @@ -1043,9 +1062,7 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa ); }); - it('dismisses browser notification after 5 seconds', async () => { - jest.useFakeTimers(); - jest.spyOn(Date, 'now').mockReturnValue(999); + it('dismisses pending browser notification after 5 seconds', async () => { jest.mocked(networkService.sendTransaction).mockResolvedValue('0x0123'); jest .mocked(getExplorerAddressByNetwork) @@ -1059,13 +1076,16 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa ); expect(browser.notifications.create).toHaveBeenCalledTimes(1); - expect(browser.notifications.create).toHaveBeenCalledWith('999', { - type: 'basic', - iconUrl: '../../../../images/icon-32.png', - title: 'Confirmed transaction', - message: `Transaction confirmed! View on the explorer.`, - priority: 2, - }); + expect(browser.notifications.create).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000', + { + type: 'basic', + iconUrl: '../../../../images/icon-32.png', + title: 'Pending transaction', + message: `Transaction pending! View on the explorer.`, + priority: 2, + } + ); expect(browser.notifications.onClosed.addListener).toHaveBeenCalledTimes( 1 @@ -1080,7 +1100,9 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa jest.advanceTimersByTime(1); expect(browser.notifications.clear).toHaveBeenCalledTimes(1); - expect(browser.notifications.clear).toHaveBeenCalledWith('999'); + expect(browser.notifications.clear).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000' + ); expect( browser.notifications.onClicked.removeListener ).toHaveBeenCalledWith( @@ -1088,5 +1110,149 @@ describe('background/services/wallet/handlers/eth_sendTransaction/eth_sendTransa .calls[0]?.[0] ); }); + + it('creates transaction confirmed browser notification if wallet is unlocked', async () => { + jest.mocked(networkService.sendTransaction).mockResolvedValue('0x0123'); + jest + .mocked(getExplorerAddressByNetwork) + .mockReturnValue('https://explorer.example.com'); + + let resolveTransaction; + jest.spyOn(provider, 'waitForTransaction').mockReturnValue( + new Promise((resolve) => { + resolveTransaction = resolve; + }) + ); + + await handler.onActionApproved( + mockAction, + undefined, + onSuccessMock, + onErrorMock + ); + + expect(browser.notifications.clear).toHaveBeenCalledTimes(0); + expect(browser.notifications.create).toHaveBeenCalledTimes(1); + expect(browser.notifications.create).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000', + expect.objectContaining({ + title: 'Pending transaction', + }) + ); + + resolveTransaction({ status: 1 }); + await Promise.resolve(); + await Promise.resolve(); + + // clears prevous pending notification + expect(browser.notifications.clear).toHaveBeenCalledTimes(1); + expect(browser.notifications.clear).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000' + ); + + // creates new notification + expect(browser.notifications.create).toHaveBeenCalledTimes(2); + expect(browser.notifications.create).toHaveBeenNthCalledWith( + 2, + '00000000-0000-0000-0000-000000000000', + expect.objectContaining({ + title: 'Confirmed transaction', + }) + ); + }); + + it('does not create transaction confirmed browser notification when wallet gets locked while waiting', async () => { + jest.mocked(networkService.sendTransaction).mockResolvedValue('0x0123'); + jest + .mocked(getExplorerAddressByNetwork) + .mockReturnValue('https://explorer.example.com'); + + let resolveTransaction; + jest.spyOn(provider, 'waitForTransaction').mockReturnValue( + new Promise((resolve) => { + resolveTransaction = resolve; + }) + ); + + await handler.onActionApproved( + mockAction, + undefined, + onSuccessMock, + onErrorMock + ); + + expect(browser.notifications.clear).toHaveBeenCalledTimes(0); + expect(browser.notifications.create).toHaveBeenCalledTimes(1); + expect(browser.notifications.create).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000', + expect.objectContaining({ + title: 'Pending transaction', + }) + ); + + (lockService as any).locked = true; + + resolveTransaction({ status: 1 }); + await Promise.resolve(); + await Promise.resolve(); + + // clears prevous pending notification + expect(browser.notifications.clear).toHaveBeenCalledTimes(1); + expect(browser.notifications.clear).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000' + ); + + // creates new notification + expect(browser.notifications.create).toHaveBeenCalledTimes(1); + }); + + it('measures time to confirmation and reports it', async () => { + jest.mocked(networkService.sendTransaction).mockResolvedValue('0x0123'); + jest + .mocked(getExplorerAddressByNetwork) + .mockReturnValue('https://explorer.example.com'); + const durationMock = measureDuration(); + jest.mocked(durationMock.end).mockReturnValue(1000); + + let resolveTransaction; + jest.spyOn(provider, 'waitForTransaction').mockReturnValue( + new Promise((resolve) => { + resolveTransaction = resolve; + }) + ); + + await handler.onActionApproved( + mockAction, + undefined, + onSuccessMock, + onErrorMock + ); + + expect(durationMock.start).toHaveBeenCalledTimes(1); + + resolveTransaction({ status: 1 }); + await jest.runAllTimersAsync(); + + expect(durationMock.end).toHaveBeenCalledTimes(1); + + // creates new notification + expect( + analyticsServicePosthog.captureEncryptedEvent + ).toHaveBeenCalledTimes(2); + expect( + analyticsServicePosthog.captureEncryptedEvent + ).toHaveBeenNthCalledWith(2, { + name: 'TransactionTimeToConfirmation', + properties: { + chainId: '0xa86a', + duration: 1000, + rpcUrl: '', + site: 'example.com', + success: true, + txType: 'eth_sendTransaction', + }, + windowId: '00000000-0000-0000-0000-000000000000', + }); + }); }); }); diff --git a/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.ts b/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.ts index 9af70e5e7..522973588 100644 --- a/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.ts +++ b/src/background/services/wallet/handlers/eth_sendTransaction/eth_sendTransaction.ts @@ -9,7 +9,6 @@ import { ethErrors } from 'eth-rpc-errors'; import { Action } from '@src/background/services/actions/models'; import { NetworkService } from '@src/background/services/network/NetworkService'; import getTargetNetworkForTx from './utils/getTargetNetworkForTx'; -import { FeatureFlagService } from '@src/background/services/featureFlags/FeatureFlagService'; import { ContractTransaction, JsonRpcApiProvider, @@ -42,9 +41,12 @@ import { getProviderForNetwork } from '@src/utils/network/getProviderForNetwork' import { BlockaidService } from '@src/background/services/blockaid/BlockaidService'; import { openApprovalWindow } from '@src/background/runtime/openApprovalWindow'; import { EnsureDefined } from '@src/background/models'; -import { NetworkWithCaipId } from '@src/background/services/network/models'; import { caipToChainId } from '@src/utils/caipConversion'; import { TxDisplayOptions } from '../models'; +import { measureDuration } from '@src/utils/measureDuration'; +import { noop } from '@src/utils/noop'; +import { NetworkWithCaipId } from '@src/background/services/network/models'; +import { LockService } from '@src/background/services/lock/LockService'; type TxPayload = EthSendTransactionParams | ContractTransaction; type Params = [TxPayload] | [TxPayload, TxDisplayOptions]; @@ -60,12 +62,12 @@ export class EthSendTransactionHandler extends DAppRequestHandler< private networkService: NetworkService, private networkFeeService: NetworkFeeService, private accountsService: AccountsService, - private featureFlagService: FeatureFlagService, private balancesService: BalanceAggregatorService, private tokenManagerService: TokenManagerService, private walletService: WalletService, private analyticsServicePosthog: AnalyticsServicePosthog, - private blockaidService: BlockaidService + private blockaidService: BlockaidService, + private lockService: LockService ) { super(); } @@ -206,6 +208,9 @@ export class EthSendTransactionHandler extends DAppRequestHandler< onError: (error: Error) => Promise, tabId?: number | undefined ) => { + const measurement = measureDuration(); + measurement.start(); + try { const network = await getTargetNetworkForTx( pendingAction.displayData.txParams, @@ -220,9 +225,11 @@ export class EthSendTransactionHandler extends DAppRequestHandler< } const provider = getProviderForNetwork(network) as JsonRpcBatchInternal; + const nonce = await provider.getTransactionCount( pendingAction.displayData.txParams.from ); + const chainId = pendingAction.displayData.chainId; const { maxFeePerGas, @@ -237,7 +244,7 @@ export class EthSendTransactionHandler extends DAppRequestHandler< const signingResult = await this.walletService.sign( { nonce, - chainId: Number(BigInt(pendingAction.displayData.chainId)), + chainId: Number(BigInt(chainId)), maxFeePerGas, maxPriorityFeePerGas, gasLimit: gasLimit, @@ -255,38 +262,61 @@ export class EthSendTransactionHandler extends DAppRequestHandler< network ); - const notificationId = Date.now().toString(); - await browser.notifications.create(notificationId, { - type: 'basic', - iconUrl: '../../../../images/icon-32.png', - title: 'Confirmed transaction', - message: `Transaction confirmed! View on the explorer.`, - priority: 2, - }); - - const openTab = async (id: string) => { - if (id === notificationId) { - const explorerUrl = getExplorerAddressByNetwork(network, txHash); - await browser.tabs.create({ url: explorerUrl }); - } - }; + const notificationId = crypto.randomUUID(); - browser.notifications.onClicked.addListener(openTab); - browser.notifications.onClosed.addListener((id: string) => { - if (id === notificationId) { - browser.notifications.onClicked.removeListener(openTab); - } - }); + await this.#createTxNotification( + notificationId, + { + type: 'basic', + iconUrl: '../../../../images/icon-32.png', + title: 'Pending transaction', + message: `Transaction pending! View on the explorer.`, + priority: 2, + }, + network, + txHash + ); - /* - notifications.onClosed is only triggered when a user close the notification. - And the notification is automatically closed 5 secs if user does not close it. - To mimic onClosed for system, using setTimeout. - */ - setTimeout(async () => { - browser.notifications.onClicked.removeListener(openTab); - await browser.notifications.clear(notificationId); - }, 5000); + provider + .waitForTransaction(txHash) + .then(async (tx) => { + const duration = measurement.end(); + + const isTxSuccessul = tx?.status === 1; + await browser.notifications.clear(notificationId); // close transaction pending notification + if (!this.lockService.locked) { + await this.#createTxNotification( + crypto.randomUUID(), + { + type: 'basic', + iconUrl: '../../../../images/icon-32.png', + title: isTxSuccessul + ? 'Confirmed transaction' + : 'Failed transaction', + message: `Transaction ${ + isTxSuccessul ? 'confirmed' : 'failed' + }! View on the explorer.`, + priority: 2, + }, + network, + txHash + ); + } + + this.analyticsServicePosthog.captureEncryptedEvent({ + name: 'TransactionTimeToConfirmation', + windowId: crypto.randomUUID(), + properties: { + duration, + txType: pendingAction.displayData.method, + chainId, + success: isTxSuccessul, + rpcUrl: network.rpcUrl, + site: pendingAction.displayData.site?.domain, + }, + }); + }) + .catch(noop); // No need to await the request here. this.analyticsServicePosthog.captureEncryptedEvent({ @@ -296,12 +326,15 @@ export class EthSendTransactionHandler extends DAppRequestHandler< address: this.accountsService.activeAccount?.addressC, txHash, method: pendingAction.method, - chainId: pendingAction.displayData.chainId, + chainId, }, }); onSuccess(txHash); } catch (err: any) { + // Stop and clean up measurement + // Some error happened during transaction creation, no need to measure end-to-end time till confirmation + measurement.end(); const errorMessage: string = err instanceof Error ? err.message : err.toString(); @@ -329,6 +362,39 @@ export class EthSendTransactionHandler extends DAppRequestHandler< } }; + async #createTxNotification( + notificationId: string, + notificationParams: browser.Notifications.CreateNotificationOptions, + network: NetworkWithCaipId, + txHash: string + ) { + await browser.notifications.create(notificationId, notificationParams); + + const openTab = async (id: string) => { + if (id === notificationId) { + const explorerUrl = getExplorerAddressByNetwork(network, txHash); + await browser.tabs.create({ url: explorerUrl }); + } + }; + + browser.notifications.onClicked.addListener(openTab); + browser.notifications.onClosed.addListener((id: string) => { + if (id === notificationId) { + browser.notifications.onClicked.removeListener(openTab); + } + }); + + /* + notifications.onClosed is only triggered when a user close the notification. + And the notification is automatically closed 5 secs if user does not close it. + To mimic onClosed for system, using setTimeout. + */ + setTimeout(async () => { + browser.notifications.onClicked.removeListener(openTab); + await browser.notifications.clear(notificationId); + }, 5000); + } + async #addGasInformation( network: NetworkWithCaipId, tx: EnsureDefined diff --git a/src/tests/setupTests.ts b/src/tests/setupTests.ts index 2b5d63e64..b4b0342b6 100644 --- a/src/tests/setupTests.ts +++ b/src/tests/setupTests.ts @@ -26,6 +26,21 @@ Object.defineProperty(global.document, 'prerendering', { value: false, }); +Object.defineProperties(global.performance, { + mark: { + value: jest.fn(), + }, + measure: { + value: jest.fn().mockReturnValue({ duration: 100 }), + }, + clearMarks: { + value: jest.fn(), + }, + clearMeasures: { + value: jest.fn(), + }, +}); + global.chrome = { runtime: { id: 'testid', diff --git a/src/utils/measureDuration.test.ts b/src/utils/measureDuration.test.ts new file mode 100644 index 000000000..e55873b9e --- /dev/null +++ b/src/utils/measureDuration.test.ts @@ -0,0 +1,61 @@ +import { measureDuration } from './measureDuration'; + +describe('utils/measureDuration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('measures duration with random ID and cleans up', () => { + const measurement = measureDuration(); + + expect(performance.mark).toHaveBeenCalledTimes(0); + + measurement.start(); + expect(performance.mark).toHaveBeenCalledTimes(1); + expect(performance.mark).toHaveBeenCalledWith( + `00000000-0000-0000-0000-000000000000-start` + ); + + const result = measurement.end(); + + expect(result).toBe(100); + expect(performance.measure).toHaveBeenCalledTimes(1); + expect(performance.measure).toHaveBeenCalledWith( + '00000000-0000-0000-0000-000000000000-measurement', + '00000000-0000-0000-0000-000000000000-start' + ); + expect(performance.clearMarks).toHaveBeenCalledTimes(1); + expect(performance.clearMarks).toHaveBeenCalledWith( + `00000000-0000-0000-0000-000000000000-start` + ); + expect(performance.clearMeasures).toHaveBeenCalledTimes(1); + expect(performance.clearMeasures).toHaveBeenCalledWith( + `00000000-0000-0000-0000-000000000000-measurement` + ); + }); + + it('uses measurement ID if provided', () => { + const measurement = measureDuration('measurementID'); + + expect(performance.mark).toHaveBeenCalledTimes(0); + + measurement.start(); + expect(performance.mark).toHaveBeenCalledTimes(1); + expect(performance.mark).toHaveBeenCalledWith(`measurementID-start`); + + const result = measurement.end(); + + expect(result).toBe(100); + expect(performance.measure).toHaveBeenCalledTimes(1); + expect(performance.measure).toHaveBeenCalledWith( + 'measurementID-measurement', + 'measurementID-start' + ); + expect(performance.clearMarks).toHaveBeenCalledTimes(1); + expect(performance.clearMarks).toHaveBeenCalledWith(`measurementID-start`); + expect(performance.clearMeasures).toHaveBeenCalledTimes(1); + expect(performance.clearMeasures).toHaveBeenCalledWith( + `measurementID-measurement` + ); + }); +}); diff --git a/src/utils/measureDuration.ts b/src/utils/measureDuration.ts new file mode 100644 index 000000000..2b10b8b35 --- /dev/null +++ b/src/utils/measureDuration.ts @@ -0,0 +1,27 @@ +export const measureDuration = ( + id?: string +): { + measurementId: string; + start: () => void; + end: () => number; +} => { + const measurementId = id ?? crypto.randomUUID(); + + const start = () => { + performance.mark(`${measurementId}-start`); + }; + + const end = (): number => { + const measurement = performance.measure( + `${measurementId}-measurement`, + `${measurementId}-start` + ); + + performance.clearMarks(`${measurementId}-start`); + performance.clearMeasures(`${measurementId}-measurement`); + + return measurement.duration; + }; + + return { measurementId, start, end }; +}; diff --git a/yarn.lock b/yarn.lock index 3d33fc619..3e00915e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -70,14 +70,14 @@ lodash "4.17.21" viem "1.19.8" -"@avalabs/core-bridge-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-bridge-sdk/-/core-bridge-sdk-3.1.0-alpha.3.tgz#bf251b7cdc641eddeb307e7501487bd41f12cb5b" - integrity sha512-wrWvXczk1hpLZTuH5BfSCvXs5QBQLBQg6pmyei1yFYUGEz5bPxxf81sHz6ECdZUYyVkL/WWzorLK5f+4aCZSFg== +"@avalabs/core-bridge-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-bridge-sdk/-/core-bridge-sdk-3.1.0-alpha.4.tgz#6ece296b5f8ae056517896fc193ee92f5e2aeebd" + integrity sha512-Qq8Z2N7NN+IvapArlhAFqDoFF5LL+dcB28s1Y5BVtSU28Ve8ccX8SGCuQ+meUjGi2xTeU+RBJhqru5WjTEMJNA== dependencies: - "@avalabs/core-coingecko-sdk" "3.1.0-alpha.3" - "@avalabs/core-utils-sdk" "3.1.0-alpha.3" - "@avalabs/core-wallets-sdk" "3.1.0-alpha.3" + "@avalabs/core-coingecko-sdk" "3.1.0-alpha.4" + "@avalabs/core-utils-sdk" "3.1.0-alpha.4" + "@avalabs/core-wallets-sdk" "3.1.0-alpha.4" "@avalabs/core-chains-sdk@3.0.1-alpha.1": version "3.0.1-alpha.1" @@ -86,12 +86,12 @@ dependencies: "@avalabs/core-utils-sdk" "3.0.1-alpha.1" -"@avalabs/core-chains-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-chains-sdk/-/core-chains-sdk-3.1.0-alpha.3.tgz#42ea2a1bc1f18c948040861a9859006d4c59b01c" - integrity sha512-/gaeqrV+L4ZiCP061BhLasdrC1UbUYnbinXvrnG5xAAY/1e2tOJ5Jw6L74Dj5sbJtMk1DBZ1tCwwkMklzSSAzw== +"@avalabs/core-chains-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-chains-sdk/-/core-chains-sdk-3.1.0-alpha.4.tgz#72b71681e8780034585f796aa1a65b0ebfbaaf1d" + integrity sha512-WX9N/XqFmwprzbL16TDuNy9Mq6afnJaYPcN8CjV/soGwyCBDg67kPAeXYKuszexc6HqBGgBbymANS29XWzFZIA== dependencies: - "@avalabs/core-utils-sdk" "3.1.0-alpha.3" + "@avalabs/core-utils-sdk" "3.1.0-alpha.4" "@avalabs/core-coingecko-sdk@3.0.1-alpha.1": version "3.0.1-alpha.1" @@ -100,26 +100,26 @@ dependencies: "@avalabs/core-utils-sdk" "3.0.1-alpha.1" -"@avalabs/core-coingecko-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-coingecko-sdk/-/core-coingecko-sdk-3.1.0-alpha.3.tgz#a9325ae45b564a20399f85e17570d74150aa6a34" - integrity sha512-Hiq7XMhLRlfsGnDv/1GdiM2U5BARWn+e2TzEq0qeqsEQJdTgdsS92FvRDiVuiL+/G+axPrJSlCcLC1l5FjtfEQ== +"@avalabs/core-coingecko-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-coingecko-sdk/-/core-coingecko-sdk-3.1.0-alpha.4.tgz#8f864c09cf5d8a12fb4e6be098cc7e5f5082e867" + integrity sha512-bm/Hk+sxQzBTPT/birp6ymfNfpsGPZ9jDBdhRNEZlKm9dhCoQM7QXhiGsrY8Wj/maGd4sg+k0PbZw96jUDw1Gg== dependencies: - "@avalabs/core-utils-sdk" "3.1.0-alpha.3" + "@avalabs/core-utils-sdk" "3.1.0-alpha.4" -"@avalabs/core-covalent-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-covalent-sdk/-/core-covalent-sdk-3.1.0-alpha.3.tgz#b3638f60d5134087dcd074ff7046de1b0d577f76" - integrity sha512-vYFq4VwUPDV0x0SI/iRm0HnXr2lmnMZjWe2ANJOAFgRxkfzLsFNGtvieKizpQaiV7NRmyppYaer894HC1sRC0g== +"@avalabs/core-covalent-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-covalent-sdk/-/core-covalent-sdk-3.1.0-alpha.4.tgz#5f8b78363edc4211973defe11f812e51f7d800e3" + integrity sha512-n//l+oGV5KCfYW2CyCXMiLh8WZ4axUp05yMhHcomOAZSlaE178mLJMwInd3NU2yEaaxG3CIFzp82hHskfWIOmg== dependencies: - "@avalabs/core-utils-sdk" "3.1.0-alpha.3" + "@avalabs/core-utils-sdk" "3.1.0-alpha.4" -"@avalabs/core-etherscan-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-etherscan-sdk/-/core-etherscan-sdk-3.1.0-alpha.3.tgz#1340c8e0303030388c4227762290b1131c53def3" - integrity sha512-otrw7E7b3yqLZSyui+sv6FBn1AM6nKwCoT07/NlWpTmiVTZHCNThvgpiNtKVemQsKZmRHAgpTvFaD/DX+J4Yyw== +"@avalabs/core-etherscan-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-etherscan-sdk/-/core-etherscan-sdk-3.1.0-alpha.4.tgz#e9a44a00e45205678d35c0c00498066f9c1fb6e1" + integrity sha512-JUyXR1N+5b6GHTQQKxppPy+VAzRUFU1hWOcqAxbBNMT0SdR6BCyhUheNqXiPgS2s49ZhNwjfBFR80lNgPFEROA== dependencies: - "@avalabs/core-utils-sdk" "3.1.0-alpha.3" + "@avalabs/core-utils-sdk" "3.1.0-alpha.4" "@avalabs/core-k2-components@4.18.0-alpha.47": version "4.18.0-alpha.47" @@ -144,20 +144,20 @@ react-hotkeys-hook "4.4.3" uuid "9.0.1" -"@avalabs/core-snowtrace-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-snowtrace-sdk/-/core-snowtrace-sdk-3.1.0-alpha.3.tgz#c1a11707ecc1269403a96870790a1c3c8954c117" - integrity sha512-UPhZHiDhj7hLp4vuzkRPCuJy+VJ4m8R7EhRbaGg1jnugVJ7LZhBbxAUh9Bj5vx/UxMGUgu43jq4BQSBO65OWxQ== +"@avalabs/core-snowtrace-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-snowtrace-sdk/-/core-snowtrace-sdk-3.1.0-alpha.4.tgz#23cf1f408e1e05f5200f47ffd0f639b0e1c8f847" + integrity sha512-AKO6Z98JsCm9UIzWSWu/ThkgNflrGueWsBTfHYZt3JHYHd3fmpZ0AaJw5BXQ3Sph/CB5XjqpYgNdmNqW/3VziA== dependencies: - "@avalabs/core-utils-sdk" "3.1.0-alpha.3" + "@avalabs/core-utils-sdk" "3.1.0-alpha.4" -"@avalabs/core-token-prices-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-token-prices-sdk/-/core-token-prices-sdk-3.1.0-alpha.3.tgz#30149e0dc8bf1797c859877b1c68b354807375e4" - integrity sha512-7dtsnPQHsrl9jIq0OVV3gbomhDB8gLRjX7xxbdHB4VeRFkIilSlxSAk2HSAG1ciNVU8qH+IfrAbI4w75ZQz0/Q== +"@avalabs/core-token-prices-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-token-prices-sdk/-/core-token-prices-sdk-3.1.0-alpha.4.tgz#b52e0c2447c6a5d6907d08f3d63eca733f6fd699" + integrity sha512-T2AlGOe2ltNf1j3355523nmePtIzhVJyNEU+Q78Dd0h6+jGQzcT+ZTQ7820wKs5anlA10d/fX2o2c/8d7CMlLQ== dependencies: - "@avalabs/core-coingecko-sdk" "3.1.0-alpha.3" - "@avalabs/core-utils-sdk" "3.1.0-alpha.3" + "@avalabs/core-coingecko-sdk" "3.1.0-alpha.4" + "@avalabs/core-utils-sdk" "3.1.0-alpha.4" "@avalabs/core-utils-sdk@3.0.1-alpha.1": version "3.0.1-alpha.1" @@ -168,10 +168,10 @@ "@hpke/core" "1.2.5" is-ipfs "6.0.2" -"@avalabs/core-utils-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-utils-sdk/-/core-utils-sdk-3.1.0-alpha.3.tgz#be7161726b82ca48c5e00d9acc7a6659b92acf25" - integrity sha512-Z0tKNxmeW4UAVAtg/d9GDH77fb2GNODOV2nSvjDaHMlq3t7oBvRLfvG+g4IIyIVS+en/j5ew6XU4ItY60Vn2Tw== +"@avalabs/core-utils-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-utils-sdk/-/core-utils-sdk-3.1.0-alpha.4.tgz#360c73f6b9ebb9821c22e75709b8b84701258d8b" + integrity sha512-Is0kF1likZwOLyoS1zBxI++6ItGELxftahMGWXOMj7X3p2jg6QgxHos4RHhgCcjI2j9oxpnMADnOnI6zRr3rKA== dependencies: "@avalabs/avalanchejs" "4.0.5" "@hpke/core" "1.2.5" @@ -201,14 +201,14 @@ ledger-bitcoin "0.2.3" xss "1.0.14" -"@avalabs/core-wallets-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/core-wallets-sdk/-/core-wallets-sdk-3.1.0-alpha.3.tgz#dd2decda282f285e75052cc0b25aa863cd4ece68" - integrity sha512-j3C2YUv/rh3mcFgcZ5SK9BAqrYpp3KmrIwWVn6dp06Da4pCwvprpvAThbKxD+pEV6EFUv3AwWLEuHTgfIslFJg== +"@avalabs/core-wallets-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/core-wallets-sdk/-/core-wallets-sdk-3.1.0-alpha.4.tgz#53b8d76d9b0b5be4873dc8174724a64325b8d6bd" + integrity sha512-hISEAvIPQBHS2o+RIL0kY+eQ/BBhzTFsSjze6HOeH1GBYmyHyA5P4ZJ7pc7+wtRU5kjHsGWx1sDySklbUYZDuQ== dependencies: "@avalabs/avalanchejs" "4.0.5" - "@avalabs/core-chains-sdk" "3.1.0-alpha.3" - "@avalabs/glacier-sdk" "3.1.0-alpha.3" + "@avalabs/core-chains-sdk" "3.1.0-alpha.4" + "@avalabs/glacier-sdk" "3.1.0-alpha.4" "@avalabs/hw-app-avalanche" "0.14.1" "@ledgerhq/hw-app-btc" "10.2.4" "@ledgerhq/hw-app-eth" "6.36.1" @@ -230,10 +230,10 @@ resolved "https://registry.yarnpkg.com/@avalabs/glacier-sdk/-/glacier-sdk-3.0.1-alpha.1.tgz#7cd1fe5d534beb0c5370642a8bb22ad94bc14e48" integrity sha512-cSf4w0dW2Kin2ImclGikT6zLm8ysNI3y9Nbzbt31tJvNj3WNQwxMO+9BUp+j9UWEhMMOOizal3t4/ieir/XKmw== -"@avalabs/glacier-sdk@3.1.0-alpha.3": - version "3.1.0-alpha.3" - resolved "https://registry.yarnpkg.com/@avalabs/glacier-sdk/-/glacier-sdk-3.1.0-alpha.3.tgz#dbf2ca1dbd827c6a02782770d4f14e9c08ad6d7c" - integrity sha512-gIFVsBURg/m64V3gnishkx+V2EcOEzqyRc7Njc9fgsOxGFluvlXv8ZEGb08rTPmw8TLU9nbUlAVcxcyV1alDQg== +"@avalabs/glacier-sdk@3.1.0-alpha.4": + version "3.1.0-alpha.4" + resolved "https://registry.yarnpkg.com/@avalabs/glacier-sdk/-/glacier-sdk-3.1.0-alpha.4.tgz#6dfcbe965f085ba752c9d99140ea8d02bfdeecfa" + integrity sha512-lS1vSl/cogouZTs1QEvxjU6ikH6MR0OLbVSqWEypYn53BO794U+6Yagufbg8leRigHDelqk8/YKhxkLDkYVwhQ== "@avalabs/hw-app-avalanche@0.14.1": version "0.14.1"