diff --git a/package.json b/package.json index 53a0ad241..b9647ef9e 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ ] }, "resolutions": { - "@metamask/snaps-sdk": "9.0.0" + "@metamask/snaps-sdk": "9.3.0" }, "devDependencies": { "@commitlint/cli": "^17.7.1", diff --git a/packages/snap/package.json b/packages/snap/package.json index 1643ca5da..85b59c42f 100644 --- a/packages/snap/package.json +++ b/packages/snap/package.json @@ -45,7 +45,7 @@ "@metamask/keyring-snap-sdk": "^4.0.0", "@metamask/snaps-cli": "^8.1.1", "@metamask/snaps-jest": "9.3.0", - "@metamask/snaps-sdk": "^9.2.0", + "@metamask/snaps-sdk": "^9.3.0", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "11.4.0", "@noble/ed25519": "2.1.0", diff --git a/packages/snap/snap.manifest.json b/packages/snap/snap.manifest.json index fd66a6c0c..d6a3f8c92 100644 --- a/packages/snap/snap.manifest.json +++ b/packages/snap/snap.manifest.json @@ -7,7 +7,7 @@ "url": "https://github.com/MetaMask/snap-solana-wallet.git" }, "source": { - "shasum": "W86v0OUr3ogUxyukFcAgbTjaqxtra+nN67DvfgLuLJ8=", + "shasum": "8ufRG4iBVewqxExSv+Ri41hNIHkWbdWamb0dy9J08Cw=", "location": { "npm": { "filePath": "dist/bundle.js", @@ -38,22 +38,7 @@ "endowment:lifecycle-hooks": {}, "endowment:network-access": {}, "endowment:cronjob": { - "jobs": [ - { - "duration": "PT30S", - "request": { - "method": "refreshSend", - "params": {} - } - }, - { - "duration": "PT20S", - "request": { - "method": "refreshConfirmationEstimation", - "params": {} - } - } - ] + "jobs": [] }, "endowment:protocol": { "scopes": { @@ -90,6 +75,6 @@ "snap_dialog": {}, "snap_getPreferences": {} }, - "platformVersion": "9.0.0", + "platformVersion": "9.3.0", "manifestVersion": "0.1" } diff --git a/packages/snap/src/core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod.ts b/packages/snap/src/core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod.ts index 807b9cb4b..d6e3d1d86 100644 --- a/packages/snap/src/core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod.ts +++ b/packages/snap/src/core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod.ts @@ -7,4 +7,8 @@ export enum ScheduleBackgroundEventMethod { OnTransactionRejected = 'onTransactionRejected', /** Use it to schedule a background event to asynchronously fetch the transactions of an account */ OnSyncAccountTransactions = 'onSyncAccountTransactions', + /** Use it to schedule a background event to refresh the send form */ + RefreshSend = 'refreshSend', + /** Use it to schedule a background event to refresh the confirmation estimation */ + RefreshConfirmationEstimation = 'refreshConfirmationEstimation', } diff --git a/packages/snap/src/core/handlers/onCronjob/backgroundEvents/index.ts b/packages/snap/src/core/handlers/onCronjob/backgroundEvents/index.ts index cd4e1282b..63ecab95d 100644 --- a/packages/snap/src/core/handlers/onCronjob/backgroundEvents/index.ts +++ b/packages/snap/src/core/handlers/onCronjob/backgroundEvents/index.ts @@ -4,6 +4,8 @@ import { onSyncAccountTransactions } from './onSyncAccountTransactions'; import { onTransactionAdded } from './onTransactionAdded'; import { onTransactionApproved } from './onTransactionApproved'; import { onTransactionRejected } from './onTransactionRejected'; +import { refreshConfirmationEstimation } from './refreshConfirmationEstimation'; +import { refreshSend } from './refreshSend'; import { ScheduleBackgroundEventMethod } from './ScheduleBackgroundEventMethod'; export const handlers: Record = @@ -15,4 +17,7 @@ export const handlers: Record = onTransactionRejected, [ScheduleBackgroundEventMethod.OnSyncAccountTransactions]: onSyncAccountTransactions, + [ScheduleBackgroundEventMethod.RefreshSend]: refreshSend, + [ScheduleBackgroundEventMethod.RefreshConfirmationEstimation]: + refreshConfirmationEstimation, }; diff --git a/packages/snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx b/packages/snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx new file mode 100644 index 000000000..a76d6c45b --- /dev/null +++ b/packages/snap/src/core/handlers/onCronjob/backgroundEvents/refreshConfirmationEstimation.tsx @@ -0,0 +1,132 @@ +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; + +import { ConfirmTransactionRequest } from '../../../../features/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest'; +import type { ConfirmTransactionRequestContext } from '../../../../features/confirmation/views/ConfirmTransactionRequest/types'; +import { state, transactionScanService } from '../../../../snapContext'; +import type { UnencryptedStateValue } from '../../../services/state/State'; +import { + CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME, + getInterfaceContextOrThrow, + updateInterface, +} from '../../../utils/interface'; +import baseLogger, { createPrefixedLogger } from '../../../utils/logger'; + +export const refreshConfirmationEstimation: OnCronjobHandler = async () => { + const logger = createPrefixedLogger( + baseLogger, + '[refreshConfirmationEstimation]', + ); + + logger.info(`Background event triggered`); + + const mapInterfaceNameToId = + (await state.getKey( + 'mapInterfaceNameToId', + )) ?? {}; + + const confirmationInterfaceId = + mapInterfaceNameToId[CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME]; + + // Don't do anything if the confirmation interface is not open + if (!confirmationInterfaceId) { + logger.info(`No interface context found`); + return; + } + + // Schedule the next run + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT20S', + request: { method: 'refreshConfirmationEstimation' }, + }, + }); + + // Update the interface context with the new rates. + try { + // Get the current context + const interfaceContext = + await getInterfaceContextOrThrow( + confirmationInterfaceId, + ); + + if ( + !interfaceContext.account?.address || + !interfaceContext.transaction || + !interfaceContext.scope || + !interfaceContext.method + ) { + logger.info(`Context is missing required fields`); + return; + } + + // Skip transaction simulation if the preference is disabled + if (!interfaceContext.preferences?.simulateOnChainActions) { + logger.info(`Transaction simulation is disabled in preferences`); + return; + } + + const fetchingConfirmationContext = { + ...interfaceContext, + scanFetchStatus: 'fetching', + } as ConfirmTransactionRequestContext; + + await updateInterface( + confirmationInterfaceId, + , + fetchingConfirmationContext, + ); + + const [scan, updatedInterfaceContextFinal] = await Promise.all([ + transactionScanService.scanTransaction({ + method: interfaceContext.method, + accountAddress: interfaceContext.account.address, + transaction: interfaceContext.transaction, + scope: interfaceContext.scope, + origin: interfaceContext.origin, + account: interfaceContext.account, + }), + getInterfaceContextOrThrow( + confirmationInterfaceId, + ), + ]); + + // Update the current context with the new rates + const updatedInterfaceContext = { + ...updatedInterfaceContextFinal, + scanFetchStatus: 'fetched' as const, + scan, + }; + + logger.info(`New scan fetched`); + + await updateInterface( + confirmationInterfaceId, + , + updatedInterfaceContext, + ); + + logger.info(`Background event suceeded`); + } catch (error) { + const fetchedInterfaceContext = + await getInterfaceContextOrThrow( + confirmationInterfaceId, + ); + + const fetchingConfirmationContext = { + ...fetchedInterfaceContext, + scanFetchStatus: 'fetched', + } as ConfirmTransactionRequestContext; + + await updateInterface( + confirmationInterfaceId, + , + fetchingConfirmationContext, + ); + + logger.info( + { error }, + `Could not update the interface. But rolled back status to fetched.`, + ); + } +}; diff --git a/packages/snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx b/packages/snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx new file mode 100644 index 000000000..86be25734 --- /dev/null +++ b/packages/snap/src/core/handlers/onCronjob/backgroundEvents/refreshSend.tsx @@ -0,0 +1,85 @@ +import type { OnCronjobHandler } from '@metamask/snaps-sdk'; + +import { DEFAULT_SEND_CONTEXT } from '../../../../features/send/render'; +import { Send } from '../../../../features/send/Send'; +import type { SendContext } from '../../../../features/send/types'; +import { assetsService, priceApiClient, state } from '../../../../snapContext'; +import type { UnencryptedStateValue } from '../../../services/state/State'; +import { + getInterfaceContextOrThrow, + getPreferences, + SEND_FORM_INTERFACE_NAME, + updateInterface, +} from '../../../utils/interface'; +import baseLogger, { createPrefixedLogger } from '../../../utils/logger'; + +export const refreshSend: OnCronjobHandler = async () => { + const logger = createPrefixedLogger(baseLogger, '[refreshSend]'); + + logger.info(`Background event triggered`); + + const [assets, mapInterfaceNameToId, preferences] = await Promise.all([ + assetsService.getAll(), + state.getKey( + 'mapInterfaceNameToId', + ), + getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), + ]); + + const assetTypes = assets.flatMap((asset) => asset.assetType); + + const sendFormInterfaceId = mapInterfaceNameToId?.[SEND_FORM_INTERFACE_NAME]; + + // Don't do anything if the send form interface is not open + if (!sendFormInterfaceId) { + logger.info(`No send form interface found`); + return; + } + + // Schedule the next run + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { duration: 'PT30S', request: { method: 'refreshSend' } }, + }); + + // First, fetch the token prices + const tokenPrices = await priceApiClient.getMultipleSpotPrices( + assetTypes, + preferences.currency, + ); + + // Save them in the state + await state.setKey('tokenPrices', tokenPrices); + + // Get the current context + const interfaceContext = + await getInterfaceContextOrThrow(sendFormInterfaceId); + + // We only want to refresh the token prices when the user is in the transaction confirmation stage + if (interfaceContext.stage !== 'transaction-confirmation') { + logger.info(`❌ Not in transaction confirmation stage`); + return; + } + + if (!interfaceContext.assets) { + logger.info(`❌ No assets found`); + return; + } + + // Update the current context with the new rates + const updatedInterfaceContext = { + ...interfaceContext, + tokenPrices: { + ...interfaceContext.tokenPrices, + ...tokenPrices, + }, + }; + + await updateInterface( + sendFormInterfaceId, + , + updatedInterfaceContext, + ); + + logger.info(`✅ Background event suceeded`); +}; diff --git a/packages/snap/src/core/handlers/onCronjob/cronjobs/CronjobMethod.ts b/packages/snap/src/core/handlers/onCronjob/cronjobs/CronjobMethod.ts index 74151653d..ccaa21221 100644 --- a/packages/snap/src/core/handlers/onCronjob/cronjobs/CronjobMethod.ts +++ b/packages/snap/src/core/handlers/onCronjob/cronjobs/CronjobMethod.ts @@ -1,4 +1 @@ -export enum CronjobMethod { - RefreshSend = 'refreshSend', - RefreshConfirmationEstimation = 'refreshConfirmationEstimation', -} +export enum CronjobMethod {} diff --git a/packages/snap/src/core/handlers/onCronjob/cronjobs/index.ts b/packages/snap/src/core/handlers/onCronjob/cronjobs/index.ts index 5ebd6cf18..5de4cd4d2 100644 --- a/packages/snap/src/core/handlers/onCronjob/cronjobs/index.ts +++ b/packages/snap/src/core/handlers/onCronjob/cronjobs/index.ts @@ -1,10 +1,5 @@ import { type OnCronjobHandler } from '@metamask/snaps-sdk'; -import { CronjobMethod } from './CronjobMethod'; -import { refreshConfirmationEstimation } from './refreshConfirmationEstimation'; -import { refreshSend } from './refreshSend'; +import type { CronjobMethod } from './CronjobMethod'; -export const handlers: Record = { - [CronjobMethod.RefreshSend]: refreshSend, - [CronjobMethod.RefreshConfirmationEstimation]: refreshConfirmationEstimation, -}; +export const handlers: Record = {}; diff --git a/packages/snap/src/core/handlers/onCronjob/cronjobs/refreshConfirmationEstimation.tsx b/packages/snap/src/core/handlers/onCronjob/cronjobs/refreshConfirmationEstimation.tsx deleted file mode 100644 index dc394a37d..000000000 --- a/packages/snap/src/core/handlers/onCronjob/cronjobs/refreshConfirmationEstimation.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import type { OnCronjobHandler } from '@metamask/snaps-sdk'; - -import { ConfirmTransactionRequest } from '../../../../features/confirmation/views/ConfirmTransactionRequest/ConfirmTransactionRequest'; -import type { ConfirmTransactionRequestContext } from '../../../../features/confirmation/views/ConfirmTransactionRequest/types'; -import { state, transactionScanService } from '../../../../snapContext'; -import type { UnencryptedStateValue } from '../../../services/state/State'; -import { - CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME, - getInterfaceContextOrThrow, - updateInterface, -} from '../../../utils/interface'; -import logger from '../../../utils/logger'; -import { CronjobMethod } from './CronjobMethod'; - -export const refreshConfirmationEstimation: OnCronjobHandler = async () => { - try { - logger.info( - `[${CronjobMethod.RefreshConfirmationEstimation}] Cronjob triggered`, - ); - - const mapInterfaceNameToId = - (await state.getKey( - 'mapInterfaceNameToId', - )) ?? {}; - - const confirmationInterfaceId = - mapInterfaceNameToId[CONFIRM_SIGN_AND_SEND_TRANSACTION_INTERFACE_NAME]; - - // Update the interface context with the new rates. - try { - if (confirmationInterfaceId) { - // Get the current context - const interfaceContext = - await getInterfaceContextOrThrow( - confirmationInterfaceId, - ); - - if ( - !interfaceContext.account?.address || - !interfaceContext.transaction || - !interfaceContext.scope || - !interfaceContext.method - ) { - logger.info( - `[${CronjobMethod.RefreshConfirmationEstimation}] Context is missing required fields`, - ); - return; - } - - // Skip transaction simulation if the preference is disabled - if (!interfaceContext.preferences?.simulateOnChainActions) { - logger.info( - `[${CronjobMethod.RefreshConfirmationEstimation}] Transaction simulation is disabled in preferences`, - ); - return; - } - - const fetchingConfirmationContext = { - ...interfaceContext, - scanFetchStatus: 'fetching', - } as ConfirmTransactionRequestContext; - - await updateInterface( - confirmationInterfaceId, - , - fetchingConfirmationContext, - ); - - const scan = await transactionScanService.scanTransaction({ - method: interfaceContext.method, - accountAddress: interfaceContext.account.address, - transaction: interfaceContext.transaction, - scope: interfaceContext.scope, - origin: interfaceContext.origin, - account: interfaceContext.account, - }); - - const updatedInterfaceContextFinal = - await getInterfaceContextOrThrow( - confirmationInterfaceId, - ); - - // Update the current context with the new rates - const updatedInterfaceContext = { - ...updatedInterfaceContextFinal, - scanFetchStatus: 'fetched' as const, - scan, - }; - - logger.info( - `[${CronjobMethod.RefreshConfirmationEstimation}] New scan fetched`, - ); - - await updateInterface( - confirmationInterfaceId, - , - updatedInterfaceContext, - ); - } - - logger.info( - `[${CronjobMethod.RefreshConfirmationEstimation}] Cronjob suceeded`, - ); - } catch (error) { - if (!confirmationInterfaceId) { - logger.info( - `[${CronjobMethod.RefreshConfirmationEstimation}] No interface context found`, - ); - return; - } - - const fetchedInterfaceContext = - await getInterfaceContextOrThrow( - confirmationInterfaceId, - ); - - const fetchingConfirmationContext = { - ...fetchedInterfaceContext, - scanFetchStatus: 'fetched', - } as ConfirmTransactionRequestContext; - - await updateInterface( - confirmationInterfaceId, - , - fetchingConfirmationContext, - ); - - logger.info( - { error }, - `[${CronjobMethod.RefreshConfirmationEstimation}] Could not update the interface. But rolled back status to fetched.`, - ); - } - } catch (error) { - logger.info( - { error }, - `[${CronjobMethod.RefreshConfirmationEstimation}] Cronjob failed`, - ); - } -}; diff --git a/packages/snap/src/core/handlers/onCronjob/cronjobs/refreshSend.tsx b/packages/snap/src/core/handlers/onCronjob/cronjobs/refreshSend.tsx deleted file mode 100644 index 3a02543e8..000000000 --- a/packages/snap/src/core/handlers/onCronjob/cronjobs/refreshSend.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import type { OnCronjobHandler } from '@metamask/snaps-sdk'; - -import { DEFAULT_SEND_CONTEXT } from '../../../../features/send/render'; -import { Send } from '../../../../features/send/Send'; -import type { SendContext } from '../../../../features/send/types'; -import { assetsService, priceApiClient, state } from '../../../../snapContext'; -import type { SpotPrices } from '../../../clients/price-api/types'; -import type { UnencryptedStateValue } from '../../../services/state/State'; -import { - getInterfaceContextOrThrow, - getPreferences, - SEND_FORM_INTERFACE_NAME, - updateInterface, -} from '../../../utils/interface'; -import logger from '../../../utils/logger'; -import { CronjobMethod } from './CronjobMethod'; - -export const refreshSend: OnCronjobHandler = async () => { - const [assets, mapInterfaceNameToId, preferences] = await Promise.all([ - assetsService.getAll(), - state.getKey( - 'mapInterfaceNameToId', - ), - getPreferences().catch(() => DEFAULT_SEND_CONTEXT.preferences), - ]); - - try { - logger.info(`[${CronjobMethod.RefreshSend}] Cronjob triggered`); - - const assetTypes = assets.flatMap((asset) => asset.assetType); - - let tokenPrices: SpotPrices = {}; - - try { - // First, fetch the token prices - tokenPrices = await priceApiClient.getMultipleSpotPrices( - assetTypes, - preferences.currency, - ); - - // Then, update the state - await state.setKey('tokenPrices', tokenPrices); - - logger.info( - `[${CronjobMethod.RefreshSend}] ✅ Token prices were properly refreshed and saved in the state.`, - ); - } catch (error) { - logger.info( - { error }, - `[${CronjobMethod.RefreshSend}] ❌ Could not update the token prices in the state.`, - ); - } - - try { - const sendFormInterfaceId = - mapInterfaceNameToId?.[SEND_FORM_INTERFACE_NAME]; - - // If the interface is open, update the context - if (sendFormInterfaceId) { - // Get the current context - const interfaceContext = - await getInterfaceContextOrThrow(sendFormInterfaceId); - - // we only want to refresh the token prices when the user is in the transaction confirmation stage - if (interfaceContext.stage !== 'transaction-confirmation') { - logger.info( - `[${CronjobMethod.RefreshSend}] ❌ Not in transaction confirmation stage`, - ); - return; - } - - if (!interfaceContext.assets) { - logger.info(`[${CronjobMethod.RefreshSend}] ❌ No assets found`); - return; - } - - // Update the current context with the new rates - const updatedInterfaceContext = { - ...interfaceContext, - tokenPrices: { - ...interfaceContext.tokenPrices, - ...tokenPrices, - }, - }; - - await updateInterface( - sendFormInterfaceId, - , - updatedInterfaceContext, - ); - } - } catch (error) { - logger.info( - { error }, - `[${CronjobMethod.RefreshSend}] ❌ Could not update the interface`, - ); - } - logger.info(`[${CronjobMethod.RefreshSend}] ✅ Cronjob suceeded`); - } catch (error) { - logger.info({ error }, `[${CronjobMethod.RefreshSend}] ❌ Cronjob failed`); - } -}; diff --git a/packages/snap/src/core/services/assets/AssetsService.test.ts b/packages/snap/src/core/services/assets/AssetsService.test.ts index 7821afbfa..6c88c1293 100644 --- a/packages/snap/src/core/services/assets/AssetsService.test.ts +++ b/packages/snap/src/core/services/assets/AssetsService.test.ts @@ -429,6 +429,42 @@ describe('AssetsService', () => { }, ); }); + + it('does not include native assets in removed array even when they have zero balance', async () => { + const nativeAssetWithZeroBalance = { + ...MOCK_ASSET_ENTITY_0, + rawAmount: '0', + }; + const tokenAssetWithZeroBalance = { + ...MOCK_ASSET_ENTITY_1, + rawAmount: '0', + }; + + // Mock that both assets existed with non-zero balance + jest.spyOn(mockAssetsRepository, 'getAll').mockResolvedValueOnce([ + MOCK_ASSET_ENTITY_0, // Native asset with non-zero balance + MOCK_ASSET_ENTITY_1, // Token asset with non-zero balance + ]); + + await assetsService.saveMany([ + nativeAssetWithZeroBalance, + tokenAssetWithZeroBalance, + ]); + + // Should emit AccountAssetListUpdated with only the token asset in the removed array + expect(emitSnapKeyringEvent).toHaveBeenCalledWith( + snap, + KeyringEvent.AccountAssetListUpdated, + { + assets: { + [MOCK_SOLANA_KEYRING_ACCOUNT_0.id]: { + added: [], + removed: [MOCK_ASSET_ENTITY_1.assetType], // Only token asset, not native + }, + }, + }, + ); + }); }); describe('hasChanged', () => { diff --git a/packages/snap/src/core/services/assets/AssetsService.ts b/packages/snap/src/core/services/assets/AssetsService.ts index e8427c33a..130430c88 100644 --- a/packages/snap/src/core/services/assets/AssetsService.ts +++ b/packages/snap/src/core/services/assets/AssetsService.ts @@ -512,6 +512,9 @@ export class AssetsService { return savedAsset && hasZeroRawAmount(savedAsset); }; + const isNativeAsset = (asset: AssetEntity) => + asset.assetType.includes(SolanaCaip19Tokens.SOL); + const assetListUpdatedPayload = assets.reduce< AccountAssetListUpdatedEvent['params']['assets'] >( @@ -527,7 +530,9 @@ export class AssetsService { ], removed: [ ...(acc[asset.keyringAccountId]?.removed ?? []), - ...(hasZeroRawAmount(asset) ? [asset.assetType] : []), + ...(hasZeroRawAmount(asset) && !isNativeAsset(asset) // Never remove native assets from the account asset list + ? [asset.assetType] + : []), ], }, }), diff --git a/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts index fd392db2a..03539c395 100644 --- a/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts +++ b/packages/snap/src/core/services/subscriptions/WebSocketConnectionService.ts @@ -69,10 +69,11 @@ export class WebSocketConnectionService { } #bindHandlers(): void { - // When the extension starts, or that the snap is updated / installed, the Snap platform has lost all its previously opened websockets, so we need to re-initialize - this.#eventEmitter.on('onStart', this.#handleOnStart.bind(this)); - this.#eventEmitter.on('onUpdate', this.#handleOnStart.bind(this)); - this.#eventEmitter.on('onInstall', this.#handleOnStart.bind(this)); + // When the extension becomes active, starts, or that the snap is updated / installed, the Snap platform might have lost its previously opened websockets, so we make sure the are open + this.#eventEmitter.on('onStart', this.#setupConnections.bind(this)); + this.#eventEmitter.on('onUpdate', this.#setupConnections.bind(this)); + this.#eventEmitter.on('onInstall', this.#setupConnections.bind(this)); + this.#eventEmitter.on('onActive', this.#setupConnections.bind(this)); this.#eventEmitter.on( 'onWebSocketEvent', @@ -83,8 +84,8 @@ export class WebSocketConnectionService { this.#eventEmitter.on('onListWebSockets', this.#listConnections.bind(this)); } - async #handleOnStart(): Promise { - this.#logger.log(`Handling onStart/onUpdate/onInstall`); + async #setupConnections(): Promise { + this.#logger.log(`Setting up connections`); const { activeNetworks } = this.#configProvider.get(); @@ -99,9 +100,10 @@ export class WebSocketConnectionService { } /** - * Opens a connection for the specified network. - * @param network - The network to open a connection for. - * @returns A promise that resolves to the connection. + * Idempotently opens a WebSocket connection for the given network. + * If a connection already exists for the network, this method does nothing. + * @param network - The network for which to open a connection. + * @returns A promise that resolves when the connection is established or already exists. */ async openConnection(network: Network): Promise { this.#logger.log(`Opening connection for network ${network}`); diff --git a/packages/snap/src/features/confirmation/views/ConfirmTransactionRequest/render.tsx b/packages/snap/src/features/confirmation/views/ConfirmTransactionRequest/render.tsx index 8fda104c3..82189c3c1 100644 --- a/packages/snap/src/features/confirmation/views/ConfirmTransactionRequest/render.tsx +++ b/packages/snap/src/features/confirmation/views/ConfirmTransactionRequest/render.tsx @@ -209,5 +209,14 @@ export async function render( id, ); + // Schedule the next refresh + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { + duration: 'PT20S', + request: { method: 'refreshConfirmationEstimation' }, + }, + }); + return dialogPromise; } diff --git a/packages/snap/src/features/send/render.tsx b/packages/snap/src/features/send/render.tsx index 4e3dddad5..477ae7bc9 100644 --- a/packages/snap/src/features/send/render.tsx +++ b/packages/snap/src/features/send/render.tsx @@ -179,5 +179,11 @@ export const renderSend: OnRpcRequestHandler = async ({ request }) => { await state.setKey(`mapInterfaceNameToId.${SEND_FORM_INTERFACE_NAME}`, id); + // Schedule the next refresh + await snap.request({ + method: 'snap_scheduleBackgroundEvent', + params: { duration: 'PT30S', request: { method: 'refreshSend' } }, + }); + return dialogPromise; }; diff --git a/packages/snap/src/index.test.ts b/packages/snap/src/index.test.ts index 2713de8f5..67fe8983a 100644 --- a/packages/snap/src/index.test.ts +++ b/packages/snap/src/index.test.ts @@ -3,7 +3,7 @@ import { installSnap } from '@metamask/snaps-jest'; import { onCronjob } from '.'; import { handlers } from './core/handlers/onCronjob'; -import { CronjobMethod } from './core/handlers/onCronjob/cronjobs/CronjobMethod'; +import { ScheduleBackgroundEventMethod } from './core/handlers/onCronjob/backgroundEvents/ScheduleBackgroundEventMethod'; jest.mock('@noble/ed25519', () => ({ getPublicKey: jest.fn(), @@ -75,7 +75,7 @@ describe('onCronjob', () => { it('calls the correct handler when the method is valid and snap is not locked', async () => { const handler = jest.fn(); - handlers[CronjobMethod.RefreshSend] = handler; + handlers[ScheduleBackgroundEventMethod.RefreshSend] = handler; const snap = { request: jest.fn().mockResolvedValue({ locked: false, active: true }), @@ -87,7 +87,7 @@ describe('onCronjob', () => { request: { id: '1', jsonrpc: '2.0', - method: CronjobMethod.RefreshSend, + method: ScheduleBackgroundEventMethod.RefreshSend, }, }); @@ -96,7 +96,7 @@ describe('onCronjob', () => { it('does not call the handler when the client is locked', async () => { const handler = jest.fn(); - handlers[CronjobMethod.RefreshSend] = handler; + handlers[ScheduleBackgroundEventMethod.RefreshSend] = handler; const snap = { request: jest.fn().mockResolvedValue({ locked: true, active: true }), @@ -108,7 +108,7 @@ describe('onCronjob', () => { request: { id: '1', jsonrpc: '2.0', - method: CronjobMethod.RefreshSend, + method: ScheduleBackgroundEventMethod.RefreshSend, }, }); @@ -117,7 +117,7 @@ describe('onCronjob', () => { it('does not call the handler when the client is inactive', async () => { const handler = jest.fn(); - handlers[CronjobMethod.RefreshSend] = handler; + handlers[ScheduleBackgroundEventMethod.RefreshSend] = handler; const snap = { request: jest.fn().mockResolvedValue({ active: false, locked: false }), @@ -129,7 +129,7 @@ describe('onCronjob', () => { request: { id: '1', jsonrpc: '2.0', - method: CronjobMethod.RefreshSend, + method: ScheduleBackgroundEventMethod.RefreshSend, }, }); @@ -138,7 +138,7 @@ describe('onCronjob', () => { it('does call the handler when the client is unlocked and active', async () => { const handler = jest.fn(); - handlers[CronjobMethod.RefreshSend] = handler; + handlers[ScheduleBackgroundEventMethod.RefreshSend] = handler; const snap = { request: jest.fn().mockResolvedValue({ locked: false, active: true }), @@ -150,7 +150,7 @@ describe('onCronjob', () => { request: { id: '1', jsonrpc: '2.0', - method: CronjobMethod.RefreshSend, + method: ScheduleBackgroundEventMethod.RefreshSend, }, }); diff --git a/packages/snap/src/index.ts b/packages/snap/src/index.ts index 768525460..c1d3b207d 100644 --- a/packages/snap/src/index.ts +++ b/packages/snap/src/index.ts @@ -2,12 +2,14 @@ import { KeyringRpcMethod } from '@metamask/keyring-api'; import { handleKeyringRequest } from '@metamask/keyring-snap-sdk'; import type { Json, + OnActiveHandler, OnAssetHistoricalPriceHandler, OnAssetsConversionHandler, OnAssetsLookupHandler, OnAssetsMarketDataHandler, OnClientRequestHandler, OnCronjobHandler, + OnInactiveHandler, OnInstallHandler, OnKeyringRequestHandler, OnNameLookupHandler, @@ -37,7 +39,7 @@ import { handlers as onRpcRequestHandlers } from './core/handlers/onRpcRequest'; import { RpcRequestMethod } from './core/handlers/onRpcRequest/types'; import { withCatchAndThrowSnapError } from './core/utils/errors'; import { getClientStatus } from './core/utils/interface'; -import logger from './core/utils/logger'; +import logger, { createPrefixedLogger } from './core/utils/logger'; import { validateOrigin } from './core/validation/validators'; import { eventHandlers as confirmSignInEvents } from './features/confirmation/views/ConfirmSignIn/events'; import { eventHandlers as confirmSignMessageEvents } from './features/confirmation/views/ConfirmSignMessage/events'; @@ -49,7 +51,6 @@ import snapContext, { clientRequestHandler, eventEmitter, keyring, - state, } from './snapContext'; installPolyfills(); @@ -180,7 +181,9 @@ export const onUserInput: OnUserInputHandler = async ({ * @see https://docs.metamask.io/snaps/reference/entry-points/#oncronjob */ export const onCronjob: OnCronjobHandler = async ({ request }) => { - logger.log('[⏱️ onCronjob]', request.method, request); + const _logger = createPrefixedLogger(logger, '[⏱️ onCronjob]'); + + _logger.log(request.method, request); const { method } = request; assert( @@ -192,22 +195,32 @@ export const onCronjob: OnCronjobHandler = async ({ request }) => { ); const result = await withCatchAndThrowSnapError(async () => { - /** - * Don't run cronjobs if client is locked or inactive - * - We don't want to call cronjobs if the client is locked - * - We don't want to call cronjobs if the client is inactive - */ + // Don't run cronjobs if client is locked or inactive const { locked, active } = await getClientStatus(); - logger.log('[🔑 onCronjob] Client status', { locked, active }); + _logger.log('Client status', { locked, active }); if (locked || !active) { return Promise.resolve(); } - logger.log('[🔑 onCronjob] Running cronjob', { method }); - - const handler = onCronjobHandlers[method]; + _logger.log('Running cronjob', { method }); + + const handler = + onCronjobHandlers[ + method as CronjobMethod | ScheduleBackgroundEventMethod + ]; + + if (!handler) { + throw new MethodNotFoundError( + `Cronjob / ScheduleBackgroundEvent method ${method} not found. Available methods: ${Object.values( + [ + ...Object.values(CronjobMethod), + ...Object.values(ScheduleBackgroundEventMethod), + ], + ).toString()}`, + ) as unknown as Error; + } return handler({ request }); }); @@ -260,6 +273,10 @@ export const onWebSocketEvent: OnWebSocketEventHandler = async ({ event }) => await eventEmitter.emitSync('onWebSocketEvent', event); }); +/* + * Lifecycle handlers + */ + export const onStart: OnStartHandler = async () => withCatchAndThrowSnapError(async () => { await eventEmitter.emitSync('onStart'); @@ -275,6 +292,18 @@ export const onInstall: OnInstallHandler = async () => await eventEmitter.emitSync('onInstall'); }); +export const onActive: OnActiveHandler = async () => { + return withCatchAndThrowSnapError(async () => { + await eventEmitter.emitSync('onActive'); + }); +}; + +export const onInactive: OnInactiveHandler = async () => { + return withCatchAndThrowSnapError(async () => { + await eventEmitter.emitSync('onInactive'); + }); +}; + export const onNameLookup: OnNameLookupHandler = async (request) => { const result = await withCatchAndThrowSnapError(async () => onNameLookupHandler(request), diff --git a/yarn.lock b/yarn.lock index 00038a0aa..0c9f33426 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3895,14 +3895,13 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^11.10.0": - version: 11.10.0 - resolution: "@metamask/controller-utils@npm:11.10.0" +"@metamask/controller-utils@npm:^11.11.0": + version: 11.11.0 + resolution: "@metamask/controller-utils@npm:11.11.0" dependencies: - "@ethereumjs/util": ^9.1.0 "@metamask/eth-query": ^4.0.0 "@metamask/ethjs-unit": ^0.3.0 - "@metamask/utils": ^11.2.0 + "@metamask/utils": ^11.4.2 "@spruceid/siwe-parser": 2.1.0 "@types/bn.js": ^5.1.5 bignumber.js: ^9.1.2 @@ -3910,9 +3909,10 @@ __metadata: cockatiel: ^3.1.2 eth-ens-namehash: ^2.0.8 fast-deep-equal: ^3.1.3 + lodash: ^4.17.21 peerDependencies: "@babel/runtime": ^7.0.0 - checksum: b6b1b3ed6b963a21dcb6c0eb9779a4e4d66b6dab7459cee18fc75db7e66a8b717e6b3095a1a981f102321ff9ebcb7eb2e842af863ef546334bd6b602e61fd77e + checksum: 2bbddc5ce21615fd07ce46a71c55fabdb2a83525807377149e20aa2f92c500b2d5534012cd16c640086cc16cd482a0997f308facd88fc1145957dcad52b1bf7e languageName: node linkType: hard @@ -4263,18 +4263,18 @@ __metadata: languageName: node linkType: hard -"@metamask/phishing-controller@npm:^12.6.0": - version: 12.6.0 - resolution: "@metamask/phishing-controller@npm:12.6.0" +"@metamask/phishing-controller@npm:^13.1.0": + version: 13.1.0 + resolution: "@metamask/phishing-controller@npm:13.1.0" dependencies: "@metamask/base-controller": ^8.0.1 - "@metamask/controller-utils": ^11.10.0 + "@metamask/controller-utils": ^11.11.0 "@noble/hashes": ^1.4.0 "@types/punycode": ^2.1.0 ethereum-cryptography: ^2.1.2 fastest-levenshtein: ^1.0.16 punycode: ^2.1.1 - checksum: 76f65c39e290daf2726c73b628c29dab01d24ae51e4ffb101decf62b38927cee9088180fd2799f652030d490e70be2d1aa7269a56d1959ec642ef9fd725598fb + checksum: a3239c76cec3821808deeb55bfe53d550d6018b64ae6318af19d973cc2cf3453223d94aadb3d91d28bf7c7afb7f24d9a0664e420f28bece904566d974371f2d2 languageName: node linkType: hard @@ -4437,9 +4437,9 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-controllers@npm:^14.1.0": - version: 14.1.0 - resolution: "@metamask/snaps-controllers@npm:14.1.0" +"@metamask/snaps-controllers@npm:^14.1.0, @metamask/snaps-controllers@npm:^14.2.0": + version: 14.2.0 + resolution: "@metamask/snaps-controllers@npm:14.2.0" dependencies: "@metamask/approval-controller": ^7.1.3 "@metamask/base-controller": ^8.0.1 @@ -4448,13 +4448,13 @@ __metadata: "@metamask/key-tree": ^10.1.1 "@metamask/object-multiplex": ^2.1.0 "@metamask/permission-controller": ^11.0.6 - "@metamask/phishing-controller": ^12.6.0 + "@metamask/phishing-controller": ^13.1.0 "@metamask/post-message-stream": ^10.0.0 "@metamask/rpc-errors": ^7.0.3 "@metamask/snaps-registry": ^3.2.3 - "@metamask/snaps-rpc-methods": ^13.3.0 - "@metamask/snaps-sdk": ^9.2.0 - "@metamask/snaps-utils": ^11.1.0 + "@metamask/snaps-rpc-methods": ^13.4.0 + "@metamask/snaps-sdk": ^9.3.0 + "@metamask/snaps-utils": ^11.2.0 "@metamask/utils": ^11.4.2 "@xstate/fsm": ^2.0.0 async-mutex: ^0.5.0 @@ -4470,29 +4470,29 @@ __metadata: semver: ^7.5.4 tar-stream: ^3.1.7 peerDependencies: - "@metamask/snaps-execution-environments": ^10.1.0 + "@metamask/snaps-execution-environments": ^10.2.0 peerDependenciesMeta: "@metamask/snaps-execution-environments": optional: true - checksum: 4e8bd5797d6c054c67e35cab114ef256517428059b8ab80d4b81e6b599bbeaff5322de1025d0a972f71fa8978cb7c4252f0c1473fab8d7ef21b28b9d5317c46c + checksum: 9fb05a4c58b1a1baf15f6c6b891ba12b8ad9597bc4d84dc27da94e75fde2244818baf887377c66cfc654f72bd890db9a0c1549983d15b9673bec812201a502e4 languageName: node linkType: hard -"@metamask/snaps-execution-environments@npm:^10.1.0": - version: 10.1.0 - resolution: "@metamask/snaps-execution-environments@npm:10.1.0" +"@metamask/snaps-execution-environments@npm:^10.2.0": + version: 10.2.0 + resolution: "@metamask/snaps-execution-environments@npm:10.2.0" dependencies: "@metamask/json-rpc-engine": ^10.0.2 "@metamask/object-multiplex": ^2.1.0 "@metamask/post-message-stream": ^10.0.0 "@metamask/providers": ^22.1.0 "@metamask/rpc-errors": ^7.0.3 - "@metamask/snaps-sdk": ^9.2.0 - "@metamask/snaps-utils": ^11.1.0 + "@metamask/snaps-sdk": ^9.3.0 + "@metamask/snaps-utils": ^11.2.0 "@metamask/superstruct": ^3.2.1 "@metamask/utils": ^11.4.2 readable-stream: ^3.6.2 - checksum: 1b417362ad8904c2d98092d6aef3e9af171fd7e6442aae637b743865cade82d4f2ade7f2aade8bebe83a997ac831194eca67f6c3a629c1b058c842b50d6462ad + checksum: 518867739ad7e9fb4d88b86f5cc6a37bd7f9d0689895486183fc2606c037d4af336476d1c3d91452d33a57eb0b6c53f63750ae2e34d2f7252c3d81b72f6a79fd languageName: node linkType: hard @@ -4560,6 +4560,22 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-rpc-methods@npm:^13.4.0, @metamask/snaps-rpc-methods@npm:^13.5.0": + version: 13.5.0 + resolution: "@metamask/snaps-rpc-methods@npm:13.5.0" + dependencies: + "@metamask/key-tree": ^10.1.1 + "@metamask/permission-controller": ^11.0.6 + "@metamask/rpc-errors": ^7.0.3 + "@metamask/snaps-sdk": ^9.3.0 + "@metamask/snaps-utils": ^11.3.0 + "@metamask/superstruct": ^3.2.1 + "@metamask/utils": ^11.4.2 + "@noble/hashes": ^1.7.1 + checksum: 1a58cc0c1434da1f603dbcd28693aedc1476940a91e4851ac13c2624f3adce4db0f5ff56eace1da605e8cf04da319fba949c83630213fbbc0a1c1ab4f30206b8 + languageName: node + linkType: hard + "@metamask/snaps-sandbox@npm:^1.0.0": version: 1.0.0 resolution: "@metamask/snaps-sandbox@npm:1.0.0" @@ -4567,22 +4583,22 @@ __metadata: languageName: node linkType: hard -"@metamask/snaps-sdk@npm:9.0.0": - version: 9.0.0 - resolution: "@metamask/snaps-sdk@npm:9.0.0" +"@metamask/snaps-sdk@npm:9.3.0": + version: 9.3.0 + resolution: "@metamask/snaps-sdk@npm:9.3.0" dependencies: "@metamask/key-tree": ^10.1.1 "@metamask/providers": ^22.1.0 - "@metamask/rpc-errors": ^7.0.2 + "@metamask/rpc-errors": ^7.0.3 "@metamask/superstruct": ^3.2.1 - "@metamask/utils": ^11.4.0 - checksum: b9031414f3ad1f3a7e9c9fb16731f01016bfcd9206f97d6594a78a35a71118c391991a1f73553480de313c31e95bcb8762c998055d7f62a1f86769153ae5e496 + "@metamask/utils": ^11.4.2 + checksum: 0ddffc266c3802ab97724af94d07356ac8e55d91030d3f246e5f2c9154c61e8eae6f0b320cafd9bd8eca245d83c5603d0aae8c7426ed12496d512728970f4153 languageName: node linkType: hard "@metamask/snaps-simulation@npm:^3.3.0": - version: 3.3.0 - resolution: "@metamask/snaps-simulation@npm:3.3.0" + version: 3.4.0 + resolution: "@metamask/snaps-simulation@npm:3.4.0" dependencies: "@metamask/base-controller": ^8.0.1 "@metamask/eth-json-rpc-middleware": ^17.0.1 @@ -4590,20 +4606,21 @@ __metadata: "@metamask/json-rpc-middleware-stream": ^8.0.7 "@metamask/key-tree": ^10.1.1 "@metamask/permission-controller": ^11.0.6 - "@metamask/phishing-controller": ^12.6.0 - "@metamask/snaps-controllers": ^14.1.0 - "@metamask/snaps-execution-environments": ^10.1.0 - "@metamask/snaps-rpc-methods": ^13.3.0 - "@metamask/snaps-sdk": ^9.2.0 - "@metamask/snaps-utils": ^11.1.0 + "@metamask/phishing-controller": ^13.1.0 + "@metamask/snaps-controllers": ^14.2.0 + "@metamask/snaps-execution-environments": ^10.2.0 + "@metamask/snaps-rpc-methods": ^13.5.0 + "@metamask/snaps-sdk": ^9.3.0 + "@metamask/snaps-utils": ^11.3.0 "@metamask/superstruct": ^3.2.1 "@metamask/utils": ^11.4.2 "@reduxjs/toolkit": ^1.9.5 fast-deep-equal: ^3.1.3 + immer: ^9.0.21 mime: ^3.0.0 readable-stream: ^3.6.2 redux-saga: ^1.2.3 - checksum: af2043d12779d9182c61eb5e4d3c4836ddc479a633f2efecc5df149d1abe6d681689f58e1e5f752565f1e50ac77753acd2f62f9950e62318a052357c3f431d38 + checksum: 4e4bf01eb626063076c4f83d7b1eaeffc9d27881fd4729a573b101626146aa9d2fc92632101c3439c889a00021576eaf70644a7c484743351cf9efbf2c771ed6 languageName: node linkType: hard @@ -4671,6 +4688,38 @@ __metadata: languageName: node linkType: hard +"@metamask/snaps-utils@npm:^11.2.0, @metamask/snaps-utils@npm:^11.3.0": + version: 11.3.0 + resolution: "@metamask/snaps-utils@npm:11.3.0" + dependencies: + "@babel/core": ^7.23.2 + "@babel/types": ^7.23.0 + "@metamask/base-controller": ^8.0.1 + "@metamask/key-tree": ^10.1.1 + "@metamask/permission-controller": ^11.0.6 + "@metamask/rpc-errors": ^7.0.3 + "@metamask/slip44": ^4.2.0 + "@metamask/snaps-registry": ^3.2.3 + "@metamask/snaps-sdk": ^9.3.0 + "@metamask/superstruct": ^3.2.1 + "@metamask/utils": ^11.4.2 + "@noble/hashes": ^1.7.1 + "@scure/base": ^1.1.1 + chalk: ^4.1.2 + cron-parser: ^4.5.0 + fast-deep-equal: ^3.1.3 + fast-json-stable-stringify: ^2.1.0 + fast-xml-parser: ^4.4.1 + luxon: ^3.5.0 + marked: ^12.0.1 + rfdc: ^1.3.0 + semver: ^7.5.4 + ses: ^1.13.1 + validate-npm-package-name: ^5.0.0 + checksum: f1d173e5856674ed036940a27748deff485db1677a09ea7f3556a80644ec0bb5200c1de2e1963eaa30d7807203a1f7bc66bdb8da9d3c1952f2b4505ee3365e81 + languageName: node + linkType: hard + "@metamask/snaps-webpack-plugin@npm:^5.0.0": version: 5.0.0 resolution: "@metamask/snaps-webpack-plugin@npm:5.0.0" @@ -4696,7 +4745,7 @@ __metadata: "@metamask/keyring-snap-sdk": ^4.0.0 "@metamask/snaps-cli": ^8.1.1 "@metamask/snaps-jest": 9.3.0 - "@metamask/snaps-sdk": ^9.2.0 + "@metamask/snaps-sdk": ^9.3.0 "@metamask/superstruct": ^3.1.0 "@metamask/utils": 11.4.0 "@noble/ed25519": 2.1.0