From d4aa5e257dbdf681cdd241decbe689f891ca4476 Mon Sep 17 00:00:00 2001 From: Chris James <1523286+cajames@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:30:31 +1000 Subject: [PATCH] feat: add session activity system (#1918) --- packages/internal/metrics/src/error.ts | 46 +++ packages/internal/metrics/src/flow.ts | 137 ++++++++ packages/internal/metrics/src/index.ts | 10 +- packages/internal/metrics/src/initialise.ts | 2 + packages/internal/metrics/src/performance.ts | 148 +-------- packages/internal/metrics/src/track.ts | 21 +- packages/internal/metrics/src/utils/id.ts | 7 + packages/internal/metrics/src/utils/state.ts | 15 +- packages/passport/sdk/package.json | 1 + packages/passport/sdk/src/types.ts | 28 +- .../zkEvm/sessionActivity/errorBoundary.ts | 33 ++ .../sdk/src/zkEvm/sessionActivity/request.ts | 62 ++++ .../zkEvm/sessionActivity/sessionActivity.ts | 138 ++++++++ packages/passport/sdk/src/zkEvm/types.ts | 5 +- .../passport/sdk/src/zkEvm/zkEvmProvider.ts | 299 +++++++++++------- sdk/package.json | 1 + yarn.lock | 2 + 17 files changed, 683 insertions(+), 272 deletions(-) create mode 100644 packages/internal/metrics/src/error.ts create mode 100644 packages/internal/metrics/src/flow.ts create mode 100644 packages/internal/metrics/src/utils/id.ts create mode 100644 packages/passport/sdk/src/zkEvm/sessionActivity/errorBoundary.ts create mode 100644 packages/passport/sdk/src/zkEvm/sessionActivity/request.ts create mode 100644 packages/passport/sdk/src/zkEvm/sessionActivity/sessionActivity.ts diff --git a/packages/internal/metrics/src/error.ts b/packages/internal/metrics/src/error.ts new file mode 100644 index 0000000000..3a13c497df --- /dev/null +++ b/packages/internal/metrics/src/error.ts @@ -0,0 +1,46 @@ +import { errorBoundary } from './utils/errorBoundary'; +import { track, TrackProperties } from './track'; + +type ErrorEventProperties = + | TrackProperties & { + isTrackError?: never; + errorMessage?: never; + errorStack?: never; + }; + +const trackErrorFn = ( + moduleName: string, + eventName: string, + error: Error, + properties?: ErrorEventProperties, +) => { + const { message } = error; + let stack = error.stack || ''; + const { cause } = error; + + if (cause instanceof Error) { + stack = `${stack} \nCause: ${cause.message}\n ${cause.stack}`; + } + + track(moduleName, `trackError_${eventName}`, { + ...(properties || {}), + errorMessage: message, + errorStack: stack, + isTrackError: true, + }); +}; + +/** + * Track an event and it's performance. Works similarly to `track`, but also includes a duration. + * @param moduleName Name of the module being tracked (for namespacing purposes), e.g. `passport` + * @param eventName Name of the event, e.g. `clickItem` + * @param error Error object to be tracked + * @param properties Other properties to be sent with the event, other than duration + * + * @example + * ```ts + * trackError("passport", "sendTransactionFailed", error); + * trackError("passport", "getItemFailed", error, { otherProperty: "value" }); + * ``` + */ +export const trackError = errorBoundary(trackErrorFn); diff --git a/packages/internal/metrics/src/flow.ts b/packages/internal/metrics/src/flow.ts new file mode 100644 index 0000000000..bdcb01d6fe --- /dev/null +++ b/packages/internal/metrics/src/flow.ts @@ -0,0 +1,137 @@ +import { AllowedTrackProperties, TrackProperties } from './track'; +import { errorBoundary } from './utils/errorBoundary'; +import { generateFlowId } from './utils/id'; +import { trackDuration } from './performance'; + +export type Flow = { + details: { + moduleName: string; + flowName: string; + flowId: string; + flowStartTime: number; + }; + /** + * Track an event in the flow + * @param eventName Name of the event + * @param properties Object containing event properties + */ + addEvent: (eventName: string, properties?: AllowedTrackProperties) => void; + /** + * Function to add new flow properties + * @param newProperties Object new properties + */ + addFlowProperties: (properties: AllowedTrackProperties) => void; +}; + +// Flow Tracking Functions +// ----------------------------------- +// Write a function to take multiple objects as arguments, and merge them into one object +const mergeProperties = ( + ...args: (TrackProperties | undefined)[] +): TrackProperties => { + const hasProperties = args.some((arg) => !!arg); + if (!hasProperties) { + return {}; + } + let finalProperties: Record = {}; + args.forEach((arg) => { + if (arg) { + finalProperties = { + ...finalProperties, + ...arg, + }; + } + }); + + return finalProperties; +}; + +const cleanEventName = (eventName: string) => eventName.replace(/[^a-zA-Z0-9\s\-_]/g, ''); +const getEventName = (flowName: string, eventName: string) => `${flowName}_${cleanEventName(eventName)}`; + +const trackFlowFn = ( + moduleName: string, + flowName: string, + properties?: AllowedTrackProperties, +): Flow => { + // Track the start of the flow + const flowId = generateFlowId(); + const flowStartTime = Date.now(); + + // Flow tracking + let currentStepCount = 0; + let previousStepTime = 0; + + let flowProperties: TrackProperties = {}; + const mergeFlowProps = (...args: (TrackProperties | undefined)[]) => mergeProperties(flowProperties, ...args, { + flowId, + flowName, + }); + + // Set up flow properties + flowProperties = mergeFlowProps(properties); + + const addFlowProperties = (newProperties: AllowedTrackProperties) => { + if (newProperties) { + flowProperties = mergeFlowProps(newProperties); + } + }; + + const addEvent = ( + eventName: string, + eventProperties?: AllowedTrackProperties, + ) => { + const event = getEventName(flowName, eventName); + + // Calculate duration since previous step + let duration = 0; + const currentTime = performance.now(); + if (currentStepCount > 0) { + duration = currentTime - previousStepTime; + } + const mergedProps = mergeFlowProps(eventProperties, { + flowEventName: eventName, + flowStep: currentStepCount, + }); + trackDuration(moduleName, event, duration, mergedProps); + + // Increment counters + currentStepCount++; + previousStepTime = currentTime; + }; + + // Trigger a Start Event as a record of creating the flow + addEvent('Start'); + + return { + details: { + moduleName, + flowName, + flowId, + flowStartTime, + }, + addEvent: errorBoundary(addEvent), + addFlowProperties: errorBoundary(addFlowProperties), + }; +}; +/** + * Track a flow of events, including the start and end of the flow. + * Works similarly to `track` + * @param moduleName Name of the module being tracked (for namespacing purposes), e.g. `passport` + * @param flowName Name of the flow, e.g. `performTransaction` + * @param properties Other properties to be sent with the event, other than duration + * + * @example + * ```ts + * const flow = trackFlow("passport", "performTransaction", { transationType: "transfer" }); + * // Do something... + * flow.addEvent("clickItem"); + * // Do something... + * flow.addFlowProperties({ item: "item1" }); + * flow.addEvent("guardianCheck", {"invisible": "true"}); + * // Do something... + * flow.addEvent("guardianCheckComplete"); + * flow.end(); + * ``` + */ +export const trackFlow = errorBoundary(trackFlowFn); diff --git a/packages/internal/metrics/src/index.ts b/packages/internal/metrics/src/index.ts index b72f8f2e84..323f48a335 100644 --- a/packages/internal/metrics/src/index.ts +++ b/packages/internal/metrics/src/index.ts @@ -1,5 +1,10 @@ +// Exporting utils +import * as localStorage from './utils/localStorage'; + export { track } from './track'; -export { trackDuration, trackFlow, Flow } from './performance'; +export { trackDuration } from './performance'; +export { Flow, trackFlow } from './flow'; +export { trackError } from './error'; export { identify } from './identify'; export { setEnvironment, @@ -8,3 +13,6 @@ export { getDetail, Detail, } from './details'; +export const utils = { + localStorage, +}; diff --git a/packages/internal/metrics/src/initialise.ts b/packages/internal/metrics/src/initialise.ts index e0374ab3a8..41db1d1fac 100644 --- a/packages/internal/metrics/src/initialise.ts +++ b/packages/internal/metrics/src/initialise.ts @@ -85,12 +85,14 @@ export const initialise = async () => { try { const runtimeDetails = flattenProperties(getRuntimeDetails()); const existingRuntimeId = getDetail(Detail.RUNTIME_ID); + const existingIdentity = getDetail(Detail.IDENTITY); const body = { version: 1, data: { runtimeDetails, runtimeId: existingRuntimeId, + uId: existingIdentity, }, }; const response = await post('/v1/sdk/initialise', body); diff --git a/packages/internal/metrics/src/performance.ts b/packages/internal/metrics/src/performance.ts index 138c5456d6..cd7ec93200 100644 --- a/packages/internal/metrics/src/performance.ts +++ b/packages/internal/metrics/src/performance.ts @@ -1,22 +1,4 @@ -import { errorBoundary } from './utils/errorBoundary'; -import { track, TrackProperties } from './track'; - -type PerformanceEventProperties = - | (TrackProperties & { - duration?: never; - }); - -export type Flow = { - details: { - moduleName: string; - flowName: string; - flowId: string; - flowStartTime: number; - }; - addEvent: (eventName: string, properties?: PerformanceEventProperties) => void; - addFlowProperties: (properties: PerformanceEventProperties) => void; - end: (endProperties?: PerformanceEventProperties) => void; -}; +import { track, AllowedTrackProperties } from './track'; /** * Track an event and it's performance. Works similarly to `track`, but also includes a duration. @@ -35,132 +17,8 @@ export const trackDuration = ( moduleName: string, eventName: string, duration: number, - properties?: PerformanceEventProperties, + properties?: AllowedTrackProperties, ) => track(moduleName, eventName, { ...(properties || {}), - duration, + duration: Math.round(duration), }); - -// Time Tracking Functions -// ----------------------------------- - -// Write a function to take multiple objects as arguments, and merge them into one object -const mergeProperties = (...args: (Record | undefined)[]) => { - const hasProperties = args.some((arg) => !!arg); - if (!hasProperties) { - return undefined; - } - let finalProperties: Record = {}; - args.forEach((arg) => { - if (arg) { - finalProperties = { - ...finalProperties, - ...arg, - }; - } - }); - - return finalProperties; -}; - -const getEventName = (flowName: string, eventName: string) => `${flowName}_${eventName}`; - -// Generate a random uuid -const generateFlowId = () => { - const s4 = () => Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); - return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; -}; - -type FlowEventProperties = PerformanceEventProperties & { - flowId?: never; - flowStartTime?: never; -}; - -const trackFlowFn = ( - moduleName: string, - flowName: string, - properties?: FlowEventProperties, -): Flow => { - // Track the start of the flow - const flowStartEventName = getEventName(flowName, 'start'); - const flowId = generateFlowId(); - const startTime = performance.now(); - const flowStartTime = Math.round(startTime + performance.timeOrigin); - - let flowProperties = mergeProperties(properties, { - flowId, - flowStartTime, - }) as FlowEventProperties; - trackDuration(moduleName, flowStartEventName, 0, flowProperties); - - const addFlowProperties = (newProperties: FlowEventProperties) => { - flowProperties = mergeProperties(flowProperties, newProperties, { - flowId, - flowStartTime, - }) as FlowEventProperties; - }; - - const addEvent = ( - eventName: string, - eventProperties?: FlowEventProperties, - ) => { - const event = getEventName(flowName, eventName); - - // Calculate time since start - const duration = Math.round(performance.now() - startTime); - // Always send the details of the startFlow props with all events in the flow - const mergedProps = mergeProperties(flowProperties, eventProperties, { - flowId, - flowStartTime, - duration, - }) as FlowEventProperties; - trackDuration(moduleName, event, duration, mergedProps); - }; - - const end = (endProperties?: FlowEventProperties) => { - // Track the end of the flow - const flowEndEventName = getEventName(flowName, 'end'); - const duration = Math.round(performance.now() - startTime); - const mergedProps = mergeProperties(flowProperties, endProperties, { - flowId, - flowStartTime, - }) as FlowEventProperties; - trackDuration(moduleName, flowEndEventName, duration, mergedProps); - }; - - return { - details: { - moduleName, - flowName, - flowId, - flowStartTime, - }, - addEvent: errorBoundary(addEvent), - addFlowProperties: errorBoundary(addFlowProperties), - end: errorBoundary(end), - }; -}; - -/** - * Track a flow of events, including the start and end of the flow. - * Works similarly to `track` - * @param moduleName Name of the module being tracked (for namespacing purposes), e.g. `passport` - * @param flowName Name of the flow, e.g. `performTransaction` - * @param properties Other properties to be sent with the event, other than duration - * - * @example - * ```ts - * const flow = trackFlow("passport", "performTransaction", { transationType: "transfer" }); - * // Do something... - * flow.addEvent("clickItem"); - * // Do something... - * flow.addFlowProperties({ item: "item1" }); - * flow.addEvent("guardianCheck", {"invisible": "true"}); - * // Do something... - * flow.addEvent("guardianCheckComplete"); - * flow.end(); - * ``` - */ -export const trackFlow = errorBoundary(trackFlowFn); diff --git a/packages/internal/metrics/src/track.ts b/packages/internal/metrics/src/track.ts index 0b60cbffe2..cf0d424a9c 100644 --- a/packages/internal/metrics/src/track.ts +++ b/packages/internal/metrics/src/track.ts @@ -16,7 +16,26 @@ import { export const POLLING_FREQUENCY = 5000; -export type TrackProperties = Record; +export type TrackProperties = Record< +string, +string | number | boolean | undefined +>; + +// List of properties that are allowed to be sent with the track request +// As these are used by other types of tracking +export type AllowedTrackProperties = TrackProperties & { + // Performance + duration?: never; + // Flow + flowId?: never; + flowName?: never; + flowEventName?: never; + flowStep?: never; + // Error + isTrackError?: never; + errorMessage?: never; + errorStack?: never; +}; const trackFn = ( moduleName: string, diff --git a/packages/internal/metrics/src/utils/id.ts b/packages/internal/metrics/src/utils/id.ts new file mode 100644 index 0000000000..8a7c7c0164 --- /dev/null +++ b/packages/internal/metrics/src/utils/id.ts @@ -0,0 +1,7 @@ +// UUID not playing well with browser, using this for now +export const generateFlowId = () => { + const s4 = () => Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; +}; diff --git a/packages/internal/metrics/src/utils/state.ts b/packages/internal/metrics/src/utils/state.ts index 26b5f80ec6..3aa0e26ffc 100644 --- a/packages/internal/metrics/src/utils/state.ts +++ b/packages/internal/metrics/src/utils/state.ts @@ -2,8 +2,8 @@ import { getItem, setItem } from './localStorage'; import { Detail } from './constants'; export enum Store { - EVENTS = 'events', - RUNTIME = 'runtime', + EVENTS = 'metrics-events', + RUNTIME = 'metrics-runtime', } // In memory storage for events and other data @@ -48,9 +48,12 @@ export const removeSentEvents = (numberOfEvents: number) => { setItem(Store.EVENTS, EVENT_STORE); }; -export const flattenProperties = ( - properties: Record, -) => { +type TrackProperties = Record< +string, +string | number | boolean | undefined +>; + +export const flattenProperties = (properties: TrackProperties) => { const propertyMap: [string, string][] = []; Object.entries(properties).forEach(([key, value]) => { if ( @@ -59,7 +62,7 @@ export const flattenProperties = ( || typeof value === 'number' || typeof value === 'boolean' ) { - propertyMap.push([key, value.toString()]); + propertyMap.push([key, value!.toString()]); } }); return propertyMap; diff --git a/packages/passport/sdk/package.json b/packages/passport/sdk/package.json index 854a91c60d..5285ebe05a 100644 --- a/packages/passport/sdk/package.json +++ b/packages/passport/sdk/package.json @@ -19,6 +19,7 @@ "@metamask/detect-provider": "^2.0.0", "axios": "^1.6.5", "ethers": "^5.7.2", + "events": "^3.3.0", "jwt-decode": "^3.1.2", "magic-sdk": "^21.2.0", "oidc-client-ts": "2.4.0", diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index 67ec544307..bcedc060a5 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -1,17 +1,24 @@ -import { ModuleConfiguration } from '@imtbl/config'; -import { - EthSigner, - IMXClient, - StarkSigner, -} from '@imtbl/x-client'; +import { Environment, ModuleConfiguration } from '@imtbl/config'; +import { EthSigner, IMXClient, StarkSigner } from '@imtbl/x-client'; import { ImxApiClients } from '@imtbl/generated-clients'; +import { Flow } from '@imtbl/metrics'; export enum PassportEvents { LOGGED_OUT = 'loggedOut', + ACCOUNTS_REQUESTED = 'accountsRequested', } +export type AccountsRequestedEvent = { + environment: Environment; + sendTransaction: (params: Array, flow: Flow) => Promise; + walletAddress: string; + passportClient: string; + flow?: Flow; +}; + export interface PassportEventMap extends Record { [PassportEvents.LOGGED_OUT]: []; + [PassportEvents.ACCOUNTS_REQUESTED]: [AccountsRequestedEvent]; } export type UserProfile = { @@ -74,7 +81,8 @@ export interface PopupOverlayOptions { disableBlockedPopupOverlay?: boolean; } -export interface PassportModuleConfiguration extends ModuleConfiguration, +export interface PassportModuleConfiguration + extends ModuleConfiguration, OidcConfiguration { /** * This flag indicates that Passport is being used in a cross-sdk bridge scenario @@ -142,11 +150,11 @@ export type DeviceErrorResponse = { }; export type PKCEData = { - state: string, - verifier: string + state: string; + verifier: string; }; export type IMXSigners = { - starkSigner: StarkSigner, + starkSigner: StarkSigner; ethSigner: EthSigner; }; diff --git a/packages/passport/sdk/src/zkEvm/sessionActivity/errorBoundary.ts b/packages/passport/sdk/src/zkEvm/sessionActivity/errorBoundary.ts new file mode 100644 index 0000000000..918debe8bb --- /dev/null +++ b/packages/passport/sdk/src/zkEvm/sessionActivity/errorBoundary.ts @@ -0,0 +1,33 @@ +import { trackError } from '@imtbl/metrics'; + +export function errorBoundary any>( + fn: T, + fallbackResult?: ReturnType, +): (...args: Parameters) => ReturnType { + return (...args) => { + try { + // Execute the original function + const result = fn(...args); + + if (result instanceof Promise) { + // Silent fail for now, in future + // we can send errors to a logging service + return result.catch((error) => { + if (error instanceof Error) { + trackError('passport', 'sessionActivityError', error); + } + return fallbackResult; + }); + } + + return result; + } catch (error: unknown | Error) { + if (error instanceof Error) { + trackError('passport', 'sessionActivityError', error); + } + // As above, fail silently for now + return fallbackResult; + } + }; +} diff --git a/packages/passport/sdk/src/zkEvm/sessionActivity/request.ts b/packages/passport/sdk/src/zkEvm/sessionActivity/request.ts new file mode 100644 index 0000000000..c3757fa179 --- /dev/null +++ b/packages/passport/sdk/src/zkEvm/sessionActivity/request.ts @@ -0,0 +1,62 @@ +import { Environment } from '@imtbl/config'; +import axios, { AxiosInstance } from 'axios'; + +// For session activity checks, always use production +// even for sandbox. + +const PROD_API = 'https://api.immutable.com'; +const SANDBOX_API = 'https://api.sandbox.immutable.com'; +const CHECK_PATH = '/v1/sdk/session-activity/check'; + +const getBaseUrl = (environment?: Environment) => { + switch (environment) { + case Environment.SANDBOX: + return SANDBOX_API; + case Environment.PRODUCTION: + return PROD_API; + default: + throw new Error('Environment not supported'); + } +}; + +let client: AxiosInstance | undefined; + +export const setupClient = (environment: Environment) => { + if (client) { + return; + } + + client = axios.create({ + baseURL: getBaseUrl(environment), + }); +}; + +type CheckParams = { + clientId: string; + wallet?: string; + checkCount?: number; + sendCount?: number; +}; +export type CheckResponse = { + contractAddress?: string; + functionName?: string; + delay?: number; +}; + +export async function get(queries: CheckParams) { + if (!client) { + throw new Error('Client not initialised'); + } + // pass queries as query string + return client! + .get(CHECK_PATH, { + params: queries, + }) + .then((res) => res.data) + .catch((error) => { + if (error.response.status === 404) { + return undefined; + } + throw error; + }); +} diff --git a/packages/passport/sdk/src/zkEvm/sessionActivity/sessionActivity.ts b/packages/passport/sdk/src/zkEvm/sessionActivity/sessionActivity.ts new file mode 100644 index 0000000000..8bc84fdadb --- /dev/null +++ b/packages/passport/sdk/src/zkEvm/sessionActivity/sessionActivity.ts @@ -0,0 +1,138 @@ +import { trackFlow, utils as metricsUtils, trackError } from '@imtbl/metrics'; +import { utils } from 'ethers'; +import { CheckResponse, get, setupClient } from './request'; +import { errorBoundary } from './errorBoundary'; +import { AccountsRequestedEvent } from '../../types'; + +// Local Storage Keys +const { getItem, setItem } = metricsUtils.localStorage; +const SESSION_ACTIVITY_COUNT_KEY = 'sessionActivitySendCount'; +const SESSION_ACTIVITY_DAY_KEY = 'sessionActivityDate'; + +// Maintain a few local counters for session activity +let checkCount = 0; +let sendCount = 0; +let currentSessionTrackCall = false; + +// Sync sendCount to localStorage +const syncSendCount = () => { + sendCount = getItem(SESSION_ACTIVITY_COUNT_KEY) || 0; + const sendDay = getItem(SESSION_ACTIVITY_DAY_KEY); + + // If no day, set count to zero. If not today, reset sendCount to 0 + const today = new Date().toISOString().split('T')[0]; + if (!sendDay || sendDay !== today) { + sendCount = 0; + } + + setItem(SESSION_ACTIVITY_DAY_KEY, today); + setItem(SESSION_ACTIVITY_COUNT_KEY, sendCount); +}; +// Run as soon as module initialised. +syncSendCount(); + +const incrementSendCount = () => { + syncSendCount(); + sendCount++; + setItem(SESSION_ACTIVITY_COUNT_KEY, sendCount); + // Reset checkCount to zero on sending + checkCount = 0; +}; + +// Fix no-promise-executor-return +const wait = async (seconds: number) => new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); +}); + +const trackSessionActivityFn = async (args: AccountsRequestedEvent) => { + // Use an existing flow if one is provided, or create a new one + const flow = args.flow || trackFlow('passport', 'sendSessionActivity'); + // If there is already a tracking call in progress, do nothing + if (currentSessionTrackCall) { + flow.addEvent('Existing Delay Early Exit'); + return; + } + currentSessionTrackCall = true; + + const { sendTransaction, environment } = args; + if (!sendTransaction) { + throw new Error('No sendTransaction function provided'); + } + // Used to set up the request client + if (!environment) { + throw new Error('No environment provided'); + } + setupClient(environment); + + const clientId = args.passportClient; + if (!clientId) { + flow.addEvent('No Passport Client ID'); + throw new Error('No Passport Client ID provided'); + } + + const from = args.walletAddress; + if (!from) { + flow.addEvent('No Passport Wallet Address'); + throw new Error('No wallet address'); + } + // Return type of get + let details: CheckResponse | undefined; + + // Make the API call + try { + flow.addEvent('Fetching details'); + details = await get({ + clientId, + wallet: from, + checkCount, + sendCount, + }); + checkCount++; + flow.addEvent('Fetched details', { checkCount }); + + if (!details) { + flow.addEvent('No details found'); + return; + } + } catch (error) { + flow.addEvent('Failed to fetch details'); + throw new Error('Failed to get details', { cause: error }); + } + + if (details && details.contractAddress && details.functionName) { + const contractInterface = () => new utils.Interface([`function ${details!.functionName}()`]); + const data = contractInterface().encodeFunctionData(details.functionName); + const to = details.contractAddress; + + // If transaction payload, send transaction + try { + flow.addEvent('Start Sending Transaction'); + const tx = await args.sendTransaction([{ to, from, data }], flow); + incrementSendCount(); + flow.addEvent('Transaction Sent', { tx }); + } catch (error) { + flow.addEvent('Failed to send Transaction'); + const err = new Error('Failed to send transaction', { cause: error }); + trackError('passport', 'sessionActivityError', err); + } + } + + // if delay, perform delay. + if (details && details.delay && details.delay > 0) { + flow.addEvent('Delaying Transaction', { delay: details.delay }); + await wait(details.delay); + setTimeout(() => { + flow.addEvent('Retrying after Delay'); + currentSessionTrackCall = false; + // eslint-disable-next-line + trackSessionWrapper({ ...args, flow }); + }, 0); + } +}; + +// Wrapper design to ensure that after track function is called, current session Track call is false. +const trackSessionWrapper = (args: AccountsRequestedEvent) => errorBoundary(trackSessionActivityFn)(args).then(() => { + currentSessionTrackCall = false; +}); + +export const trackSessionActivity = trackSessionWrapper; diff --git a/packages/passport/sdk/src/zkEvm/types.ts b/packages/passport/sdk/src/zkEvm/types.ts index 2d9c19500e..355c0a1b85 100644 --- a/packages/passport/sdk/src/zkEvm/types.ts +++ b/packages/passport/sdk/src/zkEvm/types.ts @@ -133,6 +133,7 @@ export interface EIP6963ProviderInfo { /** * Event type to announce an EIP-1193 Provider. */ -export interface EIP6963AnnounceProviderEvent extends CustomEvent { - type: 'eip6963:announceProvider' +export interface EIP6963AnnounceProviderEvent + extends CustomEvent { + type: 'eip6963:announceProvider'; } diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts index 5c7f4f054a..953418ea4c 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts @@ -2,7 +2,9 @@ import { StaticJsonRpcProvider, Web3Provider } from '@ethersproject/providers'; import { MultiRollupApiClients } from '@imtbl/generated-clients'; import { Signer } from '@ethersproject/abstract-signer'; import { utils } from 'ethers'; -import { identify, trackFlow } from '@imtbl/metrics'; +import { + Flow, identify, trackError, trackFlow, +} from '@imtbl/metrics'; import { JsonRpcRequestCallback, JsonRpcRequestPayload, @@ -17,10 +19,7 @@ import MagicAdapter from '../magicAdapter'; import TypedEventEmitter from '../utils/typedEventEmitter'; import { PassportConfiguration } from '../config'; import { - PassportEventMap, - PassportEvents, - User, - UserZkEvm, + PassportEventMap, PassportEvents, User, UserZkEvm, } from '../types'; import { RelayerClient } from './relayerClient'; import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './JsonRpcError'; @@ -29,12 +28,13 @@ import { sendTransaction } from './sendTransaction'; import GuardianClient from '../guardian'; import { signTypedDataV4 } from './signTypedDataV4'; import { personalSign } from './personalSign'; +import { trackSessionActivity } from './sessionActivity/sessionActivity'; export type ZkEvmProviderInput = { authManager: AuthManager; - magicAdapter: MagicAdapter, - config: PassportConfiguration, - multiRollupApiClients: MultiRollupApiClients, + magicAdapter: MagicAdapter; + config: PassportConfiguration; + multiRollupApiClients: MultiRollupApiClients; passportEventEmitter: TypedEventEmitter; guardianClient: GuardianClient; }; @@ -46,7 +46,15 @@ export class ZkEvmProvider implements Provider { readonly #config: PassportConfiguration; - readonly #eventEmitter: TypedEventEmitter; + /** + * intended to emit EIP-1193 events + */ + readonly #providerEventEmitter: TypedEventEmitter; + + /** + * intended to emit internal Passport events + */ + readonly #passportEventEmitter: TypedEventEmitter; readonly #guardianClient: GuardianClient; @@ -84,6 +92,7 @@ export class ZkEvmProvider implements Provider { this.#magicAdapter = magicAdapter; this.#config = config; this.#guardianClient = guardianClient; + this.#passportEventEmitter = passportEventEmitter; if (config.crossSdkBridgeEnabled) { // StaticJsonRpcProvider by default sets the referrer as "client". @@ -103,9 +112,13 @@ export class ZkEvmProvider implements Provider { }); this.#multiRollupApiClients = multiRollupApiClients; - this.#eventEmitter = new TypedEventEmitter(); + this.#providerEventEmitter = new TypedEventEmitter(); passportEventEmitter.on(PassportEvents.LOGGED_OUT, this.#handleLogout); + passportEventEmitter.on( + PassportEvents.ACCOUNTS_REQUESTED, + trackSessionActivity, + ); } #handleLogout = () => { @@ -115,7 +128,7 @@ export class ZkEvmProvider implements Provider { this.#zkEvmAddress = undefined; if (shouldEmitAccountsChanged) { - this.#eventEmitter.emit(ProviderEvent.ACCOUNTS_CHANGED, []); + this.#providerEventEmitter.emit(ProviderEvent.ACCOUNTS_CHANGED, []); } }; @@ -164,67 +177,101 @@ export class ZkEvmProvider implements Provider { return ethSigner; } + async #callSessionActivity() { + const sendTransactionClosure = async (params: Array, flow: Flow) => { + const ethSigner = await this.#getSigner(); + return await sendTransaction({ + params, + ethSigner, + guardianClient: this.#guardianClient, + rpcProvider: this.#rpcProvider, + relayerClient: this.#relayerClient, + zkevmAddress: this.#zkEvmAddress!, + flow, + }); + }; + this.#passportEventEmitter.emit(PassportEvents.ACCOUNTS_REQUESTED, { + environment: this.#config.baseConfig.environment, + sendTransaction: sendTransactionClosure, + walletAddress: this.#zkEvmAddress || '', + passportClient: this.#config.oidcConfiguration.clientId, + }); + } + async #performRequest(request: RequestArguments): Promise { + // This is required for sending session activity events + switch (request.method) { case 'eth_requestAccounts': { - if (this.#zkEvmAddress) { - return [this.#zkEvmAddress]; - } - - const flow = trackFlow('passport', 'ethRequestAccounts'); - - try { - const user = await this.#authManager.getUserOrLogin(); - flow.addEvent('endGetUserOrLogin'); - - this.#initialiseEthSigner(user); - - if (!isZkEvmUser(user)) { - flow.addEvent('startUserRegistration'); - - const ethSigner = await this.#getSigner(); - flow.addEvent('ethSignerResolved'); - - this.#zkEvmAddress = await registerZkEvmUser({ - ethSigner, - authManager: this.#authManager, - multiRollupApiClients: this.#multiRollupApiClients, - accessToken: user.accessToken, - rpcProvider: this.#rpcProvider, - flow, - }); - flow.addEvent('endUserRegistration'); - } else { - this.#zkEvmAddress = user.zkEvm.ethAddress; + const requestAccounts = async () => { + if (this.#zkEvmAddress) { + return [this.#zkEvmAddress]; } - this.#eventEmitter.emit(ProviderEvent.ACCOUNTS_CHANGED, [this.#zkEvmAddress]); - identify({ - passportId: user.profile.sub, - }); - - return [this.#zkEvmAddress]; - } catch (error) { - let errorMessage = 'Unknown error'; - if (error instanceof Error) { - errorMessage = error.message; + const flow = trackFlow('passport', 'ethRequestAccounts'); + + try { + const user = await this.#authManager.getUserOrLogin(); + flow.addEvent('endGetUserOrLogin'); + + this.#initialiseEthSigner(user); + + if (!isZkEvmUser(user)) { + flow.addEvent('startUserRegistration'); + + const ethSigner = await this.#getSigner(); + flow.addEvent('ethSignerResolved'); + + this.#zkEvmAddress = await registerZkEvmUser({ + ethSigner, + authManager: this.#authManager, + multiRollupApiClients: this.#multiRollupApiClients, + accessToken: user.accessToken, + rpcProvider: this.#rpcProvider, + flow, + }); + flow.addEvent('endUserRegistration'); + } else { + this.#zkEvmAddress = user.zkEvm.ethAddress; + } + + this.#providerEventEmitter.emit(ProviderEvent.ACCOUNTS_CHANGED, [ + this.#zkEvmAddress, + ]); + identify({ + passportId: user.profile.sub, + }); + return [this.#zkEvmAddress]; + } catch (error) { + if (error instanceof Error) { + trackError('passport', 'ethRequestAccounts', error); + } + flow.addEvent('errored'); + throw error; + } finally { + flow.addEvent('End'); } + }; - flow.addEvent('error', { errorMessage }); - throw error; - } finally { - flow.end(); - } + const addresses = await requestAccounts(); + this.#callSessionActivity(); + return addresses; } case 'eth_sendTransaction': { if (!this.#zkEvmAddress) { - throw new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'); + throw new JsonRpcError( + ProviderErrorCode.UNAUTHORIZED, + 'Unauthorised - call eth_requestAccounts first', + ); } const flow = trackFlow('passport', 'ethSendTransaction'); try { - return await this.#guardianClient.withConfirmationScreen({ width: 480, height: 720 })(async () => { + return await this.#guardianClient.withConfirmationScreen({ + width: 480, + height: 720, + })(async () => { const ethSigner = await this.#getSigner(); flow.addEvent('endGetSigner'); @@ -239,15 +286,13 @@ export class ZkEvmProvider implements Provider { }); }); } catch (error) { - let errorMessage = 'Unknown error'; if (error instanceof Error) { - errorMessage = error.message; + trackError('passport', 'eth_sendTransaction', error); } - - flow.addEvent('error', { errorMessage }); + flow.addEvent('errored'); throw error; } finally { - flow.end(); + flow.addEvent('End'); } } case 'eth_accounts': { @@ -255,13 +300,19 @@ export class ZkEvmProvider implements Provider { } case 'personal_sign': { if (!this.#zkEvmAddress) { - throw new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'); + throw new JsonRpcError( + ProviderErrorCode.UNAUTHORIZED, + 'Unauthorised - call eth_requestAccounts first', + ); } const flow = trackFlow('passport', 'personalSign'); try { - return await this.#guardianClient.withConfirmationScreen({ width: 480, height: 720 })(async () => { + return await this.#guardianClient.withConfirmationScreen({ + width: 480, + height: 720, + })(async () => { const ethSigner = await this.#getSigner(); flow.addEvent('endGetSigner'); @@ -276,27 +327,31 @@ export class ZkEvmProvider implements Provider { }); }); } catch (error) { - let errorMessage = 'Unknown error'; if (error instanceof Error) { - errorMessage = error.message; + trackError('passport', 'personal_sign', error); } - - flow.addEvent('error', { errorMessage }); + flow.addEvent('errored'); throw error; } finally { - flow.end(); + flow.addEvent('End'); } } case 'eth_signTypedData': case 'eth_signTypedData_v4': { if (!this.#zkEvmAddress) { - throw new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'); + throw new JsonRpcError( + ProviderErrorCode.UNAUTHORIZED, + 'Unauthorised - call eth_requestAccounts first', + ); } const flow = trackFlow('passport', 'ethSignTypedDataV4'); try { - return await this.#guardianClient.withConfirmationScreen({ width: 480, height: 720 })(async () => { + return await this.#guardianClient.withConfirmationScreen({ + width: 480, + height: 720, + })(async () => { const ethSigner = await this.#getSigner(); flow.addEvent('endGetSigner'); @@ -311,15 +366,13 @@ export class ZkEvmProvider implements Provider { }); }); } catch (error) { - let errorMessage = 'Unknown error'; if (error instanceof Error) { - errorMessage = error.message; + trackError('passport', 'eth_signTypedData', error); } - - flow.addEvent('error', { errorMessage }); + flow.addEvent('errored'); throw error; } finally { - flow.end(); + flow.addEvent('End'); } } case 'eth_chainId': { @@ -337,16 +390,26 @@ export class ZkEvmProvider implements Provider { case 'eth_getCode': case 'eth_getTransactionCount': { const [address, blockNumber] = request.params || []; - return this.#rpcProvider.send(request.method, [address, blockNumber || 'latest']); + return this.#rpcProvider.send(request.method, [ + address, + blockNumber || 'latest', + ]); } case 'eth_getStorageAt': { const [address, storageSlot, blockNumber] = request.params || []; - return this.#rpcProvider.send(request.method, [address, storageSlot, blockNumber || 'latest']); + return this.#rpcProvider.send(request.method, [ + address, + storageSlot, + blockNumber || 'latest', + ]); } case 'eth_call': case 'eth_estimateGas': { const [transaction, blockNumber] = request.params || []; - return this.#rpcProvider.send(request.method, [transaction, blockNumber || 'latest']); + return this.#rpcProvider.send(request.method, [ + transaction, + blockNumber || 'latest', + ]); } case 'eth_gasPrice': case 'eth_blockNumber': @@ -357,12 +420,17 @@ export class ZkEvmProvider implements Provider { return this.#rpcProvider.send(request.method, request.params || []); } default: { - throw new JsonRpcError(ProviderErrorCode.UNSUPPORTED_METHOD, 'Method not supported'); + throw new JsonRpcError( + ProviderErrorCode.UNSUPPORTED_METHOD, + 'Method not supported', + ); } } } - async #performJsonRpcRequest(request: JsonRpcRequestPayload): Promise { + async #performJsonRpcRequest( + request: JsonRpcRequestPayload, + ): Promise { const { id, jsonrpc } = request; try { const result = await this.#performRequest(request); @@ -376,9 +444,15 @@ export class ZkEvmProvider implements Provider { if (error instanceof JsonRpcError) { jsonRpcError = error; } else if (error instanceof Error) { - jsonRpcError = new JsonRpcError(RpcErrorCode.INTERNAL_ERROR, error.message); + jsonRpcError = new JsonRpcError( + RpcErrorCode.INTERNAL_ERROR, + error.message, + ); } else { - jsonRpcError = new JsonRpcError(RpcErrorCode.INTERNAL_ERROR, 'Internal error'); + jsonRpcError = new JsonRpcError( + RpcErrorCode.INTERNAL_ERROR, + 'Internal error', + ); } return { @@ -389,9 +463,7 @@ export class ZkEvmProvider implements Provider { } } - public async request( - request: RequestArguments, - ): Promise { + public async request(request: RequestArguments): Promise { try { return this.#performRequest(request); } catch (error: unknown) { @@ -415,17 +487,21 @@ export class ZkEvmProvider implements Provider { } if (Array.isArray(request)) { - Promise.all(request.map(this.#performJsonRpcRequest)).then((result) => { - callback(null, result); - }).catch((error: JsonRpcError) => { - callback(error, []); - }); + Promise.all(request.map(this.#performJsonRpcRequest)) + .then((result) => { + callback(null, result); + }) + .catch((error: JsonRpcError) => { + callback(error, []); + }); } else { - this.#performJsonRpcRequest(request).then((result) => { - callback(null, result); - }).catch((error: JsonRpcError) => { - callback(error, null); - }); + this.#performJsonRpcRequest(request) + .then((result) => { + callback(null, result); + }) + .catch((error: JsonRpcError) => { + callback(error, null); + }); } } @@ -437,17 +513,23 @@ export class ZkEvmProvider implements Provider { // Web3 >= 1.0.0-beta.38 calls `send` with method and parameters. if (typeof request === 'string') { if (typeof callbackOrParams === 'function') { - return this.sendAsync({ - method: request, - params: [], - }, callbackOrParams); + return this.sendAsync( + { + method: request, + params: [], + }, + callbackOrParams, + ); } if (callback) { - return this.sendAsync({ - method: request, - params: Array.isArray(callbackOrParams) ? callbackOrParams : [], - }, callback); + return this.sendAsync( + { + method: request, + params: Array.isArray(callbackOrParams) ? callbackOrParams : [], + }, + callback, + ); } return this.request({ @@ -469,10 +551,13 @@ export class ZkEvmProvider implements Provider { } public on(event: string, listener: (...args: any[]) => void): void { - this.#eventEmitter.on(event, listener); + this.#providerEventEmitter.on(event, listener); } - public removeListener(event: string, listener: (...args: any[]) => void): void { - this.#eventEmitter.removeListener(event, listener); + public removeListener( + event: string, + listener: (...args: any[]) => void, + ): void { + this.#providerEventEmitter.removeListener(event, listener); } } diff --git a/sdk/package.json b/sdk/package.json index a57e87ef43..e4a40eecf2 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -38,6 +38,7 @@ "ethereumjs-wallet": "^1.0.2", "ethers": "^5.7.2", "ethers-v6": "npm:ethers@6.11.1", + "events": "^3.3.0", "global-const": "^0.1.2", "https-browserify": "^1.0.0", "i18next": "^23.7.6", diff --git a/yarn.lock b/yarn.lock index f62b71c2e0..a6cb22108a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3828,6 +3828,7 @@ __metadata: cross-fetch: ^3.1.6 eslint: ^8.40.0 ethers: ^5.7.2 + events: ^3.3.0 jest: ^29.4.3 jest-environment-jsdom: ^29.4.3 jwt-decode: ^3.1.2 @@ -3915,6 +3916,7 @@ __metadata: ethereumjs-wallet: ^1.0.2 ethers: ^5.7.2 ethers-v6: "npm:ethers@6.11.1" + events: ^3.3.0 glob: ^10.2.3 global-const: ^0.1.2 https-browserify: ^1.0.0