diff --git a/packages/snaps-controllers/src/snaps/SnapController.test.tsx b/packages/snaps-controllers/src/snaps/SnapController.test.tsx index 4dd68e957d..80df389e1b 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.test.tsx +++ b/packages/snaps-controllers/src/snaps/SnapController.test.tsx @@ -10700,4 +10700,57 @@ describe('SnapController', () => { snapController.destroy(); }); }); + + describe('SnapController:updateLastInteraction', () => { + it('should update last snap interaction time', () => { + const messenger = getSnapControllerMessenger(); + + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState(), + }, + }), + ); + + const snap = snapController.getExpect(MOCK_SNAP_ID); + const handlerType = HandlerType.OnRpcRequest; + const spyOnUpdate = jest.spyOn(snapController as any, 'update'); + + snapController.updateLastInteraction(snap.id, handlerType); + + expect(spyOnUpdate).toHaveBeenCalledTimes(1); + expect(spyOnUpdate).toHaveBeenCalledWith(expect.any(Function)); + + const stateUpdater = spyOnUpdate.mock.calls[0][0] as (state: any) => void; + const mockState: { snaps: Record } = { + snaps: { [snap.id]: {} }, + }; + stateUpdater(mockState); + expect(mockState.snaps[snap.id]?.lastInteraction).toBeDefined(); + }); + + it('should not update last interaction if handler is not on allowlist', () => { + const messenger = getSnapControllerMessenger(); + + const snapController = getSnapController( + getSnapControllerOptions({ + messenger, + state: { + snaps: getPersistedSnapsState(), + }, + }), + ); + + const snap = snapController.getExpect(MOCK_SNAP_ID); + const handlerType = HandlerType.OnCronjob; + // @ts-expect-error Spying on private method + const spyOnUpdate = jest.spyOn(snapController, 'update'); + + snapController.updateLastInteraction(snap.id, handlerType); + + expect(spyOnUpdate).toHaveBeenCalledTimes(0); + }); + }); }); diff --git a/packages/snaps-controllers/src/snaps/SnapController.ts b/packages/snaps-controllers/src/snaps/SnapController.ts index 80ea54e4a0..f29fc198db 100644 --- a/packages/snaps-controllers/src/snaps/SnapController.ts +++ b/packages/snaps-controllers/src/snaps/SnapController.ts @@ -103,6 +103,7 @@ import { getLocalizedSnapManifest, MAX_FILE_SIZE, OnSettingsPageResponseStruct, + TRACKABLE_HANDLER_TYPES, } from '@metamask/snaps-utils'; import type { Json, @@ -3449,6 +3450,8 @@ export class SnapController extends BaseController< const timeout = this.#getExecutionTimeout(handlerPermissions); + this.updateLastInteraction(snapId, handlerType); + return handler({ origin, handler: handlerType, request, timeout }); } @@ -4185,4 +4188,19 @@ export class SnapController extends BaseController< runtime.state = undefined; } } + + /** + * Track usage of a Snap by updating last interaction with it. + * Note: Interaction tracking is done only for specific handler types. + * + * @param snapId - ID of a Snap. + * @param handlerType - Type of Snap handler. + */ + updateLastInteraction(snapId: SnapId, handlerType: HandlerType): void { + if (TRACKABLE_HANDLER_TYPES.has(handlerType)) { + this.update((state) => { + state.snaps[snapId].lastInteraction = new Date().toISOString(); + }); + } + } } diff --git a/packages/snaps-utils/coverage.json b/packages/snaps-utils/coverage.json index b2aafe3a34..7a20a59bf4 100644 --- a/packages/snaps-utils/coverage.json +++ b/packages/snaps-utils/coverage.json @@ -2,5 +2,5 @@ "branches": 99.74, "functions": 98.93, "lines": 99.61, - "statements": 96.94 + "statements": 96.95 } diff --git a/packages/snaps-utils/src/handler-types.test.ts b/packages/snaps-utils/src/handler-types.test.ts new file mode 100644 index 0000000000..fd0bcac320 --- /dev/null +++ b/packages/snaps-utils/src/handler-types.test.ts @@ -0,0 +1,18 @@ +import { HandlerType, TRACKABLE_HANDLER_TYPES } from './handler-types'; + +describe('TRACKABLE_HANDLER_TYPES', () => { + const expectedHandlerTypes = [ + HandlerType.OnRpcRequest, + HandlerType.OnInstall, + HandlerType.OnUpdate, + HandlerType.OnHomePage, + HandlerType.OnSettingsPage, + HandlerType.OnUserInput, + ]; + + it('should match the exact set of trackable handler types', () => { + expect(TRACKABLE_HANDLER_TYPES).toStrictEqual( + new Set(expectedHandlerTypes), + ); + }); +}); diff --git a/packages/snaps-utils/src/handler-types.ts b/packages/snaps-utils/src/handler-types.ts index 210ac70115..324dc7802c 100644 --- a/packages/snaps-utils/src/handler-types.ts +++ b/packages/snaps-utils/src/handler-types.ts @@ -40,3 +40,15 @@ export type SnapHandler = { }; export const SNAP_EXPORT_NAMES = Object.values(HandlerType); + +/** + * A subset of handler types that are allowed for Snap interaction tracking. + */ +export const TRACKABLE_HANDLER_TYPES = new Set([ + HandlerType.OnRpcRequest, + HandlerType.OnInstall, + HandlerType.OnUpdate, + HandlerType.OnHomePage, + HandlerType.OnSettingsPage, + HandlerType.OnUserInput, +]); diff --git a/packages/snaps-utils/src/snaps.ts b/packages/snaps-utils/src/snaps.ts index 10c3b4d63f..352486ce83 100644 --- a/packages/snaps-utils/src/snaps.ts +++ b/packages/snaps-utils/src/snaps.ts @@ -161,6 +161,11 @@ export type Snap = TruncatedSnap & { * Flag to signal whether this snap should hide the Snap branding like header or avatar in the UI or not. */ hideSnapBranding?: boolean; + + /** + * Date in ISO String format, representing time of the last interaction with snap. + */ + lastInteraction?: string; }; export type TruncatedSnapFields =