diff --git a/.husky/pre-commit b/.husky/pre-commit index 5b0e62ebec..5ec4a3505f 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,7 +1,7 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -# prevent heap limit allocation errors -export NODE_OPTIONS="--max-old-space-size=4096" +# prevent heap limit allocation errors - increased to 8GB +export NODE_OPTIONS="--max-old-space-size=8192" pnpm lint-staged diff --git a/package.json b/package.json index 1dfbafd411..cc9efd2ad4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@swc-node/register": "^1.10.9", "@swc/cli": "^0.4.0", "@swc/core": "^1.3.36", + "@jest/test-sequencer": "^29.7.0", "@types/chai": "^4.3.16", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", diff --git a/packages/auth/.eslintrc.cjs b/packages/auth/.eslintrc.cjs new file mode 100644 index 0000000000..3c484de84b --- /dev/null +++ b/packages/auth/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + extends: ['../../.eslintrc'], + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + }, + rules: { + // Disable all import plugin rules due to stack overflow with auth package structure + 'import/order': 'off', + 'import/no-unresolved': 'off', + 'import/named': 'off', + 'import/default': 'off', + 'import/namespace': 'off', + 'import/no-cycle': 'off', + 'import/no-named-as-default': 'off', + 'import/no-named-as-default-member': 'off', + }, +}; \ No newline at end of file diff --git a/packages/auth/jest.config.ts b/packages/auth/jest.config.ts new file mode 100644 index 0000000000..793f1a2c00 --- /dev/null +++ b/packages/auth/jest.config.ts @@ -0,0 +1,27 @@ +import type { Config } from 'jest'; +import { execSync } from 'child_process'; +import { name } from './package.json'; + +const rootDirs = execSync(`pnpm --filter ${name}... exec pwd`) + .toString() + .split('\n') + .filter(Boolean) + .map((dir) => `${dir}/dist`); + +const config: Config = { + clearMocks: true, + roots: ['/src', ...rootDirs], + coverageProvider: 'v8', + moduleDirectories: ['node_modules', 'src'], + moduleNameMapper: { '^@imtbl/(.*)$': '/../../node_modules/@imtbl/$1/src' }, + testEnvironment: 'jsdom', + transform: { + '^.+\\.(t|j)sx?$': '@swc/jest', + }, + transformIgnorePatterns: [], + restoreMocks: true, + setupFiles: ['/jest.setup.js'], +}; + +export default config; + diff --git a/packages/auth/jest.setup.js b/packages/auth/jest.setup.js new file mode 100644 index 0000000000..0a4e57c4f9 --- /dev/null +++ b/packages/auth/jest.setup.js @@ -0,0 +1,4 @@ +import { TextEncoder } from 'util'; + +global.TextEncoder = TextEncoder; + diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000000..3989823060 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,64 @@ +{ + "name": "@imtbl/auth", + "version": "0.0.0", + "description": "Authentication SDK for Immutable", + "author": "Immutable", + "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", + "homepage": "https://github.com/immutable/ts-immutable-sdk#readme", + "license": "Apache-2.0", + "main": "dist/node/index.cjs", + "module": "dist/node/index.js", + "browser": "dist/browser/index.js", + "types": "./dist/types/index.d.ts", + "exports": { + "development": { + "types": "./src/index.ts", + "browser": "./dist/browser/index.js", + "require": "./dist/node/index.cjs", + "default": "./dist/node/index.js" + }, + "default": { + "types": "./dist/types/index.d.ts", + "browser": "./dist/browser/index.js", + "require": "./dist/node/index.cjs", + "default": "./dist/node/index.js" + } + }, + "scripts": { + "build": "pnpm transpile && pnpm typegen", + "transpile": "tsup src/index.ts --config ../../tsup.config.js", + "typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types", + "pack:root": "pnpm pack --pack-destination $(dirname $(pnpm root -w))", + "lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0", + "typecheck": "tsc --customConditions default --noEmit --jsx preserve", + "test": "jest" + }, + "dependencies": { + "@imtbl/config": "workspace:*", + "@imtbl/metrics": "workspace:*", + "axios": "^1.6.5", + "jwt-decode": "^3.1.2", + "localforage": "^1.10.0", + "oidc-client-ts": "3.4.1", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@imtbl/toolkit": "workspace:*", + "@swc/core": "^1.3.36", + "@swc/jest": "^0.2.37", + "@types/jest": "^29.5.12", + "@types/node": "^18.14.2", + "@jest/test-sequencer": "^29.7.0", + "jest": "^29.4.3", + "jest-environment-jsdom": "^29.4.3", + "ts-node": "^10.9.1", + "tsup": "^8.3.0", + "typescript": "^5.6.2" + }, + "publishConfig": { + "access": "public" + }, + "repository": "immutable/ts-immutable-sdk.git", + "type": "module" +} + diff --git a/packages/auth/src/Auth.test.ts b/packages/auth/src/Auth.test.ts new file mode 100644 index 0000000000..56dad82d8c --- /dev/null +++ b/packages/auth/src/Auth.test.ts @@ -0,0 +1,186 @@ +import { Auth } from './Auth'; +import { AuthEvents, User } from './types'; +import { withMetricsAsync } from './utils/metrics'; +import jwt_decode from 'jwt-decode'; + +const trackFlowMock = jest.fn(); +const trackErrorMock = jest.fn(); +const identifyMock = jest.fn(); +const trackMock = jest.fn(); +const getDetailMock = jest.fn(); + +jest.mock('@imtbl/metrics', () => ({ + Detail: { RUNTIME_ID: 'runtime-id' }, + trackFlow: (...args: any[]) => trackFlowMock(...args), + trackError: (...args: any[]) => trackErrorMock(...args), + identify: (...args: any[]) => identifyMock(...args), + track: (...args: any[]) => trackMock(...args), + getDetail: (...args: any[]) => getDetailMock(...args), +})); + +jest.mock('jwt-decode', () => jest.fn()); + +beforeEach(() => { + trackFlowMock.mockReset(); + trackErrorMock.mockReset(); + identifyMock.mockReset(); + trackMock.mockReset(); + getDetailMock.mockReset(); + (jwt_decode as jest.Mock).mockReset(); +}); + +describe('withMetricsAsync', () => { + it('resolves with function result and tracks flow', async () => { + const flow = { + addEvent: jest.fn(), + details: { flowId: 'flow-id' }, + }; + trackFlowMock.mockReturnValue(flow); + + const result = await withMetricsAsync(async () => 'done', 'login'); + + expect(result).toEqual('done'); + expect(trackFlowMock).toHaveBeenCalledWith('passport', 'login', true); + expect(flow.addEvent).toHaveBeenCalledWith('End'); + }); + + it('tracks error when function throws', async () => { + const flow = { + addEvent: jest.fn(), + details: { flowId: 'flow-id' }, + }; + trackFlowMock.mockReturnValue(flow); + const error = new Error('boom'); + + await expect(withMetricsAsync(async () => { + throw error; + }, 'login')).rejects.toThrow(error); + + expect(trackErrorMock).toHaveBeenCalledWith('passport', 'login', error, { flowId: 'flow-id' }); + expect(flow.addEvent).toHaveBeenCalledWith('End'); + }); + + it('does not fail when non-error is thrown', async () => { + const flow = { + addEvent: jest.fn(), + details: { flowId: 'flow-id' }, + }; + trackFlowMock.mockReturnValue(flow); + + const nonError = { message: 'failure' }; + await expect(withMetricsAsync(async () => { + throw nonError as unknown as Error; + }, 'login')).rejects.toBe(nonError); + + expect(flow.addEvent).toHaveBeenCalledWith('errored'); + }); +}); + +describe('Auth', () => { + describe('getUserOrLogin', () => { + const createMockUser = (): User => ({ + accessToken: 'access', + idToken: 'id', + refreshToken: 'refresh', + expired: false, + profile: { + sub: 'user-123', + email: 'test@example.com', + nickname: 'tester', + }, + }); + + it('emits LOGGED_IN event and identifies user when login is required', async () => { + const auth = Object.create(Auth.prototype) as Auth; + const loginWithPopup = jest.fn().mockResolvedValue(createMockUser()); + + (auth as any).eventEmitter = { emit: jest.fn() }; + (auth as any).getUserInternal = jest.fn().mockResolvedValue(null); + (auth as any).loginWithPopup = loginWithPopup; + + const user = await auth.getUserOrLogin(); + + expect(loginWithPopup).toHaveBeenCalledTimes(1); + expect((auth as any).eventEmitter.emit).toHaveBeenCalledWith(AuthEvents.LOGGED_IN, user); + expect(identifyMock).toHaveBeenCalledWith({ passportId: user.profile.sub }); + }); + + it('returns cached user without triggering login', async () => { + const auth = Object.create(Auth.prototype) as Auth; + const cachedUser = createMockUser(); + + (auth as any).eventEmitter = { emit: jest.fn() }; + (auth as any).getUserInternal = jest.fn().mockResolvedValue(cachedUser); + (auth as any).loginWithPopup = jest.fn(); + + const user = await auth.getUserOrLogin(); + + expect(user).toBe(cachedUser); + expect((auth as any).loginWithPopup).not.toHaveBeenCalled(); + expect((auth as any).eventEmitter.emit).not.toHaveBeenCalled(); + expect(identifyMock).not.toHaveBeenCalled(); + }); + }); + + describe('buildExtraQueryParams', () => { + it('omits third_party_a_id when no anonymous id is provided', () => { + const auth = Object.create(Auth.prototype) as Auth; + (auth as any).userManager = { settings: { extraQueryParams: {} } }; + getDetailMock.mockReturnValue('runtime-id-value'); + + const params = (auth as any).buildExtraQueryParams(); + + expect(params.third_party_a_id).toBeUndefined(); + expect(params.rid).toEqual('runtime-id-value'); + }); + }); + + describe('username extraction', () => { + it('extracts username from id token when present', () => { + const mockOidcUser = { + id_token: 'token', + access_token: 'access', + refresh_token: 'refresh', + expired: false, + profile: { sub: 'user-123', email: 'test@example.com', nickname: 'tester' }, + }; + + (jwt_decode as jest.Mock).mockReturnValue({ + username: 'username123', + passport: undefined, + }); + + const result = (Auth as any).mapOidcUserToDomainModel(mockOidcUser); + + expect(jwt_decode).toHaveBeenCalledWith('token'); + expect(result.profile.username).toEqual('username123'); + }); + + it('maps username when creating OIDC user from device tokens', () => { + const tokenResponse = { + id_token: 'token', + access_token: 'access', + refresh_token: 'refresh', + token_type: 'Bearer', + expires_in: 3600, + }; + + (jwt_decode as jest.Mock).mockReturnValue({ + sub: 'user-123', + iss: 'issuer', + aud: 'audience', + exp: 1, + iat: 0, + email: 'test@example.com', + nickname: 'tester', + username: 'username123', + passport: undefined, + }); + + const oidcUser = (Auth as any).mapDeviceTokenResponseToOidcUser(tokenResponse); + + expect(jwt_decode).toHaveBeenCalledWith('token'); + expect(oidcUser.profile.username).toEqual('username123'); + }); + }); +}); diff --git a/packages/auth/src/Auth.ts b/packages/auth/src/Auth.ts new file mode 100644 index 0000000000..467ce6cc5f --- /dev/null +++ b/packages/auth/src/Auth.ts @@ -0,0 +1,799 @@ +import { + ErrorResponse, + ErrorTimeout, + InMemoryWebStorage, + User as OidcUser, + UserManager, + UserManagerSettings, + WebStorageStateStore, +} from 'oidc-client-ts'; +import axios from 'axios'; +import jwt_decode from 'jwt-decode'; +import localForage from 'localforage'; +import { + Detail, + getDetail, + identify, + track, + trackError, +} from '@imtbl/metrics'; +import { AuthConfiguration, IAuthConfiguration } from './config'; +import { + AuthModuleConfiguration, + User, + DirectLoginOptions, + DeviceTokenResponse, + LoginOptions, + AuthEventMap, + AuthEvents, + UserZkEvm, + OidcConfiguration, + PassportMetadata, + IdTokenPayload, + isUserZkEvm, +} from './types'; +import EmbeddedLoginPrompt from './login/embeddedLoginPrompt'; +import TypedEventEmitter from './utils/typedEventEmitter'; +import { withMetricsAsync } from './utils/metrics'; +import DeviceCredentialsManager from './storage/device_credentials_manager'; +import { PassportError, PassportErrorType, withPassportError } from './errors'; +import logger from './utils/logger'; +import { isAccessTokenExpiredOrExpiring } from './utils/token'; +import LoginPopupOverlay from './overlay/loginPopupOverlay'; +import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage'; + +const formUrlEncodedHeader = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, +}; + +const logoutEndpoint = '/v2/logout'; +const crossSdkBridgeLogoutEndpoint = '/im-logged-out'; +const authorizeEndpoint = '/authorize'; + +const getLogoutEndpointPath = (crossSdkBridgeEnabled: boolean): string => ( + crossSdkBridgeEnabled ? crossSdkBridgeLogoutEndpoint : logoutEndpoint +); + +const getAuthConfiguration = (config: IAuthConfiguration): UserManagerSettings => { + const { authenticationDomain, oidcConfiguration } = config; + + let store; + if (config.crossSdkBridgeEnabled) { + store = new LocalForageAsyncStorage('ImmutableSDKPassport', localForage.INDEXEDDB); + } else if (typeof window !== 'undefined') { + store = window.localStorage; + } else { + store = new InMemoryWebStorage(); + } + const userStore = new WebStorageStateStore({ store }); + + const endSessionEndpoint = new URL( + getLogoutEndpointPath(config.crossSdkBridgeEnabled), + authenticationDomain.replace(/^(?:https?:\/\/)?(.*)/, 'https://$1'), + ); + endSessionEndpoint.searchParams.set('client_id', oidcConfiguration.clientId); + if (oidcConfiguration.logoutRedirectUri) { + endSessionEndpoint.searchParams.set('returnTo', oidcConfiguration.logoutRedirectUri); + } + + return { + authority: authenticationDomain, + redirect_uri: oidcConfiguration.redirectUri, + popup_redirect_uri: oidcConfiguration.popupRedirectUri || oidcConfiguration.redirectUri, + client_id: oidcConfiguration.clientId, + metadata: { + authorization_endpoint: `${authenticationDomain}/authorize`, + token_endpoint: `${authenticationDomain}/oauth/token`, + userinfo_endpoint: `${authenticationDomain}/userinfo`, + end_session_endpoint: endSessionEndpoint.toString(), + revocation_endpoint: `${authenticationDomain}/oauth/revoke`, + }, + automaticSilentRenew: false, + scope: oidcConfiguration.scope, + userStore, + revokeTokenTypes: ['refresh_token'], + extraQueryParams: { + ...(oidcConfiguration.audience ? { audience: oidcConfiguration.audience } : {}), + }, + } as UserManagerSettings; +}; + +function base64URLEncode(str: ArrayBuffer | Uint8Array) { + return btoa(String.fromCharCode(...new Uint8Array(str))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +async function sha256(buffer: string) { + const encoder = new TextEncoder(); + const data = encoder.encode(buffer); + return window.crypto.subtle.digest('SHA-256', data); +} + +/** + * Public-facing Auth class for authentication + * Provides login/logout helpers and exposes auth state events + */ +export class Auth { + private readonly config: IAuthConfiguration; + + private readonly userManager: UserManager; + + private readonly deviceCredentialsManager: DeviceCredentialsManager; + + private readonly embeddedLoginPrompt: EmbeddedLoginPrompt; + + private readonly logoutMode: Exclude; + + /** + * Promise that is used to prevent multiple concurrent calls to the refresh token endpoint. + */ + private refreshingPromise: Promise | null = null; + + /** + * Event emitter for authentication events (LOGGED_IN, LOGGED_OUT) + * Exposed for wallet and passport packages to subscribe to auth state changes + */ + public readonly eventEmitter: TypedEventEmitter; + + /** + * Create a new Auth instance + * + * @param config - Auth configuration + * + * @example + * ```typescript + * import { Auth } from '@imtbl/auth'; + * + * const auth = new Auth({ + * authenticationDomain: 'https://auth.immutable.com', + * passportDomain: 'https://passport.immutable.com', + * clientId: 'your-client-id', + * redirectUri: 'https://your-app.com/callback', + * scope: 'openid profile email transact', + * }); + * ``` + */ + constructor(config: AuthModuleConfiguration) { + this.config = new AuthConfiguration(config); + this.embeddedLoginPrompt = new EmbeddedLoginPrompt(this.config); + this.userManager = new UserManager(getAuthConfiguration(this.config)); + this.deviceCredentialsManager = new DeviceCredentialsManager(); + this.logoutMode = this.config.oidcConfiguration.logoutMode || 'redirect'; + this.eventEmitter = new TypedEventEmitter(); + track('passport', 'initialise'); + } + + /** + * Login the user with extended options + * Supports cached sessions, silent login, redirect flow, and direct login + * @param options - Extended login options + * @returns Promise that resolves with the user or null + */ + async login(options?: LoginOptions): Promise { + return withMetricsAsync(async () => { + const { useCachedSession = false, useSilentLogin } = options || {}; + let user: User | null = null; + + // Try to get cached user + try { + user = await this.getUserInternal(); + } catch (error: any) { + if (error instanceof Error && !error.message.includes('Unknown or invalid refresh token')) { + trackError('passport', 'login', error); + } + if (useCachedSession) { + throw error; + } + logger.warn('Failed to retrieve a cached user session', error); + } + + // If no cached user, try silent login or regular login + if (!user && useSilentLogin) { + user = await this.forceUserRefreshInternal(); + } else if (!user && !useCachedSession) { + if (options?.useRedirectFlow) { + await this.loginWithRedirectInternal(options?.directLoginOptions); + return null; // Redirect doesn't return user immediately + } + user = await this.loginWithPopup(options?.directLoginOptions); + } + + // Emit LOGGED_IN event and identify user if logged in + if (user) { + this.handleSuccessfulLogin(user); + } + + return user; + }, 'login'); + } + + /** + * Login with redirect + * Redirects the page for authentication + * @param directLoginOptions - Optional direct login options + * @returns Promise that resolves when redirect is initiated + */ + async loginWithRedirect(directLoginOptions?: DirectLoginOptions): Promise { + await this.loginWithRedirectInternal(directLoginOptions); + } + + /** + * Login callback handler + * Call this in your redirect or popup callback page + * @returns Promise that resolves with the authenticated user or undefined (for popup flows) + */ + async loginCallback(): Promise { + return withMetricsAsync(async () => { + const user = await this.loginCallbackInternal(); + if (user) { + this.handleSuccessfulLogin(user); + } + return user; + }, 'loginCallback'); + } + + /** + * Logout the current user + * @returns Promise that resolves when logout is complete + */ + async logout(): Promise { + await withMetricsAsync(async () => { + await this.logoutInternal(); + this.eventEmitter.emit(AuthEvents.LOGGED_OUT); + }, 'logout'); + } + + /** + * Get the current authenticated user + * @returns Promise that resolves with the user or null if not authenticated + */ + async getUser(): Promise { + return this.getUserInternal(); + } + + /** + * Get the current authenticated user or initiate login if needed + * @returns Promise that resolves with an authenticated user + */ + async getUserOrLogin(): Promise { + let user: User | null = null; + try { + user = await this.getUserInternal(); + } catch (err) { + logger.warn('Failed to retrieve a cached user session', err); + } + + if (user) { + return user; + } + + const loggedInUser = await this.loginWithPopup(); + this.handleSuccessfulLogin(loggedInUser); + return loggedInUser; + } + + /** + * Get the current authenticated zkEVM user + * @returns Promise that resolves with a zkEVM-capable user + */ + async getUserZkEvm(): Promise { + return this.getUserZkEvmInternal(); + } + + /** + * Get the ID token for the current user + * @returns Promise that resolves with the ID token or undefined + */ + async getIdToken(): Promise { + return withMetricsAsync(async () => { + const user = await this.getUserInternal(); + return user?.idToken; + }, 'getIdToken', false); + } + + /** + * Get the access token for the current user + * @returns Promise that resolves with the access token or undefined + */ + async getAccessToken(): Promise { + return withMetricsAsync(async () => { + const user = await this.getUserInternal(); + return user?.accessToken; + }, 'getAccessToken', false, false); + } + + /** + * Check if user is logged in + * @returns Promise that resolves with true if user is logged in + */ + async isLoggedIn(): Promise { + const user = await this.getUser(); + return user !== null; + } + + /** + * Force a silent user refresh (for silent login) + * @returns Promise that resolves with the user or null if refresh fails + */ + async forceUserRefresh(): Promise { + return this.forceUserRefreshInternal(); + } + + /** + * Trigger a background user refresh without awaiting the result + */ + forceUserRefreshInBackground(): void { + this.forceUserRefreshInBackgroundInternal(); + } + + /** + * Get the PKCE authorization URL for login flow + * @param directLoginOptions - Optional direct login options + * @param imPassportTraceId - Optional trace ID for tracking + * @returns Promise that resolves with the authorization URL + */ + async loginWithPKCEFlow(directLoginOptions?: DirectLoginOptions, imPassportTraceId?: string): Promise { + return withMetricsAsync( + async () => this.getPKCEAuthorizationUrl(directLoginOptions, imPassportTraceId), + 'loginWithPKCEFlow', + ); + } + + /** + * Handle the PKCE login callback + * @param authorizationCode - The authorization code from the OAuth provider + * @param state - The state parameter for CSRF protection + * @returns Promise that resolves with the authenticated user + */ + async loginWithPKCEFlowCallback(authorizationCode: string, state: string): Promise { + return withMetricsAsync(async () => { + const user = await this.loginWithPKCEFlowCallbackInternal(authorizationCode, state); + this.handleSuccessfulLogin(user); + return user; + }, 'loginWithPKCEFlowCallback'); + } + + /** + * Store tokens from device flow and retrieve user + * @param tokenResponse - The token response from device flow + * @returns Promise that resolves with the authenticated user + */ + async storeTokens(tokenResponse: DeviceTokenResponse): Promise { + return withMetricsAsync(async () => { + const user = await this.storeTokensInternal(tokenResponse); + this.handleSuccessfulLogin(user); + return user; + }, 'storeTokens'); + } + + /** + * Get the logout URL + * @returns Promise that resolves with the logout URL or undefined if not available + */ + async getLogoutUrl(): Promise { + return withMetricsAsync(async () => { + await this.userManager.removeUser(); + this.eventEmitter.emit(AuthEvents.LOGGED_OUT); + const url = await this.getLogoutUrlInternal(); + return url || undefined; + }, 'getLogoutUrl'); + } + + /** + * Handle the silent logout callback + * @param url - The URL containing logout information + * @returns Promise that resolves when callback is handled + */ + async logoutSilentCallback(url: string): Promise { + return withMetricsAsync(() => this.userManager.signoutSilentCallback(url), 'logoutSilentCallback'); + } + + /** + * Get auth configuration + * @internal + * @returns IAuthConfiguration instance + */ + getConfig(): IAuthConfiguration { + return this.config; + } + + /** + * Get the configured OIDC client ID + * @returns Promise that resolves with the client ID string + */ + async getClientId(): Promise { + return this.config.oidcConfiguration.clientId; + } + + private handleSuccessfulLogin(user: User): void { + this.eventEmitter.emit(AuthEvents.LOGGED_IN, user); + identify({ passportId: user.profile.sub }); + } + + private buildExtraQueryParams( + directLoginOptions?: DirectLoginOptions, + imPassportTraceId?: string, + ): Record { + const params: Record = { + ...(this.userManager.settings?.extraQueryParams ?? {}), + rid: getDetail(Detail.RUNTIME_ID) || '', + }; + + if (directLoginOptions) { + if (directLoginOptions.directLoginMethod === 'email') { + const emailValue = directLoginOptions.email; + if (emailValue) { + params.direct = directLoginOptions.directLoginMethod; + params.email = emailValue; + } + } else { + params.direct = directLoginOptions.directLoginMethod; + } + if (directLoginOptions.marketingConsentStatus) { + params.marketingConsent = directLoginOptions.marketingConsentStatus; + } + } + + if (imPassportTraceId) { + params.im_passport_trace_id = imPassportTraceId; + } + + return params; + } + + private async loginWithRedirectInternal(directLoginOptions?: DirectLoginOptions): Promise { + await this.userManager.clearStaleState(); + await withPassportError(async () => { + const extraQueryParams = this.buildExtraQueryParams(directLoginOptions); + await this.userManager.signinRedirect({ extraQueryParams }); + }, PassportErrorType.AUTHENTICATION_ERROR); + } + + private async loginWithPopup(directLoginOptions?: DirectLoginOptions): Promise { + return withPassportError(async () => { + let directLoginOptionsToUse: DirectLoginOptions | undefined; + let imPassportTraceId: string | undefined; + if (directLoginOptions) { + directLoginOptionsToUse = directLoginOptions; + } else if (!this.config.popupOverlayOptions?.disableHeadlessLoginPromptOverlay) { + const { + imPassportTraceId: embeddedLoginPromptTraceId, + ...embeddedLoginPromptDirectLoginOptions + } = await this.embeddedLoginPrompt.displayEmbeddedLoginPrompt(); + directLoginOptionsToUse = embeddedLoginPromptDirectLoginOptions; + imPassportTraceId = embeddedLoginPromptTraceId; + } + + const popupWindowTarget = window.crypto.randomUUID(); + const signinPopup = async () => { + const extraQueryParams = this.buildExtraQueryParams(directLoginOptionsToUse, imPassportTraceId); + + return this.userManager.signinPopup({ + extraQueryParams, + popupWindowFeatures: { + width: 410, + height: 450, + }, + popupWindowTarget, + }); + }; + + return new Promise((resolve, reject) => { + signinPopup() + .then((oidcUser) => resolve(Auth.mapOidcUserToDomainModel(oidcUser))) + .catch((error: unknown) => { + if (!(error instanceof Error) || error.message !== 'Attempted to navigate on a disposed window') { + reject(error); + return; + } + + let popupHasBeenOpened = false; + const overlay = new LoginPopupOverlay(this.config.popupOverlayOptions || {}, true); + overlay.append( + async () => { + try { + if (!popupHasBeenOpened) { + popupHasBeenOpened = true; + const oidcUser = await signinPopup(); + overlay.remove(); + resolve(Auth.mapOidcUserToDomainModel(oidcUser)); + } else { + window.open('', popupWindowTarget); + } + } catch (retryError) { + overlay.remove(); + reject(retryError); + } + }, + () => { + overlay.remove(); + reject(new Error('Popup closed by user')); + }, + ); + }); + }); + }, PassportErrorType.AUTHENTICATION_ERROR); + } + + private static mapOidcUserToDomainModel = (oidcUser: OidcUser): User => { + let passport: PassportMetadata | undefined; + let username: string | undefined; + if (oidcUser.id_token) { + const idTokenPayload = jwt_decode(oidcUser.id_token); + passport = idTokenPayload?.passport; + if (idTokenPayload?.username) { + username = idTokenPayload?.username; + } + } + + const user: User = { + expired: oidcUser.expired, + idToken: oidcUser.id_token, + accessToken: oidcUser.access_token, + refreshToken: oidcUser.refresh_token, + profile: { + sub: oidcUser.profile.sub, + email: oidcUser.profile.email, + nickname: oidcUser.profile.nickname, + username, + }, + }; + if (passport?.zkevm_eth_address && passport?.zkevm_user_admin_address) { + user.zkEvm = { + ethAddress: passport.zkevm_eth_address, + userAdminAddress: passport.zkevm_user_admin_address, + }; + } + return user; + }; + + private static mapDeviceTokenResponseToOidcUser = (tokenResponse: DeviceTokenResponse): OidcUser => { + const idTokenPayload: IdTokenPayload = jwt_decode(tokenResponse.id_token); + return new OidcUser({ + id_token: tokenResponse.id_token, + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + token_type: tokenResponse.token_type, + profile: { + sub: idTokenPayload.sub, + iss: idTokenPayload.iss, + aud: idTokenPayload.aud, + exp: idTokenPayload.exp, + iat: idTokenPayload.iat, + email: idTokenPayload.email, + nickname: idTokenPayload.nickname, + passport: idTokenPayload.passport, + ...(idTokenPayload.username ? { username: idTokenPayload.username } : {}), + }, + }); + }; + + private async loginCallbackInternal(): Promise { + return withPassportError(async () => { + const oidcUser = await this.userManager.signinCallback(); + if (!oidcUser) { + return undefined; + } + return Auth.mapOidcUserToDomainModel(oidcUser); + }, PassportErrorType.AUTHENTICATION_ERROR); + } + + private async getPKCEAuthorizationUrl( + directLoginOptions?: DirectLoginOptions, + imPassportTraceId?: string, + ): Promise { + const verifier = base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32))); + const challenge = base64URLEncode(await sha256(verifier)); + const state = base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32))); + + const { + redirectUri, scope, audience, clientId, + } = this.config.oidcConfiguration; + + this.deviceCredentialsManager.savePKCEData({ state, verifier }); + + const pkceAuthorizationUrl = new URL(authorizeEndpoint, this.config.authenticationDomain); + pkceAuthorizationUrl.searchParams.set('response_type', 'code'); + pkceAuthorizationUrl.searchParams.set('code_challenge', challenge); + pkceAuthorizationUrl.searchParams.set('code_challenge_method', 'S256'); + pkceAuthorizationUrl.searchParams.set('client_id', clientId); + pkceAuthorizationUrl.searchParams.set('redirect_uri', redirectUri); + pkceAuthorizationUrl.searchParams.set('state', state); + + if (scope) pkceAuthorizationUrl.searchParams.set('scope', scope); + if (audience) pkceAuthorizationUrl.searchParams.set('audience', audience); + + if (directLoginOptions) { + if (directLoginOptions.directLoginMethod === 'email') { + const emailValue = directLoginOptions.email; + if (emailValue) { + pkceAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod); + pkceAuthorizationUrl.searchParams.set('email', emailValue); + } + } else { + pkceAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod); + } + if (directLoginOptions.marketingConsentStatus) { + pkceAuthorizationUrl.searchParams.set('marketingConsent', directLoginOptions.marketingConsentStatus); + } + } + + if (imPassportTraceId) { + pkceAuthorizationUrl.searchParams.set('im_passport_trace_id', imPassportTraceId); + } + + return pkceAuthorizationUrl.toString(); + } + + private async loginWithPKCEFlowCallbackInternal(authorizationCode: string, state: string): Promise { + return withPassportError(async () => { + const pkceData = this.deviceCredentialsManager.getPKCEData(); + if (!pkceData) { + throw new Error('No code verifier or state for PKCE'); + } + + if (state !== pkceData.state) { + throw new Error('Provided state does not match stored state'); + } + + const tokenResponse = await this.getPKCEToken(authorizationCode, pkceData.verifier); + const oidcUser = Auth.mapDeviceTokenResponseToOidcUser(tokenResponse); + const user = Auth.mapOidcUserToDomainModel(oidcUser); + await this.userManager.storeUser(oidcUser); + + return user; + }, PassportErrorType.AUTHENTICATION_ERROR); + } + + private async getPKCEToken(authorizationCode: string, codeVerifier: string): Promise { + const response = await axios.post( + `${this.config.authenticationDomain}/oauth/token`, + { + client_id: this.config.oidcConfiguration.clientId, + grant_type: 'authorization_code', + code_verifier: codeVerifier, + code: authorizationCode, + redirect_uri: this.config.oidcConfiguration.redirectUri, + }, + formUrlEncodedHeader, + ); + return response.data; + } + + private async storeTokensInternal(tokenResponse: DeviceTokenResponse): Promise { + return withPassportError(async () => { + const oidcUser = Auth.mapDeviceTokenResponseToOidcUser(tokenResponse); + const user = Auth.mapOidcUserToDomainModel(oidcUser); + await this.userManager.storeUser(oidcUser); + return user; + }, PassportErrorType.AUTHENTICATION_ERROR); + } + + private async logoutInternal(): Promise { + await withPassportError(async () => { + await this.userManager.revokeTokens(['refresh_token']); + if (this.logoutMode === 'silent') { + await this.userManager.signoutSilent(); + } else { + await this.userManager.signoutRedirect(); + } + }, PassportErrorType.LOGOUT_ERROR); + } + + private async getLogoutUrlInternal(): Promise { + const endSessionEndpoint = this.userManager.settings?.metadata?.end_session_endpoint; + if (!endSessionEndpoint) { + logger.warn('Failed to get logout URL'); + return null; + } + return endSessionEndpoint; + } + + private forceUserRefreshInBackgroundInternal() { + this.refreshTokenAndUpdatePromise().catch((error) => { + logger.warn('Failed to refresh user token', error); + }); + } + + private async forceUserRefreshInternal(): Promise { + return this.refreshTokenAndUpdatePromise().catch((error) => { + logger.warn('Failed to refresh user token', error); + return null; + }); + } + + private async refreshTokenAndUpdatePromise(): Promise { + if (this.refreshingPromise) { + return this.refreshingPromise; + } + + this.refreshingPromise = new Promise((resolve, reject) => { + (async () => { + try { + const newOidcUser = await this.userManager.signinSilent(); + if (newOidcUser) { + resolve(Auth.mapOidcUserToDomainModel(newOidcUser)); + return; + } + resolve(null); + } catch (err) { + let passportErrorType = PassportErrorType.AUTHENTICATION_ERROR; + let errorMessage = 'Failed to refresh token'; + let removeUser = true; + + if (err instanceof ErrorTimeout) { + passportErrorType = PassportErrorType.SILENT_LOGIN_ERROR; + errorMessage = `${errorMessage}: ${err.message}`; + removeUser = false; + } else if (err instanceof ErrorResponse) { + passportErrorType = PassportErrorType.NOT_LOGGED_IN_ERROR; + errorMessage = `${errorMessage}: ${err.message || err.error_description}`; + } else if (err instanceof Error) { + errorMessage = `${errorMessage}: ${err.message}`; + } else if (typeof err === 'string') { + errorMessage = `${errorMessage}: ${err}`; + } + + if (removeUser) { + try { + await this.userManager.removeUser(); + } catch (removeUserError) { + if (removeUserError instanceof Error) { + errorMessage = `${errorMessage}: Failed to remove user: ${removeUserError.message}`; + } + } + } + + reject(new PassportError(errorMessage, passportErrorType)); + } finally { + this.refreshingPromise = null; + } + })(); + }); + + return this.refreshingPromise; + } + + private async getUserInternal( + typeAssertion: (user: User) => user is T = (user: User): user is T => true, + ): Promise { + if (this.refreshingPromise) { + const refreshingUser = await this.refreshingPromise; + if (refreshingUser && typeAssertion(refreshingUser)) { + return refreshingUser; + } + return null; + } + + const oidcUser = await this.userManager.getUser(); + if (!oidcUser) return null; + + if (!isAccessTokenExpiredOrExpiring(oidcUser)) { + const user = Auth.mapOidcUserToDomainModel(oidcUser); + if (user && typeAssertion(user)) { + return user; + } + } + + if (oidcUser.refresh_token) { + const refreshedUser = await this.refreshTokenAndUpdatePromise(); + if (refreshedUser && typeAssertion(refreshedUser)) { + return refreshedUser; + } + } + + return null; + } + + private async getUserZkEvmInternal(): Promise { + const user = await this.getUserInternal(isUserZkEvm); + if (!user) { + throw new Error('Failed to obtain a User with the required ZkEvm attributes'); + } + return user; + } +} diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts new file mode 100644 index 0000000000..c4c6d5c1a1 --- /dev/null +++ b/packages/auth/src/config.ts @@ -0,0 +1,70 @@ +import { + OidcConfiguration, + AuthModuleConfiguration, + PopupOverlayOptions, +} from './types'; +import { PassportError, PassportErrorType } from './errors'; + +const validateConfiguration = ( + configuration: T, + requiredKeys: Array, + prefix?: string, +) => { + const missingKeys = requiredKeys + .map((key) => !configuration[key] && key) + .filter((n) => n) + .join(', '); + if (missingKeys !== '') { + const errorMessage = prefix + ? `${prefix} - ${missingKeys} cannot be null` + : `${missingKeys} cannot be null`; + throw new PassportError( + errorMessage, + PassportErrorType.INVALID_CONFIGURATION, + ); + } +}; + +/** + * Interface that any configuration must implement to work with AuthManager + */ +export interface IAuthConfiguration { + readonly authenticationDomain: string; + readonly passportDomain: string; + readonly oidcConfiguration: OidcConfiguration; + readonly crossSdkBridgeEnabled: boolean; + readonly popupOverlayOptions?: PopupOverlayOptions; +} + +export class AuthConfiguration implements IAuthConfiguration { + readonly authenticationDomain: string; + + readonly passportDomain: string; + + readonly oidcConfiguration: OidcConfiguration; + + readonly crossSdkBridgeEnabled: boolean; + + readonly popupOverlayOptions?: PopupOverlayOptions; + + constructor({ + authenticationDomain, + passportDomain, + crossSdkBridgeEnabled, + popupOverlayOptions, + ...oidcConfiguration + }: AuthModuleConfiguration) { + validateConfiguration(oidcConfiguration, [ + 'clientId', + 'redirectUri', + ]); + + this.oidcConfiguration = oidcConfiguration; + this.crossSdkBridgeEnabled = crossSdkBridgeEnabled || false; + this.popupOverlayOptions = popupOverlayOptions; + + // Default to production auth domain if not provided + this.authenticationDomain = authenticationDomain || 'https://auth.immutable.com'; + this.passportDomain = passportDomain || 'https://passport.immutable.com'; + } +} diff --git a/packages/auth/src/errors.test.ts b/packages/auth/src/errors.test.ts new file mode 100644 index 0000000000..6a0e67224f --- /dev/null +++ b/packages/auth/src/errors.test.ts @@ -0,0 +1,21 @@ +import { isAPIError } from './errors'; + +describe('isAPIError', () => { + it('returns true when code and message fields exist', () => { + expect(isAPIError({ code: 'BAD_REQUEST', message: 'Invalid' })).toBe(true); + }); + + it.each([ + 'Not found', + 404, + null, + undefined, + ])('returns false for non-object value: %p', (value) => { + expect(isAPIError(value)).toBe(false); + }); + + it('returns false when required fields are missing', () => { + expect(isAPIError({ code: 'BAD_REQUEST' })).toBe(false); + expect(isAPIError({ message: 'Invalid' })).toBe(false); + }); +}); diff --git a/packages/auth/src/errors.ts b/packages/auth/src/errors.ts new file mode 100644 index 0000000000..b6d1d2ae15 --- /dev/null +++ b/packages/auth/src/errors.ts @@ -0,0 +1,68 @@ +import { isAxiosError } from 'axios'; +import { imx } from '@imtbl/generated-clients'; + +export enum PassportErrorType { + AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR', + INVALID_CONFIGURATION = 'INVALID_CONFIGURATION', + WALLET_CONNECTION_ERROR = 'WALLET_CONNECTION_ERROR', + NOT_LOGGED_IN_ERROR = 'NOT_LOGGED_IN_ERROR', + SILENT_LOGIN_ERROR = 'SILENT_LOGIN_ERROR', + REFRESH_TOKEN_ERROR = 'REFRESH_TOKEN_ERROR', + USER_REGISTRATION_ERROR = 'USER_REGISTRATION_ERROR', + USER_NOT_REGISTERED_ERROR = 'USER_NOT_REGISTERED_ERROR', + LOGOUT_ERROR = 'LOGOUT_ERROR', + TRANSFER_ERROR = 'TRANSFER_ERROR', + CREATE_ORDER_ERROR = 'CREATE_ORDER_ERROR', + CANCEL_ORDER_ERROR = 'CANCEL_ORDER_ERROR', + EXCHANGE_TRANSFER_ERROR = 'EXCHANGE_TRANSFER_ERROR', + CREATE_TRADE_ERROR = 'CREATE_TRADE_ERROR', + OPERATION_NOT_SUPPORTED_ERROR = 'OPERATION_NOT_SUPPORTED_ERROR', + LINK_WALLET_ALREADY_LINKED_ERROR = 'LINK_WALLET_ALREADY_LINKED_ERROR', + LINK_WALLET_MAX_WALLETS_LINKED_ERROR = 'LINK_WALLET_MAX_WALLETS_LINKED_ERROR', + LINK_WALLET_VALIDATION_ERROR = 'LINK_WALLET_VALIDATION_ERROR', + LINK_WALLET_DUPLICATE_NONCE_ERROR = 'LINK_WALLET_DUPLICATE_NONCE_ERROR', + LINK_WALLET_GENERIC_ERROR = 'LINK_WALLET_GENERIC_ERROR', + SERVICE_UNAVAILABLE_ERROR = 'SERVICE_UNAVAILABLE_ERROR', + TRANSACTION_REJECTED = 'TRANSACTION_REJECTED', +} + +export function isAPIError(error: any): error is imx.APIError { + return ( + typeof error === 'object' + && error !== null + && 'code' in error + && 'message' in error + ); +} + +export class PassportError extends Error { + public type: PassportErrorType; + + constructor(message: string, type: PassportErrorType) { + super(message); + this.type = type; + } +} + +export const withPassportError = async ( + fn: () => Promise, + customErrorType: PassportErrorType, +): Promise => { + try { + return await fn(); + } catch (error) { + let errorMessage: string; + + if (error instanceof PassportError && error.type === PassportErrorType.SERVICE_UNAVAILABLE_ERROR) { + throw new PassportError(error.message, error.type); + } + + if (isAxiosError(error) && error.response?.data && isAPIError(error.response.data)) { + errorMessage = error.response.data.message; + } else { + errorMessage = (error as Error).message; + } + + throw new PassportError(errorMessage, customErrorType); + } +}; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000000..1add0c19f5 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,34 @@ +// Export Auth class (public API) +export { Auth } from './Auth'; + +// Export configuration +export { AuthConfiguration, type IAuthConfiguration } from './config'; + +// Export types +export type { + User, + UserProfile, + UserZkEvm, + DirectLoginMethod, + DirectLoginOptions, + LoginOptions, + DeviceTokenResponse, + OidcConfiguration, + AuthModuleConfiguration, + PopupOverlayOptions, + PassportMetadata, + IdTokenPayload, + PKCEData, + AuthEventMap, +} from './types'; +export { + isUserZkEvm, RollupType, MarketingConsentStatus, AuthEvents, +} from './types'; + +// Export TypedEventEmitter +export { default as TypedEventEmitter } from './utils/typedEventEmitter'; + +// Export errors +export { + PassportError, PassportErrorType, withPassportError, isAPIError, +} from './errors'; diff --git a/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.ts b/packages/auth/src/login/embeddedLoginPrompt.ts similarity index 87% rename from packages/passport/sdk/src/confirmation/embeddedLoginPrompt.ts rename to packages/auth/src/login/embeddedLoginPrompt.ts index e01a057824..50b384c7dc 100644 --- a/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.ts +++ b/packages/auth/src/login/embeddedLoginPrompt.ts @@ -4,7 +4,7 @@ import { EmbeddedLoginPromptResult, EmbeddedLoginPromptReceiveMessage, } from './types'; -import { PassportConfiguration } from '../config'; +import { IAuthConfiguration } from '../config'; import EmbeddedLoginPromptOverlay from '../overlay/embeddedLoginPromptOverlay'; const LOGIN_PROMPT_WINDOW_HEIGHT = 660; @@ -14,21 +14,17 @@ const LOGIN_PROMPT_KEYFRAME_STYLES_ID = 'passport-embedded-login-keyframes'; const LOGIN_PROMPT_IFRAME_ID = 'passport-embedded-login-iframe'; export default class EmbeddedLoginPrompt { - private config: PassportConfiguration; + private config: IAuthConfiguration; - constructor(config: PassportConfiguration) { + constructor(config: IAuthConfiguration) { this.config = config; } - private getHref = (anonymousId?: string) => { - let href = `${this.config.authenticationDomain}/im-embedded-login-prompt` + private getHref = () => { + const href = `${this.config.authenticationDomain}/im-embedded-login-prompt` + `?client_id=${this.config.oidcConfiguration.clientId}` + `&rid=${getDetail(Detail.RUNTIME_ID)}`; - if (anonymousId) { - href += `&third_party_a_id=${anonymousId}`; - } - return href; }; @@ -77,10 +73,10 @@ export default class EmbeddedLoginPrompt { document.head.appendChild(style); }; - private getEmbeddedLoginIFrame = (anonymousId?: string) => { + private getEmbeddedLoginIFrame = () => { const embeddedLoginPrompt = document.createElement('iframe'); embeddedLoginPrompt.id = LOGIN_PROMPT_IFRAME_ID; - embeddedLoginPrompt.src = this.getHref(anonymousId); + embeddedLoginPrompt.src = this.getHref(); embeddedLoginPrompt.style.height = '100vh'; embeddedLoginPrompt.style.width = '100vw'; embeddedLoginPrompt.style.maxHeight = `${LOGIN_PROMPT_WINDOW_HEIGHT}px`; @@ -96,9 +92,9 @@ export default class EmbeddedLoginPrompt { return embeddedLoginPrompt; }; - public displayEmbeddedLoginPrompt(anonymousId?: string): Promise { + public displayEmbeddedLoginPrompt(): Promise { return new Promise((resolve, reject) => { - const embeddedLoginPrompt = this.getEmbeddedLoginIFrame(anonymousId); + const embeddedLoginPrompt = this.getEmbeddedLoginIFrame(); const messageHandler = ({ data, origin }: MessageEvent) => { if ( origin !== this.config.authenticationDomain diff --git a/packages/passport/sdk/src/confirmation/types.ts b/packages/auth/src/login/types.ts similarity index 100% rename from packages/passport/sdk/src/confirmation/types.ts rename to packages/auth/src/login/types.ts diff --git a/packages/passport/sdk/src/overlay/constants.ts b/packages/auth/src/overlay/constants.ts similarity index 100% rename from packages/passport/sdk/src/overlay/constants.ts rename to packages/auth/src/overlay/constants.ts diff --git a/packages/passport/sdk/src/overlay/elements.ts b/packages/auth/src/overlay/elements.ts similarity index 100% rename from packages/passport/sdk/src/overlay/elements.ts rename to packages/auth/src/overlay/elements.ts diff --git a/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.ts b/packages/auth/src/overlay/embeddedLoginPromptOverlay.ts similarity index 100% rename from packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.ts rename to packages/auth/src/overlay/embeddedLoginPromptOverlay.ts diff --git a/packages/auth/src/overlay/loginPopupOverlay.ts b/packages/auth/src/overlay/loginPopupOverlay.ts new file mode 100644 index 0000000000..466f656f9b --- /dev/null +++ b/packages/auth/src/overlay/loginPopupOverlay.ts @@ -0,0 +1,85 @@ +import { PopupOverlayOptions } from '../types'; +import { PASSPORT_OVERLAY_CLOSE_ID, PASSPORT_OVERLAY_TRY_AGAIN_ID } from './constants'; +import { addLink, getBlockedOverlay, getGenericOverlay } from './elements'; + +export default class LoginPopupOverlay { + private disableGenericPopupOverlay: boolean; + + private disableBlockedPopupOverlay: boolean; + + private overlay: HTMLDivElement | undefined; + + private isBlockedOverlay: boolean; + + private tryAgainListener: (() => void) | undefined; + + private onCloseListener: (() => void) | undefined; + + constructor(popupOverlayOptions: PopupOverlayOptions, isBlockedOverlay: boolean = false) { + this.disableBlockedPopupOverlay = popupOverlayOptions.disableBlockedPopupOverlay || false; + this.disableGenericPopupOverlay = popupOverlayOptions.disableGenericPopupOverlay || false; + this.isBlockedOverlay = isBlockedOverlay; + } + + append(tryAgainOnClick: () => void, onCloseClick: () => void) { + if (this.shouldAppendOverlay()) { + this.appendOverlay(); + this.updateTryAgainButton(tryAgainOnClick); + this.updateCloseButton(onCloseClick); + } + } + + update(tryAgainOnClick: () => void) { + this.updateTryAgainButton(tryAgainOnClick); + } + + remove() { + if (this.overlay) { + this.overlay.remove(); + } + } + + private shouldAppendOverlay(): boolean { + if (this.disableGenericPopupOverlay && this.disableBlockedPopupOverlay) return false; + if (this.disableGenericPopupOverlay && !this.isBlockedOverlay) return false; + if (this.disableBlockedPopupOverlay && this.isBlockedOverlay) return false; + return true; + } + + private appendOverlay() { + if (!this.overlay) { + addLink({ id: 'link-googleapis', href: 'https://fonts.googleapis.com' }); + addLink({ id: 'link-gstatic', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' }); + const robotoFontUrl = 'https://fonts.googleapis.com/css2?' + + 'family=Roboto:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&display=swap'; + addLink({ id: 'link-roboto', href: robotoFontUrl, rel: 'stylesheet' }); + + const overlay = document.createElement('div'); + overlay.innerHTML = this.isBlockedOverlay ? getBlockedOverlay() : getGenericOverlay(); + document.body.insertAdjacentElement('beforeend', overlay); + this.overlay = overlay; + } + } + + private updateTryAgainButton(tryAgainOnClick: () => void) { + const tryAgainButton = document.getElementById(PASSPORT_OVERLAY_TRY_AGAIN_ID); + if (tryAgainButton) { + if (this.tryAgainListener) { + tryAgainButton.removeEventListener('click', this.tryAgainListener); + } + this.tryAgainListener = tryAgainOnClick; + tryAgainButton.addEventListener('click', tryAgainOnClick); + } + } + + private updateCloseButton(onCloseClick: () => void) { + const closeButton = document.getElementById(PASSPORT_OVERLAY_CLOSE_ID); + if (closeButton) { + if (this.onCloseListener) { + closeButton.removeEventListener('click', this.onCloseListener); + } + this.onCloseListener = onCloseClick; + closeButton.addEventListener('click', onCloseClick); + } + } +} diff --git a/packages/passport/sdk/src/storage/LocalForageAsyncStorage.ts b/packages/auth/src/storage/LocalForageAsyncStorage.ts similarity index 100% rename from packages/passport/sdk/src/storage/LocalForageAsyncStorage.ts rename to packages/auth/src/storage/LocalForageAsyncStorage.ts diff --git a/packages/passport/sdk/src/storage/device_credentials_manager.ts b/packages/auth/src/storage/device_credentials_manager.ts similarity index 100% rename from packages/passport/sdk/src/storage/device_credentials_manager.ts rename to packages/auth/src/storage/device_credentials_manager.ts diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts new file mode 100644 index 0000000000..4ab244ea49 --- /dev/null +++ b/packages/auth/src/types.ts @@ -0,0 +1,150 @@ +/** + * Direct login method identifier + * Known providers: 'google', 'apple', 'facebook' + * Additional providers may be supported server-side + */ +export type DirectLoginMethod = string; + +export type UserProfile = { + email?: string; + nickname?: string; + sub: string; + username?: string; +}; + +export enum RollupType { + ZKEVM = 'zkEvm', +} + +export type User = { + idToken?: string; + accessToken: string; + refreshToken?: string; + profile: UserProfile; + expired?: boolean; + [RollupType.ZKEVM]?: { + ethAddress: string; + userAdminAddress: string; + }; +}; + +export type PassportMetadata = { + zkevm_eth_address?: string; + zkevm_user_admin_address?: string; +}; + +export interface OidcConfiguration { + clientId: string; + logoutRedirectUri?: string; + logoutMode?: 'redirect' | 'silent'; + redirectUri: string; + popupRedirectUri?: string; + scope?: string; + audience?: string; +} + +export interface PopupOverlayOptions { + disableGenericPopupOverlay?: boolean; + disableBlockedPopupOverlay?: boolean; + disableHeadlessLoginPromptOverlay?: boolean; +} + +export interface AuthModuleConfiguration extends OidcConfiguration { + /** + * Authentication domain (e.g., 'https://auth.immutable.com') + */ + authenticationDomain?: string; + + /** + * Passport domain for confirmation screens (e.g., 'https://passport.immutable.com') + */ + passportDomain?: string; + + /** + * This flag indicates that Auth is being used in a cross-sdk bridge scenario + * and not directly on the web. + */ + crossSdkBridgeEnabled?: boolean; + + /** + * Options for customizing popup overlays + */ + popupOverlayOptions?: PopupOverlayOptions; +} + +type WithRequired = T & { [P in K]-?: T[P] }; + +export type UserZkEvm = WithRequired; + +export const isUserZkEvm = (user: User): user is UserZkEvm => !!user[RollupType.ZKEVM]; + +export type DeviceTokenResponse = { + access_token: string; + refresh_token?: string; + id_token: string; + token_type: string; + expires_in: number; +}; + +export type TokenPayload = { + exp?: number; +}; + +export type IdTokenPayload = { + passport?: PassportMetadata; + username?: string; + email: string; + nickname: string; + aud: string; + sub: string; + exp: number; + iss: string; + iat: number; +}; + +export type PKCEData = { + state: string; + verifier: string; +}; + +export enum MarketingConsentStatus { + OptedIn = 'opted_in', + Unsubscribed = 'unsubscribed', +} + +export type DirectLoginOptions = { + marketingConsentStatus: MarketingConsentStatus; +} & ( + | { directLoginMethod: 'email'; email: string } + | { directLoginMethod: Exclude; email?: never } +); + +/** + * Extended login options with caching and silent login support + */ +export type LoginOptions = { + /** If true, attempts to use cached session without user interaction */ + useCachedSession?: boolean; + /** If true, attempts silent authentication (force token refresh) */ + useSilentLogin?: boolean; + /** If true, uses redirect flow instead of popup flow */ + useRedirectFlow?: boolean; + /** Direct login options (social provider, email, etc.) */ + directLoginOptions?: DirectLoginOptions; +}; + +/** + * Authentication events emitted by the Auth class + */ +export enum AuthEvents { + LOGGED_OUT = 'loggedOut', + LOGGED_IN = 'loggedIn', +} + +/** + * Event map for typed event emitter + */ +export interface AuthEventMap extends Record { + [AuthEvents.LOGGED_OUT]: []; + [AuthEvents.LOGGED_IN]: [User]; +} diff --git a/packages/auth/src/utils/logger.ts b/packages/auth/src/utils/logger.ts new file mode 100644 index 0000000000..f812440c99 --- /dev/null +++ b/packages/auth/src/utils/logger.ts @@ -0,0 +1,15 @@ +const warn = (...args: any[]) => { + if (typeof process === 'undefined') { + return; + } + + const shouldLog: boolean = process?.env?.JEST_WORKER_ID === undefined; + if (shouldLog) { + // eslint-disable-next-line no-console + console.warn(...args); + } +}; + +export default { + warn, +}; diff --git a/packages/auth/src/utils/metrics.ts b/packages/auth/src/utils/metrics.ts new file mode 100644 index 0000000000..683c9ac717 --- /dev/null +++ b/packages/auth/src/utils/metrics.ts @@ -0,0 +1,29 @@ +import { Flow, trackError, trackFlow } from '@imtbl/metrics'; + +export const withMetricsAsync = async ( + fn: (flow: Flow) => Promise, + flowName: string, + trackStartEvent: boolean = true, + trackEndEvent: boolean = true, +): Promise => { + const flow: Flow = trackFlow( + 'passport', + flowName, + trackStartEvent, + ); + + try { + return await fn(flow); + } catch (error) { + if (error instanceof Error) { + trackError('passport', flowName, error, { flowId: flow.details.flowId }); + } else { + flow.addEvent('errored'); + } + throw error; + } finally { + if (trackEndEvent) { + flow.addEvent('End'); + } + } +}; diff --git a/packages/passport/sdk/src/utils/token.ts b/packages/auth/src/utils/token.ts similarity index 100% rename from packages/passport/sdk/src/utils/token.ts rename to packages/auth/src/utils/token.ts diff --git a/packages/passport/sdk/src/utils/typedEventEmitter.ts b/packages/auth/src/utils/typedEventEmitter.ts similarity index 100% rename from packages/passport/sdk/src/utils/typedEventEmitter.ts rename to packages/auth/src/utils/typedEventEmitter.ts diff --git a/packages/auth/tsconfig.eslint.json b/packages/auth/tsconfig.eslint.json new file mode 100644 index 0000000000..fee686ba0f --- /dev/null +++ b/packages/auth/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "exclude": [], + "include": ["src"] +} + diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000000..94a7d835ce --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDirs": ["src"], + "customConditions": ["development"], + "types": ["node"] + }, + "include": ["src", "src/types.ts"], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] +} + diff --git a/packages/blockchain-data/sdk/package.json b/packages/blockchain-data/sdk/package.json index 1cdd82acf0..c238d5f312 100644 --- a/packages/blockchain-data/sdk/package.json +++ b/packages/blockchain-data/sdk/package.json @@ -12,11 +12,11 @@ "devDependencies": { "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "eslint": "^8.40.0", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "exports": { diff --git a/packages/checkout/sdk-sample-app/package.json b/packages/checkout/sdk-sample-app/package.json index 4392b7d47a..85ad9548f2 100644 --- a/packages/checkout/sdk-sample-app/package.json +++ b/packages/checkout/sdk-sample-app/package.json @@ -33,7 +33,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", diff --git a/packages/checkout/sdk/package.json b/packages/checkout/sdk/package.json index 86f1365117..347f3cef1f 100644 --- a/packages/checkout/sdk/package.json +++ b/packages/checkout/sdk/package.json @@ -17,12 +17,12 @@ "axios": "^1.6.5", "ethers": "^6.13.4", "semver": "^7.4.0", - "uuid": "^8.3.2" + "uuid": "^9.0.1" }, "devDependencies": { "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "@types/semver": "^7.5.8", "@types/uuid": "^8.3.4", @@ -31,7 +31,7 @@ "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", "text-encoding": "^0.7.0", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typedoc": "^0.26.5", "typedoc-plugin-markdown": "^4.2.3", "typescript": "^5.6.2" diff --git a/packages/checkout/widgets-lib/package.json b/packages/checkout/widgets-lib/package.json index 798c368ea0..09008ae958 100644 --- a/packages/checkout/widgets-lib/package.json +++ b/packages/checkout/widgets-lib/package.json @@ -41,7 +41,7 @@ "react-dom": "^18.2.0", "react-i18next": "^13.5.0", "ts-deepmerge": "^7.0.2", - "uuid": "^8.3.2" + "uuid": "^9.0.1" }, "devDependencies": { "@0xsquid/squid-types": "^0.1.108", @@ -58,7 +58,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/lodash.debounce": "^4.0.9", "@types/node": "^18.14.2", "@types/react": "^18.3.5", diff --git a/packages/checkout/widgets-sample-app/package.json b/packages/checkout/widgets-sample-app/package.json index 81cde5f7fb..66c099798e 100644 --- a/packages/checkout/widgets-sample-app/package.json +++ b/packages/checkout/widgets-sample-app/package.json @@ -34,7 +34,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", diff --git a/packages/config/package.json b/packages/config/package.json index 722c5153e3..8856159bfe 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -10,7 +10,7 @@ "devDependencies": { "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", @@ -19,7 +19,7 @@ "jest-environment-jsdom": "^29.4.3", "prettier": "^2.8.7", "ts-node": "^10.9.1", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "engines": { diff --git a/packages/internal/bridge/sdk/package.json b/packages/internal/bridge/sdk/package.json index 7dc598374e..c247cf1dfa 100644 --- a/packages/internal/bridge/sdk/package.json +++ b/packages/internal/bridge/sdk/package.json @@ -14,12 +14,12 @@ "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", "@typechain/ethers-v6": "^0.5.1", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", "ts-node": "^10.9.1", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typechain": "^8.1.1", "typescript": "^5.6.2" }, diff --git a/packages/internal/cryptofiat/package.json b/packages/internal/cryptofiat/package.json index ac47942376..31ad99b64c 100644 --- a/packages/internal/cryptofiat/package.json +++ b/packages/internal/cryptofiat/package.json @@ -12,7 +12,7 @@ "@jest/globals": "^29.5.0", "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", @@ -23,7 +23,7 @@ "jest-environment-jsdom": "^29.4.3", "prettier": "^2.8.7", "ts-node": "^10.9.1", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "engines": { diff --git a/packages/internal/dex/sdk/package.json b/packages/internal/dex/sdk/package.json index cb34720386..ea95fc3863 100644 --- a/packages/internal/dex/sdk/package.json +++ b/packages/internal/dex/sdk/package.json @@ -15,13 +15,13 @@ "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", "@typechain/ethers-v6": "^0.5.1", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "eslint": "^8.40.0", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", "ts-node": "^10.9.1", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typechain": "^8.1.1", "typescript": "^5.6.2" }, diff --git a/packages/internal/generated-clients/package.json b/packages/internal/generated-clients/package.json index aa1d59e583..4468e3cc23 100644 --- a/packages/internal/generated-clients/package.json +++ b/packages/internal/generated-clients/package.json @@ -10,11 +10,11 @@ "devDependencies": { "@openapitools/openapi-generator-cli": "^2.13.4", "@swc/core": "^1.3.36", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "jest": "^29.4.3", "rimraf": "^6.0.1", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "engines": { diff --git a/packages/internal/metrics/package.json b/packages/internal/metrics/package.json index b31dfa360b..39c2971b84 100644 --- a/packages/internal/metrics/package.json +++ b/packages/internal/metrics/package.json @@ -12,13 +12,13 @@ "devDependencies": { "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "eslint": "^8.40.0", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", "ts-jest": "^29.1.0", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "engines": { diff --git a/packages/internal/toolkit/package.json b/packages/internal/toolkit/package.json index d32d719521..c374b318bf 100644 --- a/packages/internal/toolkit/package.json +++ b/packages/internal/toolkit/package.json @@ -20,7 +20,7 @@ "@swc/jest": "^0.2.37", "@types/axios": "^0.14.0", "@types/bn.js": "^5.1.6", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", @@ -31,7 +31,7 @@ "jest-environment-jsdom": "^29.4.3", "prettier": "^2.8.7", "ts-node": "^10.9.1", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "engines": { diff --git a/packages/minting-backend/sdk/package.json b/packages/minting-backend/sdk/package.json index f7e29c15f1..e2a2b229a9 100644 --- a/packages/minting-backend/sdk/package.json +++ b/packages/minting-backend/sdk/package.json @@ -10,13 +10,13 @@ "@imtbl/generated-clients": "workspace:*", "@imtbl/metrics": "workspace:*", "@imtbl/webhook": "workspace:*", - "uuid": "^8.3.2" + "uuid": "^9.0.1" }, "devDependencies": { "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", "@testcontainers/postgresql": "^10.9.0", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/pg": "^8.11.5", "@types/uuid": "^8.3.4", "eslint": "^8.40.0", @@ -24,7 +24,7 @@ "jest-environment-jsdom": "^29.4.3", "testcontainers": "^10.9.0", "typescript": "^5.6.2", - "tsup": "8.3.0" + "tsup": "^8.3.0" }, "exports": { "development": { diff --git a/packages/orderbook/package.json b/packages/orderbook/package.json index c57de350c4..2315b7975b 100644 --- a/packages/orderbook/package.json +++ b/packages/orderbook/package.json @@ -16,13 +16,13 @@ "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", "@typechain/ethers-v6": "^0.5.1", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "dotenv": "^16.0.3", "eslint": "^8.40.0", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", "ts-mockito": "^2.6.1", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typechain": "^8.1.1", "typescript": "^5.6.2" }, diff --git a/packages/passport/sdk-sample-app/.eslintrc.cjs b/packages/passport/sdk-sample-app/.eslintrc.cjs new file mode 100644 index 0000000000..a5864c7501 --- /dev/null +++ b/packages/passport/sdk-sample-app/.eslintrc.cjs @@ -0,0 +1,33 @@ +module.exports = { + root: true, // Prevent ESLint from looking up and applying parent's ignorePatterns + // Only extend Next.js config to avoid plugin conflicts with root .eslintrc + extends: ['next/core-web-vitals'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + rules: { + // Rules from root .eslintrc that are needed + 'import/prefer-default-export': ['off'], + 'no-console': 'off', + 'no-plusplus': ['off'], + 'max-classes-per-file': ['off'], + 'max-len': [ + 'error', + { code: 120, ignoreComments: true, ignoreTrailingComments: true }, + ], + 'no-restricted-syntax': [ + 'error', + 'ForInStatement', + 'LabeledStatement', + 'WithStatement', + ], + 'import/no-extraneous-dependencies': ['off'], + '@typescript-eslint/return-await': ['off'], + '@typescript-eslint/naming-convention': 'off', + 'react/jsx-props-no-spreading': 'off', + 'jsx-a11y/heading-has-content': 'off', + }, +}; + diff --git a/packages/passport/sdk-sample-app/.eslintrc.json b/packages/passport/sdk-sample-app/.eslintrc.json deleted file mode 100644 index 7bb36de407..0000000000 --- a/packages/passport/sdk-sample-app/.eslintrc.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "extends": [ - "../../../.eslintrc", - "next/core-web-vitals" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "./tsconfig.json", - "tsConfigRootDir": "." - }, - "rules": { - "import/prefer-default-export": ["off"], - "no-console": "off", - "no-plusplus": ["off"], - "max-classes-per-file": ["off"], - "max-len": [ - "error", - { "code": 120, "ignoreComments": true, "ignoreTrailingComments": true } - ], - "no-restricted-syntax": [ - "error", - "ForInStatement", - "LabeledStatement", - "WithStatement" - ], - "import/no-extraneous-dependencies": ["off"], - "@typescript-eslint/return-await": ["off"], - "@typescript-eslint/naming-convention": "off", - "react/jsx-props-no-spreading": "off", - "jsx-a11y/heading-has-content": "off" - } -} diff --git a/packages/passport/sdk-sample-app/package.json b/packages/passport/sdk-sample-app/package.json index 84bde0dbed..498291463e 100644 --- a/packages/passport/sdk-sample-app/package.json +++ b/packages/passport/sdk-sample-app/package.json @@ -8,6 +8,7 @@ "@imtbl/config": "workspace:*", "@imtbl/generated-clients": "workspace:*", "@imtbl/orderbook": "workspace:*", + "@imtbl/wallet": "workspace:*", "@imtbl/passport": "workspace:*", "@imtbl/x-client": "workspace:*", "@imtbl/x-provider": "workspace:*", @@ -41,7 +42,7 @@ "build": "next build", "dev": "next dev", "dev-with-sdk": "concurrently 'pnpm dev' 'pnpm run -w dev'", - "lint": "eslint ./src --ext .ts --max-warnings=0", + "lint": "eslint 'src/**/*.{ts,tsx}' --max-warnings=0", "start": "next start" } } diff --git a/packages/passport/sdk-sample-app/src/components/Status.tsx b/packages/passport/sdk-sample-app/src/components/Status.tsx index 0a62dfff41..f455bb749d 100644 --- a/packages/passport/sdk-sample-app/src/components/Status.tsx +++ b/packages/passport/sdk-sample-app/src/components/Status.tsx @@ -6,7 +6,7 @@ import { usePassportProvider } from '@/context/PassportProvider'; import CardStack from '@/components/CardStack'; function Status() { - const { imxProvider, zkEvmProvider } = usePassportProvider(); + const { imxProvider, activeZkEvmProvider } = usePassportProvider(); return ( @@ -31,7 +31,7 @@ function Status() { { - zkEvmProvider + activeZkEvmProvider ? (
diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/DefaultTransaction.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/DefaultTransaction.tsx index 7e12a521ed..adadc498bc 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/DefaultTransaction.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/DefaultTransaction.tsx @@ -17,7 +17,7 @@ function ShowGenericConfirmationScreen({ disabled, handleExampleSubmitted }: Req const [toAddress, setToAddress] = useState(defaultAddress); const [toAddressError, setToAddressError] = useState(''); const [data, setData] = useState('1234567890'); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const [params, setParams] = useState([]); const [dataError, setDataError] = useState(''); const emptyDataError = 'Data should not be empty and should be at least 10 characters'; @@ -57,17 +57,8 @@ function ShowGenericConfirmationScreen({ disabled, handleExampleSubmitted }: Req }); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [walletAddress] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setFromAddress(walletAddress || ''); - } - }; - - getAddress(); - }, [zkEvmProvider, setFromAddress]); + setFromAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/NFTApproval.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/NFTApproval.tsx index 589431eb8c..9150338950 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/NFTApproval.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/NFTApproval.tsx @@ -46,7 +46,7 @@ function NFTApproval({ disabled, handleExampleSubmitted }: RequestExampleProps) const [params, setParams] = useState([]); const [isUnSafe, setIsUnSafe] = useState(false); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const handleSetApproveType = useCallback((e: React.ChangeEvent) => { setChoosedApproveType(e.target.value as ApproveType); @@ -77,17 +77,8 @@ function NFTApproval({ disabled, handleExampleSubmitted }: RequestExampleProps) }, [fromAddress, erc721ContractAddress, toAddress, tokenId, choosedApproveType, nftApproveContract, isUnSafe]); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [walletAddress] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setFromAddress(walletAddress || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setFromAddress]); + setFromAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/NFTTransfer.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/NFTTransfer.tsx index 4b915def4e..f5eb4321b3 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/NFTTransfer.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/NFTTransfer.tsx @@ -43,7 +43,7 @@ function NFTTransfer({ disabled, handleExampleSubmitted }: RequestExampleProps) const [assets, setAssets] = useState([]); const [transfers, setTransfers] = useState[]>([]); const [fromAddress, setFromAddress] = useState(''); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const { environment } = useImmutableProvider(); const [choosedCollection, setChoosedCollection] = useState({ assets: [] } as unknown as GroupedAsset); const { blockchainData } = useImmutableProvider(); @@ -94,17 +94,8 @@ function NFTTransfer({ disabled, handleExampleSubmitted }: RequestExampleProps) const chainName = useMemo(() => chainNameMapping(environment), [environment]); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [walletAddress] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setFromAddress(walletAddress || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setFromAddress]); + setFromAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); useEffect(() => { const getAssets = async () => { diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SeaportCancel.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SeaportCancel.tsx index cbc9243b56..8891ea058b 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SeaportCancel.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SeaportCancel.tsx @@ -12,7 +12,7 @@ import { PreparedTransactionRequest } from 'ethers'; function SeaportCancel({ disabled, handleExampleSubmitted }: RequestExampleProps) { const { orderbookClient } = useImmutableProvider(); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const [orderIds, setOrderIds] = useState(''); const [walletAddress, setWalletAddress] = useState(''); @@ -28,17 +28,8 @@ function SeaportCancel({ disabled, handleExampleSubmitted }: RequestExampleProps ); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [address] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setWalletAddress(address || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setWalletAddress]); + setWalletAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); useEffect(() => { setTransactionError(''); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SeaportFulfillAvailableAdvancedOrders.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SeaportFulfillAvailableAdvancedOrders.tsx index fdf990a625..fef0d6e2ab 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SeaportFulfillAvailableAdvancedOrders.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SeaportFulfillAvailableAdvancedOrders.tsx @@ -13,7 +13,7 @@ import { PreparedTransactionRequest } from 'ethers'; function SeaportFulfillAvailableAdvancedOrders({ disabled, handleExampleSubmitted }: RequestExampleProps) { const { orderbookClient } = useImmutableProvider(); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const [listingIds, setListingIds] = useState(''); const [walletAddress, setWalletAddress] = useState(''); @@ -29,17 +29,8 @@ function SeaportFulfillAvailableAdvancedOrders({ disabled, handleExampleSubmitte ); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [address] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setWalletAddress(address || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setWalletAddress]); + setWalletAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); useEffect(() => { setTransactionError(''); @@ -79,8 +70,11 @@ function SeaportFulfillAvailableAdvancedOrders({ disabled, handleExampleSubmitte )) as TransactionAction | undefined; if (verifyAction) { + if (!activeZkEvmProvider) { + throw new Error('No zkEVM provider available to submit approval transaction'); + } const approvalTransaction = await verifyAction.buildTransaction(); - await zkEvmProvider?.request({ + await activeZkEvmProvider.request({ method: 'eth_sendTransaction', params: [approvalTransaction], }); @@ -101,7 +95,7 @@ function SeaportFulfillAvailableAdvancedOrders({ disabled, handleExampleSubmitte } finally { setIsBuildingTransaction(false); } - }, [listingIds, orderbookClient, walletAddress, zkEvmProvider]); + }, [listingIds, orderbookClient, walletAddress, activeZkEvmProvider]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SpendingCapApproval.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SpendingCapApproval.tsx index 740941645d..442343e7d7 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SpendingCapApproval.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/SpendingCapApproval.tsx @@ -37,7 +37,7 @@ function SpendingCapApproval({ disabled, handleExampleSubmitted }: RequestExampl const [params, setParams] = useState([]); const amountRange = 'Amount should larger than 0'; - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); useEffect(() => { setAmountConvertError(''); const allowAmount = amount.trim() === '' ? '0' : amount; @@ -63,17 +63,8 @@ function SpendingCapApproval({ disabled, handleExampleSubmitted }: RequestExampl }, [fromAddress, erc20ContractAddress, spender, amount, iface]); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [walletAddress] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setFromAddress(walletAddress || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setFromAddress]); + setFromAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferERC20.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferERC20.tsx index bea6ada664..98dbe591ce 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferERC20.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferERC20.tsx @@ -32,7 +32,7 @@ function TransferERC20({ disabled, handleExampleSubmitted }: RequestExampleProps const [useTransferFrom, setUseTransferFrom] = useState(false); const [amount, setAmount] = useState('0'); const [contractAddress, setContractAddress] = useState(getErc20DefaultContractAddress(environment)); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const [params, setParams] = useState([]); const [amountConvertError, setAmountConvertError] = useState(''); const amountRange = 'Amount should larger than 0 with maximum 18 digits in decimal'; @@ -80,17 +80,8 @@ function TransferERC20({ disabled, handleExampleSubmitted }: RequestExampleProps }, [fromAddress, toAddress, contractAddress, amount, erc20Transfer, useTransferFrom]); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [walletAddress] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setFromAddress(walletAddress || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setFromAddress]); + setFromAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferImx.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferImx.tsx index 22d847e539..78df5a2e0d 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferImx.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSendTransactionExamples/TransferImx.tsx @@ -10,7 +10,7 @@ function TransferImx({ disabled, handleExampleSubmitted }: RequestExampleProps) const [fromAddress, setFromAddress] = useState(''); const [toAddress, setToAddress] = useState(''); const [amount, setAmount] = useState('0'); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const [params, setParams] = useState([]); const [amountConvertError, setAmountConvertError] = useState(''); const imxTokenDecimal = 18; @@ -40,17 +40,8 @@ function TransferImx({ disabled, handleExampleSubmitted }: RequestExampleProps) }, [fromAddress, toAddress, amount]); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [walletAddress] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setFromAddress(walletAddress || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setFromAddress]); + setFromAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateERC1155ListingDefault.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateERC1155ListingDefault.tsx index 0d474a0cec..5fdd3f2795 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateERC1155ListingDefault.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateERC1155ListingDefault.tsx @@ -12,7 +12,7 @@ import { getCreateERC1155ListingPayload } from './SeaportCreateListingExample'; function SeaportCreateERC1155ListingDefault({ disabled, handleExampleSubmitted }: RequestExampleProps) { const { orderbookClient } = useImmutableProvider(); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const [params, setParams] = useState(); @@ -24,19 +24,20 @@ function SeaportCreateERC1155ListingDefault({ disabled, handleExampleSubmitted } ); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [address] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - const chainIdHex = await zkEvmProvider.request({ method: 'eth_chainId' }); + const populate = async () => { + if (activeZkEvmProvider && activeZkEvmAccount) { + const chainIdHex = await activeZkEvmProvider.request({ method: 'eth_chainId' }); const chainId = parseInt(chainIdHex, 16); - const data = getCreateERC1155ListingPayload({ seaportContractAddress, walletAddress: address, chainId }); - setParams([address, data]); + const data = getCreateERC1155ListingPayload({ + seaportContractAddress, + walletAddress: activeZkEvmAccount, + chainId, + }); + setParams([activeZkEvmAccount, data]); } }; - getAddress().catch(console.log); - }, [zkEvmProvider, seaportContractAddress]); + populate().catch(console.log); + }, [activeZkEvmProvider, activeZkEvmAccount, seaportContractAddress]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateERC721ListingDefault.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateERC721ListingDefault.tsx index c911d2534c..69daef4fbc 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateERC721ListingDefault.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateERC721ListingDefault.tsx @@ -12,7 +12,7 @@ import { getCreateERC721ListingPayload } from './SeaportCreateListingExample'; function SeaportCreateERC721ListingDefault({ disabled, handleExampleSubmitted }: RequestExampleProps) { const { orderbookClient } = useImmutableProvider(); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const [params, setParams] = useState(); @@ -24,19 +24,20 @@ function SeaportCreateERC721ListingDefault({ disabled, handleExampleSubmitted }: ); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [address] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - const chainIdHex = await zkEvmProvider.request({ method: 'eth_chainId' }); + const populate = async () => { + if (activeZkEvmProvider && activeZkEvmAccount) { + const chainIdHex = await activeZkEvmProvider.request({ method: 'eth_chainId' }); const chainId = parseInt(chainIdHex, 16); - const data = getCreateERC721ListingPayload({ seaportContractAddress, walletAddress: address, chainId }); - setParams([address, data]); + const data = getCreateERC721ListingPayload({ + seaportContractAddress, + walletAddress: activeZkEvmAccount, + chainId, + }); + setParams([activeZkEvmAccount, data]); } }; - getAddress().catch(console.log); - }, [zkEvmProvider, seaportContractAddress]); + populate().catch(console.log); + }, [activeZkEvmProvider, activeZkEvmAccount, seaportContractAddress]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateListing.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateListing.tsx index bc9d06bb6f..0c9c5cc790 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateListing.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SeaportCreateListing.tsx @@ -19,7 +19,7 @@ type TokenType = 'NATIVE' | 'ERC20'; function SeaportCreateListing({ disabled, handleExampleSubmitted }: RequestExampleProps) { const { orderbookClient } = useImmutableProvider(); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); const [walletAddress, setWalletAddress] = useState(''); const [isBuldingTransaction, setIsBuildingTransaction] = useState(false); @@ -46,17 +46,8 @@ function SeaportCreateListing({ disabled, handleExampleSubmitted }: RequestExamp ); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [address] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setWalletAddress(address || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setWalletAddress]); + setWalletAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); useEffect(() => { setSignMessageError(''); @@ -107,6 +98,9 @@ function SeaportCreateListing({ disabled, handleExampleSubmitted }: RequestExamp }); if (submitTransaction) { + if (!activeZkEvmProvider) { + throw new Error('No zkEVM provider available to submit approval transactions'); + } const approvalActions = preparedListing.actions.filter((action) => ( action.type === ActionType.TRANSACTION && action.purpose === TransactionPurpose.APPROVAL )) as TransactionAction[]; @@ -116,13 +110,13 @@ function SeaportCreateListing({ disabled, handleExampleSubmitted }: RequestExamp const unsignedTx = await approvalAction.buildTransaction(); // eslint-disable-next-line no-await-in-loop - const transactionHash = await zkEvmProvider!.request({ + const transactionHash = await activeZkEvmProvider.request({ method: 'eth_sendTransaction', params: [unsignedTx], }); // eslint-disable-next-line no-await-in-loop - await new BrowserProvider(zkEvmProvider!).waitForTransaction(transactionHash); + await new BrowserProvider(activeZkEvmProvider).waitForTransaction(transactionHash); } setOrderComponents(preparedListing.orderComponents); @@ -148,7 +142,8 @@ function SeaportCreateListing({ disabled, handleExampleSubmitted }: RequestExamp setIsBuildingTransaction(false); } }, [NFTContractAddress, buyAmount, buyType, orderbookClient, - tokenContractAddress, tokenId, walletAddress, sellTokenUnits, sellTokenType, submitTransaction, zkEvmProvider]); + tokenContractAddress, tokenId, walletAddress, sellTokenUnits, + sellTokenType, submitTransaction, activeZkEvmProvider]); const handleSetSellTokenType = (e: React.ChangeEvent) => { resetForm(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SignEtherMail.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SignEtherMail.tsx index af7bbad885..92c7e1f910 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SignEtherMail.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/SignEtherMail.tsx @@ -9,13 +9,13 @@ function SignEtherMail({ disabled, handleExampleSubmitted }: RequestExampleProps const [address, setAddress] = useState(''); const [params, setParams] = useState([]); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider, activeZkEvmAccount } = usePassportProvider(); useEffect(() => { const populateParams = async () => { - if (zkEvmProvider) { - const chainIdHex = await zkEvmProvider.request({ method: 'eth_chainId' }); - const chainId = parseInt(chainIdHex, 16).toString(); + if (activeZkEvmProvider) { + const chainIdHex = await activeZkEvmProvider.request({ method: 'eth_chainId' }); + const chainId = parseInt(chainIdHex, 16); const etherMailTypedPayload = getEtherMailTypedPayload(chainId, address); setParams([ @@ -26,20 +26,11 @@ function SignEtherMail({ disabled, handleExampleSubmitted }: RequestExampleProps }; populateParams().catch(console.log); - }, [address, zkEvmProvider]); + }, [address, activeZkEvmProvider]); useEffect(() => { - const getAddress = async () => { - if (zkEvmProvider) { - const [walletAddress] = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - setAddress(walletAddress || ''); - } - }; - - getAddress().catch(console.log); - }, [zkEvmProvider, setAddress]); + setAddress(activeZkEvmAccount || ''); + }, [activeZkEvmAccount]); const handleSubmitSignPayload = useCallback(async (e: React.FormEvent) => { e.preventDefault(); diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateEtherMail.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateEtherMail.tsx index 360ac19f42..a79db7bb3f 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateEtherMail.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/ValidateEtherMail.tsx @@ -18,19 +18,19 @@ function ValidateEtherMail({ disabled }: RequestExampleProps) { const [isLoading, setIsLoading] = useState(false); const [etherMailTypedPayload, setEtherMailTypedPayload] = useState(); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider } = usePassportProvider(); useEffect(() => { const populateParams = async () => { - if (zkEvmProvider) { - const chainIdHex = await zkEvmProvider.request({ method: 'eth_chainId' }); - const chainId = parseInt(chainIdHex, 16).toString(); + if (activeZkEvmProvider) { + const chainIdHex = await activeZkEvmProvider.request({ method: 'eth_chainId' }); + const chainId = parseInt(chainIdHex, 16); setEtherMailTypedPayload(getEtherMailTypedPayload(chainId, address)); } }; populateParams().catch(console.log); - }, [zkEvmProvider, address]); + }, [activeZkEvmProvider, address]); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); @@ -41,7 +41,7 @@ function ValidateEtherMail({ disabled }: RequestExampleProps) { setIsLoading(true); try { - if (!zkEvmProvider) { + if (!activeZkEvmProvider) { setIsValidSignature(false); setSignatureValidationMessage('zkEvmProvider cannot be null'); return; @@ -57,7 +57,7 @@ function ValidateEtherMail({ disabled }: RequestExampleProps) { address, JSON.stringify(etherMailTypedPayload), signature, - zkEvmProvider, + activeZkEvmProvider, ); setIsValidSignature(isValid); @@ -68,7 +68,7 @@ function ValidateEtherMail({ disabled }: RequestExampleProps) { } finally { setIsLoading(false); } - }, [address, etherMailTypedPayload, signature, zkEvmProvider]); + }, [address, etherMailTypedPayload, signature, activeZkEvmProvider]); return ( diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/etherMailTypedPayload.ts b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/etherMailTypedPayload.ts index e51d2415fc..a40468a7bf 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/etherMailTypedPayload.ts +++ b/packages/passport/sdk-sample-app/src/components/zkevm/EthSignTypedDataV4Examples/etherMailTypedPayload.ts @@ -1,4 +1,4 @@ -export const getEtherMailTypedPayload = (chainId: string, verifyingContract: string) => ({ +export const getEtherMailTypedPayload = (chainId: number, verifyingContract: string) => ({ domain: { name: 'Ether Mail', version: '1', diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/Request.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/Request.tsx index 805466568b..40a0a1103c 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/Request.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/Request.tsx @@ -157,7 +157,7 @@ function Request({ showModal, setShowModal }: ModalProps) { const [isInvalid, setInvalid] = useState(undefined); const { addMessage } = useStatusProvider(); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider } = usePassportProvider(); const resetForm = () => { setParams([]); @@ -173,7 +173,13 @@ function Request({ showModal, setShowModal }: ModalProps) { setInvalid(false); setLoadingRequest(true); try { - const result = await zkEvmProvider?.request(request); + if (!activeZkEvmProvider) { + addMessage('Request', 'No zkEVM provider connected. Connect via Passport or connectWallet first.'); + setLoadingRequest(false); + setInvalid(true); + return; + } + const result = await activeZkEvmProvider.request(request); setLoadingRequest(false); addMessage(request.method, result); if (onSuccess) { @@ -187,7 +193,7 @@ function Request({ showModal, setShowModal }: ModalProps) { }; const handleExampleSubmitted = async (request: RequestArguments, onSuccess?: (result?: any) => Promise) => { - if (request.params) { + if (request.params && Array.isArray(request.params)) { const newParams = params; request.params.forEach((param, i) => { try { diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/SignatureValidation/ValidateSignature.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/SignatureValidation/ValidateSignature.tsx index 0e0954184b..c0635c9cb4 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/SignatureValidation/ValidateSignature.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/SignatureValidation/ValidateSignature.tsx @@ -25,7 +25,7 @@ function ValidateSignature({ disabled, handleSignatureValidation }: ValidateSign const [signatureValidationMessage, setSignatureValidationMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); - const { zkEvmProvider } = usePassportProvider(); + const { activeZkEvmProvider } = usePassportProvider(); const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); @@ -36,7 +36,7 @@ function ValidateSignature({ disabled, handleSignatureValidation }: ValidateSign setIsLoading(true); try { - if (!zkEvmProvider) { + if (!activeZkEvmProvider) { setIsValidSignature(false); setSignatureValidationMessage('zkEvmProvider cannot be null'); return; @@ -46,7 +46,7 @@ function ValidateSignature({ disabled, handleSignatureValidation }: ValidateSign address, payload, signature, - zkEvmProvider, + activeZkEvmProvider, ); setIsValidSignature(isValid); @@ -57,7 +57,7 @@ function ValidateSignature({ disabled, handleSignatureValidation }: ValidateSign } finally { setIsLoading(false); } - }, [address, payload, signature, zkEvmProvider, handleSignatureValidation]); + }, [address, payload, signature, activeZkEvmProvider, handleSignatureValidation]); return ( diff --git a/packages/passport/sdk-sample-app/src/components/zkevm/ZkEvmWorkflow.tsx b/packages/passport/sdk-sample-app/src/components/zkevm/ZkEvmWorkflow.tsx index 36d73c55a8..0eb66d66ec 100644 --- a/packages/passport/sdk-sample-app/src/components/zkevm/ZkEvmWorkflow.tsx +++ b/packages/passport/sdk-sample-app/src/components/zkevm/ZkEvmWorkflow.tsx @@ -1,9 +1,11 @@ import React, { ChangeEvent, useCallback, + useEffect, useState, } from 'react'; import { Stack } from 'react-bootstrap'; +import { connectWallet } from '@imtbl/wallet'; import { usePassportProvider } from '@/context/PassportProvider'; import Request from '@/components/zkevm/Request'; import CardStack from '@/components/CardStack'; @@ -11,17 +13,62 @@ import { useStatusProvider } from '@/context/StatusProvider'; import WorkflowButton from '@/components/WorkflowButton'; import { FormControl, Toggle } from '@biom3/react'; import { ProviderEvent } from '@imtbl/passport'; +import { useImmutableProvider } from '@/context/ImmutableProvider'; +import { EnvironmentNames } from '@/types'; function ZkEvmWorkflow() { const [showRequest, setShowRequest] = useState(false); - const { isLoading, addMessage } = useStatusProvider(); - const { connectZkEvm, zkEvmProvider } = usePassportProvider(); + const { isLoading, addMessage, setIsLoading } = useStatusProvider(); + const { + connectZkEvm, + zkEvmProvider, + defaultWalletProvider, + activeZkEvmProvider, + setDefaultWalletProvider, + } = usePassportProvider(); + const { environment } = useImmutableProvider(); + const isSandboxEnvironment = environment === EnvironmentNames.SANDBOX; + const [isClientReady, setIsClientReady] = useState(false); + const canUseDefaultConnect = isClientReady && isSandboxEnvironment; + + useEffect(() => { + setIsClientReady(true); + }, []); const handleRequest = () => { setShowRequest(true); }; + const handleConnectDefault = useCallback(async () => { + if (!canUseDefaultConnect) { + addMessage('connectWallet (default auth)', 'Default auth connect is only available in Sandbox.'); + return; + } + setIsLoading(true); + try { + const provider = await connectWallet(); + if (provider) { + setDefaultWalletProvider(provider); + addMessage( + 'connectWallet (default auth)', + 'Connected using built-in Immutable configuration', + ); + } else { + addMessage('connectWallet (default auth)', 'No provider returned'); + } + } catch (error) { + addMessage('connectWallet (default auth)', error); + } finally { + setIsLoading(false); + } + }, [addMessage, canUseDefaultConnect, setDefaultWalletProvider, setIsLoading]); + + const handleClearDefault = useCallback(() => { + setDefaultWalletProvider(undefined); + addMessage('connectWallet (default auth)', 'Provider cleared'); + }, [addMessage, setDefaultWalletProvider]); + const zkEvmEventHandler = useCallback((eventName: string) => (args: any[]) => { addMessage(`Provider Event: ${eventName}`, args); }, [addMessage]); @@ -29,19 +76,19 @@ function ZkEvmWorkflow() { const onHandleEventsChanged = useCallback((event: ChangeEvent) => { if (event.target.checked) { Object.values(ProviderEvent).forEach((eventName) => { - zkEvmProvider?.on(eventName, zkEvmEventHandler(eventName)); + activeZkEvmProvider?.on?.(eventName, zkEvmEventHandler(eventName)); }); } else { Object.values(ProviderEvent).forEach((eventName) => { - zkEvmProvider?.removeListener(eventName, zkEvmEventHandler(eventName)); + activeZkEvmProvider?.removeListener?.(eventName, zkEvmEventHandler(eventName)); }); } - }, [zkEvmEventHandler, zkEvmProvider]); + }, [activeZkEvmProvider, zkEvmEventHandler]); return ( - {zkEvmProvider && ( + {activeZkEvmProvider && ( <> Log out events + {defaultWalletProvider && canUseDefaultConnect && ( + + Clear Default Wallet + + )} )} - {!zkEvmProvider && ( - - Connect ZkEvm - + {!activeZkEvmProvider && ( + <> + {canUseDefaultConnect && ( + + Connect with Defaults + + )} + + Connect ZkEvm + + {!isSandboxEnvironment && ( +

+ Default auth connect is only available in the Sandbox environment. +

+ )} + )}
diff --git a/packages/passport/sdk-sample-app/src/context/PassportProvider.tsx b/packages/passport/sdk-sample-app/src/context/PassportProvider.tsx index afdc50cbb5..d3ae83240a 100644 --- a/packages/passport/sdk-sample-app/src/context/PassportProvider.tsx +++ b/packages/passport/sdk-sample-app/src/context/PassportProvider.tsx @@ -1,5 +1,5 @@ import React, { - createContext, useCallback, useContext, useMemo, useState, + createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { IMXProvider } from '@imtbl/x-provider'; import { @@ -7,10 +7,16 @@ import { } from '@imtbl/passport'; import { useImmutableProvider } from '@/context/ImmutableProvider'; import { useStatusProvider } from '@/context/StatusProvider'; +import { EnvironmentNames } from '@/types'; const PassportContext = createContext<{ imxProvider: IMXProvider | undefined; zkEvmProvider: Provider | undefined; + defaultWalletProvider: Provider | undefined; + activeZkEvmProvider: Provider | undefined; + activeZkEvmAccount: string; + isSandboxEnvironment: boolean; + setDefaultWalletProvider: (provider?: Provider) => void; connectImx:() => void; connectZkEvm: () => void; logout: () => void; @@ -30,6 +36,11 @@ const PassportContext = createContext<{ }>({ imxProvider: undefined, zkEvmProvider: undefined, + defaultWalletProvider: undefined, + activeZkEvmProvider: undefined, + activeZkEvmAccount: '', + setDefaultWalletProvider: () => undefined, + isSandboxEnvironment: false, connectImx: () => undefined, connectZkEvm: () => undefined, logout: () => undefined, @@ -53,9 +64,19 @@ export function PassportProvider({ }: { children: JSX.Element | JSX.Element[] }) { const [imxProvider, setImxProvider] = useState(); const [zkEvmProvider, setZkEvmProvider] = useState(); + const [defaultWalletProvider, setDefaultWalletProvider] = useState(); + const [activeZkEvmAccount, setActiveZkEvmAccount] = useState(''); const { addMessage, setIsLoading } = useStatusProvider(); - const { passportClient } = useImmutableProvider(); + const { passportClient, environment } = useImmutableProvider(); + const isSandboxEnvironment = environment === EnvironmentNames.SANDBOX; + // `zkEvmProvider` is initialised using Passport package. + // `defaultWalletProvider` is created by connectWallet(), which includes a default auth instance underneath. + // In sandbox environment, we allow testing the default wallet provider because the + // conenectWallet works with testnet and sandbox env when no arguements are provided. + const activeZkEvmProvider = isSandboxEnvironment + ? (defaultWalletProvider || zkEvmProvider) + : zkEvmProvider; const connectImx = useCallback(async () => { try { @@ -141,6 +162,7 @@ export function PassportProvider({ await passportClient.logout(); setImxProvider(undefined); setZkEvmProvider(undefined); + setDefaultWalletProvider(undefined); } catch (err) { addMessage('Logout', err); console.error(err); @@ -288,9 +310,62 @@ export function PassportProvider({ } }, [addMessage, passportClient, setIsLoading]); + useEffect(() => { + if (environment !== EnvironmentNames.SANDBOX && defaultWalletProvider) { + setDefaultWalletProvider(undefined); + } + }, [environment, defaultWalletProvider]); + + useEffect(() => { + if (!activeZkEvmProvider) { + setActiveZkEvmAccount(''); + return; + } + + let unsubscribed = false; + + const syncAccounts = async () => { + try { + const accounts = await activeZkEvmProvider.request({ + method: 'eth_accounts', + }); + + if (!unsubscribed) { + setActiveZkEvmAccount(accounts?.[0] ?? ''); + } + } catch (error) { + console.error('Failed to get accounts', error); + } + }; + + syncAccounts(); + + const handleAccountsChanged = (accounts: string[]) => { + if (!unsubscribed) { + setActiveZkEvmAccount(accounts?.[0] ?? ''); + } + }; + + const providerWithEvents = activeZkEvmProvider as unknown as { + on?: (event: string, listener: (...args: any[]) => void) => void; + removeListener?: (event: string, listener: (...args: any[]) => void) => void; + }; + + providerWithEvents.on?.('accountsChanged', handleAccountsChanged); + + return () => { + unsubscribed = true; + providerWithEvents.removeListener?.('accountsChanged', handleAccountsChanged); + }; + }, [activeZkEvmProvider]); + const providerValues = useMemo(() => ({ imxProvider, zkEvmProvider, + defaultWalletProvider, + activeZkEvmProvider, + activeZkEvmAccount, + setDefaultWalletProvider, connectImx, connectZkEvm, logout, @@ -307,9 +382,14 @@ export function PassportProvider({ getUserInfo, getLinkedAddresses, linkWallet, + isSandboxEnvironment, }), [ imxProvider, zkEvmProvider, + defaultWalletProvider, + activeZkEvmProvider, + activeZkEvmAccount, + isSandboxEnvironment, connectImx, connectZkEvm, logout, @@ -326,6 +406,7 @@ export function PassportProvider({ getUserInfo, getLinkedAddresses, linkWallet, + setDefaultWalletProvider, ]); return ( @@ -339,6 +420,11 @@ export function usePassportProvider() { const { imxProvider, zkEvmProvider, + defaultWalletProvider, + activeZkEvmProvider, + activeZkEvmAccount, + isSandboxEnvironment, + setDefaultWalletProvider, connectImx, connectZkEvm, logout, @@ -359,6 +445,11 @@ export function usePassportProvider() { return { imxProvider, zkEvmProvider, + defaultWalletProvider, + activeZkEvmProvider, + activeZkEvmAccount, + isSandboxEnvironment, + setDefaultWalletProvider, connectImx, connectZkEvm, logout, diff --git a/packages/passport/sdk-sample-app/src/pages/index.tsx b/packages/passport/sdk-sample-app/src/pages/index.tsx index 29c089adab..ac20f5886f 100644 --- a/packages/passport/sdk-sample-app/src/pages/index.tsx +++ b/packages/passport/sdk-sample-app/src/pages/index.tsx @@ -13,7 +13,7 @@ import ZkEvmWorkflow from '@/components/zkevm/ZkEvmWorkflow'; export default function Home() { const { isLoading } = useStatusProvider(); - const { imxProvider, zkEvmProvider } = usePassportProvider(); + const { imxProvider, zkEvmProvider, defaultWalletProvider } = usePassportProvider(); return ( <> @@ -26,7 +26,7 @@ export default function Home() {
- + diff --git a/packages/passport/sdk/package.json b/packages/passport/sdk/package.json index e6871bc835..f698610719 100644 --- a/packages/passport/sdk/package.json +++ b/packages/passport/sdk/package.json @@ -7,15 +7,14 @@ "dependencies": { "@0xsequence/abi": "^2.0.25", "@0xsequence/core": "^2.0.25", + "@imtbl/auth": "workspace:*", + "@imtbl/wallet": "workspace:*", "@imtbl/config": "workspace:*", "@imtbl/generated-clients": "workspace:*", "@imtbl/metrics": "workspace:*", "@imtbl/toolkit": "workspace:*", "@imtbl/x-client": "workspace:*", "@imtbl/x-provider": "workspace:*", - "@magic-ext/oidc": "12.0.5", - "@magic-sdk/provider": "^29.0.5", - "@metamask/detect-provider": "^2.0.0", "axios": "^1.6.5", "ethers": "^6.13.4", "events": "^3.3.0", @@ -23,13 +22,13 @@ "localforage": "^1.10.0", "magic-sdk": "^29.0.5", "oidc-client-ts": "3.4.1", - "uuid": "^8.3.2" + "uuid": "^9.0.1" }, "devDependencies": { "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", "@types/axios": "^0.14.0", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/jwt-encode": "^1.0.1", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", @@ -44,8 +43,9 @@ "msw": "^1.2.2", "prettier": "^2.8.7", "ts-node": "^10.9.1", - "tsup": "8.3.0", - "typescript": "^5.6.2" + "tsup": "^8.3.0", + "typescript": "^5.6.2", + "@jest/test-sequencer": "^29.7.0" }, "engines": { "node": ">=20.11.0" diff --git a/packages/passport/sdk/src/Passport.int.test.ts b/packages/passport/sdk/src/Passport.int.test.ts deleted file mode 100644 index 4bd44d85ac..0000000000 --- a/packages/passport/sdk/src/Passport.int.test.ts +++ /dev/null @@ -1,435 +0,0 @@ -import { Magic } from 'magic-sdk'; -import { UserManager } from 'oidc-client-ts'; -import { Environment, ImmutableConfiguration } from '@imtbl/config'; -import { IMXClient } from '@imtbl/x-client'; -import encode from 'jwt-encode'; -import { TransactionRequest } from 'ethers'; -import { OidcConfiguration } from './types'; -import { mockValidIdToken } from './utils/token.test'; -import { buildPrivateVars, Passport } from './Passport'; -import { RequestArguments } from './zkEvm/types'; -import { - closeMswWorker, - useMswHandlers, - resetMswHandlers, - transactionHash, - mswHandlers, -} from './mocks/zkEvm/msw'; -import { JsonRpcError, RpcErrorCode } from './zkEvm/JsonRpcError'; -import GuardianClient from './guardian'; -import { chainIdHex, mockUserZkEvm } from './test/mocks'; - -jest.mock('./guardian'); -jest.mock('magic-sdk'); -jest.mock('oidc-client-ts'); -jest.mock('@imtbl/x-client'); - -const authenticationDomain = 'example.com'; -const redirectUri = 'example.com'; -const popupRedirectUri = 'example.com'; -const logoutRedirectUri = 'example.com'; -const clientId = 'clientId123'; -const now = Math.floor(Date.now() / 1000); -const oneHourLater = now + 3600; - -const mockValidAccessToken = encode({ - iss: 'https://example.auth0.com/', - aud: 'https://api.example.com/', - sub: 'sub123', - iat: now, - exp: oneHourLater, -}, 'secret'); - -const mockOidcUser = { - profile: { - sub: 'sub123', - email: 'test@example.com', - nickname: 'test', - }, - expired: false, - id_token: mockValidIdToken, - access_token: mockValidAccessToken, - refresh_token: 'refreshToken123', -}; - -const mockOidcUserZkevm = { - ...mockOidcUser, - id_token: encode({ - iss: 'https://example.auth0.com/', - aud: 'clientId123', - sub: 'sub123', - iat: now, - exp: oneHourLater, - email: 'test@example.com', - nickname: 'test', - passport: { - zkevm_eth_address: mockUserZkEvm.zkEvm.ethAddress, - zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress, - }, - }, 'secret'), -}; - -const oidcConfiguration: OidcConfiguration = { - clientId, - redirectUri, - popupRedirectUri, - logoutRedirectUri, -}; - -const getPassport = () => ( - new Passport({ - baseConfig: new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }), - audience: 'platform_api', - clientId, - redirectUri, - popupRedirectUri, - logoutRedirectUri, - scope: 'openid offline_access profile email transact', - popupOverlayOptions: { - disableHeadlessLoginPromptOverlay: true, - }, - }) -); - -const getZkEvmProvider = async () => { - const passport = getPassport(); - return await passport.connectEvm(); -}; - -describe('Passport', () => { - const mockSigninPopup = jest.fn(); - const mockSigninSilent = jest.fn(); - const mockGetUser = jest.fn(); - const mockLoginWithOidc = jest.fn(); - const mockMagicRequest = jest.fn(); - const mockMagicUserIsLoggedIn = jest.fn(); - let originalWindowOpen: any; - - beforeEach(() => { - jest.resetAllMocks(); - - // Mock window.open to handle popup detection in authManager - originalWindowOpen = window.open; - window.open = jest.fn().mockReturnValue({ - closed: false, - close: jest.fn(), - }); - - // Mock crypto.randomUUID for authManager login functionality - Object.defineProperty(window, 'crypto', { - value: { - randomUUID: jest.fn().mockReturnValue('mock-uuid-12345'), - }, - writable: true, - }); - - mockMagicUserIsLoggedIn.mockResolvedValue(true); - (UserManager as jest.Mock).mockImplementation(() => ({ - signinPopup: mockSigninPopup, - signinSilent: mockSigninSilent, - getUser: mockGetUser, - })); - (GuardianClient as jest.Mock).mockImplementation(() => ({ - validateEVMTransaction: jest.fn().mockResolvedValue(undefined), - withConfirmationScreen: () => (task: () => void) => task(), - })); - (Magic as jest.Mock).mockImplementation(() => ({ - openid: { loginWithOIDC: mockLoginWithOidc }, - rpcProvider: { request: mockMagicRequest }, - user: { isLoggedIn: mockMagicUserIsLoggedIn }, - })); - }); - - afterEach(() => { - resetMswHandlers(); - // Restore original window.open - window.open = originalWindowOpen; - }); - - afterAll(async () => { - closeMswWorker(); - }); - - describe('buildPrivateVars', () => { - describe('when the env is prod', () => { - it('sets the prod x URL as the basePath on imxApiClients', () => { - const baseConfig = new ImmutableConfiguration({ environment: Environment.PRODUCTION }); - - const privateVars = buildPrivateVars({ - baseConfig, - ...oidcConfiguration, - }); - - expect(privateVars.passportImxProviderFactory.imxApiClients.config.basePath).toEqual('https://api.x.immutable.com'); - }); - }); - - describe('when the env is sandbox', () => { - it('sets the sandbox x URL as the basePath on imxApiClients', () => { - const baseConfig = new ImmutableConfiguration({ environment: Environment.SANDBOX }); - - const privateVars = buildPrivateVars({ - baseConfig, - ...oidcConfiguration, - }); - - expect(privateVars.passportImxProviderFactory.imxApiClients.config.basePath).toEqual('https://api.sandbox.x.immutable.com'); - }); - }); - - describe('when overrides are provided', () => { - it('sets imxPublicApiDomain as the basePath on imxApiClients', async () => { - const baseConfig = new ImmutableConfiguration({ environment: Environment.SANDBOX }); - const immutableXClient = new IMXClient({ baseConfig }); - const overrides = { - authenticationDomain, - imxPublicApiDomain: 'guardianDomain123', - magicProviderId: 'providerId123', - magicPublishableApiKey: 'publishableKey123', - passportDomain: 'customDomain123', - relayerUrl: 'relayerUrl123', - zkEvmRpcUrl: 'zkEvmRpcUrl123', - indexerMrBasePath: 'indexerMrBasePath123', - orderBookMrBasePath: 'orderBookMrBasePath123', - passportMrBasePath: 'passportMrBasePath123', - immutableXClient, - }; - - const { passportImxProviderFactory } = buildPrivateVars({ - baseConfig, - overrides, - ...oidcConfiguration, - }); - - expect(passportImxProviderFactory.imxApiClients.config.basePath).toEqual(overrides.imxPublicApiDomain); - }); - }); - }); - - describe('zkEvm', () => { - const magicWalletAddress = '0x3082e7c88f1c8b4e24be4a75dee018ad362d84d4'; - - describe('eth_requestAccounts', () => { - describe('when the user has registered before', () => { - it('returns the users ether key', async () => { - mockGetUser.mockResolvedValue(mockOidcUserZkevm); - useMswHandlers([ - mswHandlers.rpcProvider.success, - ]); - - const zkEvmProvider = await getZkEvmProvider(); - - const accounts = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - - expect(accounts).toEqual([mockUserZkEvm.zkEvm.ethAddress]); - expect(mockGetUser).toHaveBeenCalledTimes(2); - }); - }); - - describe('when the user is logging in for the first time', () => { - beforeEach(() => { - mockMagicRequest.mockImplementation(({ method }: RequestArguments) => { - switch (method) { - case 'eth_accounts': { - return Promise.resolve([magicWalletAddress]); - } - case 'personal_sign': { - return Promise.resolve('0x05107ba1d76d8a5ba3415df36eb5af65f4c670778eed257f5704edcb03802cfc662f66b76e5aa032c2305e61ce77ed858bc9850f8c945ab6c3cb6fec796aae421c'); - } - default: { - throw new Error(`unexpected RPC method: ${method}`); - } - } - }); - }); - - it('registers the user and returns the ether key', async () => { - mockGetUser.mockResolvedValueOnce(null); - mockSigninPopup.mockResolvedValue(mockOidcUser); - mockGetUser.mockResolvedValueOnce(mockOidcUser); - mockSigninSilent.mockResolvedValueOnce(mockOidcUserZkevm); - mockGetUser.mockResolvedValue(mockOidcUserZkevm); - useMswHandlers([ - mswHandlers.rpcProvider.success, - mswHandlers.counterfactualAddress.success, - mswHandlers.api.chains.success, - mswHandlers.magicTEE.createWallet.success, - ]); - - const zkEvmProvider = await getZkEvmProvider(); - - const accounts = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - - expect(accounts).toEqual([mockUserZkEvm.zkEvm.ethAddress]); - expect(mockGetUser).toHaveBeenCalledTimes(3); - }); - - describe('when the registration request fails', () => { - it('throws an error', async () => { - mockGetUser.mockResolvedValueOnce(null); - mockSigninPopup.mockResolvedValue(mockOidcUser); - mockGetUser.mockResolvedValueOnce(mockOidcUser); - mockSigninSilent.mockResolvedValue(mockOidcUser); - mockGetUser.mockResolvedValue(mockOidcUser); - useMswHandlers([ - mswHandlers.counterfactualAddress.internalServerError, - mswHandlers.api.chains.success, - mswHandlers.magicTEE.createWallet.success, - ]); - - const zkEvmProvider = await getZkEvmProvider(); - - await expect(async () => zkEvmProvider.request({ - method: 'eth_requestAccounts', - })).rejects.toEqual(new JsonRpcError(RpcErrorCode.INTERNAL_ERROR, 'Failed to create counterfactual address: AxiosError: Request failed with status code 500')); - }); - }); - }); - }); - - describe('eth_sendTransaction', () => { - it('successfully initialises the zkEvm provider and sends a transaction', async () => { - const transferToAddress = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC'; - - useMswHandlers([ - mswHandlers.rpcProvider.success, - mswHandlers.relayer.success, - mswHandlers.guardian.evaluateTransaction.success, - mswHandlers.magicTEE.createWallet.success, - mswHandlers.magicTEE.personalSign.success, - ]); - mockMagicRequest.mockImplementation(({ method }: RequestArguments) => { - switch (method) { - case 'eth_chainId': { - return Promise.resolve(chainIdHex); - } - case 'eth_accounts': { - return Promise.resolve([magicWalletAddress]); - } - case 'personal_sign': { - return Promise.resolve('0x6b168cf5d90189eaa51d02ff3fa8ffc8956b1ea20fdd34280f521b1acca092305b9ace24e643fe64a30c528323065f5b77e1fb4045bd330aad01e7b9a07591f91b'); - } - default: { - throw new Error(`Unexpected method: ${method}`); - } - } - }); - mockGetUser.mockResolvedValue(mockOidcUserZkevm); - - const zkEvmProvider = await getZkEvmProvider(); - - await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - const transaction: TransactionRequest = { - to: transferToAddress, - value: '5000000000000000', - data: '0x00', - }; - const result = await zkEvmProvider.request({ - method: 'eth_sendTransaction', - params: [transaction], - }); - - expect(result).toEqual(transactionHash); - expect(mockGetUser).toHaveBeenCalledTimes(9); - }); - - it('ethSigner is initialised if user logs in after connectEvm', async () => { - const transferToAddress = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC'; - - useMswHandlers([ - mswHandlers.counterfactualAddress.success, - mswHandlers.rpcProvider.success, - mswHandlers.relayer.success, - mswHandlers.guardian.evaluateTransaction.success, - mswHandlers.magicTEE.createWallet.success, - mswHandlers.magicTEE.personalSign.success, - ]); - mockMagicRequest.mockImplementation(({ method }: RequestArguments) => { - switch (method) { - case 'eth_chainId': { - return Promise.resolve(chainIdHex); - } - case 'eth_accounts': { - return Promise.resolve([magicWalletAddress]); - } - case 'personal_sign': { - return Promise.resolve('0x6b168cf5d90189eaa51d02ff3fa8ffc8956b1ea20fdd34280f521b1acca092305b9ace24e643fe64a30c528323065f5b77e1fb4045bd330aad01e7b9a07591f91b'); - } - default: { - throw new Error(`Unexpected method: ${method}`); - } - } - }); - mockGetUser.mockResolvedValueOnce(null); - mockSigninPopup.mockResolvedValue(mockOidcUserZkevm); - mockSigninSilent.mockResolvedValueOnce(mockOidcUserZkevm); - - const passport = getPassport(); - - // user isn't logged in, so wont set signer when provider is instantiated - // #doc request-accounts - const provider = await passport.connectEvm(); - const accounts = await provider.request({ - method: 'eth_requestAccounts', - }); - // #enddoc request-accounts - - // user logs in, ethSigner is initialised - await passport.login(); - - mockGetUser.mockResolvedValue(mockOidcUserZkevm); - - expect(accounts).toEqual([mockUserZkEvm.zkEvm.ethAddress]); - - const transaction: TransactionRequest = { - to: transferToAddress, - value: '5000000000000000', - data: '0x00', - }; - const result = await provider.request({ - method: 'eth_sendTransaction', - params: [transaction], - }); - - expect(result).toEqual(transactionHash); - }); - }); - - describe('eth_accounts', () => { - it('returns no addresses if the user is not logged in', async () => { - const zkEvmProvider = await getZkEvmProvider(); - const accounts = await zkEvmProvider.request({ - method: 'eth_accounts', - }); - expect(accounts).toEqual([]); - }); - - it('returns the user\'s ether key if the user is logged in', async () => { - mockGetUser.mockResolvedValue(mockOidcUserZkevm); - useMswHandlers([ - mswHandlers.rpcProvider.success, - ]); - - const zkEvmProvider = await getZkEvmProvider(); - - const loggedInAccounts = await zkEvmProvider.request({ - method: 'eth_requestAccounts', - }); - - const accounts = await zkEvmProvider.request({ - method: 'eth_accounts', - }); - - expect(accounts).toEqual(loggedInAccounts); - }); - }); - }); -}); diff --git a/packages/passport/sdk/src/Passport.test.ts b/packages/passport/sdk/src/Passport.test.ts index f3faeed012..05acec97a7 100644 --- a/packages/passport/sdk/src/Passport.test.ts +++ b/packages/passport/sdk/src/Passport.test.ts @@ -1,603 +1,372 @@ import { Environment, ImmutableConfiguration } from '@imtbl/config'; -import { IMXClient } from '@imtbl/x-client'; -import { ImxApiClients, imxApiConfig, MultiRollupApiClients } from '@imtbl/generated-clients'; -import { trackError, trackFlow } from '@imtbl/metrics'; -import AuthManager from './authManager'; -import MagicTEESigner from './magic/magicTEESigner'; import { Passport } from './Passport'; -import { PassportImxProvider, PassportImxProviderFactory } from './starkEx'; -import { OidcConfiguration, UserProfile } from './types'; -import { - mockApiError, - mockLinkedAddresses, - mockLinkedWallet, - mockPassportBadRequest, - mockUser, - mockUserImx, - mockUserZkEvm, -} from './test/mocks'; -import { announceProvider, passportProviderInfo } from './zkEvm/provider/eip6963'; -import { ZkEvmProvider } from './zkEvm'; import { PassportError, PassportErrorType } from './errors/passportError'; -jest.mock('./authManager'); -jest.mock('./magic/magicTEESigner'); -jest.mock('./starkEx'); -jest.mock('./confirmation'); -jest.mock('./zkEvm'); -jest.mock('./zkEvm/provider/eip6963'); -jest.mock('@imtbl/generated-clients'); -jest.mock('@imtbl/metrics'); - -const oidcConfiguration: OidcConfiguration = { - clientId: '11111', - redirectUri: 'https://test.com', - popupRedirectUri: 'https://test.com', - logoutRedirectUri: 'https://test.com', -}; - -describe('Passport', () => { - afterEach(jest.resetAllMocks); - - let passport: Passport; - let authLoginMock: jest.Mock; - let loginCallbackMock: jest.Mock; - let logoutMock: jest.Mock; - let removeUserMock: jest.Mock; - let getUserMock: jest.Mock; - let requestRefreshTokenMock: jest.Mock; - let getProviderMock: jest.Mock; - let getProviderSilentMock: jest.Mock; - let getLinkedAddressesMock: jest.Mock; - let linkExternalWalletMock: jest.Mock; - let forceUserRefreshMock: jest.Mock; - - beforeEach(() => { - authLoginMock = jest.fn().mockReturnValue(mockUser); - loginCallbackMock = jest.fn(); - logoutMock = jest.fn(); - removeUserMock = jest.fn(); - getUserMock = jest.fn(); - requestRefreshTokenMock = jest.fn(); - getProviderMock = jest.fn(); - getProviderSilentMock = jest.fn(); - getLinkedAddressesMock = jest.fn(); - linkExternalWalletMock = jest.fn(); - forceUserRefreshMock = jest.fn(); - (AuthManager as unknown as jest.Mock).mockReturnValue({ - login: authLoginMock, - loginWithRedirect: authLoginMock, - loginCallback: loginCallbackMock, - logout: logoutMock, - removeUser: removeUserMock, - getUser: getUserMock, - requestRefreshTokenAfterRegistration: requestRefreshTokenMock, - forceUserRefresh: forceUserRefreshMock, - }); - (MagicTEESigner as unknown as jest.Mock).mockReturnValue({ - getAddress: jest.fn().mockResolvedValue('0x123'), - signMessage: jest.fn().mockResolvedValue('signature'), - }); - (PassportImxProviderFactory as jest.Mock).mockReturnValue({ - getProvider: getProviderMock, - getProviderSilent: getProviderSilentMock, - }); - (MultiRollupApiClients as jest.Mock).mockReturnValue({ - passportProfileApi: { - getUserInfo: getLinkedAddressesMock, - linkWalletV2: linkExternalWalletMock, - }, - }); - (trackFlow as unknown as jest.Mock).mockImplementation(() => ({ - addEvent: jest.fn(), - details: { - flowId: '123', - }, - })); - passport = new Passport({ - baseConfig: new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }), - ...oidcConfiguration, - }); - }); - - describe('constructor', () => { - describe('when modules have been overridden', () => { - it('sets the private property to the overridden value', () => { - const baseConfig = new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }); - const immutableXClient = new IMXClient({ - baseConfig, - }); - const imxApiClients = new ImxApiClients(imxApiConfig.getSandbox()); - const passportInstance = new Passport({ - baseConfig, - overrides: { - authenticationDomain: 'authenticationDomain123', - imxPublicApiDomain: 'guardianDomain123', - magicProviderId: 'providerId123', - magicPublishableApiKey: 'publishableKey123', - passportDomain: 'customDomain123', - relayerUrl: 'relayerUrl123', - zkEvmRpcUrl: 'zkEvmRpcUrl123', - imxApiClients, - indexerMrBasePath: 'indexerMrBasePath123', - orderBookMrBasePath: 'orderBookMrBasePath123', - passportMrBasePath: 'passportMrBasePath123', - immutableXClient, - }, - ...oidcConfiguration, - }); - // @ts-ignore - expect(passportInstance.immutableXClient).toEqual(immutableXClient); - }); - }); - }); - - describe('connectImx', () => { - it('should execute connect without error', async () => { - const passportImxProvider = {} as PassportImxProvider; - getProviderMock.mockResolvedValue(passportImxProvider); - - const result = await passport.connectImx(); - - expect(result).toBe(passportImxProvider); - expect(getProviderMock).toHaveBeenCalled(); - }); - - it('should call track error function if an error occurs', async () => { - const error = new Error('Error'); - getProviderMock.mockRejectedValue(error); - - try { - await passport.connectImx(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'connectImx', - e, - { flowId: '123' }, - ); - } - }); +jest.mock('axios', () => ({ + isAxiosError: (error: any) => Boolean(error?.isAxiosError), +})); + +const mockAuthInstances: any[] = []; + +jest.mock('@imtbl/auth', () => { + const actual = jest.requireActual('@imtbl/auth'); + const authFactory = jest.fn().mockImplementation(() => { + const instance = { + login: jest.fn(), + loginCallback: jest.fn(), + logout: jest.fn(), + getUser: jest.fn(), + storeTokens: jest.fn(), + getLogoutUrl: jest.fn(), + logoutSilentCallback: jest.fn(), + loginWithPKCEFlow: jest.fn(), + loginWithPKCEFlowCallback: jest.fn(), + eventEmitter: { emit: jest.fn(), on: jest.fn() }, + getConfig: jest.fn().mockReturnValue({}), + forceUserRefresh: jest.fn(), + forceUserRefreshInBackground: jest.fn(), + }; + mockAuthInstances.push(instance); + return instance; }); - describe('connectImxSilent', () => { - describe('when getPassportImxProvider returns null', () => { - it('returns null', async () => { - getProviderSilentMock.mockResolvedValue(null); - - const result = await passport.connectImxSilent(); - - expect(result).toBe(null); - expect(getProviderSilentMock).toHaveBeenCalled(); - }); - }); - describe('when getPassportImxProvider returns a provider', () => { - it('should return the provider', async () => { - const passportImxProvider = {} as PassportImxProvider; - getProviderSilentMock.mockResolvedValue(passportImxProvider); + return { + ...actual, + Auth: authFactory, + isUserZkEvm: jest.fn().mockReturnValue(true), + }; +}); - const result = await passport.connectImxSilent(); +jest.mock('@imtbl/wallet', () => { + const connectWalletMock = jest.fn(); + + return { + connectWallet: connectWalletMock, + ZkEvmProvider: jest.fn(), + GuardianClient: jest.fn(), + MagicTEESigner: jest.fn(), + WalletConfiguration: jest.fn(), + ConfirmationScreen: jest.fn(), + __mocked: { + connectWalletMock, + }, + }; +}); - expect(result).toBe(passportImxProvider); - expect(getProviderSilentMock).toHaveBeenCalled(); - }); - }); +const multiRollupInstances: any[] = []; - it('should call track error function if an error occurs', async () => { - const error = new Error('Unknown or invalid refresh token.'); - getProviderSilentMock.mockRejectedValue(error); - - try { - await passport.connectImxSilent(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'connectImxSilent', - e, - { flowId: '123' }, - ); - } - }); +jest.mock('@imtbl/generated-clients', () => { + const actual = jest.requireActual('@imtbl/generated-clients'); + const multiRollupApiClientsFactory = jest.fn().mockImplementation(() => { + const instance = { + passportProfileApi: { + getUserInfo: jest.fn().mockResolvedValue({ data: { linked_addresses: [] } }), + linkWalletV2: jest.fn(), + }, + guardianApi: {}, + }; + multiRollupInstances.push(instance); + return instance; }); - describe('connectEvm', () => { - it('should execute connectEvm without error and return the provider', async () => { - // #doc connect-evm - const passportProvider = await passport.connectEvm(); - // #enddoc connect-evm - - expect(passportProvider).toBeInstanceOf(ZkEvmProvider); - expect(ZkEvmProvider).toHaveBeenCalled(); - }); + return { + ...actual, + MultiRollupApiClients: multiRollupApiClientsFactory, + MagicTeeApiClients: jest.fn(), + createConfig: jest.fn((config) => config), + imxApiConfig: { + getSandbox: jest.fn(() => ({ basePath: 'sandbox' })), + getProduction: jest.fn(() => ({ basePath: 'production' })), + }, + }; +}); - it('should announce the provider by default', async () => { - passportProviderInfo.uuid = 'mock123'; - const provider = await passport.connectEvm(); +jest.mock('@imtbl/metrics', () => { + const actual = jest.requireActual('@imtbl/metrics'); + const trackErrorMock = jest.fn(); + const trackFlowMock = jest.fn(() => ({ + addEvent: jest.fn(), + details: { flowId: 'flow-id' }, + })); + + return { + ...actual, + trackError: trackErrorMock, + trackFlow: trackFlowMock, + setPassportClientId: jest.fn(), + __mocked: { + trackErrorMock, + trackFlowMock, + }, + }; +}); - expect(announceProvider).toHaveBeenCalledWith({ - info: passportProviderInfo, - provider, - }); - }); +jest.mock('./starkEx', () => { + const factoryMock = { + getProvider: jest.fn(), + getProviderSilent: jest.fn(), + }; + const passportImxProviderFactory = jest.fn().mockImplementation(() => factoryMock); + return { + PassportImxProviderFactory: passportImxProviderFactory, + }; +}); - it('should not announce the provider if called with options announceProvider false', async () => { - const passportInstance = new Passport({ - baseConfig: new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }), - ...oidcConfiguration, - }); +jest.mock('./starkEx/imxGuardianClient', () => ({ + ImxGuardianClient: jest.fn().mockImplementation(() => ({ + evaluateTransaction: jest.fn(), + })), +})); - await passportInstance.connectEvm({ announceProvider: false }); +jest.mock('./utils/imxUser', () => ({ + toUserImx: jest.fn().mockReturnValue({}), +})); - expect(announceProvider).not.toHaveBeenCalled(); - }); +const { PassportImxProviderFactory: passportImxProviderFactoryMock } = jest.requireMock('./starkEx'); +const { __mocked: metricsMocks } = jest.requireMock('@imtbl/metrics'); +const { __mocked: walletMocks } = jest.requireMock('@imtbl/wallet'); +const { trackErrorMock } = metricsMocks; +const { connectWalletMock } = walletMocks; - it('should call track error function if an error occurs', async () => { - (ZkEvmProvider as jest.Mock).mockImplementation(() => { - throw new Error('Error'); - }); - - try { - await passport.connectEvm(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'connectEvm', - e, - { flowId: '123' }, - ); - } - }); +describe('Passport', () => { + const baseConfiguration = new ImmutableConfiguration({ + environment: Environment.SANDBOX, }); - describe('loginCallback', () => { - it('should execute login callback', async () => { - await passport.loginCallback(); - - expect(loginCallbackMock).toBeCalledTimes(1); - }); - - it('should call track error function if an error occurs', async () => { - const error = new Error('error'); - loginCallbackMock.mockRejectedValue(error); - - try { - await passport.loginCallback(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'loginCallback', - e, - { flowId: '123' }, - ); - } - }); + const oidcConfiguration = { + clientId: 'client', + redirectUri: 'https://example.com/redirect', + popupRedirectUri: 'https://example.com/popup', + logoutRedirectUri: 'https://example.com/logout', + scope: 'openid profile', + }; + + const createPassport = () => new Passport({ + baseConfig: baseConfiguration, + ...oidcConfiguration, }); - describe('logout', () => { - describe('when the logout mode is silent', () => { - it('should execute logout without error', async () => { - await passport.logout(); + const getLatestAuthInstance = () => mockAuthInstances[mockAuthInstances.length - 1]; + const getLatestMultiRollupInstance = () => multiRollupInstances[multiRollupInstances.length - 1]; + const getFactoryInstance = () => (passportImxProviderFactoryMock as jest.Mock).mock.results.slice(-1)[0]?.value; - expect(logoutMock).toBeCalledTimes(1); - }); - }); - - it('should call track error function if an error occurs', async () => { - const error = new Error('error'); - logoutMock.mockRejectedValue(error); - - try { - await passport.logout(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'logout', - e, - { flowId: '123' }, - ); - } - }); + beforeEach(() => { + jest.clearAllMocks(); + mockAuthInstances.length = 0; + multiRollupInstances.length = 0; }); - describe('getUserInfo', () => { - it('should execute getUser', async () => { - getUserMock.mockReturnValue(mockUser); + describe('connectImx', () => { + it('returns provider from factory', async () => { + const passport = createPassport(); + const factory = getFactoryInstance(); + const provider = { kind: 'imx' }; + factory.getProvider.mockResolvedValue(provider); - const result = await passport.getUserInfo(); + const result = await passport.connectImx(); - expect(result).toEqual(mockUser.profile); + expect(result).toBe(provider); + expect(factory.getProvider).toHaveBeenCalledTimes(1); }); - it('should return undefined if there is no user', async () => { - getUserMock.mockReturnValue(null); - - const result = await passport.getUserInfo(); + it('tracks error when factory throws', async () => { + const passport = createPassport(); + const factory = getFactoryInstance(); + const error = new Error('boom'); + factory.getProvider.mockRejectedValue(error); - expect(result).toEqual(undefined); - }); - - it('should call track error function if an error occurs', async () => { - const error = new Error('Unknown or invalid refresh token.'); - getUserMock.mockRejectedValue(error); - - try { - await passport.getUserInfo(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'getUserInfo', - e, - { flowId: '123' }, - ); - } + await expect(passport.connectImx()).rejects.toThrow(error); + expect(trackErrorMock).toHaveBeenCalledWith('passport', 'connectImx', error, { flowId: 'flow-id' }); }); }); - describe('getIdToken', () => { - it('should execute getIdToken', async () => { - getUserMock.mockReturnValue(mockUser); - - const result = await passport.getIdToken(); - - expect(result).toEqual(mockUser.idToken); - }); - - it('should return undefined if there is no user', async () => { - getUserMock.mockReturnValue(null); - - const result = await passport.getIdToken(); + describe('connectImxSilent', () => { + it('returns null when factory resolves null', async () => { + const passport = createPassport(); + const factory = getFactoryInstance(); + factory.getProviderSilent.mockResolvedValue(null); - expect(result).toEqual(undefined); - }); + const result = await passport.connectImxSilent(); - it('should call track error function if an error occurs', async () => { - const error = new Error('Unknown or invalid refresh token.'); - getUserMock.mockRejectedValue(error); - - try { - await passport.getIdToken(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'getIdToken', - e, - { flowId: '123' }, - ); - } + expect(result).toBeNull(); + expect(factory.getProviderSilent).toHaveBeenCalledTimes(1); }); }); - describe('getAccessToken', () => { - it('should execute getAccessToken', async () => { - getUserMock.mockReturnValue(mockUser); + describe('connectEvm', () => { + it('returns provider from connectWallet', async () => { + const provider = { kind: 'zkEvm' }; + connectWalletMock.mockResolvedValue(provider); + const passport = createPassport(); - const result = await passport.getAccessToken(); + const result = await passport.connectEvm(); - expect(result).toEqual(mockUser.accessToken); + expect(result).toBe(provider); + expect(connectWalletMock).toHaveBeenCalledWith(expect.objectContaining({ + announceProvider: true, + })); }); - it('should return undefined if there is no user', async () => { - getUserMock.mockReturnValue(null); + it('passes announceProvider option through', async () => { + connectWalletMock.mockResolvedValue({ kind: 'zkEvm' }); + const passport = createPassport(); - const result = await passport.getAccessToken(); + await passport.connectEvm({ announceProvider: false }); - expect(result).toEqual(undefined); - }); - - it('should call track error function if an error occurs', async () => { - const error = new Error('Unknown or invalid refresh token.'); - getUserMock.mockRejectedValue(error); - - try { - await passport.getAccessToken(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'getAccessToken', - e, - { flowId: '123' }, - ); - } + expect(connectWalletMock).toHaveBeenCalledWith(expect.objectContaining({ + announceProvider: false, + })); }); }); - describe('getLinkedAddresses', () => { - it('should execute getLinkedAddresses', async () => { - getUserMock.mockReturnValue(mockUser); - getLinkedAddressesMock.mockReturnValue(mockLinkedAddresses); + describe('login flow', () => { + it('returns user profile from auth login', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + const user = { profile: { sub: 'user-1' } }; + auth.login.mockResolvedValue(user); - const result = await passport.getLinkedAddresses(); + const result = await passport.login(); - expect(result).toEqual(mockLinkedAddresses.data.linked_addresses); + expect(result).toEqual(user.profile); + expect(auth.login).toHaveBeenCalledWith(undefined); }); - it('should return empty array if there is no linked addresses', async () => { - getUserMock.mockReturnValue(mockUser); - getLinkedAddressesMock.mockReturnValue({ - data: { - sub: 'sub', - linked_addresses: [], - }, - }); + it('forwards login options to auth', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + auth.login.mockResolvedValue(null); - const result = await passport.getLinkedAddresses(); + await passport.login({ + useCachedSession: true, + useSilentLogin: true, + useRedirectFlow: true, + }); - expect(result).toHaveLength(0); + expect(auth.login).toHaveBeenCalledWith({ + useCachedSession: true, + useSilentLogin: true, + useRedirectFlow: true, + directLoginOptions: undefined, + }); }); - it('should call track error function if an error occurs', async () => { - const error = new Error('Unknown or invalid refresh token.'); - getUserMock.mockRejectedValue(error); - - try { - await passport.getLinkedAddresses(); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'getLinkedAddresses', - e, - { flowId: '123' }, - ); - } - }); - }); + it('calls loginCallback and logout on auth', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); - describe('login', () => { - it('should login silently if there is a user', async () => { - getUserMock.mockReturnValue(mockUserImx); - // #doc auth-users-without-wallet - const user: UserProfile | null = await passport.login(); - // #enddoc auth-users-without-wallet + await passport.loginCallback(); + await passport.logout(); - expect(getUserMock).toBeCalledTimes(1); - expect(authLoginMock).toBeCalledTimes(0); - expect(user).toEqual(mockUser.profile); + expect(auth.loginCallback).toHaveBeenCalledTimes(1); + expect(auth.logout).toHaveBeenCalledTimes(1); }); + }); - it('should login if login silently returns error', async () => { - getUserMock.mockRejectedValue(new Error('Unknown or invalid refresh token.')); - authLoginMock.mockReturnValue(mockUserImx); - const user = await passport.login(); - - expect(getUserMock).toBeCalledTimes(1); - expect(authLoginMock).toBeCalledTimes(1); - expect(user).toEqual(mockUser.profile); - }); + describe('token helpers', () => { + it('getUserInfo returns profile from auth user', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + const user = { profile: { sub: 'test', nickname: 'nick' } }; + auth.getUser.mockResolvedValue(user); - it('should login and get a user', async () => { - getUserMock.mockReturnValue(null); - authLoginMock.mockReturnValue(mockUserImx); - const user = await passport.login(); + const profile = await passport.getUserInfo(); - expect(getUserMock).toBeCalledTimes(1); - expect(authLoginMock).toBeCalledTimes(1); - expect(user).toEqual(mockUserImx.profile); + expect(profile).toEqual(user.profile); }); - it('should only login silently if useCachedSession is true', async () => { - getUserMock.mockReturnValue(mockUserImx); - const user = await passport.login({ useCachedSession: true }); + it('getIdToken and getAccessToken return from auth user', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + const user = { idToken: 'id', accessToken: 'access', profile: { sub: 'sub' } }; + auth.getUser.mockResolvedValue(user); - expect(getUserMock).toBeCalledTimes(1); - expect(authLoginMock).toBeCalledTimes(0); - expect(user).toEqual(mockUser.profile); + await expect(passport.getIdToken()).resolves.toEqual('id'); + await expect(passport.getAccessToken()).resolves.toEqual('access'); }); + }); - it('should throw error if useCachedSession is true and getUser returns error', async () => { - const error = new Error('Unknown or invalid refresh token.'); - getUserMock.mockRejectedValue(error); - authLoginMock.mockReturnValue(mockUserImx); + describe('getLinkedAddresses', () => { + it('returns linked addresses when user exists', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + const multiRollup = getLatestMultiRollupInstance(); + auth.getUser.mockResolvedValue({ profile: { sub: 'user' }, accessToken: 'token' }); + multiRollup.passportProfileApi.getUserInfo.mockResolvedValue({ + data: { linked_addresses: ['addr-1'] }, + }); - await expect(passport.login({ useCachedSession: true })).rejects.toThrow(error); - expect(getUserMock).toBeCalledTimes(1); - expect(authLoginMock).toBeCalledTimes(0); - }); + const addresses = await passport.getLinkedAddresses(); - it('should call track error function if an error occurs', async () => { - const error = new Error('Unknown or invalid refresh token.'); - getUserMock.mockRejectedValue(error); - authLoginMock.mockReturnValue(mockUserImx); - - try { - await passport.login({ useCachedSession: true }); - } catch (e) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'login', - e, - { flowId: '123' }, - ); - } + expect(multiRollup.passportProfileApi.getUserInfo).toHaveBeenCalled(); + expect(addresses).toEqual(['addr-1']); }); - it('should try to login silently if silent is true', async () => { - getUserMock.mockReturnValue(null); + it('returns empty array when no user', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + auth.getUser.mockResolvedValue(null); - await passport.login({ useSilentLogin: true }); + const addresses = await passport.getLinkedAddresses(); - expect(forceUserRefreshMock).toBeCalledTimes(1); - expect(authLoginMock).toBeCalledTimes(0); - }); - - it('should call loginWithRedirect', async () => { - getUserMock.mockReturnValue(null); - await passport.login({ useRedirectFlow: true }); - - expect(getUserMock).toBeCalledTimes(1); - expect(authLoginMock).toBeCalledTimes(1); + expect(addresses).toEqual([]); }); }); describe('linkExternalWallet', () => { const linkWalletParams = { type: 'MetaMask', - walletAddress: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - signature: 'signature123', - nonce: 'nonce123', + walletAddress: '0xabc', + signature: 'sig', + nonce: 'nonce', }; - it('should link external wallet when user is logged in', async () => { - getUserMock.mockReturnValue(mockUserZkEvm); - linkExternalWalletMock.mockReturnValue(mockLinkedWallet); - - const result = await passport.linkExternalWallet(linkWalletParams); - - expect(result).toEqual(mockLinkedWallet.data); - }); - - it('should throw error if user is not logged in', async () => { - getUserMock.mockReturnValue(null); + it('throws when user not logged in', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + auth.getUser.mockResolvedValue(null); await expect(passport.linkExternalWallet(linkWalletParams)).rejects.toThrow( new PassportError('User is not logged in', PassportErrorType.NOT_LOGGED_IN_ERROR), ); }); - it('should handle generic errors from the linkWalletV2 API call', async () => { - getUserMock.mockReturnValue(mockUserImx); - linkExternalWalletMock.mockReturnValue(mockApiError); - - try { - await passport.linkExternalWallet(linkWalletParams); - } catch (error: any) { - expect(error).toBeInstanceOf(PassportError); - expect(error.type).toEqual(PassportErrorType.LINK_WALLET_GENERIC_ERROR); - expect(error.message).toEqual(`Link wallet request failed with status code ${mockApiError.response.status}`); - } - }); + it('returns linked wallet response', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + const multiRollup = getLatestMultiRollupInstance(); + const linkedWallet = { wallet_address: '0xabc' }; + auth.getUser.mockResolvedValue({ + profile: { sub: 'user' }, + accessToken: 'token', + }); + multiRollup.passportProfileApi.linkWalletV2.mockResolvedValue({ + data: linkedWallet, + }); - it('should handle 400 bad requests from the linkWalletV2 API call', async () => { - getUserMock.mockReturnValue(mockUserImx); - linkExternalWalletMock.mockReturnValue(mockPassportBadRequest); - - try { - await passport.linkExternalWallet(linkWalletParams); - } catch (error: any) { - expect(error).toBeInstanceOf(PassportError); - expect(error.type).toEqual(PassportErrorType.LINK_WALLET_ALREADY_LINKED_ERROR); - expect(error.message).toEqual('Already linked'); - } + const result = await passport.linkExternalWallet(linkWalletParams); + + expect(result).toEqual(linkedWallet); + expect(multiRollup.passportProfileApi.linkWalletV2).toHaveBeenCalled(); }); - it('should call track error function if an error occurs', async () => { - getUserMock.mockReturnValue(mockUserImx); - linkExternalWalletMock.mockReturnValue(mockPassportBadRequest); - - try { - await passport.linkExternalWallet(linkWalletParams); - } catch (error) { - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'linkExternalWallet', - error, - ); - } + it('maps API error codes to PassportError', async () => { + const passport = createPassport(); + const auth = getLatestAuthInstance(); + const multiRollup = getLatestMultiRollupInstance(); + auth.getUser.mockResolvedValue({ + profile: { sub: 'user' }, + accessToken: 'token', + }); + const error = new Error('axios error') as Error & { isAxiosError?: boolean; response?: any }; + error.isAxiosError = true; + error.response = { data: { code: 'ALREADY_LINKED', message: 'oops' } }; + multiRollup.passportProfileApi.linkWalletV2.mockRejectedValue(error); + + await expect(passport.linkExternalWallet(linkWalletParams)).rejects.toThrow( + new PassportError('oops', PassportErrorType.LINK_WALLET_ALREADY_LINKED_ERROR), + ); + expect(trackErrorMock).toHaveBeenCalledWith('passport', 'linkExternalWallet', error); }); }); }); diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index 2859862830..90f9ef7e77 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -5,39 +5,36 @@ import { import { IMXClient } from '@imtbl/x-client'; import { Environment } from '@imtbl/config'; +import { setPassportClientId, trackError, trackFlow } from '@imtbl/metrics'; import { - identify, setPassportClientId, track, trackError, - trackFlow, -} from '@imtbl/metrics'; -import { isAxiosError } from 'axios'; -import AuthManager from './authManager'; -import MagicTEESigner from './magic/magicTEESigner'; -import { PassportImxProviderFactory } from './starkEx'; -import { PassportConfiguration } from './config'; -import { - DirectLoginOptions, + Auth, + UserProfile, DeviceTokenResponse, - isUserImx, isUserZkEvm, - LinkedWallet, - LinkWalletParams, - PassportEventMap, - PassportEvents, +} from '@imtbl/auth'; +import type { DirectLoginOptions } from '@imtbl/auth'; +import { + connectWallet, + ZkEvmProvider, + WalletConfiguration, + GuardianClient, + MagicTEESigner, + ChainConfig, + ConfirmationScreen, +} from '@imtbl/wallet'; +import type { LinkWalletParams, LinkedWallet } from '@imtbl/wallet'; +import { isAxiosError } from 'axios'; +import { PassportModuleConfiguration, - User, - UserProfile, ConnectEvmArguments, LoginArguments, } from './types'; -import { ConfirmationScreen, EmbeddedLoginPrompt } from './confirmation'; -import { ZkEvmProvider } from './zkEvm'; -import { Provider } from './zkEvm/types'; -import TypedEventEmitter from './utils/typedEventEmitter'; -import GuardianClient from './guardian'; -import logger from './utils/logger'; -import { announceProvider, passportProviderInfo } from './zkEvm/provider/eip6963'; -import { isAPIError, PassportError, PassportErrorType } from './errors/passportError'; +import { toUserImx } from './utils/imxUser'; +import { PassportImxProviderFactory } from './starkEx'; +import { PassportConfiguration } from './config'; import { withMetricsAsync } from './utils/metrics'; +import { PassportError, PassportErrorType } from './errors/passportError'; +import { ImxGuardianClient } from './starkEx/imxGuardianClient'; const buildImxClientConfig = (passportModuleConfiguration: PassportModuleConfiguration) => { if (passportModuleConfiguration.overrides) { @@ -57,102 +54,128 @@ const buildImxApiClients = (passportModuleConfiguration: PassportModuleConfigura }; export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConfiguration) => { - const config = new PassportConfiguration(passportModuleConfiguration); - const embeddedLoginPrompt = new EmbeddedLoginPrompt(config); - const authManager = new AuthManager(config, embeddedLoginPrompt); - const confirmationScreen = new ConfirmationScreen(config); - const magicTeeApiClients = new MagicTeeApiClients({ - basePath: config.magicTeeBasePath, - timeout: config.magicTeeTimeout, - magicPublishableApiKey: config.magicPublishableApiKey, - magicProviderId: config.magicProviderId, + const passportConfig = new PassportConfiguration(passportModuleConfiguration); + // Create auth configuration for confirmation screen + // Create Auth instance (public API) + const auth = new Auth({ + ...passportModuleConfiguration, + authenticationDomain: passportConfig.authenticationDomain, + crossSdkBridgeEnabled: passportModuleConfiguration.crossSdkBridgeEnabled, + popupOverlayOptions: passportModuleConfiguration.popupOverlayOptions, + passportDomain: passportConfig.passportDomain, + }); + + const authConfig = auth.getConfig(); + const confirmationScreen = new ConfirmationScreen(authConfig); + + // Create wallet configuration with concrete URLs (no environment) + // PassportConfiguration translates environment → URLs + const walletConfig = new WalletConfiguration({ + passportDomain: passportConfig.passportDomain, + zkEvmRpcUrl: passportConfig.zkEvmRpcUrl, + relayerUrl: passportConfig.relayerUrl, + indexerMrBasePath: passportConfig.multiRollupConfig.indexer.basePath || passportConfig.passportDomain, + jsonRpcReferrer: passportModuleConfiguration.jsonRpcReferrer, + forceScwDeployBeforeMessageSignature: passportModuleConfiguration.forceScwDeployBeforeMessageSignature, + crossSdkBridgeEnabled: passportModuleConfiguration.crossSdkBridgeEnabled, }); - const magicTEESigner = new MagicTEESigner(authManager, magicTeeApiClients); - const multiRollupApiClients = new MultiRollupApiClients(config.multiRollupConfig); - const passportEventEmitter = new TypedEventEmitter(); + + // Setup IMX-specific components + const multiRollupApiClients = new MultiRollupApiClients(passportConfig.multiRollupConfig); const immutableXClient = passportModuleConfiguration.overrides ? passportModuleConfiguration.overrides.immutableXClient : new IMXClient({ baseConfig: passportModuleConfiguration.baseConfig }); + // Create Guardian client for IMX provider const guardianClient = new GuardianClient({ - confirmationScreen, - config, - authManager, + config: walletConfig, + auth, guardianApi: multiRollupApiClients.guardianApi, + authConfig, + }); + + const imxGuardianClient = new ImxGuardianClient({ + auth, + guardianApi: multiRollupApiClients.guardianApi, + confirmationScreen, + crossSdkBridgeEnabled: passportModuleConfiguration.crossSdkBridgeEnabled || false, + }); + + // Create Magic TEE signer for IMX provider + const magicTeeApiClients = new MagicTeeApiClients({ + basePath: passportConfig.magicTeeBasePath, + timeout: passportConfig.magicTeeTimeout, + magicPublishableApiKey: passportConfig.magicPublishableApiKey, + magicProviderId: passportConfig.magicProviderId, }); + const magicTEESigner = new MagicTEESigner(auth, magicTeeApiClients); const imxApiClients = buildImxApiClients(passportModuleConfiguration); const passportImxProviderFactory = new PassportImxProviderFactory({ - authManager, + auth, immutableXClient, magicTEESigner, - passportEventEmitter, + passportEventEmitter: auth.eventEmitter, imxApiClients, guardianClient, + imxGuardianClient, }); return { - config, - authManager, - magicTEESigner, - confirmationScreen, - embeddedLoginPrompt, - immutableXClient, - multiRollupApiClients, - passportEventEmitter, + passportConfig, + auth, passportImxProviderFactory, - guardianClient, + environment: passportModuleConfiguration.baseConfig.environment, + // Keep walletConfig only for IMX GuardianClient + walletConfig, }; }; export class Passport { - private readonly authManager: AuthManager; - - private readonly config: PassportConfiguration; + // ============================================================================ + // DEPENDENCIES & CONFIGURATION + // ============================================================================ - private readonly confirmationScreen: ConfirmationScreen; + // Auth & Wallet (zkEVM uses these via public APIs) + private readonly auth: Auth; - private readonly embeddedLoginPrompt: EmbeddedLoginPrompt; - - private readonly immutableXClient: IMXClient; - - private readonly magicTEESigner: MagicTEESigner; + private readonly passportImxProviderFactory: PassportImxProviderFactory; private readonly multiRollupApiClients: MultiRollupApiClients; - private readonly passportImxProviderFactory: PassportImxProviderFactory; + private readonly environment: Environment; - private readonly passportEventEmitter: TypedEventEmitter; - - private readonly guardianClient: GuardianClient; + private readonly passportConfig: PassportConfiguration; constructor(passportModuleConfiguration: PassportModuleConfiguration) { const privateVars = buildPrivateVars(passportModuleConfiguration); - this.config = privateVars.config; - this.authManager = privateVars.authManager; - this.magicTEESigner = privateVars.magicTEESigner; - this.confirmationScreen = privateVars.confirmationScreen; - this.embeddedLoginPrompt = privateVars.embeddedLoginPrompt; - this.immutableXClient = privateVars.immutableXClient; - this.multiRollupApiClients = privateVars.multiRollupApiClients; - this.passportEventEmitter = privateVars.passportEventEmitter; + this.auth = privateVars.auth; this.passportImxProviderFactory = privateVars.passportImxProviderFactory; - this.guardianClient = privateVars.guardianClient; + this.passportConfig = privateVars.passportConfig; + this.multiRollupApiClients = new MultiRollupApiClients(this.passportConfig.multiRollupConfig); + this.environment = privateVars.environment; setPassportClientId(passportModuleConfiguration.clientId); - track('passport', 'initialise'); } + // ============================================================================ + // IMX-SPECIFIC METHODS + // ============================================================================ + /** * Attempts to connect to IMX silently without user interaction. * @returns {Promise} A promise that resolves to an IMX provider if successful, or null if no cached session exists * @deprecated The method `login` with an argument of `{ useCachedSession: true }` should be used in conjunction with `connectImx` instead */ public async connectImxSilent(): Promise { - return withMetricsAsync(() => this.passportImxProviderFactory.getProviderSilent(), 'connectImxSilent', false); + return withMetricsAsync( + () => this.passportImxProviderFactory.getProviderSilent(), + 'connectImxSilent', + false, + ); } /** @@ -160,50 +183,101 @@ export class Passport { * @returns {Promise} A promise that resolves to an IMX provider */ public async connectImx(): Promise { - return withMetricsAsync(() => this.passportImxProviderFactory.getProvider(), 'connectImx', false); + return withMetricsAsync( + () => this.passportImxProviderFactory.getProvider(), + 'connectImx', + false, + ); } + // ============================================================================ + // ZKEVM-SPECIFIC METHODS + // Uses Auth + Wallet packages + // ============================================================================ + /** * Connects to EVM and optionally announces the provider. + * Uses: Auth + Wallet packages * @param {Object} options - Configuration options * @param {boolean} options.announceProvider - Whether to announce the provider via EIP-6963 for wallet discovery (defaults to true) * @returns {Promise} The EVM provider instance */ - public async connectEvm(options: ConnectEvmArguments = { announceProvider: true }): Promise { + public async connectEvm(options: ConnectEvmArguments = { announceProvider: true }): Promise { return withMetricsAsync(async () => { - let user: User | null = null; - try { - user = await this.authManager.getUser(); - } catch (error) { - // Initialise the zkEvmProvider without a user + // Access PassportOverrides from PassportConfiguration + const passportOverrides = this.passportConfig.overrides; + + // Build complete chain configuration + let chainConfig: ChainConfig; + + if (passportOverrides?.zkEvmChainId) { + // Dev environment with custom chain + chainConfig = { + chainId: passportOverrides.zkEvmChainId, + name: passportOverrides.zkEvmChainName || 'Dev Chain', + rpcUrl: this.passportConfig.zkEvmRpcUrl, + relayerUrl: this.passportConfig.relayerUrl, + apiUrl: this.passportConfig.multiRollupConfig.indexer.basePath || this.passportConfig.passportDomain, + passportDomain: this.passportConfig.passportDomain, + magicPublishableApiKey: this.passportConfig.magicPublishableApiKey, + magicProviderId: this.passportConfig.magicProviderId, + magicTeeBasePath: passportOverrides.magicTeeBasePath || this.passportConfig.magicTeeBasePath, + }; + } else if (this.environment === Environment.PRODUCTION) { + // Production environment + chainConfig = { + chainId: 13371, + name: 'Immutable zkEVM', + rpcUrl: this.passportConfig.zkEvmRpcUrl, + relayerUrl: this.passportConfig.relayerUrl, + apiUrl: this.passportConfig.multiRollupConfig.indexer.basePath || this.passportConfig.passportDomain, + passportDomain: this.passportConfig.passportDomain, + magicPublishableApiKey: this.passportConfig.magicPublishableApiKey, + magicProviderId: this.passportConfig.magicProviderId, + magicTeeBasePath: this.passportConfig.magicTeeBasePath, + }; + } else { + // Sandbox/testnet environment + chainConfig = { + chainId: 13473, + name: 'Immutable zkEVM Testnet', + rpcUrl: this.passportConfig.zkEvmRpcUrl, + relayerUrl: this.passportConfig.relayerUrl, + apiUrl: this.passportConfig.multiRollupConfig.indexer.basePath || this.passportConfig.passportDomain, + passportDomain: this.passportConfig.passportDomain, + magicPublishableApiKey: this.passportConfig.magicPublishableApiKey, + magicProviderId: this.passportConfig.magicProviderId, + magicTeeBasePath: this.passportConfig.magicTeeBasePath, + }; } - const provider = new ZkEvmProvider({ - passportEventEmitter: this.passportEventEmitter, - authManager: this.authManager, - config: this.config, - multiRollupApiClients: this.multiRollupApiClients, - guardianClient: this.guardianClient, - ethSigner: this.magicTEESigner, - user, + // Use connectWallet to create the provider (it will create WalletConfiguration internally) + const provider = await connectWallet({ + auth: this.auth, + chains: [chainConfig], + crossSdkBridgeEnabled: this.passportConfig.crossSdkBridgeEnabled, + jsonRpcReferrer: this.passportConfig.jsonRpcReferrer, + forceScwDeployBeforeMessageSignature: this.passportConfig.forceScwDeployBeforeMessageSignature, + passportEventEmitter: this.auth.eventEmitter, + feeTokenSymbol: 'IMX', + announceProvider: options?.announceProvider ?? true, }); - if (options?.announceProvider) { - announceProvider({ - info: passportProviderInfo, - provider, - }); - } - return provider; }, 'connectEvm', false); } + // ============================================================================ + // SHARED METHODS (zkEVM + IMX) + // Uses Auth class (public API) + // Exception: forceUserRefresh for silent login (advanced operation) + // ============================================================================ + /** - * Initiates the login process. - * @param {Object} options - Login options - * @param {boolean} [options.useCachedSession] - If true, and no active session exists, the user won't be prompted to log in - * @param {string} [options.anonymousId] - ID used to enrich Passport internal metrics + * Logs in the user (works for both zkEVM and IMX). + * Uses: Auth class + * @param {Object} [options] - Login options + * @param {boolean} [options.useCachedSession] - If true, attempts to use a cached session without user interaction. * @param {boolean} [options.useSilentLogin] - If true, attempts silent authentication without user interaction. * Note: This takes precedence over useCachedSession if both are true * @param {boolean} [options.useRedirectFlow] - If true, uses redirect flow instead of popup flow @@ -216,163 +290,116 @@ export class Passport { * and useCachedSession is true */ public async login(options?: LoginArguments): Promise { - return withMetricsAsync(async () => { - const { useCachedSession = false, useSilentLogin } = options || {}; - let user: User | null = null; - - try { - user = await this.authManager.getUser(); - } catch (error) { - if (error instanceof Error && !error.message.includes('Unknown or invalid refresh token')) { - trackError('passport', 'login', error); - } - if (useCachedSession) { - throw error; - } - logger.warn('Failed to retrieve a cached user session', error); - } - - if (!user && useSilentLogin) { - user = await this.authManager.forceUserRefresh(); - } else if (!user && !useCachedSession) { - if (options?.useRedirectFlow) { - await this.authManager.loginWithRedirect(options?.anonymousId, options?.directLoginOptions); - } else { - user = await this.authManager.login(options?.anonymousId, options?.directLoginOptions); - } - } - - if (user) { - identify({ - passportId: user.profile.sub, - }); - this.passportEventEmitter.emit(PassportEvents.LOGGED_IN, user); - } - - return user ? user.profile : null; - }, 'login'); + // Convert Passport's LoginArguments to Auth's LoginOptions (excludes anonymousId) + const authLoginOptions = options ? { + useCachedSession: options.useCachedSession, + useSilentLogin: options.useSilentLogin, + useRedirectFlow: options.useRedirectFlow, + directLoginOptions: options.directLoginOptions, + } : undefined; + + const user = await this.auth.login(authLoginOptions); + return user ? user.profile : null; } /** - * Handles the login callback. - * @returns {Promise} A promise that resolves when the callback is processed + * Handles the login callback from the authentication service. + * Uses: Auth class + * @returns {Promise} A promise that resolves when the login callback is handled */ public async loginCallback(): Promise { - await withMetricsAsync(() => this.authManager.loginCallback(), 'loginCallback') - .then((user) => { - if (user) { - identify({ - passportId: user.profile.sub, - }); - this.passportEventEmitter.emit(PassportEvents.LOGGED_IN, user); - } - }); + await this.auth.loginCallback(); } /** - * Initiates a PKCE flow login. - * @param {DirectLoginOptions} [directLoginOptions] - If provided, directly redirects to the specified login method - * @param {string} [imPassportTraceId] - The trace ID for the PKCE flow - * @returns {string} The authorization URL for the PKCE flow + * Logs out the user (works for both zkEVM and IMX). + * Uses: Auth class + * @returns {Promise} A promise that resolves when the user is logged out */ - public loginWithPKCEFlow(directLoginOptions?: DirectLoginOptions, imPassportTraceId?: string): Promise { - return withMetricsAsync( - async () => await this.authManager.getPKCEAuthorizationUrl(directLoginOptions, imPassportTraceId), - 'loginWithPKCEFlow', - ); + public async logout(): Promise { + await this.auth.logout(); } /** - * Handles the PKCE flow login callback. - * @param {string} authorizationCode - The authorization code received from the OAuth provider - * @param {string} state - The state parameter for CSRF protection - * @returns {Promise} A promise that resolves to the user profile + * Retrieves the current user's information. + * Uses: Auth class + * @returns {Promise} A promise that resolves to the user profile if logged in, undefined otherwise */ - public async loginWithPKCEFlowCallback( - authorizationCode: string, - state: string, - ): Promise { + public async getUserInfo(): Promise { return withMetricsAsync(async () => { - const user = await this.authManager.loginWithPKCEFlowCallback( - authorizationCode, - state, - ); - this.passportEventEmitter.emit(PassportEvents.LOGGED_IN, user); - return user.profile; - }, 'loginWithPKCEFlowCallback'); + const user = await this.auth.getUser(); + return user?.profile; + }, 'getUserInfo', false); } - public async storeTokens(tokenResponse: DeviceTokenResponse): Promise { - return withMetricsAsync(async () => { - const user = await this.authManager.storeTokens(tokenResponse); - this.passportEventEmitter.emit(PassportEvents.LOGGED_IN, user); - return user.profile; - }, 'storeTokens'); + /** + * Retrieves the ID token. + * @returns {Promise} A promise that resolves to the ID token if available, undefined otherwise + */ + public async getIdToken(): Promise { + const user = await this.auth.getUser(); + return user?.idToken; } /** - * Logs out the current user. - * @returns {Promise} A promise that resolves when the logout is complete + * Retrieves the access token. + * @returns {Promise} A promise that resolves to the access token if available, undefined otherwise */ - public async logout(): Promise { - return withMetricsAsync(async () => { - await this.authManager.logout(); - this.passportEventEmitter.emit(PassportEvents.LOGGED_OUT); - }, 'logout'); + public async getAccessToken(): Promise { + const user = await this.auth.getUser(); + return user?.accessToken; } /** - * Returns the logout URL for the current user. - * @returns {Promise} The logout URL + * Retrieves the PKCE authorization URL for the login flow. + * Uses: Auth class + * @param {DirectLoginOptions} [directLoginOptions] - Optional direct login options + * @param {string} [imPassportTraceId] - Optional trace ID + * @returns {Promise} A promise that resolves to the authorization URL */ - public async getLogoutUrl(): Promise { - return withMetricsAsync(async () => { - await this.authManager.removeUser(); - this.passportEventEmitter.emit(PassportEvents.LOGGED_OUT); - return await this.authManager.getLogoutUrl(); - }, 'getLogoutUrl'); + public async loginWithPKCEFlow(directLoginOptions?: DirectLoginOptions, imPassportTraceId?: string): Promise { + return this.auth.loginWithPKCEFlow(directLoginOptions, imPassportTraceId); } /** - * Handles the silent logout callback. - * @param {string} url - The callback URL to process - * @returns {Promise} A promise that resolves when the silent logout is complete - */ - public async logoutSilentCallback(url: string): Promise { - return withMetricsAsync(() => this.authManager.logoutSilentCallback(url), 'logoutSilentCallback'); + * Handles the PKCE login callback. + * Uses: Auth class + * @param {string} authorizationCode - The authorization code from the OAuth provider + * @param {string} state - The state parameter for CSRF protection + * @returns {Promise} A promise that resolves to the user profile + */ + public async loginWithPKCEFlowCallback(authorizationCode: string, state: string): Promise { + const user = await this.auth.loginWithPKCEFlowCallback(authorizationCode, state); + return user.profile; } /** - * Retrieves the current user's information. - * @returns {Promise} A promise that resolves to the user profile if logged in, undefined otherwise - */ - public async getUserInfo(): Promise { - return withMetricsAsync(async () => { - const user = await this.authManager.getUser(); - return user?.profile; - }, 'getUserInfo', false); + * Stores the provided tokens and retrieves the user profile. + * Uses: Auth class + * @param {DeviceTokenResponse} tokenResponse - The token response from device flow + * @returns {Promise} A promise that resolves to the user profile + */ + public async storeTokens(tokenResponse: DeviceTokenResponse): Promise { + const user = await this.auth.storeTokens(tokenResponse); + return user.profile; } /** - * Retrieves the current user's ID token. - * @returns {Promise} A promise that resolves to the ID token if available, undefined otherwise + * Retrieves the logout URL. + * @returns {Promise} A promise that resolves to the logout URL, or undefined if not available */ - public async getIdToken(): Promise { - return withMetricsAsync(async () => { - const user = await this.authManager.getUser(); - return user?.idToken; - }, 'getIdToken', false); + public async getLogoutUrl(): Promise { + const url = await this.auth.getLogoutUrl(); + return url; } /** - * Retrieves the current user's access token. - * @returns {Promise} A promise that resolves to the access token if available, undefined otherwise + * Handles the silent logout callback. + * @param {string} url - The URL containing the logout information + * @returns {Promise} A promise that resolves when the silent logout callback is handled */ - public async getAccessToken(): Promise { - return withMetricsAsync(async () => { - const user = await this.authManager.getUser(); - return user?.accessToken; - }, 'getAccessToken', false, false); + public async logoutSilentCallback(url: string): Promise { + return this.auth.logoutSilentCallback(url); } /** @@ -381,10 +408,11 @@ export class Passport { */ public async getLinkedAddresses(): Promise { return withMetricsAsync(async () => { - const user = await this.authManager.getUser(); + const user = await this.auth.getUser(); if (!user?.profile.sub) { return []; } + const headers = { Authorization: `Bearer ${user.accessToken}` }; const getUserInfoResult = await this.multiRollupApiClients.passportProfileApi.getUserInfo({ headers }); return getUserInfoResult.data.linked_addresses; @@ -392,41 +420,66 @@ export class Passport { } /** - * Links an external wallet to the current user's account. + * Links an external wallet to the user's Passport account. * @param {LinkWalletParams} params - Parameters for linking the wallet * @returns {Promise} A promise that resolves to the linked wallet information - * @throws {PassportError} When: - * - User is not logged in (NOT_LOGGED_IN_ERROR) - * - User is not registered (USER_NOT_REGISTERED_ERROR) - * - Wallet is already linked (LINK_WALLET_ALREADY_LINKED_ERROR) - * - Maximum number of wallets reached (LINK_WALLET_MAX_WALLETS_LINKED_ERROR) + * @throws {PassportError} If the user is not logged in (NOT_LOGGED_IN_ERROR) + * - If the user is not registered with StarkEx (USER_NOT_REGISTERED_ERROR) + * - If the wallet is already linked (LINK_WALLET_ALREADY_LINKED_ERROR) + * - If the maximum number of wallets are linked (LINK_WALLET_MAX_WALLETS_LINKED_ERROR) * - Duplicate nonce used (LINK_WALLET_DUPLICATE_NONCE_ERROR) * - Validation fails (LINK_WALLET_VALIDATION_ERROR) * - Other generic errors (LINK_WALLET_GENERIC_ERROR) */ public async linkExternalWallet(params: LinkWalletParams): Promise { + type ApiError = { + code: string; + message: string; + }; + + const isApiError = (error: unknown): error is ApiError => ( + typeof error === 'object' + && error !== null + && 'code' in error + && 'message' in error + ); + const flow = trackFlow('passport', 'linkExternalWallet', false); - const user = await this.authManager.getUser(); - if (!user) { - throw new PassportError('User is not logged in', PassportErrorType.NOT_LOGGED_IN_ERROR); - } + try { + const user = await this.auth.getUser(); + if (!user) { + throw new PassportError('User is not logged in', PassportErrorType.NOT_LOGGED_IN_ERROR); + } - const isRegisteredWithIMX = isUserImx(user); - const isRegisteredWithZkEvm = isUserZkEvm(user); - if (!isRegisteredWithIMX && !isRegisteredWithZkEvm) { - throw new PassportError('User has not been registered', PassportErrorType.USER_NOT_REGISTERED_ERROR); - } + const isRegisteredWithZkEvm = isUserZkEvm(user); + const isRegisteredWithIMX = (() => { + try { + toUserImx(user); + return true; + } catch (imxError) { + if ( + imxError instanceof PassportError + && imxError.type === PassportErrorType.USER_NOT_REGISTERED_ERROR + ) { + return false; + } + throw imxError; + } + })(); - const headers = { Authorization: `Bearer ${user.accessToken}` }; - const linkWalletV2Request = { - type: params.type, - wallet_address: params.walletAddress, - signature: params.signature, - nonce: params.nonce, - }; + if (!isRegisteredWithIMX && !isRegisteredWithZkEvm) { + throw new PassportError('User has not been registered', PassportErrorType.USER_NOT_REGISTERED_ERROR); + } + + const headers = { Authorization: `Bearer ${user.accessToken}` }; + const linkWalletV2Request = { + type: params.type, + wallet_address: params.walletAddress, + signature: params.signature, + nonce: params.nonce, + }; - try { const linkWalletV2Result = await this.multiRollupApiClients .passportProfileApi.linkWalletV2({ linkWalletV2Request }, { headers }); return { ...linkWalletV2Result.data }; @@ -437,8 +490,12 @@ export class Passport { flow.addEvent('errored'); } + if (error instanceof PassportError) { + throw error; + } + if (isAxiosError(error) && error.response) { - if (error.response.data && isAPIError(error.response.data)) { + if (error.response.data && isApiError(error.response.data)) { const { code, message } = error.response.data; switch (code) { @@ -454,7 +511,6 @@ export class Passport { throw new PassportError(message, PassportErrorType.LINK_WALLET_GENERIC_ERROR); } } else if (error.response.status) { - // Handle unexpected error with a generic error message throw new PassportError( `Link wallet request failed with status code ${error.response.status}`, PassportErrorType.LINK_WALLET_GENERIC_ERROR, diff --git a/packages/passport/sdk/src/authManager.test.ts b/packages/passport/sdk/src/authManager.test.ts deleted file mode 100644 index ec7b4b4c65..0000000000 --- a/packages/passport/sdk/src/authManager.test.ts +++ /dev/null @@ -1,1282 +0,0 @@ -import { Environment, ImmutableConfiguration } from '@imtbl/config'; -import { User as OidcUser, UserManager, WebStorageStateStore } from 'oidc-client-ts'; -import jwt_decode from 'jwt-decode'; -import AuthManager from './authManager'; -import ConfirmationOverlay from './overlay/confirmationOverlay'; -import EmbeddedLoginPrompt from './confirmation/embeddedLoginPrompt'; -import { PassportError, PassportErrorType } from './errors/passportError'; -import { PassportConfiguration } from './config'; -import { mockUser, mockUserImx, mockUserZkEvm } from './test/mocks'; -import { isAccessTokenExpiredOrExpiring } from './utils/token'; -import { isUserZkEvm, MarketingConsentStatus, PassportModuleConfiguration } from './types'; - -jest.mock('jwt-decode'); -jest.mock('oidc-client-ts', () => ({ - ...jest.requireActual('oidc-client-ts'), - InMemoryWebStorage: jest.fn(), - UserManager: jest.fn(), - WebStorageStateStore: jest.fn(), -})); -jest.mock('./utils/token'); -jest.mock('./overlay/confirmationOverlay'); -jest.mock('./confirmation/embeddedLoginPrompt'); - -const authenticationDomain = 'auth.immutable.com'; -const clientId = '11111'; -const redirectUri = 'https://test.com'; -const popupRedirectUri = `${redirectUri}-popup`; -const logoutEndpoint = '/v2/logout'; -const crossSdkBridgeLogoutEndpoint = '/im-logged-out'; -const logoutRedirectUri = `${redirectUri}logout/callback`; - -const getConfig = (values?: Partial) => new PassportConfiguration({ - baseConfig: new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }), - clientId, - redirectUri, - popupRedirectUri, - scope: 'email profile', - popupOverlayOptions: { - disableHeadlessLoginPromptOverlay: true, - }, - ...values, -}); - -const commonOidcUser: OidcUser = { - id_token: mockUser.idToken, - access_token: mockUser.accessToken, - token_type: 'Bearer', - scope: 'openid', - expires_in: 167222, - profile: { - sub: mockUser.profile.sub, - email: mockUser.profile.email, - nickname: mockUser.profile.nickname, - }, -} as OidcUser; - -const mockOidcUser: OidcUser = { - ...commonOidcUser, - refresh_token: mockUser.refreshToken, - expired: false, -} as OidcUser; - -const mockOidcExpiredUser: OidcUser = { - ...commonOidcUser, - refresh_token: mockUser.refreshToken, - expired: true, -} as OidcUser; - -const mockOidcExpiredNoRefreshTokenUser: OidcUser = { - ...commonOidcUser, - expired: true, -} as OidcUser; - -const imxProfileData = { - imx_eth_address: mockUserImx.imx.ethAddress, - imx_stark_address: mockUserImx.imx.starkAddress, - imx_user_admin_address: mockUserImx.imx.userAdminAddress, -}; - -const zkEvmProfileData = { - zkevm_eth_address: mockUserZkEvm.zkEvm.ethAddress, - zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress, -}; - -const mockErrorMsg = 'NONO'; - -describe('AuthManager', () => { - let authManager: AuthManager; - let mockSigninPopup: jest.Mock; - let mockSigninCallback: jest.Mock; - let mockSigninRedirectCallback: jest.Mock; - let mockSignoutRedirect: jest.Mock; - let mockGetUser: jest.Mock; - let mockSigninSilent: jest.Mock; - let mockSignoutSilent: jest.Mock; - let mockStoreUser: jest.Mock; - let mockOverlayAppend: jest.Mock; - let mockOverlayRemove: jest.Mock; - let mockRevokeTokens: jest.Mock; - let mockEmbeddedLoginPrompt: jest.Mocked; - let originalWindowOpen: any; - - beforeEach(() => { - // Store original window.open and replace with mock globally - originalWindowOpen = window.open; - window.open = jest.fn().mockReturnValue({ - closed: false, - close: jest.fn(), - }); - - // Mock crypto.randomUUID globally for all tests - Object.defineProperty(window, 'crypto', { - value: { - randomUUID: jest.fn().mockReturnValue('mock-uuid-12345'), - }, - writable: true, - }); - - mockSigninPopup = jest.fn(); - mockSigninCallback = jest.fn(); - mockSigninRedirectCallback = jest.fn(); - mockSignoutRedirect = jest.fn(); - mockGetUser = jest.fn(); - mockSigninSilent = jest.fn(); - mockSignoutSilent = jest.fn(); - mockStoreUser = jest.fn(); - mockOverlayAppend = jest.fn(); - mockOverlayRemove = jest.fn(); - mockRevokeTokens = jest.fn(); - (UserManager as jest.Mock).mockImplementation((config) => ({ - signinPopup: mockSigninPopup, - signinCallback: mockSigninCallback, - signinRedirectCallback: mockSigninRedirectCallback, - signoutRedirect: mockSignoutRedirect, - signoutSilent: mockSignoutSilent, - getUser: mockGetUser, - signinSilent: mockSigninSilent, - storeUser: mockStoreUser, - revokeTokens: mockRevokeTokens, - settings: { - metadata: { - end_session_endpoint: config.metadata?.end_session_endpoint, - }, - }, - })); - (ConfirmationOverlay as jest.Mock).mockReturnValue({ - append: mockOverlayAppend, - remove: mockOverlayRemove, - }); - - mockEmbeddedLoginPrompt = { - displayEmbeddedLoginPrompt: jest.fn(), - } as unknown as jest.Mocked; - - (EmbeddedLoginPrompt as unknown as jest.Mock).mockImplementation(() => mockEmbeddedLoginPrompt); - - authManager = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); - }); - - afterEach(() => { - jest.resetAllMocks(); - // Restore original window.open - window.open = originalWindowOpen; - }); - - describe('constructor', () => { - it('should initialise AuthManager with the correct default configuration', () => { - const config = getConfig(); - const am = new AuthManager(config, mockEmbeddedLoginPrompt); - expect(am).toBeDefined(); - expect(UserManager).toBeCalledWith({ - authority: config.authenticationDomain, - client_id: config.oidcConfiguration.clientId, - extraQueryParams: {}, - mergeClaimsStrategy: { array: 'merge' }, - automaticSilentRenew: false, - metadata: { - authorization_endpoint: `${config.authenticationDomain}/authorize`, - token_endpoint: `${config.authenticationDomain}/oauth/token`, - userinfo_endpoint: `${config.authenticationDomain}/userinfo`, - end_session_endpoint: `${config.authenticationDomain}${logoutEndpoint}` - + `?client_id=${config.oidcConfiguration.clientId}`, - revocation_endpoint: `${config.authenticationDomain}/oauth/revoke`, - }, - popup_redirect_uri: config.oidcConfiguration.popupRedirectUri, - redirect_uri: config.oidcConfiguration.redirectUri, - scope: config.oidcConfiguration.scope, - userStore: expect.any(WebStorageStateStore), - revokeTokenTypes: ['refresh_token'], - }); - }); - - describe('when an audience is specified', () => { - it('should initialise AuthManager with a configuration containing audience params', () => { - const configWithAudience = getConfig({ - audience: 'audience', - }); - const am = new AuthManager(configWithAudience, mockEmbeddedLoginPrompt); - expect(am).toBeDefined(); - expect(UserManager).toBeCalledWith(expect.objectContaining({ - extraQueryParams: { - audience: configWithAudience.oidcConfiguration.audience, - }, - revokeTokenTypes: ['refresh_token'], - })); - }); - }); - - describe('when a logoutRedirectUri is specified', () => { - it('should set the endSessionEndpoint `returnTo` and `client_id` query string params', () => { - const configWithLogoutRedirectUri = getConfig({ logoutRedirectUri }); - const am = new AuthManager(configWithLogoutRedirectUri, mockEmbeddedLoginPrompt); - - const uri = new URL(logoutEndpoint, `https://${authenticationDomain}`); - uri.searchParams.append('client_id', clientId); - uri.searchParams.append('returnTo', logoutRedirectUri); - - expect(am).toBeDefined(); - expect(UserManager).toBeCalledWith(expect.objectContaining({ - metadata: expect.objectContaining({ - end_session_endpoint: uri.toString(), - }), - })); - }); - }); - }); - - describe('login', () => { - describe('when the user has not registered for any rollup', () => { - it('should get the login user and return the domain model', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); - - const result = await authManager.login(); - expect(result).toEqual(mockUser); - }); - }); - - describe('when the user has registered for imx', () => { - it('should populate the imx object', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); - (jwt_decode as jest.Mock).mockReturnValue({ - passport: { - imx_eth_address: mockUserImx.imx.ethAddress, - imx_stark_address: mockUserImx.imx.starkAddress, - imx_user_admin_address: mockUserImx.imx.userAdminAddress, - }, - }); - - const result = await authManager.login(); - - expect(result).toEqual(mockUserImx); - }); - }); - - describe('when the user has registered for zkEvm', () => { - it('should populate the zkEvm object', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); - - (jwt_decode as jest.Mock).mockReturnValue({ - passport: { - zkevm_eth_address: mockUserZkEvm.zkEvm.ethAddress, - zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress, - }, - }); - - const result = await authManager.login(); - - expect(result).toEqual(mockUserZkEvm); - }); - }); - - describe('when the user has registered for imx & zkEvm', () => { - it('should populate the imx & zkEvm objects', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); - (jwt_decode as jest.Mock).mockReturnValue({ - passport: { - zkevm_eth_address: mockUserZkEvm.zkEvm.ethAddress, - zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress, - imx_eth_address: mockUserImx.imx.ethAddress, - imx_stark_address: mockUserImx.imx.starkAddress, - imx_user_admin_address: mockUserImx.imx.userAdminAddress, - }, - }); - - const result = await authManager.login(); - - expect(result).toEqual({ - ...mockUserImx, - imx: { - ethAddress: imxProfileData.imx_eth_address, - starkAddress: imxProfileData.imx_stark_address, - userAdminAddress: imxProfileData.imx_user_admin_address, - }, - zkEvm: { - ethAddress: zkEvmProfileData.zkevm_eth_address, - userAdminAddress: zkEvmProfileData.zkevm_user_admin_address, - }, - }); - }); - }); - - describe('when the token contains a username', () => { - it('should extract username from the top level of the id token', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); - (jwt_decode as jest.Mock).mockReturnValue({ - username: 'username123', - email: mockUser.profile.email, - nickname: mockUser.profile.nickname, - sub: mockUser.profile.sub, - }); - - const result = await authManager.login(); - - expect(result).toEqual({ - ...mockUser, - profile: { - ...mockUser.profile, - username: 'username123', - }, - }); - }); - }); - - it('should throw the error if user is failed to login', async () => { - mockSigninPopup.mockImplementation(() => { - throw new Error(mockErrorMsg); - }); - - await expect(() => authManager.login()).rejects.toThrow( - new PassportError( - mockErrorMsg, - PassportErrorType.AUTHENTICATION_ERROR, - ), - ); - expect(mockOverlayAppend).not.toHaveBeenCalled(); - }); - - describe('when the popup is blocked', () => { - beforeEach(() => { - mockSigninPopup.mockImplementationOnce(() => { - throw new Error('Attempted to navigate on a disposed window'); - }); - }); - - it('should render the blocked popup overlay', async () => { - const configWithPopupOverlayOptions = getConfig({ - popupOverlayOptions: { - disableGenericPopupOverlay: false, - disableBlockedPopupOverlay: false, - }, - }); - const am = new AuthManager(configWithPopupOverlayOptions, mockEmbeddedLoginPrompt); - - // Mock the embedded login prompt to return a result - const mockEmbeddedLoginPromptResult = { - directLoginMethod: 'google' as const, - marketingConsentStatus: MarketingConsentStatus.OptedIn, - imPassportTraceId: 'test-trace-id', - }; - mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt.mockResolvedValue(mockEmbeddedLoginPromptResult); - - mockSigninPopup.mockReturnValue(mockOidcUser); - // Simulate `tryAgainOnClick` being called so that the `login()` promise can resolve - mockOverlayAppend.mockImplementation(async (tryAgainOnClick: () => Promise) => { - await tryAgainOnClick(); - }); - - const result = await am.login(); - - expect(result).toEqual(mockUser); - expect(ConfirmationOverlay).toHaveBeenCalledWith(configWithPopupOverlayOptions.popupOverlayOptions, true); - expect(mockOverlayAppend).toHaveBeenCalledTimes(1); - }); - - describe('when tryAgainOnClick is called once', () => { - beforeEach(() => { - mockOverlayAppend.mockImplementation(async (tryAgainOnClick: () => Promise) => { - await tryAgainOnClick(); - }); - }); - - it('should return a user', async () => { - mockSigninPopup.mockReturnValue(mockOidcUser); - - const result = await authManager.login(); - - expect(result).toEqual(mockUser); - expect(mockSigninPopup).toHaveBeenCalledTimes(2); - expect(mockOverlayRemove).toHaveBeenCalled(); - }); - - describe('and the user closes the popup', () => { - it('should throw an error', async () => { - mockSigninPopup.mockImplementationOnce(() => { - throw new Error('Popup closed by user'); - }); - - await expect(() => authManager.login()).rejects.toThrow( - new Error('Popup closed by user'), - ); - - expect(mockSigninPopup).toHaveBeenCalledTimes(2); - expect(mockOverlayRemove).toHaveBeenCalled(); - }); - }); - }); - - describe('when tryAgainOnClick is called multiple times', () => { - it('should call window.open with the same popupWindowTarget to focus existing popup', async () => { - let tryAgainCallback: (() => Promise) | undefined; - - mockOverlayAppend.mockImplementation(async (tryAgain: () => Promise) => { - tryAgainCallback = tryAgain; - // First call - should open new popup - await tryAgain(); - }); - - mockSigninPopup.mockReturnValue(mockOidcUser); - - const loginPromise = authManager.login(); - - // Wait for the overlay to be set up - await new Promise((resolve) => { setTimeout(resolve, 10); }); - - // Verify the popupWindowTarget was generated - const expectedTarget = 'mock-uuid-12345'; - - // Verify first signinPopup call used the target - expect(mockSigninPopup).toHaveBeenCalledWith( - expect.objectContaining({ - popupWindowTarget: expectedTarget, - }), - ); - - // Reset window.open mock to track subsequent calls - (window.open as jest.Mock).mockClear(); - - // Call tryAgain again - should focus existing popup - if (tryAgainCallback) { - await tryAgainCallback(); - } - - // Verify window.open was called with empty URL and the same target - expect(window.open).toHaveBeenCalledWith('', expectedTarget); - - await loginPromise; - }); - }); - - describe('when onCloseClick is called', () => { - it('should remove the overlay', async () => { - mockOverlayAppend.mockImplementation(async (_: () => Promise, onCloseClick: () => void) => { - onCloseClick(); - }); - - await expect(() => authManager.login()).rejects.toThrow( - new Error('Popup closed by user'), - ); - - expect(mockOverlayRemove).toHaveBeenCalled(); - }); - }); - }); - }); - - describe('getUserOrLogin', () => { - describe('when getUser returns a user', () => { - it('should return the user', async () => { - mockGetUser.mockReturnValue(mockOidcUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false); - - const result = await authManager.getUserOrLogin(); - - expect(result).toEqual(mockUser); - }); - }); - - describe('when getUser throws an error', () => { - it('calls attempts to sign in the user using signinPopup', async () => { - mockGetUser.mockImplementation(() => { - throw new Error(mockErrorMsg); - }); - mockSigninPopup.mockReturnValue(mockOidcUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false); - - const result = await authManager.getUserOrLogin(); - - expect(result).toEqual(mockUser); - }); - }); - }); - - describe('loginCallback', () => { - let mockSigninPopupCallback: jest.Mock; - - beforeEach(() => { - mockSigninPopupCallback = jest.fn(); - (UserManager as jest.Mock).mockImplementation((config) => ({ - signinPopup: mockSigninPopup, - signinCallback: mockSigninCallback, - signinPopupCallback: mockSigninPopupCallback, - signinRedirectCallback: mockSigninRedirectCallback, - signoutRedirect: mockSignoutRedirect, - signoutSilent: mockSignoutSilent, - getUser: mockGetUser, - signinSilent: mockSigninSilent, - storeUser: mockStoreUser, - revokeTokens: mockRevokeTokens, - settings: { - metadata: { - end_session_endpoint: config.metadata?.end_session_endpoint, - }, - }, - })); - authManager = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); - }); - - it('should call login callback', async () => { - await authManager.loginCallback(); - - expect(mockSigninCallback).toBeCalled(); - }); - - it('should call login redirect callback and map to domain model', async () => { - mockSigninCallback.mockReturnValue(mockOidcUser); - const user = await authManager.loginCallback(); - expect(mockSigninCallback).toBeCalled(); - expect(user).toEqual(mockUser); - }); - }); - - describe('logout', () => { - it('should call redirect logout if logout mode is redirect', async () => { - const configuration = getConfig({ - logoutMode: 'redirect', - }); - const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); - - await manager.logout(); - - expect(mockRevokeTokens).toHaveBeenCalledWith(['refresh_token']); - expect(mockSignoutRedirect).toBeCalled(); - }); - - it('should call redirect logout if logout mode is not set', async () => { - const configuration = getConfig({ - logoutMode: undefined, - }); - const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); - - await manager.logout(); - - expect(mockRevokeTokens).toHaveBeenCalledWith(['refresh_token']); - expect(mockSignoutRedirect).toBeCalled(); - }); - - it('should call silent logout if logout mode is silent', async () => { - const configuration = getConfig({ - logoutMode: 'silent', - }); - const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); - - await manager.logout(); - - expect(mockRevokeTokens).toHaveBeenCalledWith(['refresh_token']); - expect(mockSignoutSilent).toBeCalled(); - }); - - describe('when revoking of refresh tokens fails', () => { - it('should throw an error for redirect logout', async () => { - const configuration = getConfig({ - logoutMode: 'redirect', - }); - const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); - mockRevokeTokens.mockImplementation(() => { - throw new Error(mockErrorMsg); - }); - - await expect(() => manager.logout()).rejects.toThrow( - new PassportError( - mockErrorMsg, - PassportErrorType.LOGOUT_ERROR, - ), - ); - expect(mockSignoutRedirect).not.toHaveBeenCalled(); - }); - - it('should throw an error for silent logout', async () => { - const configuration = getConfig({ - logoutMode: 'silent', - }); - const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); - mockRevokeTokens.mockImplementation(() => { - throw new Error(mockErrorMsg); - }); - - await expect(() => manager.logout()).rejects.toThrow( - new PassportError( - mockErrorMsg, - PassportErrorType.LOGOUT_ERROR, - ), - ); - expect(mockSignoutSilent).not.toHaveBeenCalled(); - }); - }); - - it('should throw an error if user is failed to logout with redirect', async () => { - const configuration = getConfig({ - logoutMode: 'redirect', - }); - const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); - - mockSignoutRedirect.mockImplementation(() => { - throw new Error(mockErrorMsg); - }); - - await expect(() => manager.logout()).rejects.toThrow( - new PassportError( - mockErrorMsg, - PassportErrorType.LOGOUT_ERROR, - ), - ); - expect(mockRevokeTokens).toHaveBeenCalledWith(['refresh_token']); - }); - - it('should throw an error if user is failed to logout silently', async () => { - const configuration = getConfig({ - logoutMode: 'silent', - }); - const manager = new AuthManager(configuration, mockEmbeddedLoginPrompt); - - mockSignoutSilent.mockImplementation(() => { - throw new Error(mockErrorMsg); - }); - - await expect(() => manager.logout()).rejects.toThrow( - new PassportError( - mockErrorMsg, - PassportErrorType.LOGOUT_ERROR, - ), - ); - expect(mockRevokeTokens).toHaveBeenCalledWith(['refresh_token']); - }); - }); - - describe('forceUserRefresh', () => { - it('should call signinSilent and return the domain model', async () => { - mockSigninSilent.mockReturnValue(mockOidcUser); - - const result = await authManager.forceUserRefresh(); - - expect(result).toEqual(mockUser); - expect(mockSigninSilent).toBeCalled(); - expect(mockGetUser).not.toBeCalled(); - }); - }); - - describe('getUser', () => { - it('should retrieve the user from the userManager and return the domain model', async () => { - mockGetUser.mockReturnValue(mockOidcUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false); - - const result = await authManager.getUser(); - - expect(result).toEqual(mockUser); - }); - - it('should return null when user has no idToken', async () => { - const userWithoutIdToken = { ...mockOidcUser, id_token: undefined, refresh_token: undefined }; - mockGetUser.mockReturnValue(userWithoutIdToken); - // Restore real function behavior for this test - (isAccessTokenExpiredOrExpiring as jest.Mock).mockImplementation( - jest.requireActual('./utils/token').isAccessTokenExpiredOrExpiring, - ); - - const result = await authManager.getUser(); - - expect(result).toBeNull(); - }); - - it('should refresh token when access token is expired or expiring', async () => { - const userWithExpiringAccessToken = { ...mockOidcUser }; - mockGetUser.mockReturnValue(userWithExpiringAccessToken); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true); - mockSigninSilent.mockResolvedValue(mockOidcUser); - - const result = await authManager.getUser(); - - expect(mockSigninSilent).toBeCalledTimes(1); - expect(result).toEqual(mockUser); - }); - - it('should handle user with missing access token', async () => { - const userWithoutAccessToken = { ...mockOidcUser, access_token: undefined }; - mockGetUser.mockReturnValue(userWithoutAccessToken); - // Restore real function behavior for this test - (isAccessTokenExpiredOrExpiring as jest.Mock).mockImplementation( - jest.requireActual('./utils/token').isAccessTokenExpiredOrExpiring, - ); - mockSigninSilent.mockResolvedValue(mockOidcUser); - - const result = await authManager.getUser(); - - expect(mockSigninSilent).toBeCalledTimes(1); - expect(result).toEqual(mockUser); - }); - - it('should return user directly when access token is not expired or expiring', async () => { - const freshUser = { ...mockOidcUser }; - mockGetUser.mockReturnValue(freshUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false); - - const result = await authManager.getUser(); - - expect(mockSigninSilent).not.toHaveBeenCalled(); - expect(result).toEqual(mockUser); - }); - - it('should call signinSilent and returns user when user token is expired with the refresh token', async () => { - mockGetUser.mockReturnValue(mockOidcExpiredUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true); - mockSigninSilent.mockResolvedValue(mockOidcUser); - - const result = await authManager.getUser(); - - expect(mockSigninSilent).toBeCalledTimes(1); - expect(result).toEqual(mockUser); - }); - - it('should reject with an error when signinSilent throws a string', async () => { - mockGetUser.mockReturnValue(mockOidcExpiredUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true); - mockSigninSilent.mockRejectedValue('oops'); - - await expect(() => authManager.getUser()).rejects.toThrow( - new PassportError( - 'Failed to refresh token: oops: Failed to remove user: this.userManager.removeUser is not a function', - PassportErrorType.AUTHENTICATION_ERROR, - ), - ); - }); - - it('should return null when the user token is expired without refresh token', async () => { - mockGetUser.mockReturnValue(mockOidcExpiredNoRefreshTokenUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true); - - const result = await authManager.getUser(); - - expect(mockSigninSilent).toBeCalledTimes(0); - expect(result).toEqual(null); - }); - - it('should return null when the user token is expired with the refresh token, but signinSilent returns null', async () => { - mockGetUser.mockReturnValue(mockOidcExpiredUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true); - mockSigninSilent.mockResolvedValue(null); - const result = await authManager.getUser(); - - expect(mockSigninSilent).toBeCalledTimes(1); - expect(result).toEqual(null); - }); - - it('should return null if no user is returned', async () => { - mockGetUser.mockReturnValue(null); - - expect(await authManager.getUser()).toBeNull(); - }); - - describe('when concurrent requests forceUserRefresh are made', () => { - describe('when forceUserRefresh', () => { - it('should only call refresh the token once', async () => { - mockSigninSilent.mockReturnValue(mockOidcUser); - - await Promise.allSettled([ - authManager.forceUserRefresh(), - authManager.forceUserRefresh(), - ]); - - expect(mockSigninSilent).toBeCalledTimes(1); - }); - }); - - describe('when the user is expired', () => { - it('should only call refresh the token once', async () => { - mockGetUser.mockReturnValue(mockOidcExpiredUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(true); - mockSigninSilent.mockReturnValue(mockOidcUser); - - await Promise.allSettled([ - authManager.getUser(), - authManager.getUser(), - ]); - - expect(mockSigninSilent).toBeCalledTimes(1); - }); - }); - }); - - describe('when the user does not meet the type assertion', () => { - it('should return null', async () => { - mockGetUser.mockReturnValue(mockOidcUser); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false); - - const result = await authManager.getUser(isUserZkEvm); - - expect(result).toBeNull(); - }); - }); - - describe('when the user does meet the type assertion', () => { - it('should return the user', async () => { - mockGetUser.mockReturnValue(mockOidcUser); - (jwt_decode as jest.Mock).mockReturnValue({ - passport: { - zkevm_eth_address: mockUserZkEvm.zkEvm.ethAddress, - zkevm_user_admin_address: mockUserZkEvm.zkEvm.userAdminAddress, - }, - }); - (isAccessTokenExpiredOrExpiring as jest.Mock).mockReturnValue(false); - - const result = await authManager.getUser(isUserZkEvm); - - expect(result).toEqual(mockUserZkEvm); - }); - }); - - describe('when the user is refreshing', () => { - it('should return the refreshed used', async () => { - mockSigninSilent.mockReturnValue(mockOidcUser); - - authManager.forceUserRefreshInBackground(); - - const result = await authManager.getUser(); - expect(result).toEqual(mockUser); - - expect(mockSigninSilent).toBeCalledTimes(1); - expect(mockGetUser).toBeCalledTimes(0); - }); - }); - }); - - describe('getUserZkEvm', () => { - it('should throw an error if no user is returned', async () => { - mockGetUser.mockReturnValue(null); - - await expect(() => authManager.getUserZkEvm()).rejects.toThrow( - new Error('Failed to obtain a User with the required ZkEvm attributes'), - ); - }); - }); - - describe('getUserImx', () => { - it('should throw an error if no user is returned', async () => { - mockGetUser.mockReturnValue(null); - - await expect(() => authManager.getUserImx()).rejects.toThrow( - new Error('Failed to obtain a User with the required IMX attributes'), - ); - }); - }); - - describe('getLogoutUrl', () => { - describe('with a logged in user', () => { - describe('when a logoutRedirectUri is specified', () => { - it('should set the endSessionEndpoint `returnTo` and `client_id` query string params', async () => { - mockGetUser.mockReturnValue(mockOidcUser); - - const am = new AuthManager(getConfig({ logoutRedirectUri }), mockEmbeddedLoginPrompt); - const result = await am.getLogoutUrl(); - - expect(result).not.toBeNull(); - const uri = new URL(result!); - - expect(uri.hostname).toEqual(authenticationDomain); - expect(uri.pathname).toEqual(logoutEndpoint); - expect(uri.searchParams.get('client_id')).toEqual(clientId); - expect(uri.searchParams.get('returnTo')).toEqual(logoutRedirectUri); - }); - }); - - describe('when no post_logout_redirect_uri is specified', () => { - it('should return the endSessionEndpoint without a `returnTo` or `client_id` query string params', async () => { - mockGetUser.mockReturnValue(mockOidcUser); - - const am = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); - const result = await am.getLogoutUrl(); - - expect(result).not.toBeNull(); - const uri = new URL(result!); - - expect(uri.hostname).toEqual(authenticationDomain); - expect(uri.pathname).toEqual(logoutEndpoint); - expect(uri.searchParams.get('client_id')).toEqual(clientId); - }); - }); - - describe('when crossSdkBridgeEnabled is true', () => { - it('should use the bridge logout endpoint path', async () => { - mockGetUser.mockReturnValue(mockOidcUser); - - const am = new AuthManager( - getConfig({ - crossSdkBridgeEnabled: true, - logoutRedirectUri, - }), - mockEmbeddedLoginPrompt, - ); - const result = await am.getLogoutUrl(); - - expect(result).not.toBeNull(); - const uri = new URL(result!); - - expect(uri.hostname).toEqual(authenticationDomain); - expect(uri.pathname).toEqual(crossSdkBridgeLogoutEndpoint); - expect(uri.searchParams.get('client_id')).toEqual(clientId); - expect(uri.searchParams.get('returnTo')).toEqual(logoutRedirectUri); - }); - }); - }); - - describe('when end_session_endpoint is not available', () => { - it('should return null', async () => { - const am = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); - // eslint-disable-next-line @typescript-eslint/dot-notation - am['userManager'].settings.metadata!.end_session_endpoint = undefined; - - const result = await am.getLogoutUrl(); - expect(result).toBeNull(); - }); - }); - }); - - describe('getPKCEAuthorizationUrl', () => { - beforeEach(() => { - // Mock crypto.getRandomValues for PKCE verifier and state generation - const mockArrayBuffer = new ArrayBuffer(32); - const mockUint8Array = new Uint8Array([ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, - ]); - Object.defineProperty(window, 'crypto', { - value: { - getRandomValues: jest.fn().mockReturnValue(mockUint8Array), - subtle: { - digest: jest.fn().mockResolvedValue(mockArrayBuffer), - }, - }, - writable: true, - }); - - // Mock TextEncoder - global.TextEncoder = jest.fn().mockImplementation(() => ({ - encode: jest.fn().mockReturnValue(mockUint8Array), - })); - - // Mock btoa function used in base64URLEncode - global.btoa = jest.fn().mockReturnValue('bW9ja2VkLWJhc2U2NC1zdHJpbmc='); - }); - - it('should generate a PKCE authorization URL with required parameters', async () => { - const result = await authManager.getPKCEAuthorizationUrl(); - const url = new URL(result); - - expect(url.hostname).toEqual(authenticationDomain); - expect(url.pathname).toEqual('/authorize'); - expect(url.searchParams.get('response_type')).toEqual('code'); - expect(url.searchParams.get('code_challenge_method')).toEqual('S256'); - expect(url.searchParams.get('client_id')).toEqual(clientId); - expect(url.searchParams.get('redirect_uri')).toEqual(redirectUri); - expect(url.searchParams.get('scope')).toEqual('email profile'); - expect(url.searchParams.get('code_challenge')).toEqual('bW9ja2VkLWJhc2U2NC1zdHJpbmc'); - expect(url.searchParams.get('state')).toEqual('bW9ja2VkLWJhc2U2NC1zdHJpbmc'); - }); - - it('should not include direct parameter when directLoginMethod is not provided', async () => { - const result = await authManager.getPKCEAuthorizationUrl(); - const url = new URL(result); - - expect(url.searchParams.get('direct')).toBeNull(); - }); - - it('should include direct parameter when directLoginMethod is provided', async () => { - const directLoginMethod = 'apple'; - const result = await authManager.getPKCEAuthorizationUrl({ - directLoginMethod, - marketingConsentStatus: MarketingConsentStatus.OptedIn, - }); - const url = new URL(result); - - expect(url.searchParams.get('direct')).toEqual('apple'); - expect(url.searchParams.get('marketingConsent')).toEqual(MarketingConsentStatus.OptedIn); - }); - - it('should include direct parameter for google login method', async () => { - const directLoginMethod = 'google'; - const result = await authManager.getPKCEAuthorizationUrl({ - directLoginMethod, - marketingConsentStatus: MarketingConsentStatus.OptedIn, - }); - const url = new URL(result); - - expect(url.searchParams.get('direct')).toEqual('google'); - expect(url.searchParams.get('marketingConsent')).toEqual(MarketingConsentStatus.OptedIn); - }); - - it('should include direct parameter for facebook login method', async () => { - const directLoginMethod = 'facebook'; - const result = await authManager.getPKCEAuthorizationUrl({ - directLoginMethod, - marketingConsentStatus: MarketingConsentStatus.OptedIn, - }); - const url = new URL(result); - - expect(url.searchParams.get('direct')).toEqual('facebook'); - expect(url.searchParams.get('marketingConsent')).toEqual(MarketingConsentStatus.OptedIn); - }); - - it('should include audience parameter when specified in config', async () => { - const configWithAudience = getConfig({ audience: 'test-audience' }); - const am = new AuthManager(configWithAudience, mockEmbeddedLoginPrompt); - - const result = await am.getPKCEAuthorizationUrl(); - const url = new URL(result); - - expect(url.searchParams.get('audience')).toEqual('test-audience'); - }); - - it('should include both direct and audience parameters', async () => { - const configWithAudience = getConfig({ audience: 'test-audience' }); - const am = new AuthManager(configWithAudience, mockEmbeddedLoginPrompt); - - const result = await am.getPKCEAuthorizationUrl({ - directLoginMethod: 'apple', - marketingConsentStatus: MarketingConsentStatus.OptedIn, - }); - const url = new URL(result); - - expect(url.searchParams.get('direct')).toEqual('apple'); - expect(url.searchParams.get('marketingConsent')).toEqual(MarketingConsentStatus.OptedIn); - expect(url.searchParams.get('audience')).toEqual('test-audience'); - }); - - it('should include im_passport_trace_id parameter when imPassportTraceId is provided', async () => { - const result = await authManager.getPKCEAuthorizationUrl(undefined, 'test-trace-id'); - const url = new URL(result); - - expect(url.searchParams.get('im_passport_trace_id')).toEqual('test-trace-id'); - }); - }); - - describe('login with directLoginMethod', () => { - it('should pass directLoginMethod to login popup', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); - - await authManager.login('anonymous-id', { - directLoginMethod: 'apple', - marketingConsentStatus: MarketingConsentStatus.OptedIn, - }); - - expect(mockSigninPopup).toHaveBeenCalledWith({ - extraQueryParams: { - rid: '', - third_party_a_id: 'anonymous-id', - direct: 'apple', - marketingConsent: MarketingConsentStatus.OptedIn, - }, - popupWindowFeatures: { - width: 410, - height: 450, - }, - popupWindowTarget: expect.any(String), - }); - }); - - it('should not include direct parameter when directLoginMethod is not provided', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); - - await authManager.login('anonymous-id'); - - expect(mockSigninPopup).toHaveBeenCalledWith({ - extraQueryParams: { - rid: '', - third_party_a_id: 'anonymous-id', - }, - popupWindowFeatures: { - width: 410, - height: 450, - }, - popupWindowTarget: expect.any(String), - }); - }); - }); - - describe('loginWithRedirect with directLoginMethod', () => { - let mockSigninRedirect: jest.Mock; - - beforeEach(() => { - mockSigninRedirect = jest.fn(); - (UserManager as jest.Mock).mockReturnValue({ - signinPopup: mockSigninPopup, - signinCallback: mockSigninCallback, - signinRedirectCallback: mockSigninRedirectCallback, - signoutRedirect: mockSignoutRedirect, - signoutSilent: mockSignoutSilent, - getUser: mockGetUser, - signinSilent: mockSigninSilent, - storeUser: mockStoreUser, - revokeTokens: mockRevokeTokens, - signinRedirect: mockSigninRedirect, - clearStaleState: jest.fn(), - }); - authManager = new AuthManager(getConfig(), mockEmbeddedLoginPrompt); - }); - - it('should pass directLoginMethod to redirect login', async () => { - await authManager.loginWithRedirect('anonymous-id', { - directLoginMethod: 'google', - marketingConsentStatus: MarketingConsentStatus.OptedIn, - }); - - expect(mockSigninRedirect).toHaveBeenCalledWith({ - extraQueryParams: { - rid: '', - third_party_a_id: 'anonymous-id', - direct: 'google', - marketingConsent: MarketingConsentStatus.OptedIn, - }, - }); - }); - - it('should not include direct parameter when directLoginMethod is not provided', async () => { - await authManager.loginWithRedirect('anonymous-id'); - - expect(mockSigninRedirect).toHaveBeenCalledWith({ - extraQueryParams: { - rid: '', - third_party_a_id: 'anonymous-id', - }, - }); - }); - }); - - describe('login with displayEmbeddedLoginPrompt', () => { - beforeEach(() => { - // Enable headless login prompt overlay for these tests - const configWithEmbeddedPrompt = getConfig({ - popupOverlayOptions: { - disableHeadlessLoginPromptOverlay: false, - }, - }); - authManager = new AuthManager(configWithEmbeddedPrompt, mockEmbeddedLoginPrompt); - }); - - it('should call displayEmbeddedLoginPrompt when no directLoginOptions provided and overlay is enabled', async () => { - const mockEmbeddedLoginPromptResult = { - directLoginMethod: 'google' as const, - marketingConsentStatus: MarketingConsentStatus.OptedIn, - imPassportTraceId: 'test-trace-id', - }; - mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt.mockResolvedValue(mockEmbeddedLoginPromptResult); - mockSigninPopup.mockResolvedValue(mockOidcUser); - - await authManager.login(); - - expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).toHaveBeenCalledTimes(1); - expect(mockSigninPopup).toHaveBeenCalledWith({ - extraQueryParams: { - rid: '', - third_party_a_id: '', - direct: 'google', - marketingConsent: MarketingConsentStatus.OptedIn, - im_passport_trace_id: 'test-trace-id', - }, - popupWindowFeatures: { - width: 410, - height: 450, - }, - popupWindowTarget: expect.any(String), - }); - }); - - it('should not call displayEmbeddedLoginPrompt when directLoginOptions are provided', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); - - await authManager.login('anonymous-id', { - directLoginMethod: 'apple', - marketingConsentStatus: MarketingConsentStatus.OptedIn, - }); - - expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).not.toHaveBeenCalled(); - expect(mockSigninPopup).toHaveBeenCalledWith({ - extraQueryParams: { - rid: '', - third_party_a_id: 'anonymous-id', - direct: 'apple', - marketingConsent: MarketingConsentStatus.OptedIn, - }, - popupWindowFeatures: { - width: 410, - height: 450, - }, - popupWindowTarget: expect.any(String), - }); - }); - - it('should not call displayEmbeddedLoginPrompt when overlay is disabled', async () => { - const configWithDisabledPrompt = getConfig({ - popupOverlayOptions: { - disableHeadlessLoginPromptOverlay: true, - }, - }); - const authManagerWithDisabledPrompt = new AuthManager(configWithDisabledPrompt, mockEmbeddedLoginPrompt); - mockSigninPopup.mockResolvedValue(mockOidcUser); - - await authManagerWithDisabledPrompt.login(); - - expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).not.toHaveBeenCalled(); - }); - - it('should handle email login method from embedded prompt', async () => { - const mockEmbeddedLoginPromptResult = { - directLoginMethod: 'email' as const, - email: 'test@example.com', - marketingConsentStatus: MarketingConsentStatus.OptedIn, - imPassportTraceId: 'test-trace-id-email', - }; - mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt.mockResolvedValue(mockEmbeddedLoginPromptResult); - mockSigninPopup.mockResolvedValue(mockOidcUser); - - await authManager.login('anonymous-id'); - - expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).toHaveBeenCalledTimes(1); - expect(mockSigninPopup).toHaveBeenCalledWith({ - extraQueryParams: { - rid: '', - third_party_a_id: 'anonymous-id', - direct: 'email', - email: 'test@example.com', - marketingConsent: MarketingConsentStatus.OptedIn, - im_passport_trace_id: 'test-trace-id-email', - }, - popupWindowFeatures: { - width: 410, - height: 450, - }, - popupWindowTarget: expect.any(String), - }); - }); - - it('should propagate errors from displayEmbeddedLoginPrompt', async () => { - const embeddedPromptError = new Error('Popup closed by user'); - mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt.mockRejectedValue(embeddedPromptError); - - await expect(() => authManager.login()).rejects.toThrow( - new PassportError( - 'Popup closed by user', - PassportErrorType.AUTHENTICATION_ERROR, - ), - ); - - expect(mockSigninPopup).not.toHaveBeenCalled(); - }); - - it('should pass anonymousId to displayEmbeddedLoginPrompt', async () => { - const anonymousId = 'test-anonymous-id-12345'; - const mockEmbeddedLoginPromptResult = { - directLoginMethod: 'google' as const, - marketingConsentStatus: MarketingConsentStatus.OptedIn, - imPassportTraceId: 'test-trace-id', - }; - mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt.mockResolvedValue(mockEmbeddedLoginPromptResult); - mockSigninPopup.mockResolvedValue(mockOidcUser); - - await authManager.login(anonymousId); - - expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).toHaveBeenCalledWith(anonymousId); - expect(mockEmbeddedLoginPrompt.displayEmbeddedLoginPrompt).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/passport/sdk/src/authManager.ts b/packages/passport/sdk/src/authManager.ts deleted file mode 100644 index 36128b4314..0000000000 --- a/packages/passport/sdk/src/authManager.ts +++ /dev/null @@ -1,612 +0,0 @@ -import { - ErrorResponse, - ErrorTimeout, - InMemoryWebStorage, - User as OidcUser, - UserManager, - UserManagerSettings, - WebStorageStateStore, -} from 'oidc-client-ts'; -import axios from 'axios'; -import jwt_decode from 'jwt-decode'; -import { getDetail, Detail } from '@imtbl/metrics'; -import localForage from 'localforage'; -import DeviceCredentialsManager from './storage/device_credentials_manager'; -import logger from './utils/logger'; -import { isAccessTokenExpiredOrExpiring } from './utils/token'; -import { PassportError, PassportErrorType, withPassportError } from './errors/passportError'; -import { - DirectLoginOptions, - PassportMetadata, - User, - DeviceTokenResponse, - IdTokenPayload, - OidcConfiguration, - UserZkEvm, - isUserZkEvm, - UserImx, - isUserImx, -} from './types'; -import { PassportConfiguration } from './config'; -import ConfirmationOverlay from './overlay/confirmationOverlay'; -import { LocalForageAsyncStorage } from './storage/LocalForageAsyncStorage'; -import { EmbeddedLoginPrompt } from './confirmation'; - -const formUrlEncodedHeader = { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, -}; - -const logoutEndpoint = '/v2/logout'; -const crossSdkBridgeLogoutEndpoint = '/im-logged-out'; -const authorizeEndpoint = '/authorize'; - -const getLogoutEndpointPath = (crossSdkBridgeEnabled: boolean): string => ( - crossSdkBridgeEnabled ? crossSdkBridgeLogoutEndpoint : logoutEndpoint -); - -const getAuthConfiguration = (config: PassportConfiguration): UserManagerSettings => { - const { authenticationDomain, oidcConfiguration } = config; - - let store; - if (config.crossSdkBridgeEnabled) { - store = new LocalForageAsyncStorage('ImmutableSDKPassport', localForage.INDEXEDDB); - } else if (typeof window !== 'undefined') { - store = window.localStorage; - } else { - store = new InMemoryWebStorage(); - } - const userStore = new WebStorageStateStore({ store }); - - const endSessionEndpoint = new URL(getLogoutEndpointPath(config.crossSdkBridgeEnabled), authenticationDomain.replace(/^(?:https?:\/\/)?(.*)/, 'https://$1')); - endSessionEndpoint.searchParams.set('client_id', oidcConfiguration.clientId); - if (oidcConfiguration.logoutRedirectUri) { - endSessionEndpoint.searchParams.set('returnTo', oidcConfiguration.logoutRedirectUri); - } - - const baseConfiguration: UserManagerSettings = { - authority: authenticationDomain, - redirect_uri: oidcConfiguration.redirectUri, - popup_redirect_uri: oidcConfiguration.popupRedirectUri || oidcConfiguration.redirectUri, - client_id: oidcConfiguration.clientId, - metadata: { - authorization_endpoint: `${authenticationDomain}/authorize`, - token_endpoint: `${authenticationDomain}/oauth/token`, - userinfo_endpoint: `${authenticationDomain}/userinfo`, - end_session_endpoint: endSessionEndpoint.toString(), - revocation_endpoint: `${authenticationDomain}/oauth/revoke`, - }, - mergeClaimsStrategy: { array: 'merge' }, - automaticSilentRenew: false, // Disabled until https://github.com/authts/oidc-client-ts/issues/430 has been resolved - scope: oidcConfiguration.scope, - userStore, - revokeTokenTypes: ['refresh_token'], - extraQueryParams: { - ...(oidcConfiguration.audience ? { audience: oidcConfiguration.audience } : {}), - }, - }; - - return baseConfiguration; -}; - -function base64URLEncode(str: ArrayBuffer) { - return btoa(String.fromCharCode(...new Uint8Array(str))) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); -} - -async function sha256(buffer: string) { - const encoder = new TextEncoder(); - const data = encoder.encode(buffer); - return await window.crypto.subtle.digest('SHA-256', data); -} - -export default class AuthManager { - private userManager; - - private deviceCredentialsManager: DeviceCredentialsManager; - - private readonly config: PassportConfiguration; - - private readonly embeddedLoginPrompt: EmbeddedLoginPrompt; - - private readonly logoutMode: Exclude; - - /** - * Promise that is used to prevent multiple concurrent calls to the refresh token endpoint. - */ - private refreshingPromise: Promise | null = null; - - constructor(config: PassportConfiguration, embeddedLoginPrompt: EmbeddedLoginPrompt) { - this.config = config; - this.userManager = new UserManager(getAuthConfiguration(config)); - this.deviceCredentialsManager = new DeviceCredentialsManager(); - this.embeddedLoginPrompt = embeddedLoginPrompt; - this.logoutMode = config.oidcConfiguration.logoutMode || 'redirect'; - } - - private static mapOidcUserToDomainModel = (oidcUser: OidcUser): User => { - let passport: PassportMetadata | undefined; - let username: string | undefined; - if (oidcUser.id_token) { - const idTokenPayload = jwt_decode(oidcUser.id_token); - passport = idTokenPayload?.passport; - if (idTokenPayload?.username) { - username = idTokenPayload?.username; - } - } - - const user: User = { - expired: oidcUser.expired, - idToken: oidcUser.id_token, - accessToken: oidcUser.access_token, - refreshToken: oidcUser.refresh_token, - profile: { - sub: oidcUser.profile.sub, - email: oidcUser.profile.email, - nickname: oidcUser.profile.nickname, - username, - }, - }; - if (passport?.imx_eth_address) { - user.imx = { - ethAddress: passport.imx_eth_address, - starkAddress: passport.imx_stark_address, - userAdminAddress: passport.imx_user_admin_address, - }; - } - if (passport?.zkevm_eth_address) { - user.zkEvm = { - ethAddress: passport?.zkevm_eth_address, - userAdminAddress: passport?.zkevm_user_admin_address, - }; - } - return user; - }; - - private static mapDeviceTokenResponseToOidcUser = (tokenResponse: DeviceTokenResponse): OidcUser => { - const idTokenPayload: IdTokenPayload = jwt_decode(tokenResponse.id_token); - - const oidcUser = new OidcUser({ - id_token: tokenResponse.id_token, - access_token: tokenResponse.access_token, - refresh_token: tokenResponse.refresh_token, - token_type: tokenResponse.token_type, - profile: { - sub: idTokenPayload.sub, - iss: idTokenPayload.iss, - aud: idTokenPayload.aud, - exp: idTokenPayload.exp, - iat: idTokenPayload.iat, - email: idTokenPayload.email, - nickname: idTokenPayload.nickname, - passport: idTokenPayload.passport, - }, - }); - - const { username } = idTokenPayload; - if (username) { - oidcUser.profile.username = username; - } - return oidcUser; - }; - - private buildExtraQueryParams( - anonymousId?: string, - directLoginOptions?: DirectLoginOptions, - imPassportTraceId?: string, - ): Record { - const params: Record = { - ...(this.userManager.settings?.extraQueryParams ?? {}), - rid: getDetail(Detail.RUNTIME_ID) || '', - third_party_a_id: anonymousId || '', - }; - - if (directLoginOptions) { - // If method is email, only include direct login params if email is valid - if (directLoginOptions.directLoginMethod === 'email') { - const emailValue = directLoginOptions.email; - if (emailValue) { - params.direct = directLoginOptions.directLoginMethod; - params.email = emailValue; - } - // If email method but no valid email, disregard both direct and email params - } else { - // For non-email methods (social login), always include direct param - params.direct = directLoginOptions.directLoginMethod; - } - if (directLoginOptions.marketingConsentStatus) { - params.marketingConsent = directLoginOptions.marketingConsentStatus; - } - } - - if (imPassportTraceId) { - params.im_passport_trace_id = imPassportTraceId; - } - - return params; - } - - public async loginWithRedirect(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise { - await this.userManager.clearStaleState(); - return withPassportError(async () => { - const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptions); - - await this.userManager.signinRedirect({ - extraQueryParams, - }); - }, PassportErrorType.AUTHENTICATION_ERROR); - } - - /** - * login - * @param anonymousId Caller can pass an anonymousId if they want to associate their user's identity with immutable's internal instrumentation. - * @param directLoginOptions If provided, contains login method and marketing consent options - * @param directLoginOptions.directLoginMethod The login method to use (e.g., 'google', 'apple', 'email') - * @param directLoginOptions.marketingConsentStatus Marketing consent status ('opted_in' or 'unsubscribed') - * @param directLoginOptions.email Required when directLoginMethod is 'email' - */ - public async login(anonymousId?: string, directLoginOptions?: DirectLoginOptions): Promise { - return withPassportError(async () => { - // If directLoginOptions are provided, then the consumer has rendered their own initial login screen. - // If not, display the embedded login prompt and pass the returned direct login options and imPassportTraceId to the login popup. - let directLoginOptionsToUse: DirectLoginOptions | undefined; - let imPassportTraceId: string | undefined; - if (directLoginOptions) { - directLoginOptionsToUse = directLoginOptions; - } else if (!this.config.popupOverlayOptions.disableHeadlessLoginPromptOverlay) { - const { - imPassportTraceId: embeddedLoginPromptImPassportTraceId, - ...embeddedLoginPromptDirectLoginOptions - } = await this.embeddedLoginPrompt.displayEmbeddedLoginPrompt(anonymousId); - directLoginOptionsToUse = embeddedLoginPromptDirectLoginOptions; - imPassportTraceId = embeddedLoginPromptImPassportTraceId; - } - - const popupWindowTarget = window.crypto.randomUUID(); - const signinPopup = async () => { - const extraQueryParams = this.buildExtraQueryParams(anonymousId, directLoginOptionsToUse, imPassportTraceId); - - return this.userManager.signinPopup({ - extraQueryParams, - popupWindowFeatures: { - width: 410, - height: 450, - }, - popupWindowTarget, - }); - }; - - // This promise attempts to open the signin popup, and displays the blocked popup overlay if necessary. - return new Promise((resolve, reject) => { - signinPopup() - .then((oidcUser) => { - resolve(AuthManager.mapOidcUserToDomainModel(oidcUser)); - }) - .catch((error: unknown) => { - // Reject with the error if it is not caused by a blocked popup - if (!(error instanceof Error) || error.message !== 'Attempted to navigate on a disposed window') { - reject(error); - return; - } - - // Popup was blocked; append the blocked popup overlay to allow the user to try again. - let popupHasBeenOpened: boolean = false; - const overlay = new ConfirmationOverlay(this.config.popupOverlayOptions, true); - overlay.append( - async () => { - try { - if (!popupHasBeenOpened) { - // The user is attempting to open the popup again. It's safe to assume that this will not fail, - // as there are no async operations between the button interaction & the popup being opened. - popupHasBeenOpened = true; - const oidcUser = await signinPopup(); - overlay.remove(); - resolve(AuthManager.mapOidcUserToDomainModel(oidcUser)); - } else { - // The popup has already been opened. By calling `window.open` with the same target as the - // previously opened popup, no new window will be opened. Instead, the existing popup - // will be focused. This works as expected in most browsers at the time of implementation, but - // the following exceptions do exist: - // - Safari: Only the initial call will focus the window, subsequent calls will do nothing. - // - Firefox: The window will not be focussed, nothing will happen. - window.open('', popupWindowTarget); - } - } catch (retryError: unknown) { - overlay.remove(); - reject(retryError); - } - }, - () => { - overlay.remove(); - reject(new Error('Popup closed by user')); - }, - ); - }); - }); - }, PassportErrorType.AUTHENTICATION_ERROR); - } - - public async getUserOrLogin(): Promise { - let user: User | null = null; - try { - user = await this.getUser(); - } catch (err) { - logger.warn('Failed to retrieve a cached user session', err); - } - - return user || this.login(); - } - - public async loginCallback(): Promise { - return withPassportError(async () => { - const oidcUser = await this.userManager.signinCallback(); - if (!oidcUser) { - return undefined; - } - - return AuthManager.mapOidcUserToDomainModel(oidcUser); - }, PassportErrorType.AUTHENTICATION_ERROR); - } - - public async getPKCEAuthorizationUrl( - directLoginOptions?: DirectLoginOptions, - imPassportTraceId?: string, - ): Promise { - const verifier = base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32))); - const challenge = base64URLEncode(await sha256(verifier)); - - // https://auth0.com/docs/secure/attack-protection/state-parameters - const state = base64URLEncode(window.crypto.getRandomValues(new Uint8Array(32))); - - const { - redirectUri, scope, audience, clientId, - } = this.config.oidcConfiguration; - - this.deviceCredentialsManager.savePKCEData({ state, verifier }); - - const pKCEAuthorizationUrl = new URL(authorizeEndpoint, this.config.authenticationDomain); - pKCEAuthorizationUrl.searchParams.set('response_type', 'code'); - pKCEAuthorizationUrl.searchParams.set('code_challenge', challenge); - pKCEAuthorizationUrl.searchParams.set('code_challenge_method', 'S256'); - pKCEAuthorizationUrl.searchParams.set('client_id', clientId); - pKCEAuthorizationUrl.searchParams.set('redirect_uri', redirectUri); - pKCEAuthorizationUrl.searchParams.set('state', state); - - if (scope) pKCEAuthorizationUrl.searchParams.set('scope', scope); - if (audience) pKCEAuthorizationUrl.searchParams.set('audience', audience); - - if (directLoginOptions) { - // If method is email, only include direct login params if email is valid - if (directLoginOptions.directLoginMethod === 'email') { - const emailValue = directLoginOptions.email; - if (emailValue) { - pKCEAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod); - pKCEAuthorizationUrl.searchParams.set('email', emailValue); - } - } else { - // For non-email methods (social login), always include direct param - pKCEAuthorizationUrl.searchParams.set('direct', directLoginOptions.directLoginMethod); - } - if (directLoginOptions.marketingConsentStatus) { - pKCEAuthorizationUrl.searchParams.set('marketingConsent', directLoginOptions.marketingConsentStatus); - } - } - - if (imPassportTraceId) { - pKCEAuthorizationUrl.searchParams.set('im_passport_trace_id', imPassportTraceId); - } - - return pKCEAuthorizationUrl.toString(); - } - - public async loginWithPKCEFlowCallback(authorizationCode: string, state: string): Promise { - return withPassportError(async () => { - const pkceData = this.deviceCredentialsManager.getPKCEData(); - if (!pkceData) { - throw new Error('No code verifier or state for PKCE'); - } - - if (state !== pkceData.state) { - throw new Error('Provided state does not match stored state'); - } - - const tokenResponse = await this.getPKCEToken(authorizationCode, pkceData.verifier); - const oidcUser = AuthManager.mapDeviceTokenResponseToOidcUser(tokenResponse); - const user = AuthManager.mapOidcUserToDomainModel(oidcUser); - await this.userManager.storeUser(oidcUser); - - return user; - }, PassportErrorType.AUTHENTICATION_ERROR); - } - - private async getPKCEToken(authorizationCode: string, codeVerifier: string): Promise { - const response = await axios.post( - `${this.config.authenticationDomain}/oauth/token`, - { - client_id: this.config.oidcConfiguration.clientId, - grant_type: 'authorization_code', - code_verifier: codeVerifier, - code: authorizationCode, - redirect_uri: this.config.oidcConfiguration.redirectUri, - }, - formUrlEncodedHeader, - ); - - return response.data; - } - - public async storeTokens(tokenResponse: DeviceTokenResponse): Promise { - return withPassportError(async () => { - const oidcUser = AuthManager.mapDeviceTokenResponseToOidcUser(tokenResponse); - const user = AuthManager.mapOidcUserToDomainModel(oidcUser); - await this.userManager.storeUser(oidcUser); - - return user; - }, PassportErrorType.AUTHENTICATION_ERROR); - } - - public async logout(): Promise { - return withPassportError(async () => { - await this.userManager.revokeTokens(['refresh_token']); - - if (this.logoutMode === 'silent') { - await this.userManager.signoutSilent(); - } else { - await this.userManager.signoutRedirect(); - } - }, PassportErrorType.LOGOUT_ERROR); - } - - public async logoutSilentCallback(url: string): Promise { - return this.userManager.signoutSilentCallback(url); - } - - public async removeUser(): Promise { - return this.userManager.removeUser(); - } - - public async getLogoutUrl(): Promise { - const endSessionEndpoint = this.userManager.settings?.metadata?.end_session_endpoint; - - if (!endSessionEndpoint) { - logger.warn('Failed to get logout URL'); - return null; - } - - return endSessionEndpoint; - } - - public forceUserRefreshInBackground() { - this.refreshTokenAndUpdatePromise().catch((error) => { - logger.warn('Failed to refresh user token', error); - }); - } - - public async forceUserRefresh(): Promise { - return this.refreshTokenAndUpdatePromise().catch((error) => { - logger.warn('Failed to refresh user token', error); - return null; - }); - } - - /** - * Refreshes the token and returns the user. - * If the token is already being refreshed, returns the existing promise. - */ - private async refreshTokenAndUpdatePromise(): Promise { - if (this.refreshingPromise) return this.refreshingPromise; - - // eslint-disable-next-line no-async-promise-executor - this.refreshingPromise = new Promise(async (resolve, reject) => { - try { - const newOidcUser = await this.userManager.signinSilent(); - if (newOidcUser) { - resolve(AuthManager.mapOidcUserToDomainModel(newOidcUser)); - return; - } - resolve(null); - } catch (err) { - let passportErrorType = PassportErrorType.AUTHENTICATION_ERROR; - let errorMessage = 'Failed to refresh token'; - let removeUser = true; - - if (err instanceof ErrorTimeout) { - passportErrorType = PassportErrorType.SILENT_LOGIN_ERROR; - errorMessage = `${errorMessage}: ${err.message}`; - removeUser = false; - } else if (err instanceof ErrorResponse) { - passportErrorType = PassportErrorType.NOT_LOGGED_IN_ERROR; - errorMessage = `${errorMessage}: ${err.message || err.error_description}`; - } else if (err instanceof Error) { - errorMessage = `${errorMessage}: ${err.message}`; - } else if (typeof err === 'string') { - errorMessage = `${errorMessage}: ${err}`; - } - - if (removeUser) { - try { - await this.userManager.removeUser(); - } catch (removeUserError) { - if (removeUserError instanceof Error) { - errorMessage = `${errorMessage}: Failed to remove user: ${removeUserError.message}`; - } - } - } - - reject(new PassportError(errorMessage, passportErrorType)); - } finally { - this.refreshingPromise = null; // Reset the promise after completion - } - }); - - return this.refreshingPromise; - } - - /** - * - * @param typeAssertion {(user: User) => boolean} - Optional. If provided, then the User will be checked against - * the typeAssertion. If the user meets the requirements, then it will be typed as T and returned. If the User - * does NOT meet the type assertion, then execution will continue, and we will attempt to obtain a User that does - * meet the type assertion. - * - * This function will attempt to obtain a User in the following order: - * 1. If the User is currently refreshing, wait for the refresh to complete. - * 2. Attempt to obtain a User from storage that has not expired. - * 3. Attempt to refresh the User if a refresh token is present. - * 4. Return null if no valid User can be obtained. - */ - public async getUser( - typeAssertion: (user: User) => user is T = (user: User): user is T => true, - ): Promise { - if (this.refreshingPromise) { - const user = await this.refreshingPromise; - if (user && typeAssertion(user)) { - return user; - } - - return null; - } - - const oidcUser = await this.userManager.getUser(); - if (!oidcUser) return null; - - // if the token is not expired or expiring in 30 seconds or less, return the user - if (!isAccessTokenExpiredOrExpiring(oidcUser)) { - const user = AuthManager.mapOidcUserToDomainModel(oidcUser); - if (user && typeAssertion(user)) { - return user; - } - } - - // if the token is expired or expiring in 30 seconds or less, refresh the token - if (oidcUser.refresh_token) { - const user = await this.refreshTokenAndUpdatePromise(); - if (user && typeAssertion(user)) { - return user; - } - } - - return null; - } - - public async getUserZkEvm(): Promise { - const user = await this.getUser(isUserZkEvm); - if (!user) { - throw new Error('Failed to obtain a User with the required ZkEvm attributes'); - } - - return user; - } - - public async getUserImx(): Promise { - const user = await this.getUser(isUserImx); - if (!user) { - throw new Error('Failed to obtain a User with the required IMX attributes'); - } - - return user; - } -} diff --git a/packages/passport/sdk/src/config/config.ts b/packages/passport/sdk/src/config/config.ts index 3d1cdee7b2..08290e599d 100644 --- a/packages/passport/sdk/src/config/config.ts +++ b/packages/passport/sdk/src/config/config.ts @@ -4,6 +4,7 @@ import { OidcConfiguration, PassportModuleConfiguration, PopupOverlayOptions, + PassportOverrides, } from '../types'; import { PassportError, PassportErrorType } from '../errors/passportError'; @@ -56,8 +57,12 @@ export class PassportConfiguration { readonly forceScwDeployBeforeMessageSignature: boolean; + readonly jsonRpcReferrer?: string; + readonly popupOverlayOptions: PopupOverlayOptions; + readonly overrides?: PassportOverrides; + constructor({ baseConfig, overrides, @@ -73,14 +78,17 @@ export class PassportConfiguration { ]); this.oidcConfiguration = oidcConfiguration; this.baseConfig = baseConfig; + this.overrides = overrides; this.crossSdkBridgeEnabled = crossSdkBridgeEnabled || false; this.forceScwDeployBeforeMessageSignature = forceScwDeployBeforeMessageSignature || false; + this.jsonRpcReferrer = jsonRpcReferrer; this.popupOverlayOptions = popupOverlayOptions || { disableGenericPopupOverlay: false, disableBlockedPopupOverlay: false, disableHeadlessLoginPromptOverlay: false, }; if (overrides) { + // Note: zkEvmChainId and zkEvmChainName are optional (for dev environments) validateConfiguration( overrides, [ diff --git a/packages/passport/sdk/src/confirmation/confirmation.test.ts b/packages/passport/sdk/src/confirmation/confirmation.test.ts deleted file mode 100644 index 45098f30f1..0000000000 --- a/packages/passport/sdk/src/confirmation/confirmation.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * @jest-environment jsdom - */ -import * as GeneratedClients from '@imtbl/generated-clients'; -import { Environment, ImmutableConfiguration } from '@imtbl/config'; -import ConfirmationScreen from './confirmation'; -import SpyInstance = jest.SpyInstance; -import { testConfig } from '../test/mocks'; -import { PassportConfiguration } from '../config'; -import { PASSPORT_CONFIRMATION_EVENT_TYPE, ConfirmationReceiveMessage } from './types'; - -let windowSpy: SpyInstance; -const closeMock = jest.fn(); -const postMessageMock = jest.fn(); -const mockNewWindow = { - closed: true, focus: jest.fn(), close: closeMock, location: { href: 'http' }, postMessage: postMessageMock, -}; -const mockedOpen = jest.fn().mockReturnValue(mockNewWindow); -const addEventListenerMock = jest.fn(); -const removeEventListenerMock = jest.fn(); -const mockEtherAddress = '0x1234'; - -describe('confirmation', () => { - beforeEach(() => { - windowSpy = jest.spyOn(window, 'window', 'get'); - windowSpy.mockImplementation(() => ({ - open: mockedOpen, - screen: { - availWidth: 123, - }, - addEventListener: addEventListenerMock, - removeEventListener: removeEventListenerMock, - })); - }); - - afterEach(() => { - windowSpy.mockRestore(); - }); - - const confirmationScreen = new ConfirmationScreen(testConfig); - - describe('loading', () => { - it('will loading the confirmation screen', () => { - confirmationScreen.loading(); - expect(mockedOpen).toHaveBeenCalledTimes(1); - }); - - describe('crossSdkBridgeEnabled', () => { - it('does not open the confirmation popup if the cross sdk bridge flag is enabled', () => { - const config = new PassportConfiguration({ - baseConfig: new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }), - clientId: 'client123', - logoutRedirectUri: 'http://localhost:3000/logout', - redirectUri: 'http://localhost:3000/callback', - popupRedirectUri: 'http://localhost:3000/popup-callback', - crossSdkBridgeEnabled: true, - }); - const confirmation = new ConfirmationScreen(config); - - confirmation.loading(); - expect(mockedOpen).toHaveBeenCalledTimes(0); - }); - }); - }); - - describe('closeWindow', () => { - it('should close the window', () => { - confirmationScreen.loading(); - confirmationScreen.closeWindow(); - expect(closeMock).toBeCalledTimes(1); - }); - }); - - describe('requestConfirmation', () => { - it('should handle popup window opened', async () => { - const transactionId = 'transactionId123'; - confirmationScreen.loading(); - const res = await confirmationScreen.requestConfirmation( - transactionId, - mockEtherAddress, - GeneratedClients.mr.TransactionApprovalRequestChainTypeEnum.Starkex, - ); - - expect(res.confirmed).toEqual(false); - expect(mockNewWindow.location.href).toEqual('https://passport.sandbox.immutable.com/transaction-confirmation/transaction?transactionId=transactionId123ðerAddress=0x1234&chainType=starkex'); - }); - - it('should send `confirmation_start` postMessage', async () => { - const transactionId = 'transactionId123'; - const mockedWindowReadyValue = { - origin: testConfig.passportDomain, - data: { - eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, - messageType: ConfirmationReceiveMessage.CONFIRMATION_WINDOW_READY, - }, - }; - addEventListenerMock - .mockImplementationOnce((event, callback) => { - callback(mockedWindowReadyValue); - }); - confirmationScreen.loading(); - - await confirmationScreen.requestConfirmation( - transactionId, - mockEtherAddress, - GeneratedClients.mr.TransactionApprovalRequestChainTypeEnum.Starkex, - ); - - expect(postMessageMock).toHaveBeenCalledTimes(1); - expect(postMessageMock).toHaveBeenCalledWith( - { - eventType: 'imx_passport_confirmation', - messageType: 'confirmation_start', - }, - 'https://passport.sandbox.immutable.com', - ); - }); - - describe('when the transaction is rejected', () => { - it('should resolve with confirmed: false', async () => { - const transactionId = 'transactionId123'; - addEventListenerMock - .mockImplementationOnce((event, callback) => { - callback({ - origin: testConfig.passportDomain, - data: { - eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, - messageType: ConfirmationReceiveMessage.TRANSACTION_REJECTED, - }, - }); - }); - - const res = await confirmationScreen.requestConfirmation( - transactionId, - mockEtherAddress, - GeneratedClients.mr.TransactionApprovalRequestChainTypeEnum.Starkex, - ); - - expect(res.confirmed).toEqual(false); - }); - }); - }); - - describe('requestMessageConfirmation', () => { - it('should open a window when confirmation is required', async () => { - const messageId = 'transactionId123'; - const etherAddress = 'etherAddress123'; - confirmationScreen.loading(); - - const res = await confirmationScreen.requestMessageConfirmation(messageId, etherAddress); - - expect(res.confirmed).toEqual(false); - expect(mockNewWindow.location.href).toEqual( - 'https://passport.sandbox.immutable.com/' - + `transaction-confirmation/zkevm/message?messageID=${messageId}ðerAddress=${etherAddress}`, - ); - }); - - it('should pass the message type as a query string arg when it is provided', async () => { - const messageId = 'transactionId123'; - const etherAddress = 'etherAddress123'; - const messageType = 'erc191'; - confirmationScreen.loading(); - - const res = await confirmationScreen.requestMessageConfirmation(messageId, etherAddress, messageType); - - expect(res.confirmed).toEqual(false); - expect(mockNewWindow.location.href).toEqual( - 'https://passport.sandbox.immutable.com/transaction-confirmation/zkevm/message?' - + `messageID=${messageId}ðerAddress=${etherAddress}&messageType=${messageType}`, - ); - }); - - it('should send `confirmation_start` postMessage', async () => { - const messageId = 'transactionId123'; - const etherAddress = 'etherAddress123'; - const mockedWindowReadyValue = { - origin: testConfig.passportDomain, - data: { - eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, - messageType: ConfirmationReceiveMessage.CONFIRMATION_WINDOW_READY, - }, - }; - addEventListenerMock - .mockImplementationOnce((event, callback) => { - callback(mockedWindowReadyValue); - }); - confirmationScreen.loading(); - - await confirmationScreen.requestMessageConfirmation(messageId, etherAddress); - - expect(postMessageMock).toHaveBeenCalledTimes(1); - expect(postMessageMock).toHaveBeenCalledWith( - { - eventType: 'imx_passport_confirmation', - messageType: 'confirmation_start', - }, - 'https://passport.sandbox.immutable.com', - ); - }); - - describe('when the message is rejected', () => { - it('should resolve with confirmed: false', async () => { - const transactionId = 'transactionId123'; - addEventListenerMock - .mockImplementationOnce((event, callback) => { - callback({ - origin: testConfig.passportDomain, - data: { - eventType: PASSPORT_CONFIRMATION_EVENT_TYPE, - messageType: ConfirmationReceiveMessage.MESSAGE_REJECTED, - }, - }); - }); - - const res = await confirmationScreen.requestMessageConfirmation( - transactionId, - mockEtherAddress, - ); - - expect(res.confirmed).toEqual(false); - }); - }); - }); - - describe('showServiceUnavailable', () => { - it('should reject with "Service unavailable" when the unavailable flow is triggered', async () => { - const showConfirmationScreenMock = jest.spyOn(confirmationScreen, 'showConfirmationScreen') - .mockImplementation((href, messageHandler, resolve) => { - resolve(); - }); - - const expectedHref = 'mocked-unavailable-href'; - // biome-ignore lint/suspicious/noExplicitAny: test - jest.spyOn(confirmationScreen as any, 'getHref').mockReturnValue(expectedHref); - - await expect(confirmationScreen.showServiceUnavailable()).rejects.toThrow('Service unavailable'); - - expect(showConfirmationScreenMock).toHaveBeenCalledWith( - expectedHref, - expect.any(Function), - expect.any(Function), - ); - - showConfirmationScreenMock.mockRestore(); - }); - }); -}); diff --git a/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.test.ts b/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.test.ts deleted file mode 100644 index 17ba035081..0000000000 --- a/packages/passport/sdk/src/confirmation/embeddedLoginPrompt.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -import EmbeddedLoginPrompt from './embeddedLoginPrompt'; -import EmbeddedLoginPromptOverlay from '../overlay/embeddedLoginPromptOverlay'; -import { PassportConfiguration } from '../config'; -import { - EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, - EmbeddedLoginPromptReceiveMessage, - EmbeddedLoginPromptResult, -} from './types'; -import { MarketingConsentStatus } from '../types'; - -// Mock dependencies -jest.mock('../overlay/embeddedLoginPromptOverlay'); -jest.mock('../config'); - -describe('EmbeddedLoginPrompt', () => { - let embeddedLoginPrompt: EmbeddedLoginPrompt; - let mockConfig: jest.Mocked; - let mockOverlay: jest.Mocked; - - const mockClientId = 'test-client-id'; - - beforeEach(() => { - // Clear all mocks - jest.clearAllMocks(); - - // Mock DOM methods - document.createElement = jest.fn().mockImplementation((tagName: string) => { - if (tagName === 'iframe') { - return { id: '', src: '', style: {} }; - } - return { id: '', textContent: '' }; // For style elements - }); - document.getElementById = jest.fn(); - document.head.appendChild = jest.fn(); - - // Mock config - mockConfig = { - oidcConfiguration: { - clientId: mockClientId, - }, - authenticationDomain: 'https://auth.immutable.com', - } as jest.Mocked; - - // Mock overlay - mockOverlay = EmbeddedLoginPromptOverlay as jest.Mocked; - mockOverlay.appendOverlay = jest.fn(); - mockOverlay.remove = jest.fn(); - - embeddedLoginPrompt = new EmbeddedLoginPrompt(mockConfig); - }); - - afterEach(() => { - // Clean up event listeners - window.removeEventListener = jest.fn(); - }); - - describe('constructor', () => { - it('should initialize with provided config', () => { - expect(embeddedLoginPrompt).toBeInstanceOf(EmbeddedLoginPrompt); - }); - }); - - describe('getHref', () => { - it('should generate correct href with client ID', () => { - const href = (embeddedLoginPrompt as any).getHref(); - expect(href).toBe(`https://auth.immutable.com/im-embedded-login-prompt?client_id=${mockClientId}&rid=undefined`); - }); - - it('should generate correct href with anonymous ID', () => { - const anonymousId = 'hello-world-123'; - const href = (embeddedLoginPrompt as any).getHref(anonymousId); - expect(href).toBe('https://auth.immutable.com/im-embedded-login-prompt?' - + `client_id=${mockClientId}&rid=undefined&third_party_a_id=${anonymousId}`); - }); - }); - - describe('appendIFrameStylesIfNeeded', () => { - it('should not append styles if they already exist', () => { - const mockElement = { id: 'passport-embedded-login-keyframes' }; - (document.getElementById as jest.Mock).mockReturnValue(mockElement); - - // Clear the mock call count from beforeEach - jest.clearAllMocks(); - (document.getElementById as jest.Mock).mockReturnValue(mockElement); - - (EmbeddedLoginPrompt as any).appendIFrameStylesIfNeeded(); - - expect(document.createElement).not.toHaveBeenCalled(); - expect(document.head.appendChild).not.toHaveBeenCalled(); - }); - - it('should append styles if they do not exist', () => { - const mockStyleElement = { - id: '', - textContent: '', - }; - - (document.getElementById as jest.Mock).mockReturnValue(null); - (document.createElement as jest.Mock).mockReturnValue(mockStyleElement); - - (EmbeddedLoginPrompt as any).appendIFrameStylesIfNeeded(); - - expect(document.createElement).toHaveBeenCalledWith('style'); - expect(mockStyleElement.id).toBe('passport-embedded-login-keyframes'); - }); - }); - - describe('getEmbeddedLoginIFrame', () => { - it('should create iframe with correct properties', () => { - const mockIframe = { - id: '', - src: '', - style: {}, - }; - - // Mock createElement to return different elements based on tag name - (document.createElement as jest.Mock).mockImplementation((tagName: string) => { - if (tagName === 'iframe') { - return mockIframe; - } - return { id: '', textContent: '' }; // For style elements - }); - (document.getElementById as jest.Mock).mockReturnValue(null); - - const iframe = (embeddedLoginPrompt as any).getEmbeddedLoginIFrame(); - - expect(document.createElement).toHaveBeenCalledWith('iframe'); - expect(iframe.id).toBe('passport-embedded-login-iframe'); - }); - }); - - describe('displayEmbeddedLoginPrompt', () => { - let mockIframe: any; - let mockAddEventListener: jest.Mock; - let mockRemoveEventListener: jest.Mock; - - beforeEach(() => { - mockIframe = { - id: 'passport-embedded-login-iframe', - src: '', - style: {}, - }; - - mockAddEventListener = jest.fn(); - mockRemoveEventListener = jest.fn(); - - (document.createElement as jest.Mock).mockImplementation((tagName: string) => { - if (tagName === 'iframe') { - return mockIframe; - } - return { id: '', textContent: '' }; // For style elements - }); - (document.getElementById as jest.Mock).mockReturnValue(null); - - Object.defineProperty(window, 'addEventListener', { - value: mockAddEventListener, - writable: true, - }); - Object.defineProperty(window, 'removeEventListener', { - value: mockRemoveEventListener, - writable: true, - }); - }); - - it('should resolve with email login options when email method is selected', async () => { - const mockLoginResult: EmbeddedLoginPromptResult = { - directLoginMethod: 'email', - email: 'test@example.com', - marketingConsentStatus: MarketingConsentStatus.OptedIn, - imPassportTraceId: 'test-im-passport-trace-id', - }; - - const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - // Simulate message event - const messageHandler = mockAddEventListener.mock.calls[0][1]; - const mockEvent = { - data: { - eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, - messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED, - payload: mockLoginResult, - }, - origin: mockConfig.authenticationDomain, - }; - - messageHandler(mockEvent); - - const result = await promise; - const expectedResult: EmbeddedLoginPromptResult = { - directLoginMethod: 'email', - marketingConsentStatus: MarketingConsentStatus.OptedIn, - email: 'test@example.com', - imPassportTraceId: 'test-im-passport-trace-id', - }; - - expect(result).toEqual(expectedResult); - expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); - expect(mockOverlay.remove).toHaveBeenCalled(); - }); - - it('should resolve with non-email login options when non-email method is selected', async () => { - const mockLoginResult: EmbeddedLoginPromptResult = { - directLoginMethod: 'google', - marketingConsentStatus: MarketingConsentStatus.Unsubscribed, - imPassportTraceId: 'test-im-passport-trace-id', - }; - - const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - // Simulate message event - const messageHandler = mockAddEventListener.mock.calls[0][1]; - const mockEvent = { - data: { - eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, - messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED, - payload: mockLoginResult, - }, - origin: mockConfig.authenticationDomain, - }; - - messageHandler(mockEvent); - - const result = await promise; - const expectedResult: EmbeddedLoginPromptResult = { - directLoginMethod: 'google', - marketingConsentStatus: MarketingConsentStatus.Unsubscribed, - imPassportTraceId: 'test-im-passport-trace-id', - }; - - expect(result).toEqual(expectedResult); - expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); - expect(mockOverlay.remove).toHaveBeenCalled(); - }); - - it('should reject with error when login prompt error occurs', async () => { - const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - // Simulate error message event - const messageHandler = mockAddEventListener.mock.calls[0][1]; - const mockEvent = { - data: { - eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, - messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_ERROR, - }, - origin: mockConfig.authenticationDomain, - }; - - messageHandler(mockEvent); - - await expect(promise).rejects.toThrow('Error during embedded login prompt'); - expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); - expect(mockOverlay.remove).toHaveBeenCalled(); - }); - - it('should reject with error when login prompt is closed', async () => { - const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - // Simulate close message event - const messageHandler = mockAddEventListener.mock.calls[0][1]; - const mockEvent = { - data: { - eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, - messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_PROMPT_CLOSED, - }, - origin: mockConfig.authenticationDomain, - }; - - messageHandler(mockEvent); - - await expect(promise).rejects.toThrow('Popup closed by user'); - expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); - expect(mockOverlay.remove).toHaveBeenCalled(); - }); - - it('should reject with error for unsupported message type', async () => { - const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - // Simulate unsupported message event - const messageHandler = mockAddEventListener.mock.calls[0][1]; - const mockEvent = { - data: { - eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, - messageType: 'UNKNOWN_MESSAGE_TYPE', - }, - origin: mockConfig.authenticationDomain, - }; - - messageHandler(mockEvent); - - await expect(promise).rejects.toThrow('Unsupported message type: UNKNOWN_MESSAGE_TYPE'); - expect(mockRemoveEventListener).toHaveBeenCalledWith('message', messageHandler); - expect(mockOverlay.remove).toHaveBeenCalled(); - }); - - it('should ignore messages from wrong origin', async () => { - embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - // Simulate message from wrong origin - const messageHandler = mockAddEventListener.mock.calls[0][1]; - const mockEvent = { - data: { - eventType: EMBEDDED_LOGIN_PROMPT_EVENT_TYPE, - messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED, - payload: { directLoginMethod: 'google', marketingConsentStatus: MarketingConsentStatus.OptedIn }, - }, - origin: 'https://malicious-site.com', - }; - - messageHandler(mockEvent); - - // Should not resolve or reject yet - expect(mockRemoveEventListener).not.toHaveBeenCalled(); - expect(mockOverlay.remove).not.toHaveBeenCalled(); - }); - - it('should ignore messages with wrong event type', async () => { - embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - // Simulate message with wrong event type - const messageHandler = mockAddEventListener.mock.calls[0][1]; - const mockEvent = { - data: { - eventType: 'WRONG_EVENT_TYPE', - messageType: EmbeddedLoginPromptReceiveMessage.LOGIN_METHOD_SELECTED, - payload: { directLoginMethod: 'google', marketingConsentStatus: MarketingConsentStatus.OptedIn }, - }, - origin: mockConfig.authenticationDomain, - }; - - messageHandler(mockEvent); - - // Should not resolve or reject yet - expect(mockRemoveEventListener).not.toHaveBeenCalled(); - expect(mockOverlay.remove).not.toHaveBeenCalled(); - }); - - it('should setup overlay with close callback', async () => { - const promise = embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - expect(mockOverlay.appendOverlay).toHaveBeenCalledWith( - mockIframe, - expect.any(Function), - ); - - // Test the close callback - const closeCallback = mockOverlay.appendOverlay.mock.calls[0][1]; - closeCallback(); - - await expect(promise).rejects.toThrow('Popup closed by user'); - expect(mockRemoveEventListener).toHaveBeenCalled(); - expect(mockOverlay.remove).toHaveBeenCalled(); - }); - - it('should add message event listener', () => { - embeddedLoginPrompt.displayEmbeddedLoginPrompt(); - - expect(mockAddEventListener).toHaveBeenCalledWith('message', expect.any(Function)); - }); - }); -}); diff --git a/packages/passport/sdk/src/confirmation/popup.test.ts b/packages/passport/sdk/src/confirmation/popup.test.ts deleted file mode 100644 index f9e5051576..0000000000 --- a/packages/passport/sdk/src/confirmation/popup.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * @jest-environment jsdom - */ -import { openPopupCenter, PopUpProps } from './popup'; - -describe('openPopupCenter', () => { - let windowSpy: jest.SpyInstance; - const mockPopup = { - focus: jest.fn(), - screen: {}, - }; - const mockWindow = { - screenX: 100, - screenY: 200, - outerWidth: 1200, - outerHeight: 800, - open: jest.fn().mockReturnValue(mockPopup), - } as unknown as Window; - - beforeEach(() => { - jest.clearAllMocks(); - windowSpy = jest.spyOn(global, 'window', 'get'); - windowSpy.mockImplementation(() => mockWindow); - }); - - it('should open a new window with correct dimensions', () => { - const props: PopUpProps = { - url: 'https://www.example.com', - title: 'Example Popup', - width: 800, - height: 600, - }; - - const result = openPopupCenter(props); - - expect(result).toEqual(mockPopup); - expect(mockWindow.open).toHaveBeenCalledWith( - 'https://www.example.com', - 'Example Popup', - expect.any(String), - ); - expect(mockPopup.focus).toHaveBeenCalledTimes(1); - const screenArgs = (mockWindow.open as jest.Mock).mock.calls[0][2]; - expect(screenArgs.includes('width=800')).toBeTruthy(); - expect(screenArgs.includes('height=600')).toBeTruthy(); - expect(screenArgs.includes('left=300')).toBeTruthy(); - expect(screenArgs.includes('top=300')).toBeTruthy(); - }); - - it('should throw an error if the new window fails to open', () => { - (mockWindow.open as jest.Mock).mockImplementationOnce(() => null); - - expect(() => openPopupCenter({ - url: 'https://www.example.com', - title: 'Example Popup', - width: 800, - height: 600, - })).toThrow('Failed to open confirmation screen'); - }); -}); diff --git a/packages/passport/sdk/src/errors/passportError.test.ts b/packages/passport/sdk/src/errors/passportError.test.ts index b2fb3c8ed1..f8d6912bbc 100644 --- a/packages/passport/sdk/src/errors/passportError.test.ts +++ b/packages/passport/sdk/src/errors/passportError.test.ts @@ -1,3 +1,4 @@ +import { PassportError as AuthPassportError } from '@imtbl/auth'; import { PassportError, PassportErrorType, @@ -32,4 +33,13 @@ describe('passportError', () => { ); }); }); + + it('treats errors thrown from auth as PassportError instances', () => { + const authError = new AuthPassportError( + 'test error', + PassportErrorType.AUTHENTICATION_ERROR, + ); + + expect(authError).toBeInstanceOf(PassportError); + }); }); diff --git a/packages/passport/sdk/src/errors/passportError.ts b/packages/passport/sdk/src/errors/passportError.ts index aa6670e205..d2b089b0c9 100644 --- a/packages/passport/sdk/src/errors/passportError.ts +++ b/packages/passport/sdk/src/errors/passportError.ts @@ -1,62 +1,6 @@ -import { isAxiosError } from 'axios'; -import { imx } from '@imtbl/generated-clients'; - -export enum PassportErrorType { - AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR', - INVALID_CONFIGURATION = 'INVALID_CONFIGURATION', - WALLET_CONNECTION_ERROR = 'WALLET_CONNECTION_ERROR', - NOT_LOGGED_IN_ERROR = 'NOT_LOGGED_IN_ERROR', - SILENT_LOGIN_ERROR = 'SILENT_LOGIN_ERROR', - REFRESH_TOKEN_ERROR = 'REFRESH_TOKEN_ERROR', - USER_REGISTRATION_ERROR = 'USER_REGISTRATION_ERROR', - USER_NOT_REGISTERED_ERROR = 'USER_NOT_REGISTERED_ERROR', - LOGOUT_ERROR = 'LOGOUT_ERROR', - TRANSFER_ERROR = 'TRANSFER_ERROR', - CREATE_ORDER_ERROR = 'CREATE_ORDER_ERROR', - CANCEL_ORDER_ERROR = 'CANCEL_ORDER_ERROR', - EXCHANGE_TRANSFER_ERROR = 'EXCHANGE_TRANSFER_ERROR', - CREATE_TRADE_ERROR = 'CREATE_TRADE_ERROR', - OPERATION_NOT_SUPPORTED_ERROR = 'OPERATION_NOT_SUPPORTED_ERROR', - LINK_WALLET_ALREADY_LINKED_ERROR = 'LINK_WALLET_ALREADY_LINKED_ERROR', - LINK_WALLET_MAX_WALLETS_LINKED_ERROR = 'LINK_WALLET_MAX_WALLETS_LINKED_ERROR', - LINK_WALLET_VALIDATION_ERROR = 'LINK_WALLET_VALIDATION_ERROR', - LINK_WALLET_DUPLICATE_NONCE_ERROR = 'LINK_WALLET_DUPLICATE_NONCE_ERROR', - LINK_WALLET_GENERIC_ERROR = 'LINK_WALLET_GENERIC_ERROR', - SERVICE_UNAVAILABLE_ERROR = 'SERVICE_UNAVAILABLE_ERROR', -} - -export function isAPIError(error: any): error is imx.APIError { - return 'code' in error && 'message' in error; -} - -export class PassportError extends Error { - public type: PassportErrorType; - - constructor(message: string, type: PassportErrorType) { - super(message); - this.type = type; - } -} - -export const withPassportError = async ( - fn: () => Promise, - customErrorType: PassportErrorType, -): Promise => { - try { - return await fn(); - } catch (error) { - let errorMessage: string; - - if (error instanceof PassportError && error.type === PassportErrorType.SERVICE_UNAVAILABLE_ERROR) { - throw new PassportError(error.message, error.type); - } - - if (isAxiosError(error) && error.response?.data && isAPIError(error.response.data)) { - errorMessage = error.response.data.message; - } else { - errorMessage = (error as Error).message; - } - - throw new PassportError(errorMessage, customErrorType); - } -}; +export { + PassportError, + PassportErrorType, + withPassportError, + isAPIError, +} from '@imtbl/auth'; diff --git a/packages/passport/sdk/src/guardian/index.test.ts b/packages/passport/sdk/src/guardian/index.test.ts deleted file mode 100644 index f0aecd1168..0000000000 --- a/packages/passport/sdk/src/guardian/index.test.ts +++ /dev/null @@ -1,609 +0,0 @@ -import * as GeneratedClients from '@imtbl/generated-clients'; -import { ImmutableConfiguration } from '@imtbl/config'; -import { TransactionRequest } from 'ethers'; -import { ConfirmationScreen } from '../confirmation'; -import AuthManager from '../authManager'; -import GuardianClient from './index'; -import { mockUser, mockUserImx, mockUserZkEvm } from '../test/mocks'; -import { JsonRpcError, RpcErrorCode } from '../zkEvm/JsonRpcError'; -import { PassportConfiguration } from '../config'; -import { ChainId } from '../network/chains'; -import { PassportError, PassportErrorType } from '../errors/passportError'; - -jest.mock('../confirmation/confirmation'); - -describe('Guardian', () => { - afterEach(jest.clearAllMocks); - - let mockGetTransactionByID: jest.Mock; - let mockEvaluateTransaction: jest.Mock; - let mockEvaluateMessage : jest.Mock; - let mockEvaluateErc191Message: jest.Mock; - let getUserImxMock: jest.Mock; - let getUserZkEvmMock: jest.Mock; - - const mockConfirmationScreen = new ConfirmationScreen({} as any); - - const getGuardianClient = (crossSdkBridgeEnabled: boolean = false) => { - const guardianApi = { - getTransactionByID: mockGetTransactionByID, - evaluateTransaction: mockEvaluateTransaction, - evaluateMessage: mockEvaluateMessage, - evaluateErc191Message: mockEvaluateErc191Message, - } as Partial; - return new GuardianClient({ - confirmationScreen: mockConfirmationScreen, - config: new PassportConfiguration({ - baseConfig: {} as ImmutableConfiguration, - clientId: 'client123', - logoutRedirectUri: 'http://localhost:3000/logout', - redirectUri: 'http://localhost:3000/redirect', - popupRedirectUri: 'http://localhost:3000/redirect2', - crossSdkBridgeEnabled, - }), - authManager: { - getUserImx: getUserImxMock, - getUserZkEvm: getUserZkEvmMock, - } as unknown as AuthManager, - guardianApi: guardianApi as GeneratedClients.mr.GuardianApi, - }); - }; - - beforeEach(() => { - mockGetTransactionByID = jest.fn(); - mockEvaluateTransaction = jest.fn(); - mockEvaluateMessage = jest.fn(); - mockEvaluateErc191Message = jest.fn(); - - getUserImxMock = jest.fn().mockReturnValue(mockUserImx); - getUserZkEvmMock = jest.fn().mockReturnValue(mockUserZkEvm); - }); - - describe('evaluateImxTransaction', () => { - it('should retry getting transaction details and throw an error when transaction does not exist', async () => { - mockGetTransactionByID.mockResolvedValue({ data: { id: '1234' } }); - mockEvaluateTransaction.mockResolvedValue({ data: { confirmationRequired: false } }); - - await getGuardianClient().evaluateImxTransaction({ payloadHash: 'hash' }); - - expect(mockConfirmationScreen.requestConfirmation).toBeCalledTimes(0); - expect(mockEvaluateTransaction).toBeCalledWith({ - id: 'hash', - transactionEvaluationRequest: { chainType: 'starkex' }, - }, { - headers: { Authorization: `Bearer ${mockUser.accessToken}` }, - }); - }); - - it('should not show the confirmation screen if it is not required', async () => { - mockGetTransactionByID.mockResolvedValue({ data: { id: '1234' } }); - mockEvaluateTransaction.mockResolvedValue({ data: { confirmationRequired: false } }); - - await getGuardianClient().evaluateImxTransaction({ payloadHash: 'hash' }); - - expect(mockConfirmationScreen.requestConfirmation).toBeCalledTimes(0); - }); - - it('should show the confirmation screen when some of the confirmations are required', async () => { - mockGetTransactionByID.mockResolvedValueOnce({ data: { id: '1234' } }); - mockEvaluateTransaction - .mockResolvedValueOnce({ data: { confirmationRequired: true } }); - (mockConfirmationScreen.requestConfirmation as jest.Mock).mockResolvedValueOnce({ confirmed: true }); - - await getGuardianClient().evaluateImxTransaction({ payloadHash: 'hash' }); - - expect(mockConfirmationScreen.requestConfirmation).toHaveBeenCalledWith('hash', mockUserImx.imx.ethAddress, 'starkex'); - }); - - it('should throw error if user did not confirm the transaction', async () => { - mockGetTransactionByID.mockResolvedValueOnce({ data: { id: '1234' } }); - mockEvaluateTransaction - .mockResolvedValueOnce({ data: { confirmationRequired: true } }); - (mockConfirmationScreen.requestConfirmation as jest.Mock).mockResolvedValueOnce({ confirmed: false }); - - await expect(getGuardianClient().evaluateImxTransaction({ payloadHash: 'hash' })).rejects.toThrow('Transaction rejected by user'); - }); - - it('should throw PassportError with SERVICE_UNAVAILABLE_ERROR when evaluateTransaction returns 403', async () => { - mockEvaluateTransaction.mockRejectedValueOnce({ - isAxiosError: true, - response: { - status: 403, - }, - message: 'Request failed with status code 403', - config: {}, - }); - - mockGetTransactionByID.mockResolvedValueOnce({ data: { id: '1234' } }); - - // biome-ignore lint/suspicious/noExplicitAny: test - let caughtError: any; - try { - await getGuardianClient().evaluateImxTransaction({ payloadHash: 'hash' }); - } catch (err) { - caughtError = err; - } - - expect(caughtError).toBeInstanceOf(PassportError); - expect(caughtError.type).toBe(PassportErrorType.SERVICE_UNAVAILABLE_ERROR); - expect(caughtError.message).toBe('Service unavailable'); - - expect(mockConfirmationScreen.requestConfirmation).not.toHaveBeenCalled(); - }); - - describe('crossSdkBridgeEnabled', () => { - it('throws an error if confirmation is required and the cross sdk bridge flag is enabled', async () => { - mockGetTransactionByID.mockResolvedValueOnce({ data: { id: '1234' } }); - mockEvaluateTransaction - .mockResolvedValueOnce({ data: { confirmationRequired: true } }); - - const guardianClient = getGuardianClient(true); - - await expect(guardianClient.evaluateImxTransaction({ payloadHash: 'hash' })) - .rejects - .toThrow('Transaction requires confirmation but this functionality is not supported in this environment. Please contact Immutable support if you need to enable this feature.'); - }); - }); - }); - - describe('validateEVMTransaction', () => { - it('throws an error if the request data fails to be parsed', async () => { - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '0x456', - value: '0x', - }; - - await expect( - getGuardianClient().validateEVMTransaction({ - chainId: 'epi123', - nonce: '5', - metaTransactions: [ - { - data: transactionRequest.data, - revertOnError: true, - to: mockUserZkEvm.zkEvm.ethAddress, - value: '0x00', - nonce: 5, - }, - { - revertOnError: true, - to: '0x123', - value: '0x', - nonce: 5, - }, - ], - }), - ).rejects.toThrow( - new JsonRpcError( - RpcErrorCode.PARSE_ERROR, - 'Transaction failed to parsing: Cannot convert 0x to a BigInt', - ), - ); - }); - - it('should not show the confirmation screen if it is not required', async () => { - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '0x456', - value: '0x', - }; - - mockEvaluateTransaction.mockResolvedValue({ data: { confirmationRequired: false } }); - - await getGuardianClient().validateEVMTransaction({ - chainId: 'epi123', - nonce: '5', - metaTransactions: [ - { - data: transactionRequest.data, - revertOnError: true, - to: mockUserZkEvm.zkEvm.ethAddress, - value: '0x00', - nonce: 5, - }, - ], - }); - - expect(mockConfirmationScreen.requestConfirmation).toBeCalledTimes(0); - - expect(mockEvaluateTransaction).toBeCalledWith({ - id: 'evm', - transactionEvaluationRequest: { - chainId: 'epi123', - chainType: 'evm', - transactionData: { - nonce: '5', - userAddress: mockUserZkEvm.zkEvm.ethAddress, - metaTransactions: [ - { - data: transactionRequest.data, - delegateCall: false, - gasLimit: '0', - revertOnError: true, - target: mockUserZkEvm.zkEvm.ethAddress, - value: '0', - }, - ], - }, - }, - }, { - headers: { Authorization: `Bearer ${mockUser.accessToken}` }, - }); - }); - - it('should not close confirmation window when is a background transaction', async () => { - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '0x456', - value: '0x', - }; - - mockEvaluateTransaction.mockResolvedValue({ data: { confirmationRequired: true } }); - - await getGuardianClient().validateEVMTransaction({ - chainId: 'epi123', - nonce: '5', - metaTransactions: [ - { - data: transactionRequest.data, - revertOnError: true, - to: mockUserZkEvm.zkEvm.ethAddress, - value: '0x00', - nonce: 5, - }, - ], - isBackgroundTransaction: true, - }); - - expect(mockConfirmationScreen.requestConfirmation).toBeCalledTimes(0); - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(0); - - expect(mockEvaluateTransaction).toBeCalledWith({ - id: 'evm', - transactionEvaluationRequest: { - chainId: 'epi123', - chainType: 'evm', - transactionData: { - nonce: '5', - userAddress: mockUserZkEvm.zkEvm.ethAddress, - metaTransactions: [ - { - data: transactionRequest.data, - delegateCall: false, - gasLimit: '0', - revertOnError: true, - target: mockUserZkEvm.zkEvm.ethAddress, - value: '0', - }, - ], - }, - }, - }, { - headers: { Authorization: `Bearer ${mockUser.accessToken}` }, - }); - }); - - it('should close confirmation window when is not a background transaction', async () => { - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '0x456', - value: '0x', - }; - - mockEvaluateTransaction.mockResolvedValue({ data: { confirmationRequired: true } }); - - await getGuardianClient().validateEVMTransaction({ - chainId: 'epi123', - nonce: '5', - metaTransactions: [ - { - data: transactionRequest.data, - revertOnError: true, - to: mockUserZkEvm.zkEvm.ethAddress, - value: '0x00', - nonce: 1, - }, - ], - isBackgroundTransaction: false, - }); - - expect(mockConfirmationScreen.requestConfirmation).toBeCalledTimes(0); - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(1); - - expect(mockEvaluateTransaction).toBeCalledWith({ - id: 'evm', - transactionEvaluationRequest: { - chainId: 'epi123', - chainType: 'evm', - transactionData: { - nonce: '5', - userAddress: mockUserZkEvm.zkEvm.ethAddress, - metaTransactions: [ - { - data: transactionRequest.data, - delegateCall: false, - gasLimit: '0', - revertOnError: true, - target: mockUserZkEvm.zkEvm.ethAddress, - value: '0', - }, - ], - }, - }, - }, { - headers: { Authorization: `Bearer ${mockUser.accessToken}` }, - }); - }); - - it('should close confirmation window to validate background transaction falsy default value', async () => { - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '0x456', - value: '0x', - }; - - mockEvaluateTransaction.mockResolvedValue({ data: { confirmationRequired: true } }); - - await getGuardianClient().validateEVMTransaction({ - chainId: 'epi123', - nonce: '5', - metaTransactions: [ - { - data: transactionRequest.data, - revertOnError: true, - to: mockUserZkEvm.zkEvm.ethAddress, - value: '0x00', - nonce: 1, - }, - ], - }); - - expect(mockConfirmationScreen.requestConfirmation).toBeCalledTimes(0); - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(1); - - expect(mockEvaluateTransaction).toBeCalledWith({ - id: 'evm', - transactionEvaluationRequest: { - chainId: 'epi123', - chainType: 'evm', - transactionData: { - nonce: '5', - userAddress: mockUserZkEvm.zkEvm.ethAddress, - metaTransactions: [ - { - data: transactionRequest.data, - delegateCall: false, - gasLimit: '0', - revertOnError: true, - target: mockUserZkEvm.zkEvm.ethAddress, - value: '0', - }, - ], - }, - }, - }, { - headers: { Authorization: `Bearer ${mockUser.accessToken}` }, - }); - }); - - it('should throw PassportError with SERVICE_UNAVAILABLE_ERROR when evaluateTransaction returns 403', async () => { - mockEvaluateTransaction.mockRejectedValueOnce({ - isAxiosError: true, - response: { - status: 403, - }, - message: 'Request failed with status code 403', - config: {}, - }); - - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '0x456', - value: '0x', - }; - - // biome-ignore lint/suspicious/noExplicitAny: test - let caughtError: any; - try { - await getGuardianClient().validateEVMTransaction({ - chainId: 'epi123', - nonce: '5', - metaTransactions: [ - { - data: transactionRequest.data, - revertOnError: true, - to: mockUserZkEvm.zkEvm.ethAddress, - value: '0x00', - nonce: 5, - }, - ], - }); - } catch (err) { - caughtError = err; - } - - expect(caughtError).toBeInstanceOf(PassportError); - expect(caughtError.type).toBe(PassportErrorType.SERVICE_UNAVAILABLE_ERROR); - expect(caughtError.message).toBe('Service unavailable'); - - expect(mockConfirmationScreen.requestConfirmation).toBeCalledTimes(0); - }); - - describe('crossSdkBridgeEnabled', () => { - it('throws an error if confirmation is required and the cross sdk bridge flag is enabled', async () => { - mockEvaluateTransaction.mockResolvedValue({ data: { confirmationRequired: true } }); - - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '0x456', - value: '0x', - }; - - await expect( - getGuardianClient(true).validateEVMTransaction({ - chainId: 'epi123', - nonce: '5', - metaTransactions: [ - { - data: transactionRequest.data, - revertOnError: true, - to: mockUserZkEvm.zkEvm.ethAddress, - value: '0x00', - nonce: 5, - }, - { - revertOnError: true, - to: '0x123', - value: '0x00', - nonce: 5, - }, - ], - }), - ).rejects.toThrow( - new JsonRpcError( - RpcErrorCode.TRANSACTION_REJECTED, - 'Transaction requires confirmation but this functionality is not supported in this environment. Please contact Immutable support if you need to enable this feature.', - ), - ); - }); - }); - }); - - describe('withConfirmationScreenTask', () => { - it('should call the task and close the confirmation screen if the task fails', async () => { - const mockTask = jest.fn().mockRejectedValueOnce(new Error('Task failed')); - - await expect(getGuardianClient().withConfirmationScreenTask()(mockTask)()).rejects.toThrow('Task failed'); - - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(1); - }); - - it('should call the task and return the result if the task succeeds', async () => { - const mockTask = jest.fn().mockResolvedValueOnce('result'); - const wrappedTask = getGuardianClient().withConfirmationScreenTask()(mockTask); - - await expect(wrappedTask()).resolves.toEqual('result'); - - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(0); - }); - - it('should call showServiceUnavailable and not closeWindow when task errors with SERVICE_UNAVAILABLE_ERROR', async () => { - const mockTask = jest.fn().mockRejectedValueOnce( - new PassportError('Service unavailable', PassportErrorType.SERVICE_UNAVAILABLE_ERROR), - ); - - const wrappedTask = getGuardianClient().withConfirmationScreenTask()(mockTask); - - // biome-ignore lint/suspicious/noExplicitAny: test - let caughtError: any; - try { - await wrappedTask(); - } catch (err) { - caughtError = err; - } - - expect(caughtError).toBeInstanceOf(PassportError); - expect(caughtError.type).toBe(PassportErrorType.SERVICE_UNAVAILABLE_ERROR); - expect(caughtError.message).toBe('Service unavailable'); - - expect(mockConfirmationScreen.showServiceUnavailable).toHaveBeenCalled(); - expect(mockConfirmationScreen.closeWindow).not.toHaveBeenCalled(); - }); - - describe('withConfirmationScreen', () => { - it('should call the task and close the confirmation screen if the task fails', async () => { - const mockTask = jest.fn().mockRejectedValueOnce(new Error('Task failed')); - - await expect(getGuardianClient().withConfirmationScreen()(mockTask)).rejects.toThrow('Task failed'); - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(1); - }); - - it('should call the task and return the result if the task succeeds', async () => { - const mockTask = jest.fn().mockResolvedValueOnce('result'); - const promise = getGuardianClient().withConfirmationScreen()(mockTask); - - await expect(promise).resolves.toEqual('result'); - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(0); - }); - }); - - describe('withDefaultConfirmationScreenTask', () => { - it('should call the task and close the confirmation screen if the task fails', async () => { - const mockTask = jest.fn().mockRejectedValueOnce(new Error('Task failed')); - - await expect(getGuardianClient().withDefaultConfirmationScreenTask(mockTask)()).rejects.toThrow('Task failed'); - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(1); - }); - - it('should call the task and return the result if the task succeeds', async () => { - const mockTask = jest.fn().mockResolvedValueOnce('result'); - const wrappedTask = getGuardianClient().withDefaultConfirmationScreenTask(mockTask); - - await expect(wrappedTask()).resolves.toEqual('result'); - expect(mockConfirmationScreen.closeWindow).toBeCalledTimes(0); - }); - }); - }); - - describe('evaluateEIP712Message', () => { - const mockPayload = { chainID: '0x1234', payload: {} as GeneratedClients.mr.EIP712Message, user: mockUserZkEvm }; - - it('surfaces error message if message evaluation fails', async () => { - mockEvaluateMessage.mockRejectedValueOnce(new Error('401: Unauthorized')); - - await expect(getGuardianClient().evaluateEIP712Message(mockPayload)) - .rejects.toThrow('Message failed to validate with error: 401: Unauthorized'); - }); - - it('displays confirmation screen if confirmation is required', async () => { - mockEvaluateMessage.mockResolvedValueOnce({ data: { confirmationRequired: true, messageId: 'asd123' } }); - (mockConfirmationScreen.requestMessageConfirmation as jest.Mock).mockResolvedValueOnce({ confirmed: true }); - - await getGuardianClient().evaluateEIP712Message(mockPayload); - - expect(mockConfirmationScreen.requestMessageConfirmation).toBeCalledTimes(1); - }); - - it('displays rejection error message if user rejects confirmation', async () => { - mockEvaluateMessage.mockResolvedValueOnce({ data: { confirmationRequired: true, messageId: 'asd123' } }); - (mockConfirmationScreen.requestMessageConfirmation as jest.Mock).mockResolvedValueOnce({ confirmed: false }); - - await expect(getGuardianClient().evaluateEIP712Message(mockPayload)).rejects.toEqual(new JsonRpcError(RpcErrorCode.TRANSACTION_REJECTED, 'Signature rejected by user')); - }); - }); - - describe('evaluateERC191Message', () => { - it('surfaces error message if message evaluation fails', async () => { - mockEvaluateErc191Message.mockRejectedValueOnce(new Error('401: Unauthorized')); - - await expect(getGuardianClient().evaluateERC191Message({ - chainID: BigInt(ChainId.IMTBL_ZKEVM_DEVNET), - payload: 'payload', - })) - .rejects.toThrow('Message failed to validate with error: 401: Unauthorized'); - }); - - it('displays confirmation screen if confirmation is required', async () => { - mockEvaluateErc191Message.mockResolvedValueOnce({ data: { confirmationRequired: true, messageId: 'asd123' } }); - (mockConfirmationScreen.requestMessageConfirmation as jest.Mock).mockResolvedValueOnce({ confirmed: true }); - - await getGuardianClient().evaluateERC191Message({ - chainID: BigInt(ChainId.IMTBL_ZKEVM_DEVNET), - payload: 'payload', - }); - - expect(mockConfirmationScreen.requestMessageConfirmation).toBeCalledTimes(1); - }); - - it('displays rejection error message if user rejects confirmation', async () => { - mockEvaluateErc191Message.mockResolvedValueOnce({ data: { confirmationRequired: true, messageId: 'asd123' } }); - (mockConfirmationScreen.requestMessageConfirmation as jest.Mock).mockResolvedValueOnce({ confirmed: false }); - - await expect(getGuardianClient().evaluateERC191Message({ - chainID: BigInt(ChainId.IMTBL_ZKEVM_DEVNET), - payload: 'payload', - })).rejects.toEqual(new JsonRpcError(RpcErrorCode.TRANSACTION_REJECTED, 'Signature rejected by user')); - }); - }); -}); diff --git a/packages/passport/sdk/src/index.ts b/packages/passport/sdk/src/index.ts index ac8c9c24dd..6e969a2332 100644 --- a/packages/passport/sdk/src/index.ts +++ b/packages/passport/sdk/src/index.ts @@ -1,21 +1,28 @@ export { PassportError } from './errors/passportError'; export { Passport } from './Passport'; + +// Re-export wallet types for backward compatibility export { ProviderEvent, -} from './zkEvm/types'; -export type { - RequestArguments, - JsonRpcRequestPayload, - JsonRpcResponsePayload, - JsonRpcRequestCallback, - Provider, AccountsChangedEvent, - TypedDataPayload, -} from './zkEvm/types'; -export { JsonRpcError, ProviderErrorCode, RpcErrorCode, -} from './zkEvm/JsonRpcError'; +} from '@imtbl/wallet'; +export type { + RequestArguments, + Provider, + AccountsChangedEvent, + TypedDataPayload, + EIP6963ProviderInfo, + EIP6963ProviderDetail, +} from '@imtbl/wallet'; + +// Re-export auth types +export type { + User, +} from '@imtbl/auth'; + +// Export passport-specific types export type { LinkWalletParams, LinkedWallet, @@ -27,6 +34,7 @@ export type { DeviceTokenResponse, DirectLoginOptions, DirectLoginMethod, + ZkEvmProvider, } from './types'; export { MarketingConsentStatus, diff --git a/packages/passport/sdk/src/magic/magicTEESigner.test.ts b/packages/passport/sdk/src/magic/magicTEESigner.test.ts deleted file mode 100644 index aeb08dc3ac..0000000000 --- a/packages/passport/sdk/src/magic/magicTEESigner.test.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { MagicTeeApiClients } from '@imtbl/generated-clients'; -import { trackDuration } from '@imtbl/metrics'; -import { isAxiosError } from 'axios'; -import MagicTEESigner from './magicTEESigner'; -import AuthManager from '../authManager'; -import { PassportError, PassportErrorType } from '../errors/passportError'; -import { mockUser, mockUserImx, mockUserZkEvm } from '../test/mocks'; -import { withMetricsAsync } from '../utils/metrics'; - -// Mock all dependencies -jest.mock('@imtbl/metrics'); -jest.mock('axios'); -jest.mock('../utils/metrics'); - -describe('MagicTEESigner', () => { - let magicTEESigner: MagicTEESigner; - let mockAuthManager: jest.Mocked; - let mockMagicTeeApiClient: jest.Mocked; - let mockFlow: any; - let mockCreateWalletV1WalletPost: jest.Mock; - let mockSignMessageV1WalletSignMessagePost: jest.Mock; - - const mockWalletResponse = { - data: { - public_address: mockUserZkEvm.zkEvm.userAdminAddress, - }, - }; - - const mockSignatureResponse = { - data: { - signature: '0xsignature123', - }, - }; - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock AuthManager - mockAuthManager = { - getUser: jest.fn(), - } as any; - - // Mock API methods - mockCreateWalletV1WalletPost = jest.fn(); - mockSignMessageV1WalletSignMessagePost = jest.fn(); - - // Mock MagicTeeApiClients - mockMagicTeeApiClient = { - walletApi: { - createWalletV1WalletPost: mockCreateWalletV1WalletPost, - }, - signOperationsApi: { - signMessageV1WalletSignMessagePost: mockSignMessageV1WalletSignMessagePost, - }, - } as any; - - // Mock Flow - mockFlow = { - details: { - flowName: 'testFlow', - flowId: '123', - }, - addEvent: jest.fn(), - }; - - // Mock withMetricsAsync - (withMetricsAsync as jest.Mock).mockImplementation(async (fn) => fn(mockFlow)); - - // Mock trackDuration - (trackDuration as jest.Mock).mockImplementation(() => {}); - - // Mock isAxiosError - (isAxiosError as unknown as jest.Mock).mockImplementation((error) => error && error.isAxiosError === true); - - magicTEESigner = new MagicTEESigner(mockAuthManager, mockMagicTeeApiClient); - }); - - describe('getAddress', () => { - it('should return wallet address when user is logged in', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - const address = await magicTEESigner.getAddress(); - - expect(address).toBe(mockUserZkEvm.zkEvm.userAdminAddress); - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledWith( - { - xMagicChain: 'ETH', - }, - { headers: { Authorization: `Bearer ${mockUser.idToken}` } }, - ); - }); - - it('should throw error when user is not logged in', async () => { - mockAuthManager.getUser.mockResolvedValue(null); - - await expect(magicTEESigner.getAddress()).rejects.toThrow( - new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ), - ); - }); - - it('should reuse existing wallet for same user', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - // Call getAddress twice - const address1 = await magicTEESigner.getAddress(); - const address2 = await magicTEESigner.getAddress(); - - expect(address1).toBe(mockUserZkEvm.zkEvm.userAdminAddress); - expect(address2).toBe(mockUserZkEvm.zkEvm.userAdminAddress); - // Should only call createWallet once - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledTimes(1); - }); - - it('should create new wallet when user changes', async () => { - const user1 = { ...mockUser, profile: { ...mockUser.profile, sub: 'user1' } }; - const user2 = { ...mockUser, profile: { ...mockUser.profile, sub: 'user2' } }; - - mockAuthManager.getUser - .mockResolvedValueOnce(user1) - .mockResolvedValueOnce(user1) - .mockResolvedValueOnce(user2) - .mockResolvedValueOnce(user2); - - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - // First call with user1 - await magicTEESigner.getAddress(); - - // Second call with user2 (different user) - await magicTEESigner.getAddress(); - - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledTimes(2); - }); - - it('should handle API errors gracefully', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - const apiError = { - isAxiosError: true, - response: { - status: 500, - data: { message: 'Internal server error' }, - }, - }; - (isAxiosError as unknown as jest.Mock).mockReturnValue(true); - mockCreateWalletV1WalletPost.mockRejectedValue(apiError); - - await expect(magicTEESigner.getAddress()).rejects.toThrow( - 'MagicTEE: Failed to initialise EOA with status 500: {"message":"Internal server error"}', - ); - }); - - it('should handle network errors gracefully', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - const networkError = { - isAxiosError: true, - message: 'Network Error', - }; - (isAxiosError as unknown as jest.Mock).mockReturnValue(true); - mockCreateWalletV1WalletPost.mockRejectedValue(networkError); - - await expect(magicTEESigner.getAddress()).rejects.toThrow( - 'MagicTEE: Failed to initialise EOA: Network Error', - ); - }); - - it('should handle non-axios errors gracefully', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - const genericError = new Error('Generic error'); - (isAxiosError as unknown as jest.Mock).mockReturnValue(false); - mockCreateWalletV1WalletPost.mockRejectedValue(genericError); - - await expect(magicTEESigner.getAddress()).rejects.toThrow( - 'MagicTEE: Failed to initialise EOA: Generic error', - ); - }); - - it('should handle concurrent wallet creation requests', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - // Make two concurrent calls - const [address1, address2] = await Promise.all([ - magicTEESigner.getAddress(), - magicTEESigner.getAddress(), - ]); - - expect(address1).toBe(mockUserZkEvm.zkEvm.userAdminAddress); - expect(address2).toBe(mockUserZkEvm.zkEvm.userAdminAddress); - // Should only call createWallet once even with concurrent requests - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledTimes(1); - }); - - it('should track metrics for wallet creation', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - await magicTEESigner.getAddress(); - - expect(withMetricsAsync).toHaveBeenCalledWith( - expect.any(Function), - 'magicCreateWallet', - ); - expect(trackDuration).toHaveBeenCalledWith( - 'passport', - 'testFlow', - expect.any(Number), - ); - }); - }); - - describe('signMessage', () => { - beforeEach(() => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - mockSignMessageV1WalletSignMessagePost.mockResolvedValue(mockSignatureResponse); - }); - - it('should sign string message successfully', async () => { - const message = 'Hello, world!'; - const signature = await magicTEESigner.signMessage(message); - - expect(signature).toBe('0xsignature123'); - expect(mockSignMessageV1WalletSignMessagePost).toHaveBeenCalledWith( - { - xMagicChain: 'ETH', - signMessageRequest: { - message_base64: Buffer.from(message, 'utf-8').toString('base64'), - }, - }, - { headers: { Authorization: `Bearer ${mockUser.idToken}` } }, - ); - }); - - it('should sign Uint8Array message successfully', async () => { - const message = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" - const signature = await magicTEESigner.signMessage(message); - - expect(signature).toBe('0xsignature123'); - expect(mockSignMessageV1WalletSignMessagePost).toHaveBeenCalledWith( - { - xMagicChain: 'ETH', - signMessageRequest: { - message_base64: Buffer.from(`0x${Buffer.from(message).toString('hex')}`, 'utf-8').toString('base64'), - }, - }, - { headers: { Authorization: `Bearer ${mockUser.idToken}` } }, - ); - }); - - it('should throw error when user is not logged in', async () => { - mockAuthManager.getUser.mockResolvedValue(null); - - await expect(magicTEESigner.signMessage('test')).rejects.toThrow( - new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ), - ); - }); - - it('should handle API errors gracefully', async () => { - const apiError = { - isAxiosError: true, - response: { - status: 400, - data: { message: 'Invalid signature request' }, - }, - }; - (isAxiosError as unknown as jest.Mock).mockReturnValue(true); - mockSignMessageV1WalletSignMessagePost.mockRejectedValue(apiError); - - await expect(magicTEESigner.signMessage('test')).rejects.toThrow( - 'MagicTEE: Failed to sign message using EOA with status 400: {"message":"Invalid signature request"}', - ); - }); - - it('should handle network errors gracefully', async () => { - const networkError = { - isAxiosError: true, - message: 'Network Error', - }; - (isAxiosError as unknown as jest.Mock).mockReturnValue(true); - mockSignMessageV1WalletSignMessagePost.mockRejectedValue(networkError); - - await expect(magicTEESigner.signMessage('test')).rejects.toThrow( - 'MagicTEE: Failed to sign message using EOA: Network Error', - ); - }); - - it('should handle non-axios errors gracefully', async () => { - const genericError = new Error('Generic error'); - (isAxiosError as unknown as jest.Mock).mockReturnValue(false); - mockSignMessageV1WalletSignMessagePost.mockRejectedValue(genericError); - - await expect(magicTEESigner.signMessage('test')).rejects.toThrow( - 'MagicTEE: Failed to sign message using EOA: Generic error', - ); - }); - - it('should track metrics for message signing', async () => { - await magicTEESigner.signMessage('test'); - - expect(withMetricsAsync).toHaveBeenCalledWith( - expect.any(Function), - 'magicSignMessage', - ); - expect(trackDuration).toHaveBeenCalledWith( - 'passport', - 'testFlow', - expect.any(Number), - ); - }); - - it('should ensure wallet is created before signing', async () => { - await magicTEESigner.signMessage('test'); - - // Should call both createWallet and signMessage - expect(mockCreateWalletV1WalletPost).toHaveBeenCalled(); - expect(mockSignMessageV1WalletSignMessagePost).toHaveBeenCalled(); - }); - }); - - describe('error handling in createWallet', () => { - it('should reset createWalletPromise on error', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - - const error = new Error('API Error'); - mockCreateWalletV1WalletPost - .mockRejectedValueOnce(error) - .mockResolvedValueOnce(mockWalletResponse); - - // First call should fail - await expect(magicTEESigner.getAddress()).rejects.toThrow('API Error'); - - // Second call should succeed (promise should be reset) - const address = await magicTEESigner.getAddress(); - expect(address).toBe(mockUserZkEvm.zkEvm.userAdminAddress); - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledTimes(2); - }); - }); - - describe('headers generation', () => { - it('should generate correct headers for authenticated user', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - await magicTEESigner.getAddress(); - - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledWith( - expect.any(Object), - { - headers: { - Authorization: `Bearer ${mockUser.idToken}`, - }, - }, - ); - }); - - it('should throw error when trying to generate headers for null user', async () => { - mockAuthManager.getUser.mockResolvedValue(null); - - await expect(magicTEESigner.getAddress()).rejects.toThrow( - new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ), - ); - }); - }); - - describe('wallet address validation', () => { - describe('IMX user wallet address validation', () => { - it('should throw error when IMX user wallet address does not match TEE wallet address', async () => { - const imxUserWithMismatchedAddress = { - ...mockUserImx, - imx: { - ...mockUserImx.imx, - userAdminAddress: '0xdifferentaddress123', - }, - }; - - mockAuthManager.getUser.mockResolvedValue(imxUserWithMismatchedAddress); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - await expect(magicTEESigner.getAddress()).rejects.toThrow( - new PassportError( - 'Wallet address mismatch.' - + `Rollup: IMX, TEE address: ${mockWalletResponse.data.public_address}, ` - + `profile address: ${imxUserWithMismatchedAddress.imx.userAdminAddress}`, - PassportErrorType.WALLET_CONNECTION_ERROR, - ), - ); - }); - - it('should succeed when IMX user wallet address matches TEE wallet address', async () => { - const imxUserWithMatchingAddress = { - ...mockUserImx, - imx: { - ...mockUserImx.imx, - userAdminAddress: mockWalletResponse.data.public_address, - }, - }; - - mockAuthManager.getUser.mockResolvedValue(imxUserWithMatchingAddress); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - const address = await magicTEESigner.getAddress(); - - expect(address).toBe(mockWalletResponse.data.public_address); - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledWith( - { - xMagicChain: 'ETH', - }, - { headers: { Authorization: `Bearer ${imxUserWithMatchingAddress.idToken}` } }, - ); - }); - }); - - describe('zkEVM user wallet address validation', () => { - it('should throw error when zkEVM user wallet address does not match TEE wallet address', async () => { - const zkEvmUserWithMismatchedAddress = { - ...mockUserZkEvm, - zkEvm: { - ...mockUserZkEvm.zkEvm, - userAdminAddress: '0xdifferentaddress456', - }, - }; - - mockAuthManager.getUser.mockResolvedValue(zkEvmUserWithMismatchedAddress); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - await expect(magicTEESigner.getAddress()).rejects.toThrow( - new PassportError( - 'Wallet address mismatch.' - + `Rollup: zkEVM, TEE address: ${mockWalletResponse.data.public_address}, ` - + `profile address: ${zkEvmUserWithMismatchedAddress.zkEvm.userAdminAddress}`, - PassportErrorType.WALLET_CONNECTION_ERROR, - ), - ); - }); - - it('should succeed when zkEVM user wallet address matches TEE wallet address', async () => { - const zkEvmUserWithMatchingAddress = { - ...mockUserZkEvm, - zkEvm: { - ...mockUserZkEvm.zkEvm, - userAdminAddress: mockWalletResponse.data.public_address, - }, - }; - - mockAuthManager.getUser.mockResolvedValue(zkEvmUserWithMatchingAddress); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - const address = await magicTEESigner.getAddress(); - - expect(address).toBe(mockWalletResponse.data.public_address); - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledWith( - { - xMagicChain: 'ETH', - }, - { headers: { Authorization: `Bearer ${zkEvmUserWithMatchingAddress.idToken}` } }, - ); - }); - }); - - describe('regular user (no wallet validation)', () => { - it('should succeed for regular user without IMX or zkEVM properties', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUser); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - const address = await magicTEESigner.getAddress(); - - expect(address).toBe(mockWalletResponse.data.public_address); - expect(mockCreateWalletV1WalletPost).toHaveBeenCalledWith( - { - xMagicChain: 'ETH', - }, - { headers: { Authorization: `Bearer ${mockUser.idToken}` } }, - ); - }); - }); - - describe('wallet address validation in signMessage', () => { - it('should validate wallet address before signing message for IMX user', async () => { - const imxUserWithMismatchedAddress = { - ...mockUserImx, - imx: { - ...mockUserImx.imx, - userAdminAddress: '0xdifferentaddress123', - }, - }; - - mockAuthManager.getUser.mockResolvedValue(imxUserWithMismatchedAddress); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - await expect(magicTEESigner.signMessage('test message')).rejects.toThrow( - new PassportError( - 'Wallet address mismatch.' - + `Rollup: IMX, TEE address: ${mockWalletResponse.data.public_address}, ` - + `profile address: ${imxUserWithMismatchedAddress.imx.userAdminAddress}`, - PassportErrorType.WALLET_CONNECTION_ERROR, - ), - ); - }); - - it('should validate wallet address before signing message for zkEVM user', async () => { - const zkEvmUserWithMismatchedAddress = { - ...mockUserZkEvm, - zkEvm: { - ...mockUserZkEvm.zkEvm, - userAdminAddress: '0xdifferentaddress456', - }, - }; - - mockAuthManager.getUser.mockResolvedValue(zkEvmUserWithMismatchedAddress); - mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - - await expect(magicTEESigner.signMessage('test message')).rejects.toThrow( - new PassportError( - 'Wallet address mismatch.' - + `Rollup: zkEVM, TEE address: ${mockWalletResponse.data.public_address}, ` - + `profile address: ${zkEvmUserWithMismatchedAddress.zkEvm.userAdminAddress}`, - PassportErrorType.WALLET_CONNECTION_ERROR, - ), - ); - }); - }); - }); -}); diff --git a/packages/passport/sdk/src/mocks/zkEvm/msw.ts b/packages/passport/sdk/src/mocks/zkEvm/msw.ts deleted file mode 100644 index 6c3f92773e..0000000000 --- a/packages/passport/sdk/src/mocks/zkEvm/msw.ts +++ /dev/null @@ -1,197 +0,0 @@ -import 'cross-fetch/polyfill'; -import { MockedRequest, RequestHandler, rest } from 'msw'; -import { SetupServer, setupServer } from 'msw/node'; -import { ChainName } from '../../network/chains'; -import { RelayerTransactionRequest } from '../../zkEvm/relayerClient'; -import { JsonRpcRequestPayload } from '../../zkEvm/types'; -import { chainId, chainIdHex, mockUserZkEvm } from '../../test/mocks'; - -export const relayerId = '0x745'; -export const transactionHash = '0x867'; - -const mandatoryHandlers = [ - rest.get('https://api.sandbox.immutable.com/v1/sdk/session-activity/check', async (req, res, ctx) => res(ctx.status(404))), - rest.post('https://api.immutable.com/v1/sdk/metrics', async (req, res, ctx) => res(ctx.status(200))), - rest.post('https://rpc.testnet.immutable.com', async (req, res, ctx) => { - const body = await req.json(); - switch (body.method) { - case 'eth_chainId': { - return res( - ctx.json({ - id: body.id, - jsonrpc: '2.0', - result: chainIdHex, - }), - ); - } - default: { - return undefined; - } - } - }), - rest.post('https://tee.express.magiclabs.com/v1/wallet', (req, res, ctx) => res( - ctx.status(201), - ctx.json({ - public_address: mockUserZkEvm.zkEvm.userAdminAddress, - }), - )), - rest.post('https://tee.express.magiclabs.com/v1/wallet/sign/message', (req, res, ctx) => res( - ctx.status(200), - ctx.json({ - signature: '0x6b168cf5d90189eaa51d02ff3fa8ffc8956b1ea20fdd34280f521b1acca092305b9ace24e643fe64a30c528323065f5b77e1fb4045bd330aad01e7b9a07591f91b', - }), - )), -]; - -const chainName = `${encodeURIComponent(ChainName.IMTBL_ZKEVM_TESTNET)}`; -export const mswHandlers = { - magicTEE: { - createWallet: { - success: rest.post('https://tee.express.magiclabs.com/v1/wallet', (req, res, ctx) => res( - ctx.status(201), - ctx.json({ - public_address: mockUserZkEvm.zkEvm.userAdminAddress, - }), - )), - internalServerError: rest.post('https://tee.express.magiclabs.com/v1/wallet', (req, res, ctx) => res(ctx.status(500))), - }, - personalSign: { - success: rest.post('https://tee.express.magiclabs.com/v1/wallet/sign/message', (req, res, ctx) => res( - ctx.status(200), - ctx.json({ - signature: '0x6b168cf5d90189eaa51d02ff3fa8ffc8956b1ea20fdd34280f521b1acca092305b9ace24e643fe64a30c528323065f5b77e1fb4045bd330aad01e7b9a07591f91b', - }), - )), - internalServerError: rest.post('https://tee.express.magiclabs.com/v1/wallet/sign/message', (req, res, ctx) => res(ctx.status(500))), - }, - }, - counterfactualAddress: { - success: rest.post( - `https://api.sandbox.immutable.com/v2/chains/${chainName}/passport/counterfactual-address`, - (req, res, ctx) => res( - ctx.status(201), - ctx.json({ - counterfactual_address: mockUserZkEvm.zkEvm.ethAddress, - }), - ), - ), - internalServerError: rest.post( - `https://api.sandbox.immutable.com/v2/chains/${chainName}/passport/counterfactual-address`, - (req, res, ctx) => res(ctx.status(500)), - ), - }, - rpcProvider: { - success: rest.post('https://rpc.testnet.immutable.com', async (req, res, ctx) => { - const body = await req.json(); - switch (body.method) { - case 'eth_call': { - return res( - ctx.json({ - id: body.id, - jsonrpc: '2.0', - result: '0x00000000000000000000000000000000000000000000000000000000000000b9', - }), - ); - } - default: { - return undefined; - } - } - }), - }, - relayer: { - success: rest.post('https://api.sandbox.immutable.com/relayer-mr/v1/transactions', async (req, res, ctx) => { - const body = await req.json(); - switch (body.method) { - case 'eth_sendTransaction': { - return res( - ctx.json({ - id: 1, - jsonrpc: '2.0', - result: relayerId, - }), - ); - } - case 'im_getTransactionByHash': { - return res( - ctx.json({ - id: 1, - jsonrpc: '2.0', - result: { - status: 'SUBMITTED', - chainId, - relayerId, - hash: transactionHash, - }, - }), - ); - } - case 'im_getFeeOptions': { - return res( - ctx.json({ - id: 1, - jsonrpc: '2.0', - result: [ - { - tokenPrice: '0x1dfd14000', - tokenSymbol: 'IMX', - tokenDecimals: 18, - tokenAddress: '0x123', - recipientAddress: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', - }, - ], - }), - ); - } - default: { - return undefined; - } - } - }), - }, - guardian: { - evaluateTransaction: { - success: rest.post('https://api.sandbox.immutable.com/guardian/v1/transactions/evm/evaluate', (req, res, ctx) => res(ctx.status(200))), - }, - }, - api: { - chains: { - success: rest.get('https://api.sandbox.immutable.com/v1/chains', async (req, res, ctx) => res(ctx.json({ - result: [ - { - id: 'eip155:13473', - name: 'Immutable zkEVM Test', - rpc_url: 'https://rpc.testnet.immutable.com', - }, - ], - }))), - }, - }, -}; - -let mswWorker: SetupServer; -const getMswWorker = (): SetupServer => { - if (!mswWorker) { - mswWorker = setupServer(); - mswWorker.listen({ - onUnhandledRequest: (request: MockedRequest, print: { error: () => void }) => { - // eslint-disable-next-line no-console - console.error('Unexpected request', request.url.href, request.text()); - print.error(); - }, - }); - } - return mswWorker; -}; - -export const resetMswHandlers = () => { - getMswWorker().resetHandlers(...mandatoryHandlers); -}; - -export const useMswHandlers = (handlers: RequestHandler[]) => { - getMswWorker().use(...mandatoryHandlers, ...handlers); -}; - -export const closeMswWorker = () => { - getMswWorker().close(); -}; diff --git a/packages/passport/sdk/src/network/retry.test.ts b/packages/passport/sdk/src/network/retry.test.ts deleted file mode 100644 index b0e624bf5f..0000000000 --- a/packages/passport/sdk/src/network/retry.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { MAX_RETRIES, RetryOption, retryWithDelay } from './retry'; - -describe('retryWithDelay', () => { - it('retryWithDelay should retry with default 5 times', async () => { - const mockFunc = jest.fn().mockRejectedValue('error'); - - await expect(retryWithDelay(mockFunc)).rejects.toThrow('Retry failed'); - - expect(mockFunc).toHaveBeenCalledTimes(MAX_RETRIES + 1); - }, 15000); - - it('retryWithDelay should not retry when function call resolved', async () => { - const mockFunc = jest.fn().mockReturnValue('success'); - - await retryWithDelay(mockFunc); - - expect(mockFunc).toHaveBeenCalledTimes(1); - }); - - it('finallyFn should be called if retry reached the max time', async () => { - const mockFunc = jest.fn().mockRejectedValue('error'); - const mockFinallyFn = jest.fn(); - await expect(retryWithDelay(mockFunc, { finallyFn: mockFinallyFn })).rejects.toThrow('Retry failed'); - - expect(mockFunc).toHaveBeenCalledTimes(MAX_RETRIES + 1); - expect(mockFinallyFn).toBeCalledTimes(1); - }); - - it('retryWithDelay should retry with custom option', async () => { - const mockFunc = jest.fn().mockRejectedValue('error'); - const option: RetryOption = { - retries: 2, - interval: 100, - finalErr: new Error('custom'), - }; - - await expect(retryWithDelay(mockFunc, option)).rejects.toThrow('custom'); - - expect(mockFunc).toHaveBeenCalledTimes(3); - }); -}); diff --git a/packages/passport/sdk/src/overlay/confirmationOverlay.test.ts b/packages/passport/sdk/src/overlay/confirmationOverlay.test.ts deleted file mode 100644 index 76a9cb5890..0000000000 --- a/packages/passport/sdk/src/overlay/confirmationOverlay.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import ConfirmationOverlay from './confirmationOverlay'; - -describe('confirmationOverlay', () => { - beforeEach(() => { - document.body.innerHTML = ''; - }); - - it('should append generic overlay', () => { - const overlay = new ConfirmationOverlay({}, false); - overlay.append(() => {}, () => {}); - expect(document.body.innerHTML).toContain('passport-overlay'); - }); - - it('should append blocked overlay', () => { - const overlay = new ConfirmationOverlay({}, true); - overlay.append(() => {}, () => {}); - expect(document.body.innerHTML).toContain('passport-overlay'); - }); - - it('should not append generic overlay when generic disabled', () => { - const overlay = new ConfirmationOverlay({ disableGenericPopupOverlay: true }, false); - overlay.append(() => {}, () => {}); - expect(document.body.innerHTML).not.toContain('passport-overlay'); - }); - - it('should append overlay if only generic disabled and is blocked', () => { - const overlay = new ConfirmationOverlay({ disableGenericPopupOverlay: true }, true); - overlay.append(() => {}, () => {}); - expect(document.body.innerHTML).toContain('passport-overlay'); - }); - - it('should not append blocked overlay when blocked disabled', () => { - const overlay = new ConfirmationOverlay({ disableBlockedPopupOverlay: true }, true); - overlay.append(() => {}, () => {}); - expect(document.body.innerHTML).not.toContain('passport-overlay'); - }); - - it('should append generic overlay when only blocked disabled', () => { - const overlay = new ConfirmationOverlay({ disableBlockedPopupOverlay: true }, false); - overlay.append(() => {}, () => {}); - expect(document.body.innerHTML).toContain('passport-overlay'); - }); - - it('should not append generic overlay when overlays disabled', () => { - const overlay = new ConfirmationOverlay({ - disableGenericPopupOverlay: true, - disableBlockedPopupOverlay: true, - }, false); - overlay.append(() => {}, () => {}); - expect(document.body.innerHTML).not.toContain('passport-overlay'); - }); - - it('should not append blocked overlay when overlays disabled', () => { - const overlay = new ConfirmationOverlay({ - disableGenericPopupOverlay: true, - disableBlockedPopupOverlay: true, - }, true); - overlay.append(() => {}, () => {}); - expect(document.body.innerHTML).not.toContain('passport-overlay'); - }); -}); diff --git a/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.test.ts b/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.test.ts deleted file mode 100644 index 88bc9bb114..0000000000 --- a/packages/passport/sdk/src/overlay/embeddedLoginPromptOverlay.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import EmbeddedLoginPromptOverlay from './embeddedLoginPromptOverlay'; -import { PASSPORT_OVERLAY_CONTENTS_ID } from './constants'; -import { getEmbeddedLoginPromptOverlay } from './elements'; - -// Mock dependencies -jest.mock('./elements'); - -describe('EmbeddedLoginPromptOverlay', () => { - let mockOverlayHTML: string; - let mockOverlayDiv: HTMLDivElement; - let mockOverlayContents: HTMLDivElement; - let mockIframe: HTMLIFrameElement; - let mockCloseCallback: jest.Mock; - - beforeEach(() => { - // Clear all mocks - jest.clearAllMocks(); - - // Reset static properties - (EmbeddedLoginPromptOverlay as any).overlay = undefined; - (EmbeddedLoginPromptOverlay as any).onCloseListener = undefined; - (EmbeddedLoginPromptOverlay as any).closeButton = undefined; - - // Mock HTML elements - mockOverlayHTML = '
Test overlay content
'; - mockOverlayDiv = { - innerHTML: '', - addEventListener: jest.fn(), - remove: jest.fn(), - } as unknown as HTMLDivElement; - - mockOverlayContents = { - appendChild: jest.fn(), - } as unknown as HTMLDivElement; - - mockIframe = { - id: 'test-iframe', - } as HTMLIFrameElement; - - mockCloseCallback = jest.fn(); - - // Mock DOM methods - document.createElement = jest.fn().mockReturnValue(mockOverlayDiv); - document.body.insertAdjacentElement = jest.fn(); - document.querySelector = jest.fn().mockReturnValue(mockOverlayContents); - - // Mock the getEmbeddedLoginPromptOverlay function - (getEmbeddedLoginPromptOverlay as jest.Mock).mockReturnValue(mockOverlayHTML); - }); - - describe('remove', () => { - it('should remove event listener and overlay when they exist', () => { - const mockCloseButton = { - removeEventListener: jest.fn(), - } as unknown as HTMLButtonElement; - - // Set up the static properties as if overlay was created - (EmbeddedLoginPromptOverlay as any).overlay = mockOverlayDiv; - (EmbeddedLoginPromptOverlay as any).closeButton = mockCloseButton; - (EmbeddedLoginPromptOverlay as any).onCloseListener = mockCloseCallback; - - EmbeddedLoginPromptOverlay.remove(); - - expect(mockCloseButton.removeEventListener).toHaveBeenCalledWith('click', mockCloseCallback); - expect(mockOverlayDiv.remove).toHaveBeenCalled(); - expect((EmbeddedLoginPromptOverlay as any).overlay).toBeUndefined(); - expect((EmbeddedLoginPromptOverlay as any).closeButton).toBeUndefined(); - expect((EmbeddedLoginPromptOverlay as any).onCloseListener).toBeUndefined(); - }); - - it('should handle removal when overlay does not exist', () => { - // Ensure overlay is undefined - (EmbeddedLoginPromptOverlay as any).overlay = undefined; - (EmbeddedLoginPromptOverlay as any).closeButton = undefined; - (EmbeddedLoginPromptOverlay as any).onCloseListener = undefined; - - // Should not throw an error - expect(() => EmbeddedLoginPromptOverlay.remove()).not.toThrow(); - }); - - it('should handle removal when close button does not exist but overlay does', () => { - (EmbeddedLoginPromptOverlay as any).overlay = mockOverlayDiv; - (EmbeddedLoginPromptOverlay as any).closeButton = undefined; - (EmbeddedLoginPromptOverlay as any).onCloseListener = mockCloseCallback; - - EmbeddedLoginPromptOverlay.remove(); - - expect(mockOverlayDiv.remove).toHaveBeenCalled(); - expect((EmbeddedLoginPromptOverlay as any).overlay).toBeUndefined(); - expect((EmbeddedLoginPromptOverlay as any).closeButton).toBeUndefined(); - expect((EmbeddedLoginPromptOverlay as any).onCloseListener).toBeUndefined(); - }); - - it('should handle removal when close listener does not exist', () => { - const mockCloseButton = { - removeEventListener: jest.fn(), - } as unknown as HTMLButtonElement; - - (EmbeddedLoginPromptOverlay as any).overlay = mockOverlayDiv; - (EmbeddedLoginPromptOverlay as any).closeButton = mockCloseButton; - (EmbeddedLoginPromptOverlay as any).onCloseListener = undefined; - - EmbeddedLoginPromptOverlay.remove(); - - expect(mockCloseButton.removeEventListener).not.toHaveBeenCalled(); - expect(mockOverlayDiv.remove).toHaveBeenCalled(); - }); - }); - - describe('appendOverlay', () => { - it('should create and append overlay when it does not exist', () => { - EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); - - expect(document.createElement).toHaveBeenCalledWith('div'); - expect(mockOverlayDiv.innerHTML).toBe(mockOverlayHTML); - expect(document.body.insertAdjacentElement).toHaveBeenCalledWith('beforeend', mockOverlayDiv); - expect(document.querySelector).toHaveBeenCalledWith(`#${PASSPORT_OVERLAY_CONTENTS_ID}`); - expect(mockOverlayContents.appendChild).toHaveBeenCalledWith(mockIframe); - expect(mockOverlayDiv.addEventListener).toHaveBeenCalledWith('click', mockCloseCallback); - expect((EmbeddedLoginPromptOverlay as any).overlay).toBe(mockOverlayDiv); - }); - - it('should not create overlay if it already exists', () => { - // Set overlay as already existing - (EmbeddedLoginPromptOverlay as any).overlay = mockOverlayDiv; - - EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); - - expect(document.createElement).not.toHaveBeenCalled(); - expect(document.body.insertAdjacentElement).not.toHaveBeenCalled(); - expect(document.querySelector).not.toHaveBeenCalled(); - }); - - it('should handle case when overlay contents element is not found', () => { - (document.querySelector as jest.Mock).mockReturnValue(null); - - EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); - - expect(document.createElement).toHaveBeenCalledWith('div'); - expect(mockOverlayDiv.innerHTML).toBe(mockOverlayHTML); - expect(document.body.insertAdjacentElement).toHaveBeenCalledWith('beforeend', mockOverlayDiv); - expect(document.querySelector).toHaveBeenCalledWith(`#${PASSPORT_OVERLAY_CONTENTS_ID}`); - // Should not try to append iframe if contents element not found - expect(mockOverlayContents.appendChild).not.toHaveBeenCalled(); - expect(mockOverlayDiv.addEventListener).toHaveBeenCalledWith('click', mockCloseCallback); - expect((EmbeddedLoginPromptOverlay as any).overlay).toBe(mockOverlayDiv); - }); - - it('should use getEmbeddedLoginPromptOverlay for overlay HTML', () => { - EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); - - expect(getEmbeddedLoginPromptOverlay).toHaveBeenCalled(); - expect(mockOverlayDiv.innerHTML).toBe(mockOverlayHTML); - }); - - it('should add click event listener to overlay', () => { - EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); - - expect(mockOverlayDiv.addEventListener).toHaveBeenCalledWith('click', mockCloseCallback); - }); - }); - - describe('static properties', () => { - it('should initialize static properties as undefined', () => { - expect((EmbeddedLoginPromptOverlay as any).overlay).toBeUndefined(); - expect((EmbeddedLoginPromptOverlay as any).onCloseListener).toBeUndefined(); - expect((EmbeddedLoginPromptOverlay as any).closeButton).toBeUndefined(); - }); - - it('should maintain overlay reference after creation', () => { - EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); - - expect((EmbeddedLoginPromptOverlay as any).overlay).toBe(mockOverlayDiv); - }); - }); - - describe('integration scenarios', () => { - it('should handle complete lifecycle: create, use, remove', () => { - // Create overlay - EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); - - expect((EmbeddedLoginPromptOverlay as any).overlay).toBe(mockOverlayDiv); - expect(mockOverlayContents.appendChild).toHaveBeenCalledWith(mockIframe); - expect(mockOverlayDiv.addEventListener).toHaveBeenCalledWith('click', mockCloseCallback); - - // Remove overlay - EmbeddedLoginPromptOverlay.remove(); - - expect(mockOverlayDiv.remove).toHaveBeenCalled(); - expect((EmbeddedLoginPromptOverlay as any).overlay).toBeUndefined(); - }); - - it('should handle multiple append calls without creating multiple overlays', () => { - const secondIframe = { id: 'second-iframe' } as HTMLIFrameElement; - const secondCallback = jest.fn(); - - // First append - EmbeddedLoginPromptOverlay.appendOverlay(mockIframe, mockCloseCallback); - - // Reset mocks to track second call - jest.clearAllMocks(); - - // Second append - EmbeddedLoginPromptOverlay.appendOverlay(secondIframe, secondCallback); - - // Should not create new overlay - expect(document.createElement).not.toHaveBeenCalled(); - expect(document.body.insertAdjacentElement).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/passport/sdk/src/starkEx/imxGuardianClient.ts b/packages/passport/sdk/src/starkEx/imxGuardianClient.ts new file mode 100644 index 0000000000..1d35a38828 --- /dev/null +++ b/packages/passport/sdk/src/starkEx/imxGuardianClient.ts @@ -0,0 +1,106 @@ +import axios from 'axios'; +import { Auth } from '@imtbl/auth'; +import { mr as MultiRollup } from '@imtbl/generated-clients'; +import { ConfirmationScreen, retryWithDelay } from '@imtbl/wallet'; +import { PassportError, PassportErrorType } from '../errors/passportError'; +import { toUserImx } from '../utils/imxUser'; + +const transactionRejectedCrossSdkBridgeError = 'Transaction requires confirmation but this functionality is not' + + ' supported in this environment. Please contact Immutable support if you need to enable this feature.'; + +type ImxGuardianClientParams = { + auth: Auth; + guardianApi: MultiRollup.GuardianApi; + confirmationScreen: ConfirmationScreen; + crossSdkBridgeEnabled?: boolean; +}; + +export class ImxGuardianClient { + private readonly auth: Auth; + + private readonly guardianApi: MultiRollup.GuardianApi; + + private readonly confirmationScreen: ConfirmationScreen; + + private readonly crossSdkBridgeEnabled: boolean; + + constructor({ + auth, + guardianApi, + confirmationScreen, + crossSdkBridgeEnabled = false, + }: ImxGuardianClientParams) { + this.auth = auth; + this.guardianApi = guardianApi; + this.confirmationScreen = confirmationScreen; + this.crossSdkBridgeEnabled = crossSdkBridgeEnabled; + } + + public async evaluateTransaction(payloadHash: string): Promise { + const user = await this.auth.getUser(); + if (!user) { + throw new PassportError('User has been logged out', PassportErrorType.NOT_LOGGED_IN_ERROR); + } + + const imxUser = toUserImx(user); + const headers = { Authorization: `Bearer ${imxUser.accessToken}` }; + + try { + const finallyFn = () => { + this.confirmationScreen.closeWindow(); + }; + + const transactionRes = await retryWithDelay( + async () => this.guardianApi.getTransactionByID({ + transactionID: payloadHash, + chainType: 'starkex', + }, { headers }), + { finallyFn }, + ); + + if (!transactionRes.data.id) { + throw new PassportError( + 'Transaction does not exist', + PassportErrorType.TRANSFER_ERROR, + ); + } + + const evaluationResponse = await this.guardianApi.evaluateTransaction({ + id: payloadHash, + transactionEvaluationRequest: { + chainType: 'starkex', + }, + }, { headers }); + + const { confirmationRequired } = evaluationResponse.data; + if (confirmationRequired) { + if (this.crossSdkBridgeEnabled) { + throw new PassportError( + transactionRejectedCrossSdkBridgeError, + PassportErrorType.TRANSACTION_REJECTED, + ); + } + + const confirmationResult = await this.confirmationScreen.requestConfirmation( + payloadHash, + imxUser.imx.ethAddress, + MultiRollup.TransactionApprovalRequestChainTypeEnum.Starkex, + ); + + if (!confirmationResult.confirmed) { + throw new PassportError( + 'Transaction rejected by user', + PassportErrorType.TRANSACTION_REJECTED, + ); + } + } else { + this.confirmationScreen.closeWindow(); + } + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 403) { + throw new PassportError('Service unavailable', PassportErrorType.SERVICE_UNAVAILABLE_ERROR); + } + throw error; + } + } +} diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts deleted file mode 100644 index e5fb70d0cd..0000000000 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { Environment, ImmutableConfiguration } from '@imtbl/config'; -import { - imx, - ImxApiClients, -} from '@imtbl/generated-clients'; -import { - IMXClient, - NftTransferDetails, - StarkSigner, - UnsignedExchangeTransferRequest, - UnsignedOrderRequest, - UnsignedTransferRequest, -} from '@imtbl/x-client'; -import { trackError, trackFlow } from '@imtbl/metrics'; -import registerPassportStarkEx from './workflows/registration'; -import { mockUser, mockUserImx } from '../test/mocks'; -import { PassportError, PassportErrorType } from '../errors/passportError'; -import { PassportImxProvider } from './passportImxProvider'; -import { - batchNftTransfer, cancelOrder, createOrder, createTrade, exchangeTransfer, transfer, -} from './workflows'; -import { PassportEventMap, PassportEvents } from '../types'; -import TypedEventEmitter from '../utils/typedEventEmitter'; -import AuthManager from '../authManager'; -import MagicTEESigner from '../magic/magicTEESigner'; -import { getStarkSigner } from './getStarkSigner'; -import GuardianClient from '../guardian'; - -jest.mock('ethers', () => ({ - ...jest.requireActual('ethers'), - BrowserProvider: jest.fn(), -})); -jest.mock('./workflows'); -jest.mock('./workflows/registration'); -jest.mock('./getStarkSigner'); -jest.mock('@imtbl/generated-clients'); -jest.mock('@imtbl/x-client'); -jest.mock('@imtbl/metrics'); - -describe('PassportImxProvider', () => { - afterEach(jest.resetAllMocks); - - let passportImxProvider: PassportImxProvider; - - const immutableXClient = new IMXClient({ - baseConfig: new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }), - }); - - const mockAuthManager = { - login: jest.fn(), - getUser: jest.fn(), - forceUserRefresh: jest.fn(), - }; - - const mockStarkSigner = { - signMessage: jest.fn(), - getAddress: jest.fn(), - getYCoordinate: jest.fn(), - } as StarkSigner; - - const mockMagicTEESigner = { - getAddress: jest.fn(), - signMessage: jest.fn(), - }; - - const mockGuardianClient = { - withDefaultConfirmationScreenTask: (task: () => any) => task, - withConfirmationScreenTask: () => (task: () => any) => task, - }; - - let passportEventEmitter: TypedEventEmitter; - - const imxApiClients = new ImxApiClients({} as any); - - beforeEach(() => { - jest.restoreAllMocks(); - (registerPassportStarkEx as jest.Mock).mockResolvedValue(null); - passportEventEmitter = new TypedEventEmitter(); - mockAuthManager.getUser.mockResolvedValue(mockUserImx); - - // Metrics - (trackFlow as unknown as jest.Mock).mockImplementation(() => ({ - addEvent: jest.fn(), - details: { - flowId: '123', - }, - })); - - // Signers - (getStarkSigner as jest.Mock).mockResolvedValue(mockStarkSigner); - - passportImxProvider = new PassportImxProvider({ - authManager: mockAuthManager as unknown as AuthManager, - magicTEESigner: mockMagicTEESigner as unknown as MagicTEESigner, - guardianClient: mockGuardianClient as unknown as GuardianClient, - immutableXClient, - passportEventEmitter, - imxApiClients, - }); - }); - - describe('async signer initialisation', () => { - it('initialises the eth and stark signers correctly', async () => { - // The promise is created in the constructor but not awaited until a method is called - await passportImxProvider.getAddress(); - - expect(getStarkSigner).toHaveBeenCalledWith(mockMagicTEESigner); - }); - - it('initialises the eth and stark signers only once', async () => { - await passportImxProvider.getAddress(); - await passportImxProvider.getAddress(); - await passportImxProvider.getAddress(); - - expect(getStarkSigner).toHaveBeenCalledTimes(1); - }); - - it('re-throws the initialisation error when a method is called', async () => { - mockAuthManager.getUser.mockResolvedValue(mockUserImx); - // Signers - (getStarkSigner as jest.Mock).mockRejectedValue(new Error('error')); - - // Metrics - (trackFlow as unknown as jest.Mock).mockImplementation(() => ({ - addEvent: jest.fn(), - details: { - flowId: '123', - }, - })); - - const pp = new PassportImxProvider({ - authManager: mockAuthManager as unknown as AuthManager, - magicTEESigner: mockMagicTEESigner as unknown as MagicTEESigner, - guardianClient: mockGuardianClient as unknown as GuardianClient, - immutableXClient, - passportEventEmitter: new TypedEventEmitter(), - imxApiClients: new ImxApiClients({} as any), - }); - - await expect(pp.registerOffchain()).rejects.toThrow(new Error('error')); - }); - }); - - describe('transfer', () => { - it('calls transfer workflow', async () => { - const withDefaultConfirmationSpy = jest.spyOn(mockGuardianClient, 'withDefaultConfirmationScreenTask'); - const returnValue = {} as imx.CreateTransferResponseV1; - const request = {} as UnsignedTransferRequest; - - (transfer as jest.Mock).mockResolvedValue(returnValue); - const result = await passportImxProvider.transfer(request); - - expect(withDefaultConfirmationSpy).toBeCalled(); - expect(transfer as jest.Mock) - .toHaveBeenCalledWith({ - request, - user: mockUserImx, - starkSigner: mockStarkSigner, - transfersApi: immutableXClient.transfersApi, - guardianClient: mockGuardianClient, - }); - expect(result) - .toEqual(returnValue); - }); - }); - - describe('isRegisteredOffchain', () => { - it('should return true when a user is registered', async () => { - const isRegistered = await passportImxProvider.isRegisteredOffchain(); - expect(isRegistered).toEqual(true); - }); - - it('should return false when a user is not registered', async () => { - mockAuthManager.getUser.mockResolvedValue({}); - const isRegistered = await passportImxProvider.isRegisteredOffchain(); - expect(isRegistered).toEqual(false); - }); - - it('should bubble up the error if user is not logged in', async () => { - mockAuthManager.getUser.mockResolvedValue(undefined); - - await expect(passportImxProvider.isRegisteredOffchain()).rejects.toThrow(new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - )); - }); - }); - - describe('createOrder', () => { - it('calls createOrder workflow', async () => { - const withDefaultConfirmationScreenSpy = jest.spyOn(mockGuardianClient, 'withDefaultConfirmationScreenTask'); - const returnValue = {} as imx.CreateOrderResponse; - const request = {} as UnsignedOrderRequest; - - (createOrder as jest.Mock).mockResolvedValue(returnValue); - const result = await passportImxProvider.createOrder(request); - expect(withDefaultConfirmationScreenSpy).toBeCalled(); - - expect(createOrder) - .toHaveBeenCalledWith({ - request, - user: mockUserImx, - starkSigner: mockStarkSigner, - ordersApi: immutableXClient.ordersApi, - guardianClient: mockGuardianClient, - }); - expect(result) - .toEqual(returnValue); - }); - }); - - describe('cancelOrder', () => { - it('calls cancelOrder workflow', async () => { - const withDefaultConfirmationScreenSpy = jest.spyOn(mockGuardianClient, 'withDefaultConfirmationScreenTask'); - const returnValue = {} as imx.CancelOrderResponse; - const request = {} as imx.GetSignableCancelOrderRequest; - - (cancelOrder as jest.Mock).mockResolvedValue(returnValue); - const result = await passportImxProvider.cancelOrder(request); - - expect(withDefaultConfirmationScreenSpy).toBeCalled(); - - expect(cancelOrder) - .toHaveBeenCalledWith({ - request, - user: mockUserImx, - starkSigner: mockStarkSigner, - ordersApi: immutableXClient.ordersApi, - guardianClient: mockGuardianClient, - }); - expect(result) - .toEqual(returnValue); - }); - }); - - describe('createTrade', () => { - it('calls createTrade workflow', async () => { - const returnValue = {} as imx.CreateTradeResponse; - const request = {} as imx.GetSignableTradeRequest; - - const withDefaultConfirmationScreenSpy = jest.spyOn(mockGuardianClient, 'withDefaultConfirmationScreenTask'); - (createTrade as jest.Mock).mockResolvedValue(returnValue); - const result = await passportImxProvider.createTrade(request); - expect(withDefaultConfirmationScreenSpy).toBeCalled(); - - expect(createTrade) - .toHaveBeenCalledWith({ - request, - user: mockUserImx, - starkSigner: mockStarkSigner, - tradesApi: immutableXClient.tradesApi, - guardianClient: mockGuardianClient, - }); - expect(result) - .toEqual(returnValue); - }); - }); - - describe('batchNftTransfer', () => { - it('calls batchNftTransfer workflow', async () => { - const returnValue = {} as imx.CreateTransferResponse; - const request = [] as NftTransferDetails[]; - const withConfirmationScreenSpy = jest.spyOn(mockGuardianClient, 'withConfirmationScreenTask'); - - (batchNftTransfer as jest.Mock).mockResolvedValue(returnValue); - const result = await passportImxProvider.batchNftTransfer(request); - - expect(withConfirmationScreenSpy).toBeCalledWith({ height: 784, width: 480 }); - expect(batchNftTransfer) - .toHaveBeenCalledWith({ - request, - user: mockUserImx, - starkSigner: mockStarkSigner, - transfersApi: immutableXClient.transfersApi, - guardianClient: mockGuardianClient, - }); - expect(result) - .toEqual(returnValue); - }); - }); - - describe('exchangeTransfer', () => { - it('calls the exchangeTransfer workflow', async () => { - const returnValue = {} as imx.CreateTransferResponseV1; - const request = {} as UnsignedExchangeTransferRequest; - - (exchangeTransfer as jest.Mock).mockResolvedValue(returnValue); - const result = await passportImxProvider.exchangeTransfer(request); - - expect(exchangeTransfer) - .toHaveBeenCalledWith({ - request, - user: mockUserImx, - starkSigner: mockStarkSigner, - exchangesApi: immutableXClient.exchangeApi, - }); - expect(result) - .toEqual(returnValue); - }); - }); - - describe('deposit', () => { - it('should throw error', async () => { - expect(passportImxProvider.deposit) - .toThrow( - new PassportError( - 'Operation not supported', - PassportErrorType.OPERATION_NOT_SUPPORTED_ERROR, - ), - ); - }); - }); - - describe('prepareWithdrawal', () => { - it('should throw error', async () => { - expect(passportImxProvider.prepareWithdrawal) - .toThrow( - new PassportError( - 'Operation not supported', - PassportErrorType.OPERATION_NOT_SUPPORTED_ERROR, - ), - ); - }); - }); - - describe('completeWithdrawal', () => { - it('should throw error', async () => { - expect(passportImxProvider.completeWithdrawal) - .toThrow( - new PassportError( - 'Operation not supported', - PassportErrorType.OPERATION_NOT_SUPPORTED_ERROR, - ), - ); - }); - }); - - describe('getAddress', () => { - it('should return user ether key address', async () => { - const response = await passportImxProvider.getAddress(); - expect(response) - .toEqual(mockUserImx.imx.ethAddress); - }); - }); - - describe('registerOffChain', () => { - it('should register the user and update the provider instance user', async () => { - mockAuthManager.login.mockResolvedValue(mockUser); - mockAuthManager.forceUserRefresh.mockResolvedValue({ ...mockUser, imx: { ethAddress: '', starkAddress: '', userAdminAddress: '' } }); - await passportImxProvider.registerOffchain(); - - expect(registerPassportStarkEx).toHaveBeenCalledWith({ - ethSigner: mockMagicTEESigner, - starkSigner: mockStarkSigner, - imxApiClients: new ImxApiClients({} as any), - }, mockUserImx.accessToken); - expect(mockAuthManager.forceUserRefresh).toHaveBeenCalledTimes(1); - }); - }); - - describe.each([ - ['transfer' as const, 'imxTransfer', {} as UnsignedTransferRequest], - ['createOrder' as const, 'imxCreateOrder', {} as UnsignedOrderRequest], - ['cancelOrder' as const, 'imxCancelOrder', {} as imx.GetSignableCancelOrderRequest], - ['createTrade' as const, 'imxCreateTrade', {} as imx.GetSignableTradeRequest], - ['batchNftTransfer' as const, 'imxBatchNftTransfer', [] as NftTransferDetails[]], - ['exchangeTransfer' as const, 'imxExchangeTransfer', {} as UnsignedExchangeTransferRequest], - ['getAddress' as const, 'imxGetAddress', {} as any], - ['isRegisteredOffchain' as const, 'imxIsRegisteredOffchain', {} as any], - ])('when the user has been logged out - %s', (methodName, eventName, args) => { - beforeEach(() => { - passportEventEmitter.emit(PassportEvents.LOGGED_OUT); - }); - - it(`should return an error for ${methodName}`, async () => { - await expect(async () => passportImxProvider[methodName!](args)) - .rejects - .toThrow( - new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ), - ); - }); - - it(`should track metrics when error thrown for ${methodName}`, async () => { - try { - await passportImxProvider[methodName!](args); - } catch (error) { - expect(trackFlow).toHaveBeenCalledWith( - 'passport', - eventName, - true, - ); - expect(trackError).toHaveBeenCalledWith( - 'passport', - eventName, - error, - { flowId: '123' }, - ); - } - }); - }); - - describe.each([ - ['transfer' as const, 'imxTransfer', {} as UnsignedTransferRequest], - ['createOrder' as const, 'imxCreateOrder', {} as UnsignedOrderRequest], - ['cancelOrder' as const, 'imxCancelOrder', {} as imx.GetSignableCancelOrderRequest], - ['createTrade' as const, 'imxCreateTrade', {} as imx.GetSignableTradeRequest], - ['batchNftTransfer' as const, 'imxBatchNftTransfer', [] as NftTransferDetails[]], - ['exchangeTransfer' as const, 'imxExchangeTransfer', {} as UnsignedExchangeTransferRequest], - ['getAddress' as const, 'imxGetAddress', {} as any], - ['isRegisteredOffchain' as const, 'imxIsRegisteredOffchain', {} as any], - ])('when the user\'s access token is expired and cannot be retrieved', (methodName, eventName, args) => { - beforeEach(() => { - mockAuthManager.getUser.mockResolvedValue(null); - }); - - it(`should return an error for ${methodName}`, async () => { - await expect(async () => passportImxProvider[methodName!](args)) - .rejects - .toThrow( - new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ), - ); - }); - - it(`should track metrics when error thrown for ${methodName}`, async () => { - try { - await passportImxProvider[methodName!](args); - } catch (error) { - expect(trackFlow).toHaveBeenCalledWith( - 'passport', - eventName, - true, - ); - expect(trackError).toHaveBeenCalledWith( - 'passport', - eventName, - error, - { flowId: '123' }, - ); - } - }); - }); -}); diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.ts index fb7a0e9a3f..7da9fb610b 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.ts @@ -14,11 +14,12 @@ import { ImxApiClients, } from '@imtbl/generated-clients'; import { TransactionResponse } from 'ethers'; -import AuthManager from '../authManager'; -import GuardianClient from '../guardian'; import { - PassportEventMap, PassportEvents, UserImx, User, isUserImx, -} from '../types'; + Auth, AuthEventMap, AuthEvents, TypedEventEmitter, User, +} from '@imtbl/auth'; +import { GuardianClient, MagicTEESigner } from '@imtbl/wallet'; +import { toUserImx, UserImx } from '../utils/imxUser'; +import { ImxGuardianClient } from './imxGuardianClient'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { batchNftTransfer, cancelOrder, createOrder, createTrade, exchangeTransfer, transfer, @@ -26,16 +27,15 @@ import { import registerOffchain from './workflows/registerOffchain'; import { getStarkSigner } from './getStarkSigner'; import { withMetricsAsync } from '../utils/metrics'; -import MagicTEESigner from '../magic/magicTEESigner'; -import TypedEventEmitter from '../utils/typedEventEmitter'; export interface PassportImxProviderOptions { - authManager: AuthManager; + auth: Auth; immutableXClient: IMXClient; - passportEventEmitter: TypedEventEmitter; + passportEventEmitter: TypedEventEmitter; magicTEESigner: MagicTEESigner; imxApiClients: ImxApiClients; guardianClient: GuardianClient; + imxGuardianClient: ImxGuardianClient; } type RegisteredUserAndStarkSigner = { @@ -44,7 +44,7 @@ type RegisteredUserAndStarkSigner = { }; export class PassportImxProvider implements IMXProvider { - protected readonly authManager: AuthManager; + protected readonly auth: Auth; private readonly immutableXClient: IMXClient; @@ -54,6 +54,8 @@ export class PassportImxProvider implements IMXProvider { protected magicTEESigner: MagicTEESigner; + private readonly imxGuardianClient: ImxGuardianClient; + /** * This property is set during initialisation and stores the signers in a promise. * This property is not meant to be accessed directly, but through the @@ -65,21 +67,23 @@ export class PassportImxProvider implements IMXProvider { private signerInitialisationError: unknown | undefined; constructor({ - authManager, + auth, immutableXClient, passportEventEmitter, magicTEESigner, imxApiClients, guardianClient, + imxGuardianClient, }: PassportImxProviderOptions) { - this.authManager = authManager; + this.auth = auth; this.immutableXClient = immutableXClient; this.magicTEESigner = magicTEESigner; this.imxApiClients = imxApiClients; this.guardianClient = guardianClient; + this.imxGuardianClient = imxGuardianClient; this.#initialiseSigner(); - passportEventEmitter.on(PassportEvents.LOGGED_OUT, this.handleLogout); + passportEventEmitter.on(AuthEvents.LOGGED_OUT, this.handleLogout); } private handleLogout = (): void => { @@ -112,7 +116,7 @@ export class PassportImxProvider implements IMXProvider { } async #getAuthenticatedUser(): Promise { - const user = await this.authManager.getUser(); + const user = await this.auth.getUser(); if (!user || !this.starkSigner) { throw new PassportError( @@ -144,15 +148,8 @@ export class PassportImxProvider implements IMXProvider { this.#getStarkSigner(), ]); - if (!isUserImx(user)) { - throw new PassportError( - 'User has not been registered with StarkEx', - PassportErrorType.USER_NOT_REGISTERED_ERROR, - ); - } - return { - user, + user: toUserImx(user), starkSigner, }; } @@ -167,7 +164,7 @@ export class PassportImxProvider implements IMXProvider { user, starkSigner, transfersApi: this.immutableXClient.transfersApi, - guardianClient: this.guardianClient, + guardianClient: this.imxGuardianClient, }); }, )(), 'imxTransfer'); @@ -184,8 +181,8 @@ export class PassportImxProvider implements IMXProvider { return await registerOffchain( this.magicTEESigner, starkSigner, - user, - this.authManager, + toUserImx(user), + this.auth, this.imxApiClients, ); }, @@ -196,8 +193,19 @@ export class PassportImxProvider implements IMXProvider { async isRegisteredOffchain(): Promise { return withMetricsAsync( async () => { - const user = await this.#getAuthenticatedUser(); - return !!user.imx; + try { + const user = await this.#getAuthenticatedUser(); + const imxUser = toUserImx(user); + return !!imxUser.imx; + } catch (error) { + if ( + error instanceof PassportError + && error.type === PassportErrorType.USER_NOT_REGISTERED_ERROR + ) { + return false; + } + throw error; + } }, 'imxIsRegisteredOffchain', ); @@ -220,7 +228,7 @@ export class PassportImxProvider implements IMXProvider { user, starkSigner, ordersApi: this.immutableXClient.ordersApi, - guardianClient: this.guardianClient, + guardianClient: this.imxGuardianClient, }); }, )(), 'imxCreateOrder'); @@ -238,7 +246,7 @@ export class PassportImxProvider implements IMXProvider { user, starkSigner, ordersApi: this.immutableXClient.ordersApi, - guardianClient: this.guardianClient, + guardianClient: this.imxGuardianClient, }); }, )(), 'imxCancelOrder'); @@ -254,7 +262,7 @@ export class PassportImxProvider implements IMXProvider { user, starkSigner, tradesApi: this.immutableXClient.tradesApi, - guardianClient: this.guardianClient, + guardianClient: this.imxGuardianClient, }); }, )(), 'imxCreateTrade'); @@ -274,7 +282,7 @@ export class PassportImxProvider implements IMXProvider { starkSigner, transfersApi: this.immutableXClient.transfersApi, - guardianClient: this.guardianClient, + guardianClient: this.imxGuardianClient, }); })(), 'imxBatchNftTransfer'); } @@ -326,14 +334,8 @@ export class PassportImxProvider implements IMXProvider { async getAddress(): Promise { return withMetricsAsync(async () => { const user = await this.#getAuthenticatedUser(); - if (!isUserImx(user)) { - throw new PassportError( - 'User has not been registered with StarkEx', - PassportErrorType.USER_NOT_REGISTERED_ERROR, - ); - } - - return Promise.resolve(user.imx.ethAddress); + const imxUser = toUserImx(user); + return Promise.resolve(imxUser.imx.ethAddress); }, 'imxGetAddress'); } } diff --git a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts deleted file mode 100644 index cac0d07975..0000000000 --- a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { IMXClient } from '@imtbl/x-client'; -import { ImxApiClients } from '@imtbl/generated-clients'; -import { PassportImxProviderFactory } from './passportImxProviderFactory'; -import MagicTEESigner from '../magic/magicTEESigner'; -import AuthManager from '../authManager'; -import { PassportError, PassportErrorType } from '../errors/passportError'; -import { PassportEventMap } from '../types'; -import TypedEventEmitter from '../utils/typedEventEmitter'; -import { PassportImxProvider } from './passportImxProvider'; -import GuardianClient from '../guardian'; - -jest.mock('./workflows/registration'); -jest.mock('./passportImxProvider'); -jest.mock('@imtbl/generated-clients'); - -describe('PassportImxProviderFactory', () => { - const mockAuthManager = { - getUser: jest.fn(), - getUserOrLogin: jest.fn(), - }; - const imxApiClients = new ImxApiClients({} as any); - - const mockMagicTEESigner = {}; - const immutableXClient = { - usersApi: {}, - } as IMXClient; - const guardianClient = {} as GuardianClient; - const passportEventEmitter = new TypedEventEmitter(); - const passportImxProviderFactory = new PassportImxProviderFactory({ - immutableXClient, - authManager: mockAuthManager as unknown as AuthManager, - magicTEESigner: mockMagicTEESigner as unknown as MagicTEESigner, - passportEventEmitter, - imxApiClients, - guardianClient, - }); - const mockPassportImxProvider = {}; - - beforeEach(() => { - jest.restoreAllMocks(); - (PassportImxProvider as jest.Mock).mockImplementation(() => mockPassportImxProvider); - }); - - describe('getProviderSilent', () => { - describe('when no user is logged in', () => { - it('should return null', async () => { - mockAuthManager.getUser.mockResolvedValue(null); - - const result = await passportImxProviderFactory.getProviderSilent(); - - expect(result).toBe(null); - expect(mockAuthManager.getUser).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('getProvider', () => { - describe('when the user has no idToken', () => { - it('should throw an error', async () => { - mockAuthManager.getUserOrLogin.mockResolvedValue({ idToken: null }); - - await expect(() => passportImxProviderFactory.getProvider()).rejects.toThrow( - new PassportError( - 'Failed to initialise', - PassportErrorType.WALLET_CONNECTION_ERROR, - ), - ); - expect(mockAuthManager.getUserOrLogin).toHaveBeenCalledTimes(1); - }); - }); - - it('should return a PassportImxProvider instance', async () => { - mockAuthManager.getUserOrLogin.mockResolvedValue({ idToken: 'id123' }); - - const result = await passportImxProviderFactory.getProvider(); - - expect(result).toBe(mockPassportImxProvider); - expect(mockAuthManager.getUserOrLogin).toHaveBeenCalledTimes(1); - expect(PassportImxProvider).toHaveBeenCalledWith({ - magicTEESigner: mockMagicTEESigner, - authManager: mockAuthManager, - immutableXClient, - passportEventEmitter, - imxApiClients: new ImxApiClients({} as any), - guardianClient, - }); - }); - }); -}); diff --git a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts index 8e113b25e5..a703d17e24 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts @@ -1,59 +1,63 @@ import { IMXClient } from '@imtbl/x-client'; import { IMXProvider } from '@imtbl/x-provider'; import { ImxApiClients } from '@imtbl/generated-clients'; +import { Auth, AuthEventMap, TypedEventEmitter } from '@imtbl/auth'; +import { GuardianClient, MagicTEESigner } from '@imtbl/wallet'; import { PassportError, PassportErrorType } from '../errors/passportError'; -import AuthManager from '../authManager'; -import { PassportEventMap, User } from '../types'; +import { User } from '../types'; import { PassportImxProvider } from './passportImxProvider'; -import GuardianClient from '../guardian'; -import MagicTEESigner from '../magic/magicTEESigner'; -import TypedEventEmitter from '../utils/typedEventEmitter'; +import { ImxGuardianClient } from './imxGuardianClient'; export type PassportImxProviderFactoryInput = { - authManager: AuthManager; + auth: Auth; immutableXClient: IMXClient; magicTEESigner: MagicTEESigner; - passportEventEmitter: TypedEventEmitter; + passportEventEmitter: TypedEventEmitter; imxApiClients: ImxApiClients; guardianClient: GuardianClient; + imxGuardianClient: ImxGuardianClient; }; export class PassportImxProviderFactory { - private readonly authManager: AuthManager; + private readonly auth: Auth; private readonly immutableXClient: IMXClient; private readonly magicTEESigner: MagicTEESigner; - private readonly passportEventEmitter: TypedEventEmitter; + private readonly passportEventEmitter: TypedEventEmitter; public readonly imxApiClients: ImxApiClients; private readonly guardianClient: GuardianClient; + private readonly imxGuardianClient: ImxGuardianClient; + constructor({ - authManager, + auth, immutableXClient, magicTEESigner, passportEventEmitter, imxApiClients, guardianClient, + imxGuardianClient, }: PassportImxProviderFactoryInput) { - this.authManager = authManager; + this.auth = auth; this.immutableXClient = immutableXClient; this.magicTEESigner = magicTEESigner; this.passportEventEmitter = passportEventEmitter; this.imxApiClients = imxApiClients; this.guardianClient = guardianClient; + this.imxGuardianClient = imxGuardianClient; } public async getProvider(): Promise { - const user = await this.authManager.getUserOrLogin(); + const user = await this.auth.getUserOrLogin(); return this.createProviderInstance(user); } public async getProviderSilent(): Promise { - const user = await this.authManager.getUser(); + const user = await this.auth.getUser(); if (!user) { return null; } @@ -70,12 +74,13 @@ export class PassportImxProviderFactory { } return new PassportImxProvider({ - authManager: this.authManager, + auth: this.auth, immutableXClient: this.immutableXClient, passportEventEmitter: this.passportEventEmitter, magicTEESigner: this.magicTEESigner, imxApiClients: this.imxApiClients, guardianClient: this.guardianClient, + imxGuardianClient: this.imxGuardianClient, }); } } diff --git a/packages/passport/sdk/src/starkEx/workflows/exchange.test.ts b/packages/passport/sdk/src/starkEx/workflows/exchange.test.ts deleted file mode 100644 index 1831408c50..0000000000 --- a/packages/passport/sdk/src/starkEx/workflows/exchange.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { imx } from '@imtbl/generated-clients'; -import { ETHAmount } from '@imtbl/x-client'; -import { exchangeTransfer } from './exchange'; -import { mockErrorMessage, mockStarkSignature, mockUserImx } from '../../test/mocks'; -import { PassportError, PassportErrorType } from '../../errors/passportError'; - -describe('exchangeTransfer', () => { - afterEach(jest.resetAllMocks); - - const getExchangeSignableTransferMock = jest.fn(); - const createExchangeTransferMock = jest.fn(); - const mockStarkAddress = '0x1111...'; - let exchangesApiMock: imx.ExchangesApi; - - const mockStarkSigner = { - getAddress: jest.fn(), - signMessage: jest.fn(), - getYCoordinate: jest.fn(), - }; - - const ethAmount: ETHAmount = { - type: 'ETH', - amount: '100', - }; - - const exchangeTransferRequest = { - ...ethAmount, - receiver: '0x456...', - transactionID: 'abc123', - }; - - beforeEach(() => { - exchangesApiMock = { - getExchangeSignableTransfer: getExchangeSignableTransferMock, - createExchangeTransfer: createExchangeTransferMock, - } as unknown as imx.ExchangesApi; - }); - - it('should returns success exchange transfer result', async () => { - const mockGetExchangeSignableTransferResponse = { - data: { - payload_hash: 'hash123', - sender_stark_key: 'senderKey', - sender_vault_id: 'senderVault', - receiver_stark_key: 'receiverKey', - receiver_vault_id: 'receiverVault', - asset_id: 'assetID', - amount: 100, - nonce: 'nonce123', - expiration_timestamp: 123456789, - }, - }; - - const mockCreateExchangeTransferResponse = { - data: { - sent_signature: 'signature123', - status: 'SUCCESS', - time: '2022-01-01T00:00:00Z', - transfer_id: 'transfer123', - }, - }; - - mockStarkSigner.getAddress.mockResolvedValue(mockStarkAddress); - getExchangeSignableTransferMock.mockResolvedValue( - mockGetExchangeSignableTransferResponse, - ); - createExchangeTransferMock.mockResolvedValue( - mockCreateExchangeTransferResponse, - ); - mockStarkSigner.signMessage.mockResolvedValue(mockStarkSignature); - - const mockHeader = { - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Authorization: `Bearer ${mockUserImx.accessToken}`, - }, - }; - - const response: imx.CreateTransferResponseV1 = await exchangeTransfer({ - user: mockUserImx, - starkSigner: mockStarkSigner, - request: exchangeTransferRequest, - exchangesApi: exchangesApiMock, - }); - - expect(createExchangeTransferMock).toHaveBeenCalledWith( - { - createTransferRequest: { - amount: 100, - asset_id: 'assetID', - expiration_timestamp: 123456789, - nonce: 'nonce123', - receiver_stark_key: 'receiverKey', - receiver_vault_id: 'receiverVault', - sender_stark_key: 'senderKey', - sender_vault_id: 'senderVault', - stark_signature: 'starkSignature', - }, - id: 'abc123', - }, - mockHeader, - ); - expect(getExchangeSignableTransferMock).toHaveBeenCalledWith({ - getSignableTransferRequest: { - amount: ethAmount.amount, - receiver: exchangeTransferRequest.receiver, - sender: mockUserImx.imx.ethAddress, - token: { - data: { - decimals: 18, - }, - type: ethAmount.type, - }, - }, - id: exchangeTransferRequest.transactionID, - }); - - expect(mockStarkSigner.signMessage).toBeCalledWith( - mockGetExchangeSignableTransferResponse.data.payload_hash, - ); - expect(mockStarkSigner.getAddress).toHaveBeenCalled(); - expect(response).toEqual({ - sent_signature: mockCreateExchangeTransferResponse.data.sent_signature, - status: mockCreateExchangeTransferResponse.data.status, - time: mockCreateExchangeTransferResponse.data.time, - transfer_id: mockCreateExchangeTransferResponse.data.transfer_id, - }); - }); - - it('should return error if failed to call public api', async () => { - getExchangeSignableTransferMock.mockRejectedValue( - new Error(mockErrorMessage), - ); - - await expect(() => exchangeTransfer({ - user: mockUserImx, - starkSigner: mockStarkSigner, - request: exchangeTransferRequest, - exchangesApi: exchangesApiMock, - })).rejects.toThrow( - new PassportError( - mockErrorMessage, - PassportErrorType.EXCHANGE_TRANSFER_ERROR, - ), - ); - }); -}); diff --git a/packages/passport/sdk/src/starkEx/workflows/order.test.ts b/packages/passport/sdk/src/starkEx/workflows/order.test.ts deleted file mode 100644 index 12bc7ccd3f..0000000000 --- a/packages/passport/sdk/src/starkEx/workflows/order.test.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { imx } from '@imtbl/generated-clients'; -import { ETHAmount, UnsignedOrderRequest } from '@imtbl/x-client'; -import GuardianClient from '../../guardian'; -import { PassportError, PassportErrorType } from '../../errors/passportError'; -import { mockErrorMessage, mockStarkSignature, mockUserImx } from '../../test/mocks'; -import { cancelOrder, createOrder } from './order'; - -jest.mock('../../guardian'); - -describe('order', () => { - const mockGuardianClient = new GuardianClient({} as any); - - beforeEach(() => { - (mockGuardianClient.withDefaultConfirmationScreenTask as jest.Mock).mockImplementation((task) => task); - }); - - afterEach(jest.resetAllMocks); - - const mockStarkSigner = { - signMessage: jest.fn(), - getAddress: jest.fn(), - getYCoordinate: jest.fn(), - }; - - describe('createOrder', () => { - let mockGetSignableCreateOrder: jest.Mock; - let mockCreateOrder: jest.Mock; - let mockOrdersApi: imx.OrdersApi; - - const buy = { type: 'ETH', amount: '2' } as ETHAmount; - const sell = { type: 'ERC721', tokenId: '123', tokenAddress: '0x9999' }; - const expiration_timestamp = 1334302; - const orderRequest = { - buy, - sell, - expiration_timestamp, - }; - - beforeEach(() => { - mockGetSignableCreateOrder = jest.fn(); - mockCreateOrder = jest.fn(); - mockOrdersApi = { - getSignableOrder: mockGetSignableCreateOrder, - createOrderV3: mockCreateOrder, - } as unknown as imx.OrdersApi; - }); - - it('should returns success createOrder result', async () => { - const mockSignableOrderRequest = { - getSignableOrderRequestV3: { - amount_buy: buy.amount, - amount_sell: '1', - token_buy: { - data: { - decimals: 18, - }, - type: 'ETH', - }, - token_sell: { - data: { - token_address: sell.tokenAddress, - token_id: sell.tokenId, - }, - type: 'ERC721', - }, - fees: undefined, - expiration_timestamp, - user: mockUserImx.imx.ethAddress, - split_fees: true, - }, - }; - - const mockSignableOrderResponse = { - data: { - payload_hash: '123123', - amount_buy: buy.amount, - amount_sell: '1', - asset_id_buy: '5530812', - asset_id_sell: '8024836', - expiration_timestamp, - nonce: '847570072', - stark_key: '0x1234', - vault_id_buy: - '0x02705737cd248ac819034b5de474c8f0368224f72a0fda9e031499d519992d9e', - vault_id_sell: - '0x04006590f0986f008231e309b980e81f8a55944a702ec633b47ceb326242c9f8', - }, - }; - const { - payload_hash: mockPayloadHash, - ...restSignableOrderResponse - } = mockSignableOrderResponse.data; - const mockCreateOrderRequest = { - createOrderRequest: { - ...restSignableOrderResponse, - stark_signature: mockStarkSignature, - fees: undefined, - include_fees: true, - }, - }; - const mockHeader = { - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Authorization: `Bearer ${mockUserImx.accessToken}`, - }, - }; - const mockReturnValue = { - status: 'success', - time: 111, - order_id: 123, - }; - - mockGetSignableCreateOrder.mockResolvedValue(mockSignableOrderResponse); - mockStarkSigner.signMessage.mockResolvedValue(mockStarkSignature); - mockCreateOrder.mockResolvedValue({ - data: mockReturnValue, - }); - - const result = await createOrder({ - ordersApi: mockOrdersApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: orderRequest as UnsignedOrderRequest, - guardianClient: mockGuardianClient, - }); - - expect(mockGetSignableCreateOrder).toBeCalledWith(mockSignableOrderRequest, mockHeader); - expect(mockGuardianClient.evaluateImxTransaction) - .toBeCalledWith({ payloadHash: mockSignableOrderResponse.data.payload_hash }); - expect(mockStarkSigner.signMessage).toBeCalledWith(mockPayloadHash); - expect(mockCreateOrder).toBeCalledWith( - mockCreateOrderRequest, - mockHeader, - ); - expect(result).toEqual(mockReturnValue); - }); - - it('should return error if failed to call public api', async () => { - mockGetSignableCreateOrder.mockRejectedValue(new Error(mockErrorMessage)); - - await expect(() => createOrder({ - ordersApi: mockOrdersApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: orderRequest as UnsignedOrderRequest, - guardianClient: mockGuardianClient, - })).rejects.toThrow( - new PassportError( - mockErrorMessage, - PassportErrorType.CREATE_ORDER_ERROR, - ), - ); - }); - - it('should return error if transfer is rejected by user', async () => { - mockGetSignableCreateOrder.mockRejectedValue(new Error(mockErrorMessage)); - const mockSignableOrderResponse = { - data: { - payload_hash: '123123', - amount_buy: buy.amount, - amount_sell: '1', - asset_id_buy: '5530812', - asset_id_sell: '8024836', - expiration_timestamp, - nonce: '847570072', - stark_key: '0x1234', - vault_id_buy: - '0x02705737cd248ac819034b5de474c8f0368224f72a0fda9e031499d519992d9e', - vault_id_sell: - '0x04006590f0986f008231e309b980e81f8a55944a702ec633b47ceb326242c9f8', - }, - }; - - mockGetSignableCreateOrder.mockResolvedValue(mockSignableOrderResponse); - mockStarkSigner.signMessage.mockResolvedValue(mockStarkSignature); - (mockGuardianClient.evaluateImxTransaction as jest.Mock) - .mockRejectedValue(new Error('Transaction rejected by user')); - - await expect(() => createOrder({ - ordersApi: mockOrdersApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: orderRequest as UnsignedOrderRequest, - guardianClient: mockGuardianClient, - })).rejects.toThrowError(new PassportError( - 'Transaction rejected by user', - PassportErrorType.CREATE_ORDER_ERROR, - )); - expect(mockGuardianClient.evaluateImxTransaction) - .toBeCalledWith({ payloadHash: mockSignableOrderResponse.data.payload_hash }); - }); - }); - - describe('cancelOrder', () => { - let mockGetSignableCancelOrder: jest.Mock; - let mockCancelOrder: jest.Mock; - let mockOrdersApi: imx.OrdersApi; - const orderId = 54321; - const cancelOrderRequest = { - order_id: orderId, - }; - - beforeEach(() => { - mockGetSignableCancelOrder = jest.fn(); - mockCancelOrder = jest.fn(); - mockOrdersApi = { - getSignableCancelOrderV3: mockGetSignableCancelOrder, - cancelOrderV3: mockCancelOrder, - } as unknown as imx.OrdersApi; - }); - - it('should returns success cancelOrder result', async () => { - const mockSignableCancelOrderRequest = { - getSignableCancelOrderRequest: { - ...cancelOrderRequest, - }, - }; - const mockSignableCancelOrderResponse = { - data: { - payload_hash: '123123', - }, - }; - - const { payload_hash: mockPayloadHash } = mockSignableCancelOrderResponse.data; - - const mockCancelOrderRequest = { - id: orderId.toString(), - cancelOrderRequest: { - order_id: orderId, - stark_signature: mockStarkSignature, - }, - }; - - const mockHeader = { - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Authorization: `Bearer ${mockUserImx.accessToken}`, - }, - }; - - const mockReturnValue = { - order_id: orderId, - status: 'success', - }; - - mockGetSignableCancelOrder.mockResolvedValue( - mockSignableCancelOrderResponse, - ); - mockStarkSigner.signMessage.mockResolvedValue(mockStarkSignature); - mockCancelOrder.mockResolvedValue({ - data: mockReturnValue, - }); - - const result = await cancelOrder({ - ordersApi: mockOrdersApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: cancelOrderRequest, - guardianClient: mockGuardianClient, - }); - - expect(mockGetSignableCancelOrder).toBeCalledWith( - mockSignableCancelOrderRequest, - mockHeader, - ); - expect(mockStarkSigner.signMessage).toBeCalledWith(mockPayloadHash); - expect(mockGuardianClient.evaluateImxTransaction) - .toBeCalledWith({ payloadHash: mockPayloadHash }); - expect(mockCancelOrder).toBeCalledWith( - mockCancelOrderRequest, - mockHeader, - ); - expect(result).toEqual(mockReturnValue); - }); - - it('should return error if transfer is rejected by user', async () => { - const mockSignableCancelOrderResponse = { - data: { - payload_hash: '123123', - }, - }; - - mockGetSignableCancelOrder.mockResolvedValue(mockSignableCancelOrderResponse); - mockStarkSigner.signMessage.mockResolvedValue(mockStarkSignature); - (mockGuardianClient.evaluateImxTransaction as jest.Mock) - .mockRejectedValue(new Error('Transaction rejected by user')); - - await expect(() => cancelOrder({ - ordersApi: mockOrdersApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: cancelOrderRequest, - guardianClient: mockGuardianClient, - })).rejects.toThrowError(new PassportError( - 'Transaction rejected by user', - PassportErrorType.CANCEL_ORDER_ERROR, - )); - }); - - it('should return error if failed to call public api', async () => { - mockGetSignableCancelOrder.mockRejectedValue(new Error(mockErrorMessage)); - - await expect(() => cancelOrder({ - ordersApi: mockOrdersApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: cancelOrderRequest, - guardianClient: mockGuardianClient, - })).rejects.toThrow( - new PassportError( - mockErrorMessage, - PassportErrorType.CANCEL_ORDER_ERROR, - ), - ); - }); - }); -}); diff --git a/packages/passport/sdk/src/starkEx/workflows/order.ts b/packages/passport/sdk/src/starkEx/workflows/order.ts index 178fda2302..4e05300b55 100644 --- a/packages/passport/sdk/src/starkEx/workflows/order.ts +++ b/packages/passport/sdk/src/starkEx/workflows/order.ts @@ -4,16 +4,16 @@ import { UnsignedOrderRequest, } from '@imtbl/x-client'; import { convertToSignableToken } from '@imtbl/toolkit'; +import { ImxGuardianClient } from '../imxGuardianClient'; import { PassportErrorType, withPassportError } from '../../errors/passportError'; import { UserImx } from '../../types'; -import GuardianClient from '../../guardian'; type CancelOrderParams = { request: imx.GetSignableCancelOrderRequest; ordersApi: imx.OrdersApi; user: UserImx; starkSigner: StarkSigner; - guardianClient: GuardianClient; + guardianClient: ImxGuardianClient; }; type CreateOrderParams = { @@ -21,7 +21,7 @@ type CreateOrderParams = { ordersApi: imx.OrdersApi; user: UserImx; starkSigner: StarkSigner; - guardianClient: GuardianClient; + guardianClient: ImxGuardianClient; }; const ERC721 = 'ERC721'; @@ -57,9 +57,7 @@ export async function createOrder({ { headers }, ); - await guardianClient.evaluateImxTransaction({ - payloadHash: getSignableOrderResponse.data.payload_hash, - }); + await guardianClient.evaluateTransaction(getSignableOrderResponse.data.payload_hash); const { payload_hash: payloadHash } = getSignableOrderResponse.data; @@ -115,9 +113,7 @@ export async function cancelOrder({ getSignableCancelOrderRequest, }, { headers }); - await guardianClient.evaluateImxTransaction({ - payloadHash: getSignableCancelOrderResponse.data.payload_hash, - }); + await guardianClient.evaluateTransaction(getSignableCancelOrderResponse.data.payload_hash); const { payload_hash: payloadHash } = getSignableCancelOrderResponse.data; diff --git a/packages/passport/sdk/src/starkEx/workflows/registerOffchain.test.ts b/packages/passport/sdk/src/starkEx/workflows/registerOffchain.test.ts deleted file mode 100644 index 8bb8421b21..0000000000 --- a/packages/passport/sdk/src/starkEx/workflows/registerOffchain.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { AxiosError } from 'axios'; -import { ImxApiClients } from '@imtbl/generated-clients'; -import { StarkSigner } from '@imtbl/x-client'; -import { Signer, BrowserProvider } from 'ethers'; -import AuthManager from '../../authManager'; -import { mockUserImx } from '../../test/mocks'; -import registerPassportStarkEx from './registration'; -import { PassportError, PassportErrorType } from '../../errors/passportError'; -import registerOffchain from './registerOffchain'; - -jest.mock('ethers', () => ({ - ...jest.requireActual('ethers'), - BrowserProvider: jest.fn(), -})); -jest.mock('./registration'); -jest.mock('@imtbl/generated-clients'); - -const mockGetSigner = jest.fn(); - -const mockLogin = jest.fn(); - -const mockForceUserRefresh = jest.fn(); -const mockAuthManager = { - login: mockLogin, - forceUserRefresh: mockForceUserRefresh, -} as unknown as AuthManager; - -const mockEthSigner = { getAddress: jest.fn() } as unknown as Signer; - -const mockStarkSigner = { - signMessage: jest.fn(), - getAddress: jest.fn(), - getYCoordinate: jest.fn(), -} as unknown as StarkSigner; - -const mockReturnHash = '0x123'; - -mockGetSigner.mockReturnValue(mockEthSigner); -(BrowserProvider as unknown as jest.Mock).mockReturnValue({ - getSigner: mockGetSigner, -}); - -(registerPassportStarkEx as jest.Mock).mockResolvedValue(mockReturnHash); - -describe('registerOffchain', () => { - describe('when we exceed the number of attempts to obtain a user with the correct metadata', () => { - it('should throw an error', async () => { - const imxApiClients = new ImxApiClients({} as any); - - await (expect(() => registerOffchain( - mockEthSigner, - mockStarkSigner, - mockUserImx, - mockAuthManager, - imxApiClients, - )).rejects.toThrow(new PassportError( - 'Retry failed', - PassportErrorType.REFRESH_TOKEN_ERROR, - ))); - - expect(registerPassportStarkEx).toHaveBeenCalledWith( - { ethSigner: mockEthSigner, starkSigner: mockStarkSigner, imxApiClients }, - mockUserImx.accessToken, - ); - - expect(mockAuthManager.forceUserRefresh).toHaveBeenCalledTimes(4); - }); - }); - - describe('when registration is successful', () => { - it('should register the user and return the transaction hash as a string', async () => { - const imxApiClients = new ImxApiClients({} as any); - mockForceUserRefresh.mockResolvedValue(mockUserImx); - - const txHash = await registerOffchain( - mockEthSigner, - mockStarkSigner, - mockUserImx, - mockAuthManager, - imxApiClients, - ); - - expect(txHash).toEqual(mockReturnHash); - expect(registerPassportStarkEx).toHaveBeenCalledWith({ - ethSigner: mockEthSigner, - starkSigner: mockStarkSigner, - imxApiClients, - }, mockUserImx.accessToken); - expect(mockAuthManager.forceUserRefresh).toHaveBeenCalledTimes(1); - }); - - describe('when registration fails due to a 409 conflict', () => { - it('should refresh the user to get the updated token', async () => { - const imxApiClients = new ImxApiClients({} as any); - // create axios error with status 409 - const err = new AxiosError('User already registered'); - err.response = { - ...err.response, - status: 409, - } as typeof err.response; - - (registerPassportStarkEx as jest.Mock).mockRejectedValue(err); - mockForceUserRefresh.mockResolvedValue(mockUserImx); - - const hash = await registerOffchain( - mockEthSigner, - mockStarkSigner, - mockUserImx, - mockAuthManager, - imxApiClients, - ); - - expect(mockAuthManager.forceUserRefresh).toHaveBeenCalledTimes(1); - expect(hash).toEqual({ tx_hash: '' }); - }); - }); - }); -}); diff --git a/packages/passport/sdk/src/starkEx/workflows/registerOffchain.ts b/packages/passport/sdk/src/starkEx/workflows/registerOffchain.ts index 794efa0445..c16e4bb880 100644 --- a/packages/passport/sdk/src/starkEx/workflows/registerOffchain.ts +++ b/packages/passport/sdk/src/starkEx/workflows/registerOffchain.ts @@ -1,19 +1,26 @@ import axios from 'axios'; import { ImxApiClients, imx } from '@imtbl/generated-clients'; import { EthSigner, StarkSigner } from '@imtbl/x-client'; -import AuthManager from '../../authManager'; +import { Auth, User } from '@imtbl/auth'; +import { retryWithDelay } from '@imtbl/wallet'; import { PassportErrorType, withPassportError } from '../../errors/passportError'; -import { retryWithDelay } from '../../network/retry'; -import { User } from '../../types'; +import { toUserImx } from '../../utils/imxUser'; import registerPassportStarkEx from './registration'; -async function forceUserRefresh(authManager: AuthManager) { +async function forceUserRefresh(auth: Auth) { // User metadata is updated asynchronously. Poll userinfo endpoint until it is updated. await retryWithDelay(async () => { - const user = await authManager.forceUserRefresh(); // force refresh to get updated user info - if (user?.imx) return user; + const user = await auth.forceUserRefresh(); // force refresh to get updated user info + if (!user) { + return Promise.reject(new Error('user wallet addresses not exist')); + } - return Promise.reject(new Error('user wallet addresses not exist')); + try { + toUserImx(user); + return user; + } catch { + return Promise.reject(new Error('user wallet addresses not exist')); + } }); } @@ -21,7 +28,7 @@ export default async function registerOffchain( userAdminKeySigner: EthSigner, starkSigner: StarkSigner, unregisteredUser: User, - authManager: AuthManager, + auth: Auth, imxApiClients: ImxApiClients, ) { return withPassportError(async () => { @@ -34,13 +41,13 @@ export default async function registerOffchain( }, unregisteredUser.accessToken, ); - await forceUserRefresh(authManager); + await forceUserRefresh(auth); return response; } catch (err: any) { if (axios.isAxiosError(err) && err.response?.status === 409) { // The user already registered, but the user token is not updated yet. - await forceUserRefresh(authManager); + await forceUserRefresh(auth); return { tx_hash: '' }; } diff --git a/packages/passport/sdk/src/starkEx/workflows/trades.test.ts b/packages/passport/sdk/src/starkEx/workflows/trades.test.ts deleted file mode 100644 index a30324afc3..0000000000 --- a/packages/passport/sdk/src/starkEx/workflows/trades.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { imx } from '@imtbl/generated-clients'; -import { createTrade } from './trades'; -import { mockErrorMessage, mockStarkSignature, mockUserImx } from '../../test/mocks'; -import { PassportError, PassportErrorType } from '../../errors/passportError'; -import GuardianClient from '../../guardian'; - -jest.mock('../../guardian'); - -const mockPayloadHash = 'test_payload_hash'; -const mockSignableTradeRequest = { - getSignableTradeRequest: { - expiration_timestamp: 1231234, - fees: [], - order_id: 1234, - user: mockUserImx.imx.ethAddress, - }, -}; -const mockSignableTradeResponseData = { - amount_buy: '2', - amount_sell: '1', - asset_id_buy: '1234', - asset_id_sell: '4321', - expiration_timestamp: 0, - fee_info: [], - nonce: 0, - stark_key: '0x1234', - vault_id_buy: '0x02705737c', - vault_id_sell: '0x04006590f', -}; -const mockSignableTradeResponse = { - data: { - ...mockSignableTradeResponseData, - payload_hash: mockPayloadHash, - readable_transaction: 'test_readable_transaction', - signable_message: 'test_signable_message', - verification_signature: 'test_verification_signature', - }, -}; -const mockCreateTradeRequest = { - createTradeRequest: { - ...mockSignableTradeResponseData, - stark_signature: mockStarkSignature, - fees: [], - include_fees: true, - order_id: 1234, - }, -}; -const mockHeader = { - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Authorization: `Bearer ${mockUserImx.accessToken}`, - }, -}; -const mockReturnValue = { - status: 'success', - trade_id: 123, -}; -const mockStarkSigner = { - signMessage: jest.fn(), - getAddress: jest.fn(), - getYCoordinate: jest.fn(), -}; - -describe('Trades', () => { - const mockGuardianClient = new GuardianClient({} as any); - - beforeEach(() => { - (mockGuardianClient.withDefaultConfirmationScreenTask as jest.Mock).mockImplementation((task) => task); - }); - - describe('createTrade', () => { - afterEach(jest.resetAllMocks); - - const mockGetSignableTrade = jest.fn(); - const mockCreateTrade = jest.fn(); - - const mockTradesApi: imx.TradesApi = { - getSignableTrade: mockGetSignableTrade, - createTradeV3: mockCreateTrade, - } as unknown as imx.TradesApi; - - it('should successfully create a trade ', async () => { - mockGetSignableTrade.mockResolvedValue(mockSignableTradeResponse); - mockStarkSigner.signMessage.mockResolvedValue(mockStarkSignature); - mockCreateTrade.mockResolvedValue({ - data: mockReturnValue, - }); - - const result = await createTrade({ - tradesApi: mockTradesApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: mockSignableTradeRequest.getSignableTradeRequest, - guardianClient: mockGuardianClient, - }); - - expect(mockGetSignableTrade).toBeCalledWith(mockSignableTradeRequest, mockHeader); - expect(mockStarkSigner.signMessage).toBeCalledWith(mockPayloadHash); - expect(mockGuardianClient.evaluateImxTransaction) - .toBeCalledWith({ payloadHash: mockPayloadHash }); - expect(mockCreateTrade).toBeCalledWith( - mockCreateTradeRequest, - mockHeader, - ); - expect(result).toEqual(mockReturnValue); - }); - - it('should return error if transfer is rejected by user', async () => { - mockGetSignableTrade.mockResolvedValue(mockSignableTradeResponse); - (mockGuardianClient.evaluateImxTransaction as jest.Mock).mockRejectedValue(new Error('Transaction rejected by user')); - - await expect(() => createTrade({ - tradesApi: mockTradesApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: mockSignableTradeRequest.getSignableTradeRequest, - guardianClient: mockGuardianClient, - })).rejects.toThrowError('Transaction rejected by user'); - - expect(mockGuardianClient.evaluateImxTransaction) - .toBeCalledWith({ payloadHash: mockPayloadHash }); - }); - - it('should return error if failed to call public api', async () => { - mockGetSignableTrade.mockRejectedValue(new Error(mockErrorMessage)); - - await expect(() => createTrade({ - tradesApi: mockTradesApi, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: mockSignableTradeRequest.getSignableTradeRequest, - guardianClient: mockGuardianClient, - })).rejects.toThrow( - new PassportError( - mockErrorMessage, - PassportErrorType.CREATE_TRADE_ERROR, - ), - ); - }); - }); -}); diff --git a/packages/passport/sdk/src/starkEx/workflows/trades.ts b/packages/passport/sdk/src/starkEx/workflows/trades.ts index 159af74c75..489aec11f0 100644 --- a/packages/passport/sdk/src/starkEx/workflows/trades.ts +++ b/packages/passport/sdk/src/starkEx/workflows/trades.ts @@ -1,15 +1,15 @@ import { imx } from '@imtbl/generated-clients'; import { StarkSigner } from '@imtbl/x-client'; +import { ImxGuardianClient } from '../imxGuardianClient'; import { PassportErrorType, withPassportError } from '../../errors/passportError'; import { UserImx } from '../../types'; -import GuardianClient from '../../guardian'; type CreateTradeParams = { request: imx.GetSignableTradeRequest; tradesApi: imx.TradesApi; user: UserImx; starkSigner: StarkSigner; - guardianClient: GuardianClient, + guardianClient: ImxGuardianClient, }; export async function createTrade({ @@ -35,9 +35,7 @@ export async function createTrade({ }, { headers }); - await guardianClient.evaluateImxTransaction({ - payloadHash: getSignableTradeResponse.data.payload_hash, - }); + await guardianClient.evaluateTransaction(getSignableTradeResponse.data.payload_hash); const { payload_hash: payloadHash } = getSignableTradeResponse.data; const starkSignature = await starkSigner.signMessage(payloadHash); diff --git a/packages/passport/sdk/src/starkEx/workflows/transfer.test.ts b/packages/passport/sdk/src/starkEx/workflows/transfer.test.ts deleted file mode 100644 index 726bbe8950..0000000000 --- a/packages/passport/sdk/src/starkEx/workflows/transfer.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { imx } from '@imtbl/generated-clients'; -import { UnsignedTransferRequest } from '@imtbl/x-client'; -import { PassportError, PassportErrorType } from '../../errors/passportError'; -import { mockErrorMessage, mockStarkSignature, mockUserImx } from '../../test/mocks'; -import { batchNftTransfer, transfer } from './transfer'; -import GuardianClient from '../../guardian'; - -jest.mock('../../guardian'); - -describe('transfer', () => { - const mockGuardianClient = new GuardianClient({} as any); - - beforeEach(() => { - (mockGuardianClient.withDefaultConfirmationScreenTask as jest.Mock).mockImplementation((task) => task); - (mockGuardianClient.withConfirmationScreenTask as jest.Mock).mockImplementation(() => (task: any) => task); - }); - - afterEach(jest.resetAllMocks); - - const mockStarkSigner = { - signMessage: jest.fn(), - getAddress: jest.fn(), - getYCoordinate: jest.fn(), - }; - - describe('single transfer', () => { - let getSignableTransferV1Mock: jest.Mock; - let createTransferV1Mock: jest.Mock; - let transferApiMock: imx.TransfersApi; - - const mockReceiver = 'AAA'; - const type = 'ERC721'; - const tokenId = '111'; - const tokenAddress = '0x1234'; - const mockTransferRequest = { - type, - tokenId, - tokenAddress, - receiver: mockReceiver, - }; - - beforeEach(() => { - getSignableTransferV1Mock = jest.fn(); - createTransferV1Mock = jest.fn(); - transferApiMock = { - getSignableTransferV1: getSignableTransferV1Mock, - createTransferV1: createTransferV1Mock, - } as unknown as imx.TransfersApi; - }); - - it('should return success transfer result', async () => { - const mockSignableTransferRequest = { - getSignableTransferRequest: { - amount: '1', - receiver: mockReceiver, - sender: mockUserImx.imx.ethAddress, - token: { - data: { token_address: tokenAddress, token_id: tokenId }, - type, - }, - }, - }; - const mockSignableTransferV1Response = { - data: { - payload_hash: '123123', - sender_stark_key: 'starkKey', - sender_vault_id: '111', - receiver_stark_key: 'starkKey2', - receiver_vault_id: '222', - asset_id: tokenId, - amount: '1', - nonce: '5321', - expiration_timestamp: '1234', - }, - }; - const { - payload_hash: mockPayloadHash, - ...restSignableTransferV1Response - } = mockSignableTransferV1Response.data; - const mockCreateTransferRequest = { - createTransferRequest: { - ...restSignableTransferV1Response, - stark_signature: mockStarkSignature, - }, - }; - const mockHeader = { - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Authorization: `Bearer ${mockUserImx.accessToken}`, - }, - }; - const mockReturnValue = { - sent_signature: '0x1c8aff950685c2ed4bc3174f3472287b56d95', - status: 'success', - time: 111, - transfer_id: 123, - }; - - getSignableTransferV1Mock.mockResolvedValue( - mockSignableTransferV1Response, - ); - mockStarkSigner.signMessage.mockResolvedValue(mockStarkSignature); - createTransferV1Mock.mockResolvedValue({ - data: mockReturnValue, - }); - - const result = await transfer({ - transfersApi: transferApiMock, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: mockTransferRequest as UnsignedTransferRequest, - guardianClient: mockGuardianClient, - }); - - expect(getSignableTransferV1Mock).toBeCalledWith(mockSignableTransferRequest, mockHeader); - expect(mockStarkSigner.signMessage).toBeCalledWith(mockPayloadHash); - expect(mockGuardianClient.evaluateImxTransaction) - .toBeCalledWith({ payloadHash: mockPayloadHash }); - expect(createTransferV1Mock).toBeCalledWith(mockCreateTransferRequest, mockHeader); - expect(result).toEqual(mockReturnValue); - }); - - it('should return error if failed to call public api', async () => { - getSignableTransferV1Mock.mockRejectedValue(new Error(mockErrorMessage)); - - await expect(() => transfer({ - transfersApi: transferApiMock, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: mockTransferRequest as UnsignedTransferRequest, - guardianClient: mockGuardianClient, - })).rejects.toThrow( - new PassportError( - mockErrorMessage, - PassportErrorType.TRANSFER_ERROR, - ), - ); - }); - - it('should return error if transfer is rejected by user', async () => { - const mockSignableTransferV1Response = { - data: { - payload_hash: '123123', - sender_stark_key: 'starkKey', - sender_vault_id: '111', - receiver_stark_key: 'starkKey2', - receiver_vault_id: '222', - asset_id: tokenId, - amount: '1', - nonce: '5321', - expiration_timestamp: '1234', - }, - }; - getSignableTransferV1Mock.mockResolvedValue( - mockSignableTransferV1Response, - ); - - (mockGuardianClient.evaluateImxTransaction as jest.Mock) - .mockRejectedValue(new Error('Transaction rejected by user')); - - await expect(() => transfer({ - transfersApi: transferApiMock, - starkSigner: mockStarkSigner, - user: mockUserImx, - request: mockTransferRequest as UnsignedTransferRequest, - guardianClient: mockGuardianClient, - })).rejects.toThrow(new PassportError( - 'Transaction rejected by user', - PassportErrorType.TRANSFER_ERROR, - )); - }); - }); - - describe('batchNftTransfer', () => { - let mockGetSignableTransfer: jest.Mock; - let mockCreateTransfer: jest.Mock; - let mockTransferApi: imx.TransfersApi; - - const transferRequest = [ - { - tokenId: '1', - tokenAddress: 'token_address', - receiver: 'receiver_eth_address', - }, - ]; - - beforeEach(() => { - mockGetSignableTransfer = jest.fn(); - mockCreateTransfer = jest.fn(); - mockTransferApi = { - getSignableTransfer: mockGetSignableTransfer, - createTransfer: mockCreateTransfer, - } as unknown as imx.TransfersApi; - }); - - it('should make a successful batch transfer request', async () => { - const mockTransferResponse = { - data: { - transfer_ids: ['transfer_id_1'], - }, - }; - const sender_stark_key = 'sender_stark_key'; - const sender_vault_id = 'sender_vault_id'; - const receiver_stark_key = 'receiver_stark_key'; - const receiver_vault_id = 'receiver_vault_id'; - const asset_id = 'asset_id'; - const amount = 'amount'; - const nonce = 'nonce'; - const expiration_timestamp = 'expiration_timestamp'; - const payload_hash = 'payload_hash'; - const mockSignableTransferResponse = { - data: { - sender_stark_key, - signable_responses: [ - { - sender_vault_id, - receiver_stark_key, - receiver_vault_id, - asset_id, - amount, - nonce, - expiration_timestamp, - payload_hash, - }, - ], - }, - }; - const mockHeader = { - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - Authorization: `Bearer ${mockUserImx.accessToken}`, - }, - }; - - mockGetSignableTransfer.mockResolvedValue(mockSignableTransferResponse); - mockStarkSigner.signMessage.mockResolvedValue(mockStarkSignature); - mockCreateTransfer.mockResolvedValue(mockTransferResponse); - - const result = await batchNftTransfer({ - user: mockUserImx, - starkSigner: mockStarkSigner, - request: transferRequest, - transfersApi: mockTransferApi, - guardianClient: mockGuardianClient, - }); - - expect(result).toEqual({ - transfer_ids: mockTransferResponse.data.transfer_ids, - }); - expect(mockGetSignableTransfer).toHaveBeenCalledWith({ - getSignableTransferRequestV2: { - sender_ether_key: mockUserImx.imx.ethAddress, - signable_requests: [ - { - amount: '1', - token: { - type: 'ERC721', - data: { - token_id: transferRequest[0].tokenId, - token_address: transferRequest[0].tokenAddress, - }, - }, - receiver: transferRequest[0].receiver, - }, - ], - }, - }, mockHeader); - expect(mockStarkSigner.signMessage).toHaveBeenCalled(); - expect(mockGuardianClient.evaluateImxTransaction) - .toBeCalledWith({ payloadHash: payload_hash }); - expect(mockCreateTransfer).toHaveBeenCalledWith( - { - createTransferRequestV2: { - sender_stark_key, - requests: [ - { - sender_vault_id, - receiver_stark_key, - receiver_vault_id, - asset_id, - amount, - nonce, - expiration_timestamp, - stark_signature: mockStarkSignature, - }, - ], - }, - }, - mockHeader, - ); - }); - - it('should return error if failed to call public api', async () => { - mockGetSignableTransfer.mockRejectedValue(new Error(mockErrorMessage)); - - await expect(() => batchNftTransfer({ - user: mockUserImx, - starkSigner: mockStarkSigner, - request: transferRequest, - transfersApi: mockTransferApi, - guardianClient: mockGuardianClient, - })).rejects.toThrow( - new PassportError( - mockErrorMessage, - PassportErrorType.TRANSFER_ERROR, - ), - ); - }); - - it('should return error if transfer is rejected by user', async () => { - const sender_stark_key = 'sender_stark_key'; - const sender_vault_id = 'sender_vault_id'; - const receiver_stark_key = 'receiver_stark_key'; - const receiver_vault_id = 'receiver_vault_id'; - const asset_id = 'asset_id'; - const amount = 'amount'; - const nonce = 'nonce'; - const expiration_timestamp = 'expiration_timestamp'; - const payload_hash = 'payload_hash'; - const mockSignableTransferResponse = { - data: { - sender_stark_key, - signable_responses: [ - { - sender_vault_id, - receiver_stark_key, - receiver_vault_id, - asset_id, - amount, - nonce, - expiration_timestamp, - payload_hash, - }, - ], - }, - }; - mockGetSignableTransfer.mockResolvedValue(mockSignableTransferResponse); - - (mockGuardianClient.evaluateImxTransaction as jest.Mock).mockRejectedValue(new Error('Transaction rejected by user')); - await expect(() => batchNftTransfer({ - user: mockUserImx, - starkSigner: mockStarkSigner, - request: transferRequest, - transfersApi: mockTransferApi, - guardianClient: mockGuardianClient, - })).rejects.toThrow( - new PassportError( - 'Transaction rejected by user', - PassportErrorType.TRANSFER_ERROR, - ), - ); - }); - }); -}); diff --git a/packages/passport/sdk/src/starkEx/workflows/transfer.ts b/packages/passport/sdk/src/starkEx/workflows/transfer.ts index 6934e66a4a..e446672275 100644 --- a/packages/passport/sdk/src/starkEx/workflows/transfer.ts +++ b/packages/passport/sdk/src/starkEx/workflows/transfer.ts @@ -5,9 +5,9 @@ import { UnsignedTransferRequest, } from '@imtbl/x-client'; import { convertToSignableToken } from '@imtbl/toolkit'; +import { ImxGuardianClient } from '../imxGuardianClient'; import { PassportErrorType, withPassportError } from '../../errors/passportError'; import { UserImx } from '../../types'; -import GuardianClient from '../../guardian'; const ERC721 = 'ERC721'; @@ -16,7 +16,7 @@ type TransferRequest = { user: UserImx; starkSigner: StarkSigner; transfersApi: imx.TransfersApi; - guardianClient: GuardianClient; + guardianClient: ImxGuardianClient; }; type BatchTransfersParams = { @@ -24,7 +24,7 @@ type BatchTransfersParams = { user: UserImx; starkSigner: StarkSigner; transfersApi: imx.TransfersApi; - guardianClient: GuardianClient; + guardianClient: ImxGuardianClient; }; export async function transfer({ @@ -54,9 +54,7 @@ export async function transfer({ { headers }, ); - await guardianClient.evaluateImxTransaction({ - payloadHash: signableResult.data.payload_hash, - }); + await guardianClient.evaluateTransaction(signableResult.data.payload_hash); const signableResultData = signableResult.data; const { payload_hash: payloadHash } = signableResultData; @@ -126,9 +124,9 @@ export async function batchNftTransfer({ { headers }, ); - await guardianClient.evaluateImxTransaction({ - payloadHash: signableResult.data.signable_responses[0]?.payload_hash, - }); + await guardianClient.evaluateTransaction( + signableResult.data.signable_responses[0]?.payload_hash as string, + ); const requests = await Promise.all( signableResult.data.signable_responses.map(async (resp) => { diff --git a/packages/passport/sdk/src/test/mocks.ts b/packages/passport/sdk/src/test/mocks.ts deleted file mode 100644 index e0b2f0d733..0000000000 --- a/packages/passport/sdk/src/test/mocks.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Environment, ImmutableConfiguration } from '@imtbl/config'; -import { User, UserImx, UserZkEvm } from '../types'; -import { PassportConfiguration } from '../config'; -import { ChainId } from '../network/chains'; - -export const mockErrorMessage = 'Server is down'; -export const mockStarkSignature = 'starkSignature'; - -export const chainId = ChainId.IMTBL_ZKEVM_TESTNET; -export const chainIdHex = '0x34a1'; -export const chainIdEip155 = `eip155:${chainId}`; - -const accessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuYXV0aDAuY29tLyIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUuY29tL2NhbGFuZGFyL3YxLyIsInN1YiI6InVzcl8xMjMiLCJpYXQiOjE0NTg3ODU3OTYsImV4cCI6MTQ1ODg3MjE5Nn0.CA7eaHjIHz5NxeIJoFK9krqaeZrPLwmMmgI_XiQiIkQ'; - -export const testConfig = new PassportConfiguration({ - baseConfig: new ImmutableConfiguration({ - environment: Environment.SANDBOX, - }), - clientId: 'client123', - logoutRedirectUri: 'http://localhost:3000/logout', - redirectUri: 'http://localhost:3000/callback', - popupRedirectUri: 'http://localhost:3000/callback2', -}); - -export const mockUser: User = { - accessToken, - idToken: 'id123', - refreshToken: 'refresh123', - profile: { - sub: 'email|123', - email: 'test@immutable.com', - nickname: 'test', - }, - expired: false, -}; - -export const mockUserImx: UserImx = { - ...mockUser, - imx: { - ethAddress: 'imxEthAddress123', - starkAddress: 'imxStarkAddress123', - userAdminAddress: 'imxUserAdminAddress123', - }, -}; - -export const mockUserZkEvm: UserZkEvm = { - ...mockUser, - zkEvm: { - ethAddress: '0x0000000000000000000000000000000000000001', - userAdminAddress: '0x0000000000000000000000000000000000000002', - }, -}; - -export const mockLinkedAddresses = { - data: { - sub: 'sub', - linked_addresses: [ - '0x123', - '0x456', - ], - }, -}; - -export const mockListChains = { - data: { - result: [ - { - id: 'eip155:13473', - name: 'Immutable zkEVM Test', - rpc_url: 'https://rpc.testnet.immutable.com', - }, - ], - }, -}; - -export const mockLinkedWallet = { - data: { - address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', - type: 'MetaMask', - created_at: '2021-08-31T00:00:00Z', - updated_at: '2021-08-31T00:00:00Z', - name: 'Test', - clientName: 'Passport Dashboard', - }, - status: 200, -}; - -export const mockApiError = { - response: { - data: { - code: 'api_error', - message: 'API error occurred', - }, - status: 500, - }, -}; -export const mockPassportBadRequest = { - response: { - data: { - code: 'ALREADY_LINKED', - message: 'Already linked', - }, - status: 400, - }, -}; diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index fcadf3a851..40fb417b56 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -10,11 +10,9 @@ import { Flow } from '@imtbl/metrics'; */ export type DirectLoginMethod = string; -export enum PassportEvents { - LOGGED_OUT = 'loggedOut', - LOGGED_IN = 'loggedIn', - ACCOUNTS_REQUESTED = 'accountsRequested', -} +// Re-export events from auth and wallet +export { AuthEvents } from '@imtbl/auth'; +export { WalletEvents } from '@imtbl/wallet'; export type AccountsRequestedEvent = { environment: Environment; @@ -24,48 +22,14 @@ export type AccountsRequestedEvent = { flow?: Flow; }; -export interface PassportEventMap extends Record { - [PassportEvents.LOGGED_OUT]: []; - [PassportEvents.LOGGED_IN]: [User]; - [PassportEvents.ACCOUNTS_REQUESTED]: [AccountsRequestedEvent]; -} - -export type UserProfile = { - email?: string; - nickname?: string; - sub: string; - username?: string; -}; - -export enum RollupType { - IMX = 'imx', - ZKEVM = 'zkEvm', -} - -export type User = { - idToken?: string; - accessToken: string; - refreshToken?: string; - profile: UserProfile; - expired?: boolean; - [RollupType.IMX]?: { - ethAddress: string; - starkAddress: string; - userAdminAddress: string; - }; - [RollupType.ZKEVM]?: { - ethAddress: string; - userAdminAddress: string; - }; -}; - -export type PassportMetadata = { - imx_eth_address: string; - imx_stark_address: string; - imx_user_admin_address: string; - zkevm_eth_address: string; - zkevm_user_admin_address: string; -}; +export type { + User, + UserProfile, + DeviceTokenResponse, + IdTokenPayload, +} from '@imtbl/auth'; +export { isUserZkEvm } from '@imtbl/auth'; +export type { UserImx } from './utils/imxUser'; export interface OidcConfiguration { clientId: string; @@ -90,6 +54,24 @@ export interface PassportOverrides { orderBookMrBasePath: string; passportMrBasePath: string; imxApiClients?: ImxApiClients; // needs to be optional because ImxApiClients is not exposed publicly + + /** + * Custom chain ID for dev environments (optional) + * If provided, overrides the default chainId based on environment + */ + zkEvmChainId?: number; + + /** + * Custom chain name for dev environments (optional) + * Used when zkEvmChainId is provided + */ + zkEvmChainName?: string; + + /** + * Magic TEE base path (optional, for dev/custom environments) + * Defaults to 'https://tee.express.magiclabs.com' + */ + magicTeeBasePath?: string; } export interface PopupOverlayOptions { @@ -127,63 +109,25 @@ export interface PassportModuleConfiguration forceScwDeployBeforeMessageSignature?: boolean; } -type WithRequired = T & { [P in K]-?: T[P] }; - -export type UserImx = WithRequired; -export type UserZkEvm = WithRequired; - -export const isUserZkEvm = (user: User): user is UserZkEvm => !!user[RollupType.ZKEVM]; -export const isUserImx = (user: User): user is UserImx => !!user[RollupType.IMX]; - -export type DeviceTokenResponse = { - access_token: string; - refresh_token?: string; - id_token: string; - token_type: string; - expires_in: number; -}; - export type TokenPayload = { exp?: number; }; -export type IdTokenPayload = { - passport?: PassportMetadata; - email: string; - nickname: string; - username?: string; - aud: string; - sub: string; - exp: number; - iss: string; - iat: number; -}; - export type PKCEData = { state: string; verifier: string; }; -export type LinkWalletParams = { - type: string; - walletAddress: string; - signature: string; - nonce: string; -}; - -export type LinkedWallet = { - address: string; - type: string; - created_at: string; - updated_at: string; - name?: string; - clientName: string; -}; +// Re-export wallet linking types from wallet package +export type { LinkWalletParams, LinkedWallet } from '@imtbl/wallet'; export type ConnectEvmArguments = { announceProvider: boolean; }; +// Export ZkEvmProvider for return type +export type { ZkEvmProvider } from '@imtbl/wallet'; + export type LoginArguments = { useCachedSession?: boolean; anonymousId?: string; diff --git a/packages/passport/sdk/src/utils/imxUser.ts b/packages/passport/sdk/src/utils/imxUser.ts new file mode 100644 index 0000000000..14a313198c --- /dev/null +++ b/packages/passport/sdk/src/utils/imxUser.ts @@ -0,0 +1,53 @@ +import jwt_decode from 'jwt-decode'; +import type { User, IdTokenPayload } from '@imtbl/auth'; +import { PassportError, PassportErrorType } from '../errors/passportError'; + +type ImxMetadata = { + imx_eth_address?: string; + imx_stark_address?: string; + imx_user_admin_address?: string; +}; + +type PassportPayload = IdTokenPayload & { + passport?: ImxMetadata; +}; + +export type UserImx = User & { + imx: { + ethAddress: string; + starkAddress: string; + userAdminAddress: string; + }; +}; + +export const toUserImx = (user: User): UserImx => { + if (!user.idToken) { + throw new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ); + } + + const payload = jwt_decode(user.idToken); + const metadata = payload.passport; + + if ( + !metadata?.imx_eth_address + || !metadata?.imx_stark_address + || !metadata?.imx_user_admin_address + ) { + throw new PassportError( + 'User has not been registered with StarkEx', + PassportErrorType.USER_NOT_REGISTERED_ERROR, + ); + } + + return { + ...user, + imx: { + ethAddress: metadata.imx_eth_address, + starkAddress: metadata.imx_stark_address, + userAdminAddress: metadata.imx_user_admin_address, + }, + }; +}; diff --git a/packages/passport/sdk/src/utils/metrics.test.ts b/packages/passport/sdk/src/utils/metrics.test.ts deleted file mode 100644 index 1b43ba1e68..0000000000 --- a/packages/passport/sdk/src/utils/metrics.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { trackError, trackFlow } from '@imtbl/metrics'; -import { withMetrics, withMetricsAsync } from './metrics'; - -jest.mock('@imtbl/metrics'); - -describe('passport metrics', () => { - beforeEach(() => { - (trackFlow as unknown as jest.Mock).mockImplementation(() => ({ - addEvent: jest.fn(), - details: { - flowId: '123', - }, - })); - }); - - describe('withMetrics', () => { - it('should execute the function successfully', () => { - const returnValue = 'success'; - const mockFn = jest.fn(); - mockFn.mockReturnValue(returnValue); - - expect(withMetrics(mockFn, 'myFlow')).toEqual(returnValue); - }); - - it('should track and re-throw error', () => { - const mockFn = jest.fn().mockImplementation(() => { - throw new Error('error'); - }); - - try { - withMetrics(mockFn, 'myFlow'); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect(error).toMatchObject({ - message: 'error', - }); - expect(trackFlow).toBeCalledTimes(1); - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'myFlow', - error, - { flowId: '123' }, - ); - } - }); - }); - - describe('withMetricsAsync', () => { - it('should execute the async function successfully', async () => { - const returnValue = 'success'; - const mockFn = jest.fn(); - mockFn.mockResolvedValue(returnValue); - - expect(await withMetricsAsync(mockFn, 'myFlow')).toEqual(returnValue); - }); - - it('should track and re-throw error', async () => { - const errorFunction = jest.fn(); - errorFunction.mockRejectedValue(new Error('error')); - - try { - await withMetricsAsync(errorFunction, 'myFlow'); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect(error).toMatchObject({ - message: 'error', - }); - expect(trackFlow).toBeCalledTimes(1); - expect(trackError).toHaveBeenCalledWith( - 'passport', - 'myFlow', - error, - { flowId: '123' }, - ); - } - }); - }); -}); diff --git a/packages/passport/sdk/src/utils/metrics.ts b/packages/passport/sdk/src/utils/metrics.ts index 3198a51d09..683c9ac717 100644 --- a/packages/passport/sdk/src/utils/metrics.ts +++ b/packages/passport/sdk/src/utils/metrics.ts @@ -1,33 +1,5 @@ import { Flow, trackError, trackFlow } from '@imtbl/metrics'; -export const withMetrics = ( - fn: (flow: Flow) => T, - flowName: string, - trackStartEvent: boolean = true, - trackEndEvent: boolean = true, -): T => { - const flow: Flow = trackFlow( - 'passport', - flowName, - trackStartEvent, - ); - - try { - return fn(flow); - } catch (error) { - if (error instanceof Error) { - trackError('passport', flowName, error, { flowId: flow.details.flowId }); - } else { - flow.addEvent('errored'); - } - throw error; - } finally { - if (trackEndEvent) { - flow.addEvent('End'); - } - } -}; - export const withMetricsAsync = async ( fn: (flow: Flow) => Promise, flowName: string, diff --git a/packages/passport/sdk/src/utils/string.test.ts b/packages/passport/sdk/src/utils/string.test.ts deleted file mode 100644 index 7bf836251b..0000000000 --- a/packages/passport/sdk/src/utils/string.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { hexToString } from './string'; - -describe('string', () => { - describe('hexToString', () => { - it('should return hex if it is not a valid hex', () => { - const hex = '0x123'; - const hex2 = 'test'; - - expect(hexToString(hex)).toEqual(hex); - expect(hexToString(hex2)).toEqual(hex2); - }); - - it('should return utf8 string if it is a valid utf8', () => { - const hex = '0x68656c6c6f20776f726c64'; - - expect(hexToString(hex)).toEqual('hello world'); - }); - - it('should return utf8 string if it is a valid utf8 with leading zeros', () => { - const hex = '0x0068656c6c6f20776f726c64'; // 'hello world' with leading zero - - expect(hexToString(hex)).toEqual('hello world'); - }); - - it('should return empty string if input is an empty string', () => { - const hex = ''; - - expect(hexToString(hex)).toEqual(''); - }); - - it('should return hex if it is a valid hex but not a valid utf8', () => { - const hex = '0x1234567890abcdef'; // valid hex but not a valid utf8 - - expect(hexToString(hex)).toEqual(hex); - }); - }); -}); diff --git a/packages/passport/sdk/src/utils/token.test.ts b/packages/passport/sdk/src/utils/token.test.ts deleted file mode 100644 index e42bee942b..0000000000 --- a/packages/passport/sdk/src/utils/token.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import encode from 'jwt-encode'; -import { - User as OidcUser, -} from 'oidc-client-ts'; -import { isAccessTokenExpiredOrExpiring } from './token'; - -const now = Math.floor(Date.now() / 1000); -const oneHourLater = now + 3600; -const oneHourBefore = now - 3600; -const fifteenSecondsLater = now + 15; -const fortyFiveSecondsLater = now + 45; - -const mockExpiredIdToken = encode({ - iat: oneHourBefore, - exp: oneHourBefore, -}, 'secret'); - -export const mockValidIdToken = encode({ - iat: now, - exp: oneHourLater, -}, 'secret'); - -const mockFreshAccessToken = encode({ - exp: fortyFiveSecondsLater, // Expires in 45 seconds (outside 30-second buffer) -}, 'secret'); - -describe('isAccessTokenExpiredOrExpiring', () => { - it('should return true if access token is missing', () => { - const user = { - id_token: mockValidIdToken, - access_token: undefined, - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(true); - }); - - it('should return true if id token is missing', () => { - const mockValidAccessToken = encode({ - exp: oneHourLater, - }, 'secret'); - - const user = { - id_token: undefined, - access_token: mockValidAccessToken, - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(true); - }); - - it('should return true if access token is expired', () => { - const mockExpiredAccessToken = encode({ - exp: oneHourBefore, - }, 'secret'); - - const user = { - id_token: mockValidIdToken, - access_token: mockExpiredAccessToken, - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(true); - }); - - it('should return true if access token is expiring within 30 seconds', () => { - const mockExpiringAccessToken = encode({ - exp: fifteenSecondsLater, // Expires in 15 seconds (within 30-second buffer) - }, 'secret'); - - const user = { - id_token: mockValidIdToken, - access_token: mockExpiringAccessToken, - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(true); - }); - - it('should return true if access token is valid but id token is expired', () => { - const user = { - id_token: mockExpiredIdToken, - access_token: mockFreshAccessToken, - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(true); - }); - - it('should return true if access token is valid but id token is expiring within 30 seconds', () => { - const expiringIdToken = encode({ - iat: now, - exp: now + 15, // Expires in 15 seconds - }, 'secret'); - - const user = { - id_token: expiringIdToken, - access_token: mockFreshAccessToken, - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(true); - }); - - it('should return false if both tokens are valid and not expiring', () => { - const user = { - id_token: mockValidIdToken, - access_token: mockFreshAccessToken, - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(false); - }); - - it('should return true if access token is malformed', () => { - const user = { - id_token: mockValidIdToken, - access_token: 'invalid-jwt-token', - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(true); - }); - - it('should return true if access token has no exp claim (security vulnerability)', () => { - const accessTokenWithoutExp = encode({ - iat: now, - sub: 'user123', - }, 'secret'); - - const user = { - id_token: mockValidIdToken, - access_token: accessTokenWithoutExp, - } as unknown as OidcUser; - expect(isAccessTokenExpiredOrExpiring(user)).toBe(true); - }); -}); diff --git a/packages/passport/sdk/src/utils/typedEventEmitter.test.ts b/packages/passport/sdk/src/utils/typedEventEmitter.test.ts deleted file mode 100644 index e33bf435e0..0000000000 --- a/packages/passport/sdk/src/utils/typedEventEmitter.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import TypedEventEmitter from './typedEventEmitter'; - -type TestEvents = { - testEvent1: [Array]; - testEvent2: [{ id: number }], -}; - -describe('TypedEventEmitter', () => { - it('should be able to emit and listen to events', () => { - const eventEmitter = new TypedEventEmitter(); - - const testEvent1Handler = jest.fn(); - const testEvent2Handler = jest.fn(); - - eventEmitter.on('testEvent1', testEvent1Handler); - eventEmitter.on('testEvent2', testEvent2Handler); - - eventEmitter.emit('testEvent1', [1, 2, 3]); - eventEmitter.emit('testEvent2', { id: 1 }); - - expect(testEvent1Handler).toHaveBeenCalledWith([1, 2, 3]); - expect(testEvent2Handler).toHaveBeenCalledWith({ id: 1 }); - - eventEmitter.removeListener('testEvent1', testEvent1Handler); - eventEmitter.removeListener('testEvent2', testEvent2Handler); - - eventEmitter.emit('testEvent1', [4, 5, 6]); - eventEmitter.emit('testEvent2', { id: 2 }); - - expect(testEvent1Handler).toHaveBeenCalledTimes(1); - expect(testEvent2Handler).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/personalSign.test.ts b/packages/passport/sdk/src/zkEvm/personalSign.test.ts deleted file mode 100644 index 42ee4f3515..0000000000 --- a/packages/passport/sdk/src/zkEvm/personalSign.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Flow } from '@imtbl/metrics'; -import { JsonRpcProvider, Signer } from 'ethers'; -import { personalSign } from './personalSign'; -import { - packSignatures, - signERC191Message, -} from './walletHelpers'; -import { chainId } from '../test/mocks'; -import { RelayerClient } from './relayerClient'; -import GuardianClient from '../guardian'; - -jest.mock('./walletHelpers'); - -describe('personalSign', () => { - const eoaAddress = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const message = 'hello'; - - const eoaSignature = '02011b1d383526a2815d26550eb314b5d7e05513273300439b63b94e127c13e1bae9f3f24ab42717c7ae2e25fb82e7fd24afc320690413ca6581c798f91cce8296bd21f4f35a4b33b882a5401499f829481d8ed8d3de23741b0103'; - const relayerSignature = '02011b1d383526a2815d26550eb314b5d7e0551327330043c4d07715346a7d5517ecbc32304fc1ccdcd52fea386c94c3b58b90410f20cd1d5c6db8fa1f03c34e82dce78c3445ce38583e0b0689c69b8fbedbc33d3a2e45431b0103'; - const packedSignatures = '0x000202011b1d383526a2815d26550eb314b5d7e0551327330043c4d07715346a7d5517ecbc32304fc1ccdcd52fea386c94c3b58b90410f20cd1d5c6db8fa1f03c34e82dce78c3445ce38583e0b0689c69b8fbedbc33d3a2e45431b01030001d25acf5eef26fb627f91e02ebd111580030ab8fb0a55567ac8cc66c34de7ae98185125a76adc6ee2fea042c7fce9c85a41e790ce3529f93dfec281bf56620ef21b02'; - - // Mocks - const ethSigner = { - getAddress: jest.fn(), - }; - const rpcProvider = { - getNetwork: jest.fn(), - }; - const relayerClient = { - imSign: jest.fn(), - }; - const guardianClient = { - evaluateERC191Message: jest.fn(), - }; - const flow = { - addEvent: jest.fn(), - }; - - beforeEach(() => { - jest.resetAllMocks(); - - // Wallet helper mocks - (packSignatures as jest.Mock).mockReturnValue(packedSignatures); - (signERC191Message as jest.Mock).mockResolvedValue(eoaSignature); - - ethSigner.getAddress.mockResolvedValue(eoaAddress); - relayerClient.imSign.mockResolvedValue(relayerSignature); - rpcProvider.getNetwork.mockResolvedValue({ chainId }); - }); - - describe('when a valid address and message are provided', () => { - it('returns a signature', async () => { - const result = await personalSign({ - params: [message, eoaAddress], - ethSigner: ethSigner as unknown as Signer, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as unknown as GuardianClient, - zkEvmAddress: eoaAddress, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(packedSignatures); - expect(guardianClient.evaluateERC191Message).toHaveBeenCalledWith({ - payload: message, - chainID: chainId, - }); - expect(relayerClient.imSign).toHaveBeenCalledWith(eoaAddress, message); - expect(signERC191Message).toHaveBeenCalledWith( - BigInt(chainId), - message, - ethSigner, - eoaAddress, - ); - }); - }); - - describe('when a valid address and hex encoded message are provided', () => { - it('returns a signature', async () => { - const hexMessage = '0x68656c6c6f'; // 'hello' in hex - - const result = await personalSign({ - params: [hexMessage, eoaAddress], - ethSigner: ethSigner as unknown as Signer, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as unknown as GuardianClient, - zkEvmAddress: eoaAddress, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(packedSignatures); - expect(guardianClient.evaluateERC191Message).toHaveBeenCalledWith({ - payload: message, - chainID: chainId, - }); - expect(relayerClient.imSign).toHaveBeenCalledWith(eoaAddress, message); - expect(signERC191Message).toHaveBeenCalledWith( - BigInt(chainId), - message, - ethSigner, - eoaAddress, - ); - }); - }); - - describe('when an argument is missing', () => { - it('throws an error', async () => { - await expect(personalSign({ - params: [eoaAddress], - ethSigner: ethSigner as unknown as Signer, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as unknown as GuardianClient, - zkEvmAddress: eoaAddress, - flow: flow as unknown as Flow, - })).rejects.toThrow('personal_sign requires an address and a message'); - }); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/relayerClient.test.ts b/packages/passport/sdk/src/zkEvm/relayerClient.test.ts deleted file mode 100644 index 4afd8f1c98..0000000000 --- a/packages/passport/sdk/src/zkEvm/relayerClient.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { JsonRpcApiProvider, JsonRpcProvider } from 'ethers'; -import AuthManager from '../authManager'; -import { RelayerClient } from './relayerClient'; -import { PassportConfiguration } from '../config'; -import { UserZkEvm } from '../types'; -import { RelayerTransactionStatus, TypedDataPayload } from './types'; -import { chainId, chainIdEip155 } from '../test/mocks'; - -describe('relayerClient', () => { - const transactionHash = '0x456'; - const config = { - relayerUrl: 'https://example.com', - }; - const user = { - accessToken: 'accessToken123', - }; - - const rpcProvider: Partial = { - getNetwork: jest.fn().mockResolvedValue({ chainId, name: '' }), - }; - const relayerClient = new RelayerClient({ - config: config as PassportConfiguration, - rpcProvider: rpcProvider as JsonRpcProvider, - authManager: { - getUserZkEvm: jest.fn().mockResolvedValue(user as UserZkEvm), - } as unknown as AuthManager, - }); - - let originalFetch: any; - beforeAll(() => { - originalFetch = global.fetch; - global.fetch = jest.fn(); - }); - - afterEach(jest.clearAllMocks); - - afterAll(() => { - global.fetch = originalFetch; - }); - - describe('ethSendTransaction', () => { - it('calls relayer with the correct arguments', async () => { - const to = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const data = '0x123'; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ result: transactionHash })), - json: () => ({ - result: transactionHash, - }), - }); - - const result = await relayerClient.ethSendTransaction(to, data); - expect(result).toEqual(transactionHash); - expect(global.fetch).toHaveBeenCalledWith(`${config.relayerUrl}/v1/transactions`, expect.objectContaining({ - method: 'POST', - headers: { - Authorization: `Bearer ${user.accessToken}`, - 'Content-Type': 'application/json', - }, - })); - expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toMatchObject({ - id: 1, - jsonrpc: '2.0', - method: 'eth_sendTransaction', - params: [{ - to, - data, - chainId: chainIdEip155, - }], - }); - }); - - it('throws error from JSON response when response contains error field', async () => { - const to = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const data = '0x123'; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 401, - statusText: 'Unauthorized', - text: () => Promise.resolve('{"error":"invalid_token"}'), - }); - - await expect(relayerClient.ethSendTransaction(to, data)).rejects.toThrow( - 'invalid_token', - ); - }); - - it('throws HTTP error for non-ok response without error field', async () => { - const to = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const data = '0x123'; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - text: () => Promise.resolve('{"result":"some_result"}'), - }); - - await expect(relayerClient.ethSendTransaction(to, data)).rejects.toThrow( - 'Relayer HTTP error: 500. Content: "{"result":"some_result"}"', - ); - }); - - it('throws JSON parse error for invalid response', async () => { - const to = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const data = '0x123'; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - text: () => Promise.resolve('invalid json'), - }); - - await expect(relayerClient.ethSendTransaction(to, data)).rejects.toThrow( - 'Relayer JSON parse error: Unexpected token \'i\', "invalid json" is not valid JSON. Content: "invalid json"', - ); - }); - }); - - describe('imGetTransactionByHash', () => { - it('calls relayer with the correct arguments', async () => { - const relayerId = '0x789'; - const relayerTransaction = { - status: RelayerTransactionStatus.SUCCESSFUL, - chainId, - relayerId, - hash: transactionHash, - }; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ result: relayerTransaction })), - json: () => ({ - result: relayerTransaction, - }), - }); - - const result = await relayerClient.imGetTransactionByHash(transactionHash); - expect(result).toEqual(relayerTransaction); - expect(global.fetch).toHaveBeenCalledWith(`${config.relayerUrl}/v1/transactions`, expect.objectContaining({ - method: 'POST', - headers: { - Authorization: `Bearer ${user.accessToken}`, - 'Content-Type': 'application/json', - }, - })); - expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toMatchObject({ - id: 1, - jsonrpc: '2.0', - method: 'im_getTransactionByHash', - params: [transactionHash], - }); - }); - }); - - describe('imGetFeeOptions', () => { - it('calls relayer with the correct arguments', async () => { - const userAddress = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const data = '0x123'; - - const feeOptions = [{ - tokenPrice: '0x64', - tokenSymbol: 'IMX', - tokenDecimals: 18, - recipientAddress: '0xf5102ff309F690F16Fd7B9b3c7eC5e0d5eA502f1', - }]; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ result: feeOptions })), - json: () => ({ - result: feeOptions, - }), - }); - - const result = await relayerClient.imGetFeeOptions(userAddress, data); - expect(result).toEqual(feeOptions); - expect(global.fetch).toHaveBeenCalledWith(`${config.relayerUrl}/v1/transactions`, expect.objectContaining({ - method: 'POST', - headers: { - Authorization: `Bearer ${user.accessToken}`, - 'Content-Type': 'application/json', - }, - })); - expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toMatchObject({ - id: 1, - jsonrpc: '2.0', - method: 'im_getFeeOptions', - params: [{ - userAddress, - data, - chainId: chainIdEip155, - }], - }); - }); - }); - - describe('imSignTypedData', () => { - it('calls relayer with the correct arguments', async () => { - const address = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const eip712Payload = {} as TypedDataPayload; - const relayerSignature = '0x123'; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ result: relayerSignature })), - json: () => ({ - result: relayerSignature, - }), - }); - - const result = await relayerClient.imSignTypedData(address, eip712Payload); - expect(result).toEqual(relayerSignature); - expect(global.fetch).toHaveBeenCalledWith(`${config.relayerUrl}/v1/transactions`, expect.objectContaining({ - method: 'POST', - headers: { - Authorization: `Bearer ${user.accessToken}`, - 'Content-Type': 'application/json', - }, - })); - expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toMatchObject({ - id: 1, - jsonrpc: '2.0', - method: 'im_signTypedData', - params: [{ - address, - eip712Payload, - chainId: chainIdEip155, - }], - }); - }); - }); - - describe('imSign', () => { - it('calls relayer with the correct arguments', async () => { - const address = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const message = 'hello'; - const relayerSignature = '0x123'; - - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ result: relayerSignature })), - json: () => ({ - result: relayerSignature, - }), - }); - - const result = await relayerClient.imSign(address, message); - - expect(result).toEqual(relayerSignature); - expect(global.fetch).toHaveBeenCalledWith(`${config.relayerUrl}/v1/transactions`, expect.objectContaining({ - method: 'POST', - headers: { - Authorization: `Bearer ${user.accessToken}`, - 'Content-Type': 'application/json', - }, - })); - expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toMatchObject({ - id: 1, - jsonrpc: '2.0', - method: 'im_sign', - params: [{ - address, - message, - chainId: chainIdEip155, - }], - }); - }); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/sendDeployTransactionAndPersonalSign.test.ts b/packages/passport/sdk/src/zkEvm/sendDeployTransactionAndPersonalSign.test.ts deleted file mode 100644 index 6d7eb5bdbf..0000000000 --- a/packages/passport/sdk/src/zkEvm/sendDeployTransactionAndPersonalSign.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { Flow } from '@imtbl/metrics'; -import { Signer, JsonRpcProvider } from 'ethers'; -import { sendDeployTransactionAndPersonalSign } from './sendDeployTransactionAndPersonalSign'; -import { mockUserZkEvm } from '../test/mocks'; -import { RelayerClient } from './relayerClient'; -import GuardianClient from '../guardian'; -import * as transactionHelpers from './transactionHelpers'; -import * as personalSign from './personalSign'; - -jest.mock('./transactionHelpers'); -jest.mock('./personalSign'); - -describe('sendDeployTransactionAndPersonalSign', () => { - const signedTransactions = 'signedTransactions123'; - const relayerTransactionId = 'relayerTransactionId123'; - const transactionHash = 'transactionHash123'; - const signedMessage = 'signedMessage123'; - - const nonce = BigInt(5); - - const params = ['message to sign']; - const rpcProvider = { - detectNetwork: jest.fn(), - }; - const relayerClient = { - imGetFeeOptions: jest.fn(), - ethSendTransaction: jest.fn(), - imGetTransactionByHash: jest.fn(), - }; - const guardianClient = { - validateEVMTransaction: jest.fn(), - withConfirmationScreen: jest.fn(), - }; - const ethSigner = { - getAddress: jest.fn(), - } as Partial as Signer; - const flow = { - addEvent: jest.fn(), - }; - - beforeEach(() => { - jest.resetAllMocks(); - (transactionHelpers.prepareAndSignTransaction as jest.Mock).mockResolvedValue({ - signedTransactions, - relayerId: relayerTransactionId, - nonce, - }); - (transactionHelpers.pollRelayerTransaction as jest.Mock).mockResolvedValue({ - hash: transactionHash, - }); - (personalSign.personalSign as jest.Mock).mockResolvedValue(signedMessage); - (guardianClient.withConfirmationScreen as jest.Mock) - .mockImplementation(() => (task: () => void) => task()); - }); - - it('calls prepareAndSignTransaction with the correct arguments', async () => { - await sendDeployTransactionAndPersonalSign({ - params, - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(transactionHelpers.prepareAndSignTransaction).toHaveBeenCalledWith({ - transactionRequest: { to: mockUserZkEvm.zkEvm.ethAddress, value: 0 }, - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as unknown as GuardianClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - flow: flow as unknown as Flow, - }); - }); - - it('calls personalSign with the correct arguments', async () => { - await sendDeployTransactionAndPersonalSign({ - params, - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(personalSign.personalSign).toHaveBeenCalledWith({ - params, - ethSigner, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - guardianClient: guardianClient as unknown as GuardianClient, - relayerClient: relayerClient as unknown as RelayerClient, - flow: flow as unknown as Flow, - }); - }); - - it('calls pollRelayerTransaction with the correct arguments', async () => { - await sendDeployTransactionAndPersonalSign({ - params, - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(transactionHelpers.pollRelayerTransaction).toHaveBeenCalledWith( - relayerClient as unknown as RelayerClient, - relayerTransactionId, - flow as unknown as Flow, - ); - }); - - it('returns the signed message', async () => { - const result = await sendDeployTransactionAndPersonalSign({ - params, - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(signedMessage); - }); - - it('calls guardianClient.withConfirmationScreen with the correct arguments', async () => { - await sendDeployTransactionAndPersonalSign({ - params, - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(guardianClient.withConfirmationScreen).toHaveBeenCalled(); - }); - - it('throws an error if any step fails', async () => { - const error = new Error('Something went wrong'); - (transactionHelpers.prepareAndSignTransaction as jest.Mock).mockRejectedValue(error); - - await expect( - sendDeployTransactionAndPersonalSign({ - params, - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }), - ).rejects.toThrow(error); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/sendTransaction.test.ts b/packages/passport/sdk/src/zkEvm/sendTransaction.test.ts deleted file mode 100644 index 1b44feaa3a..0000000000 --- a/packages/passport/sdk/src/zkEvm/sendTransaction.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Flow } from '@imtbl/metrics'; -import { Signer, TransactionRequest, JsonRpcProvider } from 'ethers'; -import { sendTransaction } from './sendTransaction'; -import { mockUserZkEvm } from '../test/mocks'; -import { RelayerClient } from './relayerClient'; -import GuardianClient from '../guardian'; -import * as transactionHelpers from './transactionHelpers'; - -jest.mock('./transactionHelpers'); -jest.mock('../network/retry'); - -describe('sendTransaction', () => { - const signedTransactions = 'signedTransactions123'; - const relayerTransactionId = 'relayerTransactionId123'; - const transactionHash = 'transactionHash123'; - - const nonce = BigInt(5); - - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '0x456', - value: '0x00', - }; - const rpcProvider = { - detectNetwork: jest.fn(), - }; - const relayerClient = { - imGetFeeOptions: jest.fn(), - ethSendTransaction: jest.fn(), - imGetTransactionByHash: jest.fn(), - }; - const guardianClient = { - validateEVMTransaction: jest.fn(), - }; - const ethSigner = { - getAddress: jest.fn(), - } as Partial as Signer; - const flow = { - addEvent: jest.fn(), - }; - - beforeEach(() => { - jest.resetAllMocks(); - (transactionHelpers.prepareAndSignTransaction as jest.Mock).mockResolvedValue({ - signedTransactions, - relayerId: relayerTransactionId, - nonce, - }); - (transactionHelpers.pollRelayerTransaction as jest.Mock).mockResolvedValue({ - hash: transactionHash, - }); - }); - - it('calls prepareAndSignTransaction with the correct arguments', async () => { - await sendTransaction({ - params: [transactionRequest], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(transactionHelpers.prepareAndSignTransaction).toHaveBeenCalledWith({ - transactionRequest, - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as unknown as GuardianClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - flow: flow as unknown as Flow, - isBackgroundTransaction: false, - }); - }); - - it('calls pollRelayerTransaction with the correct arguments', async () => { - await sendTransaction({ - params: [transactionRequest], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(transactionHelpers.pollRelayerTransaction).toHaveBeenCalledWith( - relayerClient as unknown as RelayerClient, - relayerTransactionId, - flow as unknown as Flow, - ); - }); - - it('returns the transaction hash', async () => { - const result = await sendTransaction({ - params: [transactionRequest], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(transactionHash); - }); - - it('throws an error if pollRelayerTransaction fails', async () => { - const error = new Error('Transaction failed'); - (transactionHelpers.pollRelayerTransaction as jest.Mock).mockRejectedValue(error); - - await expect( - sendTransaction({ - params: [transactionRequest], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }), - ).rejects.toThrow(error); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/signEjectionTransaction.test.ts b/packages/passport/sdk/src/zkEvm/signEjectionTransaction.test.ts deleted file mode 100644 index 501834963c..0000000000 --- a/packages/passport/sdk/src/zkEvm/signEjectionTransaction.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Flow } from '@imtbl/metrics'; -import { Signer, TransactionRequest } from 'ethers'; -import { mockUserZkEvm } from '../test/mocks'; -import * as transactionHelpers from './transactionHelpers'; -import { signEjectionTransaction } from './signEjectionTransaction'; -import { JsonRpcError, RpcErrorCode } from './JsonRpcError'; - -jest.mock('./transactionHelpers'); -jest.mock('../network/retry'); - -describe('im_signEjectionTransaction', () => { - const signedTransactionPayload = { - to: mockUserZkEvm.zkEvm.ethAddress, - data: '123', - chainId: '1', - }; - - const transactionRequest: TransactionRequest = { - to: mockUserZkEvm.zkEvm.ethAddress, - nonce: 5, - chainId: 1, - value: BigInt('5'), - }; - const ethSigner = { - getAddress: jest.fn(), - } as Partial as Signer; - const flow = { - addEvent: jest.fn(), - }; - - beforeEach(() => { - jest.resetAllMocks(); - (transactionHelpers.prepareAndSignEjectionTransaction as jest.Mock).mockResolvedValue( - signedTransactionPayload, - ); - }); - - it('calls prepareAndSignEjectionTransaction with the correct arguments', async () => { - await signEjectionTransaction({ - params: [transactionRequest], - ethSigner, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - flow: flow as unknown as Flow, - }); - - expect(transactionHelpers.prepareAndSignEjectionTransaction).toHaveBeenCalledWith({ - transactionRequest, - ethSigner, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - flow: flow as unknown as Flow, - }); - }); - - it('calls signEjectionTransaction with invalid params', async () => { - await expect(signEjectionTransaction({ - params: [transactionRequest, { test: 'test' }], - ethSigner, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - flow: flow as unknown as Flow, - })).rejects.toThrow( - new JsonRpcError(RpcErrorCode.INVALID_PARAMS, 'im_signEjectionTransaction requires a singular param (hash)'), - ); - }); - - it('returns the transaction hash', async () => { - const result = await signEjectionTransaction({ - params: [transactionRequest], - ethSigner, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(signedTransactionPayload); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/signTypedDataV4.test.ts b/packages/passport/sdk/src/zkEvm/signTypedDataV4.test.ts deleted file mode 100644 index 9d3ab09777..0000000000 --- a/packages/passport/sdk/src/zkEvm/signTypedDataV4.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { Flow } from '@imtbl/metrics'; -import { Signer, JsonRpcProvider } from 'ethers'; -import GuardianClient from '../guardian'; -import { signAndPackTypedData } from './walletHelpers'; -import { - chainId, - chainIdHex, -} from '../test/mocks'; -import { RelayerClient } from './relayerClient'; -import { signTypedDataV4 } from './signTypedDataV4'; -import { JsonRpcError, RpcErrorCode } from './JsonRpcError'; -import { TypedDataPayload } from './types'; - -jest.mock('./walletHelpers'); - -describe('signTypedDataV4', () => { - const address = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const eip712Payload: TypedDataPayload = { - types: { EIP712Domain: [] }, - domain: {}, - primaryType: '', - message: {}, - }; - const relayerSignature = '02011b1d383526a2815d26550eb314b5d7e0551327330043c4d07715346a7d5517ecbc32304fc1ccdcd52fea386c94c3b58b90410f20cd1d5c6db8fa1f03c34e82dce78c3445ce38583e0b0689c69b8fbedbc33d3a2e45431b0103'; - const combinedSignature = '0x000202011b1d383526a2815d26550eb314b5d7e0551327330043c4d07715346a7d5517ecbc32304fc1ccdcd52fea386c94c3b58b90410f20cd1d5c6db8fa1f03c34e82dce78c3445ce38583e0b0689c69b8fbedbc33d3a2e45431b01030001d25acf5eef26fb627f91e02ebd111580030ab8fb0a55567ac8cc66c34de7ae98185125a76adc6ee2fea042c7fce9c85a41e790ce3529f93dfec281bf56620ef21b02'; - const ethSigner = {} as Signer; - const rpcProvider = { - getNetwork: jest.fn(), - }; - const relayerClient = { - imSignTypedData: jest.fn(), - }; - const guardianClient = { - evaluateEIP712Message: jest.fn(), - }; - const flow = { - addEvent: jest.fn(), - }; - - beforeEach(() => { - jest.resetAllMocks(); - relayerClient.imSignTypedData.mockResolvedValue(relayerSignature); - (signAndPackTypedData as jest.Mock).mockResolvedValueOnce( - combinedSignature, - ); - rpcProvider.getNetwork.mockResolvedValue({ chainId: BigInt(chainId) }); - }); - - describe('when a valid address and json are provided', () => { - it('returns a signature', async () => { - const result = await signTypedDataV4({ - method: 'eth_signTypedData_v4', - params: [address, JSON.stringify(eip712Payload)], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as unknown as GuardianClient, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(combinedSignature); - expect(relayerClient.imSignTypedData).toHaveBeenCalledWith( - address, - eip712Payload, - ); - expect(signAndPackTypedData).toHaveBeenCalledWith( - eip712Payload, - relayerSignature, - BigInt(chainId), - address, - ethSigner, - ); - }); - }); - - describe('when a valid address and object are provided', () => { - it('returns a signature', async () => { - const result = await signTypedDataV4({ - method: 'eth_signTypedData_v4', - params: [address, eip712Payload], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as any, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(combinedSignature); - expect(relayerClient.imSignTypedData).toHaveBeenCalledWith( - address, - eip712Payload, - ); - expect(signAndPackTypedData).toHaveBeenCalledWith( - eip712Payload, - relayerSignature, - BigInt(chainId), - address, - ethSigner, - ); - }); - }); - - describe('when an argument is missing', () => { - it('should throw an error', async () => { - await expect(async () => ( - signTypedDataV4({ - method: 'eth_signTypedData_v4', - params: [address], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as any, - flow: flow as unknown as Flow, - }) - )).rejects.toThrow( - new JsonRpcError(RpcErrorCode.INVALID_PARAMS, 'eth_signTypedData_v4 requires an address and a typed data JSON'), - ); - }); - }); - - describe('when an invalid JSON is provided', () => { - it('should throw an error', async () => { - await expect(async () => ( - signTypedDataV4({ - method: 'eth_signTypedData_v4', - params: [address, '*~<|8)-/-<'], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as any, - flow: flow as unknown as Flow, - }) - )).rejects.toMatchObject({ - code: RpcErrorCode.INVALID_PARAMS, - // Using stringMatching to avoid differing error message formats across Node - message: expect.stringMatching(/Failed to parse typed data JSON: SyntaxError: Unexpected token.*/), - }); - }); - }); - - describe('when the typedDataPayload is missing a required property', () => { - it('should throw an error', async () => { - const payload = { - domain: {}, - primaryType: '', - message: {}, - }; - - await expect(async () => ( - signTypedDataV4({ - method: 'eth_signTypedData_v4', - params: [address, payload], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as any, - flow: flow as unknown as Flow, - }) - )).rejects.toThrow( - new JsonRpcError(RpcErrorCode.INVALID_PARAMS, 'Invalid typed data argument. The following properties are required: types, domain, primaryType, message'), - ); - }); - }); - - describe('when a different chainId is used', () => { - it('should throw an error', async () => { - await expect(async () => ( - signTypedDataV4({ - method: 'eth_signTypedData_v4', - params: [ - address, - { - ...eip712Payload, - domain: { - chainId: 5, - }, - }, - ], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as any, - flow: flow as unknown as Flow, - }) - )).rejects.toThrow( - new JsonRpcError(RpcErrorCode.INVALID_PARAMS, `Invalid chainId, expected ${chainId}`), - ); - }); - }); - - it.each([chainIdHex, `${chainId}`])('converts the chainId to a number and returns a signature', async (testChainId: any) => { - const payload: TypedDataPayload = { - ...eip712Payload, - domain: { - chainId: testChainId, - }, - }; - const result = await signTypedDataV4({ - method: 'eth_signTypedData_v4', - params: [ - address, - payload, - ], - ethSigner, - rpcProvider: rpcProvider as unknown as JsonRpcProvider, - relayerClient: relayerClient as unknown as RelayerClient, - guardianClient: guardianClient as any, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(combinedSignature); - expect(relayerClient.imSignTypedData).toHaveBeenCalledWith( - address, - payload, - ); - expect(signAndPackTypedData).toHaveBeenCalledWith( - payload, - relayerSignature, - BigInt(chainId), - address, - ethSigner, - ); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts deleted file mode 100644 index 0d6f80465d..0000000000 --- a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { Flow } from '@imtbl/metrics'; -import { JsonRpcProvider } from 'ethers'; -import { RelayerClient } from './relayerClient'; -import GuardianClient from '../guardian'; -import { FeeOption, RelayerTransactionStatus } from './types'; -import { JsonRpcError, RpcErrorCode } from './JsonRpcError'; -import { pollRelayerTransaction, prepareAndSignEjectionTransaction, prepareAndSignTransaction } from './transactionHelpers'; -import * as walletHelpers from './walletHelpers'; -import { retryWithDelay } from '../network/retry'; -import MagicTeeAdapter from '../magic/magicTEESigner'; - -jest.mock('./walletHelpers', () => ({ - __esModule: true, - ...jest.requireActual('./walletHelpers'), -})); -jest.mock('../network/retry'); - -describe('transactionHelpers', () => { - const flow = { addEvent: jest.fn() } as unknown as Flow; - - const magicTeeAdapter = { - personalSign: jest.fn(), - createWallet: jest.fn(), - } as unknown as MagicTeeAdapter; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('pollRelayerTransaction', () => { - const relayerId = 'relayerId123'; - const transactionHash = 'transactionHash123'; - const relayerClient = { - imGetFeeOptions: jest.fn(), - ethSendTransaction: jest.fn(), - imGetTransactionByHash: jest.fn(), - } as unknown as RelayerClient; - - it('returns the transaction when successful', async () => { - const successfulTx = { status: RelayerTransactionStatus.SUCCESSFUL, hash: transactionHash }; - (retryWithDelay as jest.Mock).mockResolvedValue(successfulTx); - - const result = await pollRelayerTransaction(relayerClient, relayerId, flow); - - expect(result).toEqual(successfulTx); - expect(flow.addEvent).toHaveBeenCalledWith('endRetrieveRelayerTransaction'); - }); - - it('throws an error for failed transactions', async () => { - const failedTx = { status: RelayerTransactionStatus.FAILED, statusMessage: 'Transaction failed' }; - (retryWithDelay as jest.Mock).mockResolvedValue(failedTx); - - await expect(pollRelayerTransaction(relayerClient, relayerId, flow)) - .rejects.toThrow(new JsonRpcError( - RpcErrorCode.RPC_SERVER_ERROR, - 'Transaction failed to submit with status FAILED. Error message: Transaction failed', - )); - }); - - it('throws an error for cancelled transactions', async () => { - const cancelledTx = { status: RelayerTransactionStatus.CANCELLED, statusMessage: 'Transaction cancelled' }; - (retryWithDelay as jest.Mock).mockResolvedValue(cancelledTx); - - await expect(pollRelayerTransaction(relayerClient, relayerId, flow)) - .rejects.toThrow(new JsonRpcError( - RpcErrorCode.RPC_SERVER_ERROR, - 'Transaction failed to submit with status CANCELLED. Error message: Transaction cancelled', - )); - }); - }); - - describe('prepareAndSignTransaction', () => { - const chainId = 123n; - const nonce = BigInt(5); - const zkEvmAddresses = { - ethAddress: '0x1234567890123456789012345678901234567890', - userAdminAddress: '0x4567890123456789012345678901234567890123', - }; - const transactionRequest = { - to: '0x1234567890123456789012345678901234567890', - data: '0x456', - value: '0x00', - }; - - const signedTransactions = 'signedTransactions123'; - const relayerId = 'relayerId123'; - - const imxFeeOption: FeeOption = { - tokenPrice: '0x1', - tokenSymbol: 'IMX', - tokenDecimals: 18, - tokenAddress: '0x1337', - recipientAddress: '0x7331', - }; - - const rpcProvider = { - getNetwork: jest.fn().mockResolvedValue({ chainId }), - } as unknown as JsonRpcProvider; - - const relayerClient = { - imGetFeeOptions: jest.fn().mockResolvedValue([imxFeeOption]), - ethSendTransaction: jest.fn().mockResolvedValue(relayerId), - } as unknown as RelayerClient; - - const guardianClient = { - validateEVMTransaction: jest.fn().mockResolvedValue(undefined), - } as unknown as GuardianClient; - - beforeEach(() => { - jest.resetAllMocks(); - jest.spyOn(walletHelpers, 'signMetaTransactions').mockResolvedValue(signedTransactions); - jest.spyOn(walletHelpers, 'getNonce').mockResolvedValue(nonce); - jest.spyOn(walletHelpers, 'encodedTransactions').mockReturnValue('encodedTransactions123'); - (rpcProvider.getNetwork as jest.Mock).mockResolvedValue({ chainId }); - jest.spyOn(relayerClient, 'imGetFeeOptions').mockResolvedValue([imxFeeOption]); - jest.spyOn(relayerClient, 'ethSendTransaction').mockResolvedValue(relayerId); - jest.spyOn(guardianClient, 'validateEVMTransaction').mockResolvedValue(undefined); - }); - - it('prepares and signs transaction correctly', async () => { - const result = await prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - }); - - expect(result).toEqual({ - signedTransactions, - relayerId, - nonce, - }); - - expect(rpcProvider.getNetwork).toHaveBeenCalled(); - expect(guardianClient.validateEVMTransaction).toHaveBeenCalled(); - expect(walletHelpers.signMetaTransactions).toHaveBeenCalled(); - expect(relayerClient.ethSendTransaction).toHaveBeenCalledWith(zkEvmAddresses.ethAddress, signedTransactions); - expect(flow.addEvent).toHaveBeenCalledWith('endDetectNetwork'); - expect(flow.addEvent).toHaveBeenCalledWith('endBuildMetaTransactions'); - expect(flow.addEvent).toHaveBeenCalledWith('endValidateEVMTransaction'); - expect(flow.addEvent).toHaveBeenCalledWith('endGetSignedMetaTransactions'); - expect(flow.addEvent).toHaveBeenCalledWith('endRelayerSendTransaction'); - }); - - it('handles sponsored transactions correctly', async () => { - const sponsoredFeeOption = { ...imxFeeOption, tokenPrice: '0' }; - (relayerClient.imGetFeeOptions as jest.Mock).mockResolvedValue([sponsoredFeeOption]); - - await prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - }); - - expect(guardianClient.validateEVMTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - metaTransactions: expect.arrayContaining([ - expect.objectContaining({ - data: transactionRequest.data, - revertOnError: true, - to: transactionRequest.to, - value: '0x00', - nonce: expect.any(BigInt), - }), - ]), - }), - ); - }); - - it('handles non-sponsored transactions correctly', async () => { - const nonSponsoredFeeOption: FeeOption = { - tokenPrice: '0x1', // Non-zero value in hex - tokenSymbol: 'IMX', - tokenDecimals: 18, - tokenAddress: '0x1337', - recipientAddress: '0x7331', - }; - (relayerClient.imGetFeeOptions as jest.Mock).mockResolvedValue([nonSponsoredFeeOption]); - - await prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - }); - - expect(guardianClient.validateEVMTransaction).toHaveBeenCalledWith( - expect.objectContaining({ - metaTransactions: expect.arrayContaining([ - expect.objectContaining({ - data: transactionRequest.data, - revertOnError: true, - to: transactionRequest.to, - value: '0x00', - nonce: expect.any(BigInt), - }), - expect.objectContaining({ - to: '0x7331', - value: expect.any(BigInt), - revertOnError: true, - nonce: expect.any(BigInt), - }), - ]), - }), - ); - - expect(walletHelpers.signMetaTransactions).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - data: transactionRequest.data, - revertOnError: true, - to: transactionRequest.to, - value: '0x00', - nonce: expect.any(BigInt), - }), - expect.objectContaining({ - to: '0x7331', - value: expect.any(BigInt), - revertOnError: true, - nonce: expect.any(BigInt), - }), - ]), - expect.any(BigInt), - expect.any(BigInt), - zkEvmAddresses.ethAddress, - magicTeeAdapter, - ); - }); - - it('signs the transaction when the nonce is zero', async () => { - jest.spyOn(walletHelpers, 'getNonce').mockResolvedValue(0n); - - const result = await prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - }); - - expect(result).toEqual({ - signedTransactions, - relayerId, - nonce: 0n, - }); - }); - - it('throws an error when validateEVMTransaction fails', async () => { - (guardianClient.validateEVMTransaction as jest.Mock).mockRejectedValue(new Error('Validation failed')); - - await expect(prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - })).rejects.toThrow('Validation failed'); - - expect(guardianClient.validateEVMTransaction).toHaveBeenCalled(); - expect(walletHelpers.signMetaTransactions).toHaveBeenCalled(); // This will be called due to parallelization - expect(relayerClient.ethSendTransaction).not.toHaveBeenCalled(); - }); - - it('throws an error when signMetaTransactions fails', async () => { - (walletHelpers.signMetaTransactions as jest.Mock).mockRejectedValue(new Error('Signing failed')); - - await expect(prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - })).rejects.toThrow('Signing failed'); - }); - - it('throws an error when ethSendTransaction fails', async () => { - (relayerClient.ethSendTransaction as jest.Mock).mockRejectedValue(new Error('Transaction send failed')); - - await expect(prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - })).rejects.toThrow('Transaction send failed'); - }); - - it('throws an error when imGetFeeOptions returns undefined', async () => { - (relayerClient.imGetFeeOptions as jest.Mock).mockResolvedValue(undefined); - - await expect(prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - })).rejects.toThrow('Invalid fee options received from relayer'); - }); - - it('throws an error when imGetFeeOptions returns null', async () => { - (relayerClient.imGetFeeOptions as jest.Mock).mockResolvedValue(null); - - await expect(prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - })).rejects.toThrow('Invalid fee options received from relayer'); - }); - - it('throws an error when imGetFeeOptions returns a non-array', async () => { - (relayerClient.imGetFeeOptions as jest.Mock).mockResolvedValue({ invalid: 'response' }); - - await expect(prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - })).rejects.toThrow('Invalid fee options received from relayer'); - }); - }); - - describe('prepareAndSignEjectionTransaction', () => { - const chainId = 123; - - const transactionRequest = { - to: '0x1234567890123456789012345678901234567890', - data: '0x456', - value: '0x00', - chainId, - }; - - const zkEvmAddresses = { - ethAddress: '0x1234567890123456789012345678901234567890', - userAdminAddress: '0x4567890123456789012345678901234567890123', - }; - const signedTransactions = 'signedTransactions123'; - - beforeEach(() => { - jest.resetAllMocks(); - jest.spyOn(walletHelpers, 'signMetaTransactions').mockResolvedValue(signedTransactions); - }); - - describe('when the nonce is 0', () => { - it('prepares and signs transaction correctly', async () => { - const result = await prepareAndSignEjectionTransaction({ - transactionRequest: { - ...transactionRequest, - nonce: 0, - }, - ethSigner: magicTeeAdapter, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - }); - - expect(result).toEqual({ - chainId: 'eip155:123', - data: signedTransactions, - to: zkEvmAddresses.ethAddress, - }); - }); - }); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/user/registerZkEvmUser.test.ts b/packages/passport/sdk/src/zkEvm/user/registerZkEvmUser.test.ts deleted file mode 100644 index b032d0b7ff..0000000000 --- a/packages/passport/sdk/src/zkEvm/user/registerZkEvmUser.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { signRaw } from '@imtbl/toolkit'; -import { MultiRollupApiClients } from '@imtbl/generated-clients'; -import { Flow } from '@imtbl/metrics'; -import { JsonRpcProvider, JsonRpcSigner, Signer } from 'ethers'; -import { ChainId, ChainName } from '../../network/chains'; -import { registerZkEvmUser } from './registerZkEvmUser'; -import AuthManager from '../../authManager'; -import { mockListChains, mockUserZkEvm } from '../../test/mocks'; - -jest.mock('ethers', () => ({ - ...jest.requireActual('ethers'), - JsonRpcSigner: jest.fn(), - JsonRpcProvider: jest.fn(), -})); -jest.mock('@imtbl/toolkit'); - -describe('registerZkEvmUser', () => { - const ethSignerMock = { - getAddress: jest.fn(), - }; - const authManager = { - getUser: jest.fn(), - forceUserRefreshInBackground: jest.fn(), - }; - const multiRollupApiClients = { - passportApi: { - createCounterfactualAddressV2: jest.fn(), - }, - chainsApi: { - listChains: jest.fn(), - }, - }; - const jsonRPCProvider = { - getNetwork: jest.fn(), - }; - const flow = { - addEvent: jest.fn(), - }; - const ethereumAddress = '0x3082e7c88f1c8b4e24be4a75dee018ad362d84d4'; - const ethereumSignature = '0xcc63b10814e3ab4b2dff6762a6712e40c23db00c11f2c54bcc699babdbf1d2bc3096fec623da4784fafb7f6da65338d91e3c846ef52e856c2f5f86c4cf10790900'; - const accessToken = 'accessToken123'; - - beforeEach(() => { - jest.restoreAllMocks(); - (JsonRpcSigner as unknown as jest.Mock).mockImplementation(() => ethSignerMock); - ethSignerMock.getAddress.mockResolvedValue(ethereumAddress); - (signRaw as jest.Mock).mockResolvedValue(ethereumSignature); - multiRollupApiClients.chainsApi.listChains.mockResolvedValue(mockListChains); - jsonRPCProvider.getNetwork.mockResolvedValue({ chainId: ChainId.IMTBL_ZKEVM_TESTNET }); - }); - - describe('when createCounterfactualAddressV2 doesn\'t return a 201', () => { - it('should throw an error', async () => { - multiRollupApiClients.passportApi.createCounterfactualAddressV2.mockRejectedValue( - new Error('Internal server error'), - ); - await expect(async () => registerZkEvmUser({ - authManager: authManager as unknown as AuthManager, - ethSigner: ethSignerMock as unknown as Signer, - multiRollupApiClients: multiRollupApiClients as unknown as MultiRollupApiClients, - accessToken, - rpcProvider: jsonRPCProvider as unknown as JsonRpcProvider, - flow: flow as unknown as Flow, - })).rejects.toThrow('Failed to create counterfactual address: Error: Internal server error'); - }); - }); - - it('should return a user that has registered with zkEvm', async () => { - multiRollupApiClients.passportApi.createCounterfactualAddressV2.mockResolvedValue({ - status: 201, - data: { - counterfactual_address: mockUserZkEvm.zkEvm.ethAddress, - }, - }); - - const result = await registerZkEvmUser({ - authManager: authManager as unknown as AuthManager, - ethSigner: ethSignerMock as unknown as Signer, - multiRollupApiClients: multiRollupApiClients as unknown as MultiRollupApiClients, - accessToken, - rpcProvider: jsonRPCProvider as unknown as JsonRpcProvider, - flow: flow as unknown as Flow, - }); - - expect(result).toEqual(mockUserZkEvm.zkEvm.ethAddress); - expect(multiRollupApiClients.passportApi.createCounterfactualAddressV2).toHaveBeenCalledWith({ - chainName: ChainName.IMTBL_ZKEVM_TESTNET, - createCounterfactualAddressRequest: { - ethereum_address: ethereumAddress, - ethereum_signature: ethereumSignature, - }, - }, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - expect(authManager.forceUserRefreshInBackground).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/walletHelpers.test.ts b/packages/passport/sdk/src/zkEvm/walletHelpers.test.ts deleted file mode 100644 index 375650eff2..0000000000 --- a/packages/passport/sdk/src/zkEvm/walletHelpers.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { - Contract, Wallet, JsonRpcProvider, ErrorCode, -} from 'ethers'; -import { TypedDataPayload } from './types'; -import { - getNonce, signMetaTransactions, signAndPackTypedData, packSignatures, - coerceNonceSpace, - encodeNonce, -} from './walletHelpers'; - -jest.mock('ethers', () => ({ - ...jest.requireActual('ethers'), - Contract: jest.fn(), -})); - -// SCW addr -const walletAddress = '0x7EEC32793414aAb720a90073607733d9e7B0ecD0'; -// User EOA private key -const signer = new Wallet('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'); - -describe('signMetaTransactions', () => { - // NOTE: Generated with https://github.com/immutable/wallet-contracts/blob/348add7d2fde13d8f7f83aae0882ad2d97546d72/tests/ImmutableDeployment.spec.ts#L69 - it('should correctly generate the signature for a given transaction', async () => { - const transactions = [ - { - delegateCall: false, - revertOnError: true, - gasLimit: 1000000, - to: '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC', - value: '500000000000000000', - data: '0x', - }, - ]; - const nonce = 0; - const chainId = 1779; - - const signature = await signMetaTransactions( - transactions, - nonce, - BigInt(chainId), - walletAddress, - signer, - ); - - expect(signature).toBe('0x7a9a1628000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000f42400000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc00000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004600010001904a25850e09260d88f3fc46fab4901e7c979fc583fe9d30a12c51ba5636355a1351b8ce823f765568d8b88cddd9c8ede9f1cc17dfd7ca953e05ecbbbdf8f51e1c020000000000000000000000000000000000000000000000000000'); - }); -}); - -describe('signAndPackTypedData', () => { - const typedDataPayload = JSON.parse('{"domain":{"name":"Ether Mail","version":"1","chainId":13472,"verifyingContract":"0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]}}') as TypedDataPayload; - const relayerSignature = '02011b1d383526a2815d26550eb314b5d7e05513273300439b63b94e127c13e1bae9f3f24ab42717c7ae2e25fb82e7fd24afc320690413ca6581c798f91cce8296bd21f4f35a4b33b882a5401499f829481d8ed8d3de23741b0103'; - const chainId = 13472; - const signaturePrefixWithThreshold = '0x0002'; - const eoaSignatureWeight = '0001'; - const ethSignFlag = '02'; - - it('should correctly generate the signature for a given typed data payload', async () => { - const expectedSignature = '0x000202011b1d383526a2815d26550eb314b5d7e05513273300439b63b94e127c13e1bae9f3f24ab42717c7ae2e25fb82e7fd24afc320690413ca6581c798f91cce8296bd21f4f35a4b33b882a5401499f829481d8ed8d3de23741b01030001aec95114a3b8cf3c9693177a2abd8321cf775366a6c6aadf5953e082680fd90c6cb44972a1635b5e9f7f02490a47425be37a1965a6cbaaaa64404cb2cf3880f71c02'; - - const signature = await signAndPackTypedData( - typedDataPayload, - relayerSignature, - BigInt(chainId), - walletAddress, - signer, - ); - - expect(signature).toEqual(expectedSignature); - }); - - describe('when the EOA address is smaller than the Immutable signer address', () => { - it('should include the EOA signature in the combined signature first', async () => { - // The following wallet has an address of `0x15...` which is SMALLER than the Immutable signer address (`0x1B...`), - // and so its signature should be FIRST in the combined signature - const lowAddressSigner = new Wallet('0xdac4f6ad57b2977b13c57b65ee7c98d07f4e4afccdf04849e7df7da03fa928be'); - const signMessageSpy = jest.spyOn(lowAddressSigner, 'signMessage'); - - const result = await signAndPackTypedData( - typedDataPayload, - relayerSignature, - BigInt(chainId), - walletAddress, - lowAddressSigner, - ); - - const eoaSignature = await signMessageSpy.mock.results[0].value; - const eoaSignatureWithoutPrefix = eoaSignature.slice(2); // Remove leading `0x` - expect(result).toEqual([ - signaturePrefixWithThreshold, - eoaSignatureWeight, - eoaSignatureWithoutPrefix, - ethSignFlag, - relayerSignature, - ].join('')); - }); - }); - - describe('when the EOA address is greater than the Immutable signer address', () => { - it('should include the Immutable signer signature in the combined signature first', async () => { - // The wallet used here has an address of `0x7E...` which is GREATER than the Immutable signer address (`0x1B...`), - // and so its signature should be LAST in the combined signature - const signMessageSpy = jest.spyOn(signer, 'signMessage'); - - const result = await signAndPackTypedData( - typedDataPayload, - relayerSignature, - BigInt(chainId), - walletAddress, - signer, - ); - - const eoaSignature = await signMessageSpy.mock.results[0].value; - const eoaSignatureWithoutPrefix = eoaSignature.slice(2); // Remove leading `0x` - expect(result).toEqual([ - signaturePrefixWithThreshold, - relayerSignature, - eoaSignatureWeight, - eoaSignatureWithoutPrefix, - ethSignFlag, - ].join('')); - }); - }); -}); - -describe('coerceNonceSpace', () => { - describe('with no space', () => { - it('should default to 0', () => { - expect(coerceNonceSpace()).toEqual(BigInt(0)); - }); - }); - - describe('with space', () => { - it('should return the space', () => { - expect(coerceNonceSpace(BigInt(12345))).toEqual(BigInt(12345)); - }); - }); -}); - -describe('encodeNonce', () => { - describe('with no space', () => { - it('should not left shift the nonce', () => { - expect(encodeNonce(BigInt(0), BigInt(1))).toEqual(BigInt(1)); - }); - }); - - describe('with space', () => { - it('should left shift the nonce by 96 bits', () => { - expect(encodeNonce(BigInt(1), BigInt(0))).toEqual(BigInt('0x01000000000000000000000000')); - }); - }); -}); - -describe('getNonce', () => { - const rpcProvider = {} as JsonRpcProvider; - const nonceMock = jest.fn(); - - beforeEach(() => { - jest.resetAllMocks(); - (Contract as unknown as jest.Mock).mockImplementation(() => ({ - readNonce: nonceMock, - })); - }); - - describe('when an error is thrown', () => { - describe('and the error is BAD_DATA', () => { - it('should return 0', async () => { - const error = { code: 'BAD_DATA' }; - - nonceMock.mockRejectedValue(error); - - const result = await getNonce(rpcProvider, walletAddress); - - expect(result).toEqual(BigInt(0)); - }); - }); - - describe('and the error is NOT BAD_DATA', () => { - it('should throw the error', async () => { - const error = new Error('call revert exception'); - Object.defineProperty(error, 'code', { value: 'CALL_EXCEPTION' satisfies ErrorCode }); - nonceMock.mockRejectedValue(error); - - await expect(() => getNonce(rpcProvider, walletAddress)).rejects.toThrow(error); - }); - }); - }); - - describe('when a BigNumber is returned', () => { - it('should return a number', async () => { - nonceMock.mockResolvedValue(BigInt(20)); - - const result = await getNonce(rpcProvider, walletAddress); - - expect(result).toEqual(BigInt(20)); - }); - }); -}); - -describe('packSignatures', () => { - it('should correctly pack the signatures', () => { - // Note EOA signature is automatically prefixed with `0x` - const eoaSignature = '0x52a0079dd7a1be93a41fd029c98b680b31790748d176aef193b72f3bb8db16e126ec98994733d393eff53e0d7f2f2db6f649ad0243dbd0694e0c38e2d1fb56da1c'; - const eoaAddress = '0x1b711a03f7908446a068a5ad96dea38c7eb4ca76'; - // Note Relayer signature is NOT prefixed with `0x` - const relayerSignature = '0201cff469e561d9dce5b1185cd2ac1fa961f8fbde6100436353ef96529666cdbf574bac5b86be10f404f6b1508e7855295a99e7e2e605ec07a69c24fb3a65f229b821fd85c320feedccfa7c388e736a1c5a228c45c8ec1a1c0203'; - - const packedSignatures = packSignatures(eoaSignature, eoaAddress, relayerSignature); - - expect(packedSignatures).toBe('0x0002000152a0079dd7a1be93a41fd029c98b680b31790748d176aef193b72f3bb8db16e126ec98994733d393eff53e0d7f2f2db6f649ad0243dbd0694e0c38e2d1fb56da1c020201cff469e561d9dce5b1185cd2ac1fa961f8fbde6100436353ef96529666cdbf574bac5b86be10f404f6b1508e7855295a99e7e2e605ec07a69c24fb3a65f229b821fd85c320feedccfa7c388e736a1c5a228c45c8ec1a1c0203'); - }); -}); diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts deleted file mode 100644 index fc86bb734d..0000000000 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { identify, trackFlow } from '@imtbl/metrics'; -import { JsonRpcProvider, toBeHex } from 'ethers'; -import AuthManager from '../authManager'; -import { ZkEvmProvider, ZkEvmProviderInput } from './zkEvmProvider'; -import { sendTransaction } from './sendTransaction'; -import { JsonRpcError, ProviderErrorCode } from './JsonRpcError'; -import GuardianClient from '../guardian'; -import { RelayerClient } from './relayerClient'; -import { Provider, RequestArguments } from './types'; -import { PassportEventMap, PassportEvents } from '../types'; -import TypedEventEmitter from '../utils/typedEventEmitter'; -import { mockUser, mockUserZkEvm, testConfig } from '../test/mocks'; -import { signTypedDataV4 } from './signTypedDataV4'; -import MagicTEESigner from '../magic/magicTEESigner'; -import { signEjectionTransaction } from './signEjectionTransaction'; - -jest.mock('ethers', () => ({ - ...jest.requireActual('ethers'), - JsonRpcProvider: jest.fn(), -})); -jest.mock('@imtbl/metrics'); -jest.mock('./relayerClient'); -jest.mock('./user'); -jest.mock('./sendTransaction'); -jest.mock('./signEjectionTransaction'); -jest.mock('./signTypedDataV4'); - -describe('ZkEvmProvider', () => { - let passportEventEmitter: TypedEventEmitter; - const config = testConfig; - const magicTEESigner = { - getAddress: jest.fn(), - signMessage: jest.fn(), - } as Partial as MagicTEESigner; - const ethSigner = magicTEESigner; - const authManager = { - getUserOrLogin: jest.fn().mockResolvedValue(mockUserZkEvm), - getUser: jest.fn().mockResolvedValue(mockUserZkEvm), - }; - const guardianClient = { - withConfirmationScreen: jest.fn().mockImplementation(() => (task: () => void) => task()), - } as unknown as GuardianClient; - - const multiRollupApiClients = { - passportApi: { - createCounterfactualAddressV2: jest.fn(), - }, - chainsApi: { - listChains: jest.fn(), - }, - } as any; - - beforeEach(() => { - passportEventEmitter = new TypedEventEmitter(); - jest.resetAllMocks(); - (trackFlow as unknown as jest.Mock).mockImplementation(() => ({ - addEvent: jest.fn(), - end: jest.fn(), - details: { - flowId: '123', - }, - })); - (guardianClient.withConfirmationScreen as jest.Mock) - .mockImplementation(() => (task: () => void) => task()); - }); - - const getProvider = () => { - const constructorParameters = { - config, - authManager: authManager as Partial as AuthManager, - passportEventEmitter, - guardianClient, - ethSigner, - multiRollupApiClients, - user: null, - } as Partial; - - return new ZkEvmProvider(constructorParameters as ZkEvmProviderInput); - }; - - describe('constructor', () => { - describe('when an application session exists', () => { - it('initialises the signer', async () => { - getProvider(); - - await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - - // Constructor doesn't call getUser or getAddress during initialization - expect(authManager.getUser).not.toHaveBeenCalled(); - expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); - }); - - describe('and the user has not registered before', () => { - it('does not call session activity', async () => { - const onAccountsRequested = jest.fn(); - passportEventEmitter.on(PassportEvents.ACCOUNTS_REQUESTED, onAccountsRequested); - getProvider(); - - await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - - expect(onAccountsRequested).not.toHaveBeenCalled(); - }); - }); - describe('and the user has registered before', () => { - it('calls session activity', async () => { - const onAccountsRequested = jest.fn(); - passportEventEmitter.on(PassportEvents.ACCOUNTS_REQUESTED, onAccountsRequested); - getProvider(); - - await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - - expect(onAccountsRequested).not.toHaveBeenCalled(); - }); - }); - }); - - describe('when a login occurs outside of the zkEvm provider', () => { - beforeEach(() => { - authManager.getUser.mockResolvedValue(null); - }); - - describe('and the user has not registered before', () => { - it('does not call session activity', async () => { - const onAccountsRequested = jest.fn(); - passportEventEmitter.on(PassportEvents.ACCOUNTS_REQUESTED, onAccountsRequested); - getProvider(); - passportEventEmitter.emit(PassportEvents.LOGGED_IN, mockUser); - - await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - - expect(onAccountsRequested).not.toHaveBeenCalled(); - }); - - describe('and the user has registered before', () => { - it('calls session activity', async () => { - const onAccountsRequested = jest.fn(); - passportEventEmitter.on(PassportEvents.ACCOUNTS_REQUESTED, onAccountsRequested); - getProvider(); - passportEventEmitter.emit(PassportEvents.LOGGED_IN, mockUserZkEvm); - - await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - - expect(onAccountsRequested).toHaveBeenCalledTimes(1); - }); - }); - }); - }); - }); - - describe('eth_requestAccounts', () => { - it('should return the ethAddress if already logged in', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - const provider = getProvider(); - - const resultOne = await provider.request({ method: 'eth_requestAccounts', params: [] }); - const resultTwo = await provider.request({ method: 'eth_requestAccounts', params: [] }); - - expect(resultOne).toEqual([mockUserZkEvm.zkEvm.ethAddress]); - expect(resultTwo).toEqual([mockUserZkEvm.zkEvm.ethAddress]); - expect(authManager.getUser).toBeCalledTimes(2); - }); - - it('should emit accountsChanged event and identify user when user logs in', async () => { - authManager.getUser.mockResolvedValue(null); - - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - const provider = getProvider(); - const onAccountsChanged = jest.fn(); - - provider.on('accountsChanged', onAccountsChanged); - - // #doc eth_request-accounts - const accounts = await provider.request({ method: 'eth_requestAccounts' }); - // #enddoc eth_request-accounts - - expect(accounts).toEqual([mockUserZkEvm.zkEvm.ethAddress]); - expect(onAccountsChanged).toHaveBeenCalledWith([mockUserZkEvm.zkEvm.ethAddress]); - expect(identify).toHaveBeenCalledWith({ - passportId: mockUserZkEvm.profile.sub, - }); - }); - }); - - describe('eth_sendTransaction', () => { - const transaction = { - from: '0x123', - to: '0x456', - value: '1', - }; - - it('should throw an error if the user is not logged in', async () => { - authManager.getUser.mockResolvedValue(null); - - const provider = getProvider(); - - await expect(async () => ( - provider.request({ method: 'eth_sendTransaction', params: [transaction] }) - )).rejects.toThrow( - new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'), - ); - }); - - it('should open a confirmation screen', async () => { - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - await provider.request({ method: 'eth_sendTransaction', params: [transaction] }); - - expect(guardianClient.withConfirmationScreen).toBeCalledTimes(1); - }); - - it('should call sendTransaction with the correct params', async () => { - const transactionHash = '0x789'; - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(mockUserZkEvm); - (sendTransaction as jest.Mock).mockResolvedValue(transactionHash); - - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - const result = await provider.request({ - method: 'eth_sendTransaction', - params: [transaction], - }); - - expect(result).toEqual(transactionHash); - expect(sendTransaction).toHaveBeenCalledWith({ - params: [transaction], - guardianClient, - ethSigner, - rpcProvider: expect.any(Object), - relayerClient: expect.any(RelayerClient), - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - flow: expect.any(Object), - }); - }); - }); - - describe('im_signEjectionTransaction', () => { - const transaction = { - from: '0x123', - to: '0x456', - value: '1', - nonce: BigInt(5), - chainId: 1, - }; - - it('should throw an error if the user is not logged in', async () => { - authManager.getUser.mockResolvedValue(null); - - const provider = getProvider(); - - await expect(async () => ( - provider.request({ method: 'im_signEjectionTransaction', params: [transaction] }) - )).rejects.toThrow( - new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'), - ); - }); - - it('should call im_signEjectionTransaction with the correct params', async () => { - const signedTransaction = '0x789'; - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(mockUserZkEvm); - (signEjectionTransaction as jest.Mock).mockResolvedValue(signedTransaction); - - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - const result = await provider.request({ - method: 'im_signEjectionTransaction', - params: [transaction], - }); - - expect(result).toEqual(signedTransaction); - expect(signEjectionTransaction).toHaveBeenCalledWith({ - params: [transaction], - ethSigner, - zkEvmAddress: mockUserZkEvm.zkEvm.ethAddress, - flow: expect.any(Object), - }); - }); - }); - - describe('eth_signTypedData_v4', () => { - const address = '0xd64b0d2d72bb1b3f18046b8a7fc6c9ee6bccd287'; - const typedDataPayload = '{}'; - - it('should throw an error if the user is not logged in', async () => { - authManager.getUser.mockResolvedValue(null); - - const provider = getProvider(); - - await expect(async () => ( - provider.request({ method: 'eth_signTypedData_v4', params: [address, typedDataPayload] }) - )).rejects.toThrow( - new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'), - ); - }); - - it('should call eth_signTypedData_v4 with the correct params', async () => { - const signature = '0x123'; - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(mockUserZkEvm); - (signTypedDataV4 as jest.Mock).mockResolvedValue(signature); - - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - const result = await provider.request({ - method: 'eth_signTypedData_v4', - params: [address, typedDataPayload], - }); - - expect(result).toEqual(signature); - expect(signTypedDataV4).toHaveBeenCalledWith({ - method: 'eth_signTypedData_v4', - params: [address, typedDataPayload], - guardianClient, - ethSigner, - rpcProvider: expect.any(Object), - relayerClient: expect.any(RelayerClient), - flow: expect.any(Object), - }); - }); - - it('should open a confirmation screen', async () => { - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(mockUserZkEvm); - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - await provider.request({ method: 'eth_signTypedData_v4', params: [address, typedDataPayload] }); - - expect(guardianClient.withConfirmationScreen).toBeCalledTimes(1); - }); - }); - - describe('isPassport', () => { - it('should be set to true', () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - - expect(provider.isPassport).toBe(true); - expect((provider as Provider).isPassport).toBe(true); - }); - }); - - describe('when the user has been logged out', () => { - const unauthorisedError = new JsonRpcError(ProviderErrorCode.UNAUTHORIZED, 'Unauthorised - call eth_requestAccounts first'); - - describe('and eth_sendTransaction is called', () => { - it('throws an unauthorized error', async () => { - authManager.getUser.mockResolvedValue(null); - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - passportEventEmitter.emit(PassportEvents.LOGGED_OUT); - - await expect(provider.request({ method: 'eth_sendTransaction' })).rejects.toThrowError( - unauthorisedError, - ); - }); - }); - - describe('and eth_signTypedDataV4 is called', () => { - it('throws an unauthorized error', async () => { - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(null); - - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - passportEventEmitter.emit(PassportEvents.LOGGED_OUT); - - await expect(provider.request({ method: 'eth_signTypedData_v4' })).rejects.toThrowError( - unauthorisedError, - ); - }); - }); - - describe('and eth_accounts is called', () => { - it('returns an empty array', async () => { - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(null); - - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - passportEventEmitter.emit(PassportEvents.LOGGED_OUT); - const result = await provider.request({ method: 'eth_accounts' }); - - expect(result).toEqual([]); - }); - }); - - it('should emit accountsChanged', async () => { - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - - const onAccountsChanged = jest.fn(); - provider.on('accountsChanged', onAccountsChanged); - passportEventEmitter.emit(PassportEvents.LOGGED_OUT); - - expect(onAccountsChanged).toHaveBeenCalledWith([]); - }); - }); - - describe('eth_chainId', () => { - const chainId = 13371; - const getNetworkMock = jest.fn(); - const sendMock = jest.fn(); - - beforeEach(() => { - jest.resetAllMocks(); - - (JsonRpcProvider as unknown as jest.Mock).mockImplementation(() => ({ - send: sendMock, - getNetwork: getNetworkMock, - })); - }); - - it('should call detectNetwork', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - getNetworkMock.mockResolvedValueOnce({ chainId }); - - const provider = getProvider(); - - const providerParams = { method: 'eth_chainId', params: [] }; - const result = await provider.request(providerParams); - - expect(getNetworkMock).toBeCalledTimes(1); - expect(sendMock).not.toBeCalled(); - expect(result).toBe(toBeHex(chainId)); - }); - }); - - describe('passthrough methods', () => { - const sendMock = jest.fn(); - type Request = { - requestArgument: RequestArguments, - returnValue: any, - }; - const passthroughMethods: Array = [ - { requestArgument: { method: 'eth_getStorageAt', params: ['0x123', '0x0', '0x1'] }, returnValue: '0x' }, - { requestArgument: { method: 'eth_getBalance', params: ['0x123', '0x1'] }, returnValue: '0x1' }, - { requestArgument: { method: 'eth_getCode', params: ['0x123', '0x1'] }, returnValue: '0x' }, - { requestArgument: { method: 'eth_gasPrice', params: [] }, returnValue: '0x2' }, - { requestArgument: { method: 'eth_estimateGas', params: [{ to: '0x1', value: 0 }, '0x1'] }, returnValue: '0x3' }, - { requestArgument: { method: 'eth_call', params: [{ to: '0x1', value: 0 }, '0x1'] }, returnValue: '0x' }, - { requestArgument: { method: 'eth_blockNumber', params: [] }, returnValue: '0x4' }, - { - requestArgument: { method: 'eth_getBlockByHash', params: ['0x1', false] }, - returnValue: { - baseFeePerGas: '0x7', - difficulty: '0x0', - extraData: '0x496c6c756d696e61746520446d6f63726174697a6520447374726962757465', - gasLimit: '0x1c9c380', - gasUsed: '0x8c6cee', - hash: '0xec484a535316996705454b53ce5a1f4af0f64e399c2855ec05753df2d0e1a83b', - logsBloom: '0x00000244841000100041010000100000011148010108100000000008000002100040010020840000010000010008000000801820002c00612094290801200800100c20000c0202080080000804000002000009c0020110002c0200020000002004000800064000000a9400060000090042045400200000101440201000018102501082040000800020100000008580042100040001101000000ca4020002000202088c09000000201004188100080000200091400000000080c0500c0025402004400002080100000000442080200044000001000000000150000d020100a0000014002000420100000840000400202080040000000000028024800000400008', - miner: '0x1e2cd78882b12d3954a049fd82ffd691565dc0a5', - mixHash: '0x6e864fb8be5c362d52a206823a6bf64d0310e97f2f8904e51d8419d569349f6c', - nonce: '0x0000000000000000', - number: '0x3a33ad', - parentHash: '0xdb557161e52f5becee1483e3bbd5714baca9a940a11433d101546ac77977beff', - receiptsRoot: '0x79c1493bb19bf5454d6ccff5c277df7f8c045702099a5a5a84a8d11eaa919fb3', - sha3Uncles: '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', - size: '0xc0cd', - stateRoot: '0x8125b0d797b82408652a90255e7f7059314552a00b9587890e39cc3fed83f7f3', - timestamp: '0x64a258e8', - totalDifficulty: '0x3c656d23029ab0', - transactions: [ - '0x415a3c9e9dceb2974b74ddd739097f838e200e103370f688314a5f01c15e769c', - ], - transactionsRoot: '0xa87c27d6395631c8c779f9070b319268740355579f3b11d9454c2834b5943951', - uncles: [], - withdrawals: [ - { - index: '0xc8f201', - validatorIndex: '0x3a6', - address: '0xe276bc378a527a8792b353cdca5b5e53263dfb9e', - amount: '0xf00', - }, - ], - withdrawalsRoot: '0x9b35c0a8f97d4a06af667bd48ae1e7ca405218326ec384c443b6cb7960f88d5d', - }, - }, - { - requestArgument: { method: 'eth_getBlockByNumber', params: ['earliest', false] }, - returnValue: { - baseFeePerGas: '0x7', - difficulty: '0x0', - extraData: '0x496c6c756d696e61746520446d6f63726174697a6520447374726962757465', - gasLimit: '0x1c9c380', - gasUsed: '0x8c6cee', - hash: '0xec484a535316996705454b53ce5a1f4af0f64e399c2855ec05753df2d0e1a83b', - logsBloom: '0x00000244841000100041010000100000011148010108100000000008000002100040010020840000010000010008000000801820002c00612094290801200800100c20000c0202080080000804000002000009c0020110002c0200020000002004000800064000000a9400060000090042045400200000101440201000018102501082040000800020100000008580042100040001101000000ca4020002000202088c09000000201004188100080000200091400000000080c0500c0025402004400002080100000000442080200044000001000000000150000d020100a0000014002000420100000840000400202080040000000000028024800000400008', - miner: '0x1e2cd78882b12d3954a049fd82ffd691565dc0a5', - mixHash: '0x6e864fb8be5c362d52a206823a6bf64d0310e97f2f8904e51d8419d569349f6c', - nonce: '0x0000000000000000', - number: '0x3a33ad', - parentHash: '0xdb557161e52f5becee1483e3bbd5714baca9a940a11433d101546ac77977beff', - receiptsRoot: '0x79c1493bb19bf5454d6ccff5c277df7f8c045702099a5a5a84a8d11eaa919fb3', - sha3Uncles: '0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347', - size: '0xc0cd', - stateRoot: '0x8125b0d797b82408652a90255e7f7059314552a00b9587890e39cc3fed83f7f3', - timestamp: '0x64a258e8', - totalDifficulty: '0x3c656d23029ab0', - transactions: [ - '0x415a3c9e9dceb2974b74ddd739097f838e200e103370f688314a5f01c15e769c', - ], - transactionsRoot: '0xa87c27d6395631c8c779f9070b319268740355579f3b11d9454c2834b5943951', - uncles: [], - withdrawals: [ - { - index: '0xc8f201', - validatorIndex: '0x3a6', - address: '0xe276bc378a527a8792b353cdca5b5e53263dfb9e', - amount: '0xf00', - }, - ], - withdrawalsRoot: '0x9b35c0a8f97d4a06af667bd48ae1e7ca405218326ec384c443b6cb7960f88d5d', - }, - }, - { - requestArgument: { method: 'eth_getTransactionByHash', params: ['0x1'] }, - returnValue: { - blockHash: '0xec484a535316996705454b53ce5a1f4af0f64e399c2855ec05753df2d0e1a83b', - blockNumber: '0x3a33ad', - from: '0x781ed6f2834d692fd75002c7f2f406c5ed1c6996', - gas: '0xb513', - gasPrice: '0x9502f907', - maxFeePerGas: '0x9502f90e', - maxPriorityFeePerGas: '0x9502f900', - hash: '0x415a3c9e9dceb2974b74ddd739097f838e200e103370f688314a5f01c15e769c', - input: '0x095ea7b3000000000000000000000000a81373e6070bc2d9d25216dbe52a979c850e261f000000000000000000000000000000000000000000000005f40bfa403363a8a3', - nonce: '0x28', - to: '0x922a99b817f501af4c3dfc1ce359d7ec9dbdf8a3', - transactionIndex: '0x0', - value: '0x0', - type: '0x2', - accessList: [], - chainId: '0xaa36a7', - v: '0x0', - r: '0xdd4c1526e293d8139500d84104cd28498d3570603c9f0698e47e7406a552b388', - s: '0x1a3f1c65aa1ea66ef595422f7dd4c43a4800156225f02ffc22272093e41b780', - }, - }, - { - requestArgument: { method: 'eth_getTransactionReceipt', params: ['0x1'] }, - returnValue: { - blockHash: '0xec484a535316996705454b53ce5a1f4af0f64e399c2855ec05753df2d0e1a83b', - blockNumber: '0x3a33ad', - from: '0x781ed6f2834d692fd75002c7f2f406c5ed1c6996', - gas: '0xb513', - gasPrice: '0x9502f907', - maxFeePerGas: '0x9502f90e', - maxPriorityFeePerGas: '0x9502f900', - hash: '0x415a3c9e9dceb2974b74ddd739097f838e200e103370f688314a5f01c15e769c', - input: '0x095ea7b3000000000000000000000000a81373e6070bc2d9d25216dbe52a979c850e261f000000000000000000000000000000000000000000000005f40bfa403363a8a3', - nonce: '0x28', - to: '0x922a99b817f501af4c3dfc1ce359d7ec9dbdf8a3', - transactionIndex: '0x0', - value: '0x0', - type: '0x2', - accessList: [], - chainId: '0xaa36a7', - v: '0x0', - r: '0xdd4c1526e293d8139500d84104cd28498d3570603c9f0698e47e7406a552b388', - s: '0x1a3f1c65aa1ea66ef595422f7dd4c43a4800156225f02ffc22272093e41b780', - }, - }, - { requestArgument: { method: 'eth_getTransactionCount', params: ['0x1', '0x1'] }, returnValue: '0x6' }, - ]; - - beforeEach(() => { - jest.resetAllMocks(); - - (JsonRpcProvider as unknown as jest.Mock).mockImplementation(() => ({ - send: sendMock, - })); - }); - - it.each(passthroughMethods)('should passthrough %s to the rpcProvider', async ({ requestArgument, returnValue }) => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - sendMock.mockResolvedValueOnce(returnValue); - - const provider = getProvider(); - - // NOTE: params are static since we are only testing the call is - // forwarded with whatever parameters it's called with. Might not match - // the actual parameters for a specific method. - const result = await provider.request(requestArgument); - - expect(sendMock).toBeCalledTimes(1); - expect(sendMock).toBeCalledWith(requestArgument.method, requestArgument.params); - expect(result).toBe(returnValue); - }); - - describe('eth_getBalance', () => { - it('defaults the `blockNumber` argument to `latest` if not provided', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_getBalance', params: ['0x1'] }); - - expect(sendMock).toBeCalledWith('eth_getBalance', ['0x1', 'latest']); - }); - }); - - describe('eth_getCode', () => { - it('defaults the `blockNumber` argument to `latest` if not provided', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_getCode', params: ['0x1'] }); - - expect(sendMock).toBeCalledWith('eth_getCode', ['0x1', 'latest']); - }); - }); - - describe('eth_getTransactionCount', () => { - it('defaults the `blockNumber` argument to `latest` if not provided', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_getTransactionCount', params: ['0x1'] }); - - expect(sendMock).toBeCalledWith('eth_getTransactionCount', ['0x1', 'latest']); - }); - }); - - describe('eth_getStorageAt', () => { - it('defaults the `blockNumber` argument to `latest` if not provided', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_getStorageAt', params: ['0x1', '0x2'] }); - - expect(sendMock).toBeCalledWith('eth_getStorageAt', ['0x1', '0x2', 'latest']); - }); - }); - - describe('eth_call', () => { - it('defaults the `blockNumber` argument to `latest` if not provided', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_call', params: [{ to: '0x1' }] }); - - expect(sendMock).toBeCalledWith('eth_call', [{ to: '0x1' }, 'latest']); - }); - }); - - describe('eth_estimateGas', () => { - it('defaults the `blockNumber` argument to `latest` if not provided', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - const provider = getProvider(); - await provider.request({ method: 'eth_estimateGas', params: [{ to: '0x1' }] }); - - expect(sendMock).toBeCalledWith('eth_estimateGas', [{ to: '0x1' }, 'latest']); - }); - }); - }); -}); diff --git a/packages/wallet/.eslintrc.cjs b/packages/wallet/.eslintrc.cjs new file mode 100644 index 0000000000..3c484de84b --- /dev/null +++ b/packages/wallet/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + extends: ['../../.eslintrc'], + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + }, + rules: { + // Disable all import plugin rules due to stack overflow with auth package structure + 'import/order': 'off', + 'import/no-unresolved': 'off', + 'import/named': 'off', + 'import/default': 'off', + 'import/namespace': 'off', + 'import/no-cycle': 'off', + 'import/no-named-as-default': 'off', + 'import/no-named-as-default-member': 'off', + }, +}; \ No newline at end of file diff --git a/packages/wallet/jest.config.ts b/packages/wallet/jest.config.ts new file mode 100644 index 0000000000..793f1a2c00 --- /dev/null +++ b/packages/wallet/jest.config.ts @@ -0,0 +1,27 @@ +import type { Config } from 'jest'; +import { execSync } from 'child_process'; +import { name } from './package.json'; + +const rootDirs = execSync(`pnpm --filter ${name}... exec pwd`) + .toString() + .split('\n') + .filter(Boolean) + .map((dir) => `${dir}/dist`); + +const config: Config = { + clearMocks: true, + roots: ['/src', ...rootDirs], + coverageProvider: 'v8', + moduleDirectories: ['node_modules', 'src'], + moduleNameMapper: { '^@imtbl/(.*)$': '/../../node_modules/@imtbl/$1/src' }, + testEnvironment: 'jsdom', + transform: { + '^.+\\.(t|j)sx?$': '@swc/jest', + }, + transformIgnorePatterns: [], + restoreMocks: true, + setupFiles: ['/jest.setup.js'], +}; + +export default config; + diff --git a/packages/wallet/jest.setup.js b/packages/wallet/jest.setup.js new file mode 100644 index 0000000000..0a4e57c4f9 --- /dev/null +++ b/packages/wallet/jest.setup.js @@ -0,0 +1,4 @@ +import { TextEncoder } from 'util'; + +global.TextEncoder = TextEncoder; + diff --git a/packages/wallet/package.json b/packages/wallet/package.json new file mode 100644 index 0000000000..f29ef22659 --- /dev/null +++ b/packages/wallet/package.json @@ -0,0 +1,70 @@ +{ + "name": "@imtbl/wallet", + "version": "0.0.0", + "description": "Wallet SDK for Immutable", + "author": "Immutable", + "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", + "homepage": "https://github.com/immutable/ts-immutable-sdk#readme", + "license": "Apache-2.0", + "main": "dist/node/index.cjs", + "module": "dist/node/index.js", + "browser": "dist/browser/index.js", + "types": "./dist/types/index.d.ts", + "exports": { + "development": { + "types": "./src/index.ts", + "browser": "./dist/browser/index.js", + "require": "./dist/node/index.cjs", + "default": "./dist/node/index.js" + }, + "default": { + "types": "./dist/types/index.d.ts", + "browser": "./dist/browser/index.js", + "require": "./dist/node/index.cjs", + "default": "./dist/node/index.js" + } + }, + "scripts": { + "build": "pnpm transpile && pnpm typegen", + "transpile": "tsup src/index.ts --config ../../tsup.config.js", + "typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types", + "pack:root": "pnpm pack --pack-destination $(dirname $(pnpm root -w))", + "lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0", + "typecheck": "tsc --customConditions default --noEmit --jsx preserve", + "test": "jest" + }, + "dependencies": { + "@0xsequence/abi": "^2.0.25", + "@0xsequence/core": "^2.0.25", + "@imtbl/auth": "workspace:*", + "@imtbl/config": "workspace:*", + "@imtbl/generated-clients": "workspace:*", + "@imtbl/metrics": "workspace:*", + "@magic-ext/oidc": "12.0.5", + "@magic-sdk/provider": "^29.0.5", + "@metamask/detect-provider": "^2.0.0", + "axios": "^1.6.5", + "ethers": "^6.13.4", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@imtbl/toolkit": "workspace:*", + "@swc/core": "^1.3.36", + "@swc/jest": "^0.2.37", + "@types/jest": "^29.5.12", + "@types/node": "^18.14.2", + "@types/uuid": "^8.3.4", + "@jest/test-sequencer": "^29.7.0", + "jest": "^29.4.3", + "jest-environment-jsdom": "^29.4.3", + "ts-node": "^10.9.1", + "tsup": "^8.3.0", + "typescript": "^5.6.2" + }, + "publishConfig": { + "access": "public" + }, + "repository": "immutable/ts-immutable-sdk.git", + "type": "module" +} + diff --git a/packages/wallet/src/config.ts b/packages/wallet/src/config.ts new file mode 100644 index 0000000000..08c0ee3ee2 --- /dev/null +++ b/packages/wallet/src/config.ts @@ -0,0 +1,61 @@ +/** + * Configuration for wallet operations + * Contains concrete URLs and settings - no environment abstraction + * + * Note: This is a low-level configuration class. For high-level usage, + * use Passport SDK which handles environment → URL translation. + */ +export interface WalletConfigurationParams { + /** Passport domain URL */ + passportDomain: string; + + /** zkEVM RPC URL */ + zkEvmRpcUrl: string; + + /** Relayer URL for transaction submission */ + relayerUrl: string; + + /** Indexer/API base path */ + indexerMrBasePath: string; + + /** Optional referrer URL for JSON-RPC requests */ + jsonRpcReferrer?: string; + + /** If true, forces SCW deployment before message signature */ + forceScwDeployBeforeMessageSignature?: boolean; + + /** Cross-SDK bridge mode flag */ + crossSdkBridgeEnabled?: boolean; + + /** Preferred token symbol to use when paying relayer fees (defaults to 'IMX') */ + feeTokenSymbol?: string; +} + +export class WalletConfiguration { + readonly passportDomain: string; + + readonly zkEvmRpcUrl: string; + + readonly relayerUrl: string; + + readonly indexerMrBasePath: string; + + readonly jsonRpcReferrer?: string; + + readonly forceScwDeployBeforeMessageSignature: boolean; + + readonly crossSdkBridgeEnabled: boolean; + + readonly feeTokenSymbol: string; + + constructor(params: WalletConfigurationParams) { + this.passportDomain = params.passportDomain; + this.zkEvmRpcUrl = params.zkEvmRpcUrl; + this.relayerUrl = params.relayerUrl; + this.indexerMrBasePath = params.indexerMrBasePath; + this.jsonRpcReferrer = params.jsonRpcReferrer; + this.forceScwDeployBeforeMessageSignature = params.forceScwDeployBeforeMessageSignature || false; + this.crossSdkBridgeEnabled = params.crossSdkBridgeEnabled || false; + this.feeTokenSymbol = params.feeTokenSymbol || 'IMX'; + } +} diff --git a/packages/passport/sdk/src/confirmation/confirmation.ts b/packages/wallet/src/confirmation/confirmation.ts similarity index 98% rename from packages/passport/sdk/src/confirmation/confirmation.ts rename to packages/wallet/src/confirmation/confirmation.ts index f8069ca7cb..721e6adb90 100644 --- a/packages/passport/sdk/src/confirmation/confirmation.ts +++ b/packages/wallet/src/confirmation/confirmation.ts @@ -8,7 +8,7 @@ import { ConfirmationSendMessage, } from './types'; import { openPopupCenter } from './popup'; -import { PassportConfiguration } from '../config'; +import { IAuthConfiguration } from '@imtbl/auth'; import ConfirmationOverlay from '../overlay/confirmationOverlay'; const CONFIRMATION_WINDOW_TITLE = 'Confirm this transaction'; @@ -24,7 +24,7 @@ type MessageHandler = (arg0: MessageEvent) => void; type MessageType = 'erc191' | 'eip712'; export default class ConfirmationScreen { - private config: PassportConfiguration; + private config: IAuthConfiguration; private confirmationWindow: Window | undefined; @@ -36,7 +36,7 @@ export default class ConfirmationScreen { private timer: NodeJS.Timeout | undefined; - constructor(config: PassportConfiguration) { + constructor(config: IAuthConfiguration) { this.config = config; this.overlayClosed = false; } @@ -194,12 +194,12 @@ export default class ConfirmationScreen { width: popupOptions?.width || CONFIRMATION_WINDOW_WIDTH, height: popupOptions?.height || CONFIRMATION_WINDOW_HEIGHT, }); - this.overlay = new ConfirmationOverlay(this.config.popupOverlayOptions); + this.overlay = new ConfirmationOverlay(this.config.popupOverlayOptions || {}); } catch (error) { // If an error is thrown here then the popup is blocked const errorMessage = error instanceof Error ? error.message : String(error); trackError('passport', 'confirmationPopupDenied', new Error(errorMessage)); - this.overlay = new ConfirmationOverlay(this.config.popupOverlayOptions, true); + this.overlay = new ConfirmationOverlay(this.config.popupOverlayOptions || {}, true); } this.overlay.append( diff --git a/packages/passport/sdk/src/confirmation/index.ts b/packages/wallet/src/confirmation/index.ts similarity index 55% rename from packages/passport/sdk/src/confirmation/index.ts rename to packages/wallet/src/confirmation/index.ts index 3ef532c235..491f50e900 100644 --- a/packages/passport/sdk/src/confirmation/index.ts +++ b/packages/wallet/src/confirmation/index.ts @@ -1,3 +1,2 @@ export { default as ConfirmationScreen } from './confirmation'; -export { default as EmbeddedLoginPrompt } from './embeddedLoginPrompt'; export * from './types'; diff --git a/packages/passport/sdk/src/confirmation/popup.ts b/packages/wallet/src/confirmation/popup.ts similarity index 100% rename from packages/passport/sdk/src/confirmation/popup.ts rename to packages/wallet/src/confirmation/popup.ts diff --git a/packages/wallet/src/confirmation/types.ts b/packages/wallet/src/confirmation/types.ts new file mode 100644 index 0000000000..621f2000c1 --- /dev/null +++ b/packages/wallet/src/confirmation/types.ts @@ -0,0 +1,19 @@ +export enum ConfirmationSendMessage { + CONFIRMATION_START = 'confirmation_start', +} + +export enum ConfirmationReceiveMessage { + CONFIRMATION_WINDOW_READY = 'confirmation_window_ready', + TRANSACTION_CONFIRMED = 'transaction_confirmed', + TRANSACTION_ERROR = 'transaction_error', + TRANSACTION_REJECTED = 'transaction_rejected', + MESSAGE_CONFIRMED = 'message_confirmed', + MESSAGE_ERROR = 'message_error', + MESSAGE_REJECTED = 'message_rejected', +} + +export type ConfirmationResult = { + confirmed: boolean; +}; + +export const PASSPORT_CONFIRMATION_EVENT_TYPE = 'imx_passport_confirmation'; diff --git a/packages/wallet/src/connectWallet.test.ts b/packages/wallet/src/connectWallet.test.ts new file mode 100644 index 0000000000..a6a402caec --- /dev/null +++ b/packages/wallet/src/connectWallet.test.ts @@ -0,0 +1,101 @@ +jest.mock('@imtbl/auth', () => { + const Auth = jest.fn().mockImplementation(() => ({ + getConfig: jest.fn().mockReturnValue({ + authenticationDomain: 'https://auth.immutable.com', + passportDomain: 'https://passport.immutable.com', + oidcConfiguration: { + clientId: 'client', + redirectUri: 'https://redirect', + }, + }), + getUser: jest.fn().mockResolvedValue({ profile: { sub: 'user' } }), + })); + + const TypedEventEmitter = jest.fn().mockImplementation(() => ({ + emit: jest.fn(), + on: jest.fn(), + })); + + return { Auth, TypedEventEmitter }; +}); + +const multiRollupInstance = { + guardianApi: {}, +}; + +jest.mock('@imtbl/generated-clients', () => ({ + MultiRollupApiClients: jest.fn().mockImplementation(() => multiRollupInstance), + MagicTeeApiClients: jest.fn().mockImplementation(() => ({})), + createConfig: jest.fn((config) => config), + mr: { GuardianApi: jest.fn().mockImplementation(() => ({})) }, +})); + +jest.mock('./guardian', () => jest.fn().mockImplementation(() => ({ + getPreferredFeeTokenSymbol: jest.fn().mockReturnValue('IMX'), +}))); + +jest.mock('./magic/magicTEESigner', () => jest.fn().mockImplementation(() => ({ + getAddress: jest.fn(), +}))); + +jest.mock('./zkEvm/zkEvmProvider', () => ({ + ZkEvmProvider: jest.fn(), +})); + +jest.mock('./provider/eip6963', () => ({ + announceProvider: jest.fn(), + passportProviderInfo: { name: 'passport', rdns: 'com.immutable.passport', icon: '' }, +})); + +const { connectWallet } = require('./connectWallet'); + +const { announceProvider } = jest.requireMock('./provider/eip6963'); +const { ZkEvmProvider } = jest.requireMock('./zkEvm/zkEvmProvider'); + +const chain = { + chainId: 13473, + rpcUrl: 'https://rpc.sandbox.immutable.com', + relayerUrl: 'https://relayer.sandbox.immutable.com', + apiUrl: 'https://api.sandbox.immutable.com', + name: 'Immutable zkEVM Testnet', +}; + +const createAuthStub = () => ({ + getConfig: jest.fn().mockReturnValue({ + authenticationDomain: 'https://auth.immutable.com', + passportDomain: 'https://passport.immutable.com', + oidcConfiguration: { + clientId: 'client', + redirectUri: 'https://redirect', + }, + }), + getUser: jest.fn().mockResolvedValue({ profile: { sub: 'user' } }), + loginCallback: jest.fn(), + eventEmitter: { emit: jest.fn(), on: jest.fn() }, +}); + +describe('connectWallet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('announces provider by default', async () => { + const auth = createAuthStub(); + + const provider = await connectWallet({ auth, chains: [chain] }); + + expect(ZkEvmProvider).toHaveBeenCalled(); + expect(announceProvider).toHaveBeenCalledWith({ + info: expect.any(Object), + provider, + }); + }); + + it('does not announce provider when disabled', async () => { + const auth = createAuthStub(); + + await connectWallet({ auth, chains: [chain], announceProvider: false }); + + expect(announceProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/wallet/src/connectWallet.ts b/packages/wallet/src/connectWallet.ts new file mode 100644 index 0000000000..befaac8023 --- /dev/null +++ b/packages/wallet/src/connectWallet.ts @@ -0,0 +1,281 @@ +import { + Auth, + IAuthConfiguration, + TypedEventEmitter, +} from '@imtbl/auth'; +import { + MultiRollupApiClients, + MagicTeeApiClients, + createConfig, + mr, +} from '@imtbl/generated-clients'; +import { ZkEvmProvider } from './zkEvm/zkEvmProvider'; +import { ConnectWalletOptions, PassportEventMap, ChainConfig } from './types'; +import { WalletConfiguration } from './config'; +import GuardianClient from './guardian'; +import MagicTEESigner from './magic/magicTEESigner'; +import { announceProvider, passportProviderInfo } from './provider/eip6963'; +import { DEFAULT_CHAINS } from './presets'; +import { MAGIC_CONFIG, IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID } from './constants'; + +/** + * Type guard to check if chainId is a valid key for MAGIC_CONFIG + */ +function isValidMagicChainId(chainId: number): chainId is keyof typeof MAGIC_CONFIG { + return chainId in MAGIC_CONFIG; +} + +/** + * Get Magic configuration for a specific chain + * Prioritizes chain-specific config (for dev/custom environments), + * falls back to hard-coded defaults for standard chains + * @internal + */ +function getMagicConfigForChain(chain: ChainConfig): { + magicPublishableApiKey: string; + magicProviderId: string; +} { + // 1. Use chain-specific magic config if provided (dev/custom environments) + if (chain.magicPublishableApiKey && chain.magicProviderId) { + return { + magicPublishableApiKey: chain.magicPublishableApiKey, + magicProviderId: chain.magicProviderId, + }; + } + + // 2. Fallback to hard-coded defaults for standard chains (prod/sandbox) + const { chainId } = chain; + if (isValidMagicChainId(chainId)) { + return MAGIC_CONFIG[chainId]; + } + + // 3. Error for unknown chains without magic config + throw new Error( + `No Magic configuration available for chain ${chain.chainId}. ` + + 'Please provide magicPublishableApiKey and magicProviderId in ChainConfig.', + ); +} + +const DEFAULT_PRODUCTION_CLIENT_ID = 'PtQRK4iRJ8GkXjiz6xfImMAYhPhW0cYk'; +const DEFAULT_SANDBOX_CLIENT_ID = 'mjtCL8mt06BtbxSkp2vbrYStKWnXVZfo'; +const DEFAULT_AUTH_SCOPE = 'openid profile email offline_access transact'; +const DEFAULT_AUTH_AUDIENCE = 'platform_api'; +const DEFAULT_REDIRECT_FALLBACK = 'https://auth.immutable.com/im-logged-in'; +const DEFAULT_AUTHENTICATION_DOMAIN = 'https://auth.immutable.com'; +const SANDBOX_DOMAIN_REGEX = /(sandbox|testnet)/i; + +function isSandboxChain(chain: ChainConfig): boolean { + if (chain.chainId === IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID) { + return true; + } + + const domainCandidate = chain.apiUrl || chain.passportDomain || ''; + return SANDBOX_DOMAIN_REGEX.test(domainCandidate); +} + +function derivePassportDomain(chain: ChainConfig): string { + if (chain.passportDomain) { + return chain.passportDomain; + } + + if (chain.apiUrl) { + try { + const apiUrl = new URL(chain.apiUrl); + const updatedHost = apiUrl.hostname.replace('api.', 'passport.'); + return `${apiUrl.protocol}//${updatedHost}`; + } catch { + return chain.apiUrl.replace('api.', 'passport.'); + } + } + + return 'https://passport.immutable.com'; +} + +function deriveAuthenticationDomain(): string { + return DEFAULT_AUTHENTICATION_DOMAIN; +} + +function deriveRedirectUri(): string { + return DEFAULT_REDIRECT_FALLBACK; +} + +function getDefaultClientId(chain: ChainConfig): string { + return isSandboxChain(chain) ? DEFAULT_SANDBOX_CLIENT_ID : DEFAULT_PRODUCTION_CLIENT_ID; +} + +function createDefaultAuth(initialChain: ChainConfig, options: ConnectWalletOptions): Auth { + const passportDomain = derivePassportDomain(initialChain); + const authenticationDomain = deriveAuthenticationDomain(); + const redirectUri = deriveRedirectUri(); + + return new Auth({ + clientId: getDefaultClientId(initialChain), + redirectUri, + popupRedirectUri: redirectUri, + logoutRedirectUri: redirectUri, + scope: DEFAULT_AUTH_SCOPE, + audience: DEFAULT_AUTH_AUDIENCE, + authenticationDomain, + passportDomain, + popupOverlayOptions: options.popupOverlayOptions, + crossSdkBridgeEnabled: options.crossSdkBridgeEnabled, + }); +} + +/** + * Connect wallet with the provided configuration + * + * @param config - Wallet configuration + * @returns EIP-1193 compliant provider with multi-chain support + * + * If no Auth instance is provided, a default Immutable-hosted client id will be used. + * + * @example + * ```typescript + * import { Auth } from '@imtbl/auth'; + * import { connectWallet, IMMUTABLE_ZKEVM_MAINNET_CHAIN } from '@imtbl/wallet'; + * + * // Create auth + * const auth = new Auth({ + * authenticationDomain: 'https://auth.immutable.com', + * passportDomain: 'https://passport.immutable.com', + * clientId: 'your-client-id', + * redirectUri: 'https://your-app.com/callback', + * scope: 'openid profile email offline_access transact', + * }); + * + * // Connect wallet (defaults to testnet + mainnet, starts on testnet) + * const provider = await connectWallet({ auth }); + * + * // Or specify a single chain + * const provider = await connectWallet({ + * auth, + * chains: [IMMUTABLE_ZKEVM_MAINNET_CHAIN], + * }); + * ``` + */ +export async function connectWallet( + config: ConnectWalletOptions = {}, +): Promise { + // Use default chains if not provided (testnet + mainnet) + const chains = config.chains && config.chains.length > 0 + ? config.chains + : DEFAULT_CHAINS; + + // Default to first chain (testnet by default) + const initialChainId = config.initialChainId || chains[0].chainId; + const initialChain = chains.find((c) => c.chainId === initialChainId); + + if (!initialChain) { + throw new Error(`Initial chain ${initialChainId} not found in chains configuration`); + } + + // 1. Create basic configuration for the APIs + const apiConfig = createConfig({ basePath: initialChain.apiUrl }); + + // 2. Create MultiRollupApiClients + const multiRollupApiClients = new MultiRollupApiClients({ + indexer: apiConfig, + orderBook: apiConfig, + passport: apiConfig, + }); + + // 3. Resolve Auth (use provided instance or create a default) + const auth = config.auth ?? createDefaultAuth(initialChain, config); + if (!config.auth && typeof window !== 'undefined') { + window.addEventListener('message', async (event) => { + if (event.data.code && event.data.state) { + // append to the current querystring making sure both cases of no existing and having existing params are handled + const currentQueryString = window.location.search; + const newQueryString = new URLSearchParams(currentQueryString); + newQueryString.set('code', event.data.code); + newQueryString.set('state', event.data.state); + window.history.replaceState(null, '', `?${newQueryString.toString()}`); + await auth.loginCallback(); + // remove the code and state from the querystring + newQueryString.delete('code'); + newQueryString.delete('state'); + window.history.replaceState(null, '', `?${newQueryString.toString()}`); + } + }); + } + + // 4. Extract Auth configuration and current user + const authConfig: IAuthConfiguration = auth.getConfig(); + const user = await auth.getUser(); + + // 5. Create wallet configuration with concrete URLs + const walletConfig = new WalletConfiguration({ + passportDomain: initialChain.passportDomain || initialChain.apiUrl.replace('api.', 'passport.'), + zkEvmRpcUrl: initialChain.rpcUrl, + relayerUrl: initialChain.relayerUrl, + indexerMrBasePath: initialChain.apiUrl, + jsonRpcReferrer: config.jsonRpcReferrer, + forceScwDeployBeforeMessageSignature: config.forceScwDeployBeforeMessageSignature, + crossSdkBridgeEnabled: config.crossSdkBridgeEnabled, + feeTokenSymbol: config.feeTokenSymbol, + }); + + // 6. Create GuardianClient + const guardianApi = new mr.GuardianApi(apiConfig); + + const guardianClient = new GuardianClient({ + config: walletConfig, + auth, + guardianApi, + authConfig, + }); + + // 7. Get Magic config for initial chain (from chain config or hard-coded default) + const magicConfig = getMagicConfigForChain(initialChain); + + // 8. Create MagicTEESigner with Magic TEE base path (separate from backend API) + const magicTeeBasePath = initialChain.magicTeeBasePath || 'https://tee.express.magiclabs.com'; + const magicTeeApiClients = new MagicTeeApiClients({ + basePath: magicTeeBasePath, + timeout: 10000, + magicPublishableApiKey: magicConfig.magicPublishableApiKey, + magicProviderId: magicConfig.magicProviderId, + }); + + const ethSigner = new MagicTEESigner(auth, magicTeeApiClients); + + // 9. Determine session activity API URL (only for mainnet, testnet, devnet) + let sessionActivityApiUrl: string | null = null; + if (initialChain.chainId === 13371) { + // Mainnet + sessionActivityApiUrl = 'https://api.immutable.com'; + } else if (initialChain.chainId === 13473) { + // Testnet + sessionActivityApiUrl = 'https://api.sandbox.immutable.com'; + } else if (initialChain.apiUrl) { + // Devnet - use the apiUrl from chain config + sessionActivityApiUrl = initialChain.apiUrl; + } + // For any other chain, sessionActivityApiUrl remains null (no session activity tracking) + + // 10. Create PassportEventEmitter + const passportEventEmitter = config.passportEventEmitter || new TypedEventEmitter(); + + // 11. Create ZkEvmProvider + const provider = new ZkEvmProvider({ + auth, + config: walletConfig, + multiRollupApiClients, + passportEventEmitter, + guardianClient, + ethSigner, + user, + sessionActivityApiUrl, + }); + + // 12. Announce provider via EIP-6963 + if (config.announceProvider !== false) { + announceProvider({ + info: passportProviderInfo, + provider, + }); + } + + return provider; +} diff --git a/packages/wallet/src/constants.ts b/packages/wallet/src/constants.ts new file mode 100644 index 0000000000..462e32eb7d --- /dev/null +++ b/packages/wallet/src/constants.ts @@ -0,0 +1,24 @@ +/** + * Chain ID constants for Immutable networks + */ + +/** Immutable zkEVM Mainnet chain ID */ +export const IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID = 13371; + +/** Immutable zkEVM Testnet chain ID */ +export const IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID = 13473; + +/** + * Magic configuration for Immutable networks + * @internal + */ +export const MAGIC_CONFIG = { + [IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID]: { + magicPublishableApiKey: 'pk_live_10F423798A540ED7', + magicProviderId: 'aa80b860-8869-4f13-9000-6a6ad3d20017', + }, + [IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID]: { + magicPublishableApiKey: 'pk_live_10F423798A540ED7', + magicProviderId: 'aa80b860-8869-4f13-9000-6a6ad3d20017', + }, +} as const; diff --git a/packages/wallet/src/errors.ts b/packages/wallet/src/errors.ts new file mode 100644 index 0000000000..2e55076677 --- /dev/null +++ b/packages/wallet/src/errors.ts @@ -0,0 +1,33 @@ +export enum WalletErrorType { + WALLET_CONNECTION_ERROR = 'WALLET_CONNECTION_ERROR', + TRANSACTION_REJECTED = 'TRANSACTION_REJECTED', + INVALID_CONFIGURATION = 'INVALID_CONFIGURATION', + UNAUTHORIZED = 'UNAUTHORIZED', + GUARDIAN_ERROR = 'GUARDIAN_ERROR', + SERVICE_UNAVAILABLE_ERROR = 'SERVICE_UNAVAILABLE_ERROR', + NOT_LOGGED_IN_ERROR = 'NOT_LOGGED_IN_ERROR', +} + +export class WalletError extends Error { + readonly type: WalletErrorType; + + constructor(message: string, type: WalletErrorType) { + super(message); + this.name = 'WalletError'; + this.type = type; + } +} + +export async function withWalletError( + fn: () => Promise, + defaultErrorType: WalletErrorType, +): Promise { + try { + return await fn(); + } catch (err: any) { + if (err instanceof WalletError) { + throw err; + } + throw new WalletError(err?.message ?? 'Unknown error', defaultErrorType); + } +} diff --git a/packages/passport/sdk/src/guardian/index.ts b/packages/wallet/src/guardian/index.ts similarity index 75% rename from packages/passport/sdk/src/guardian/index.ts rename to packages/wallet/src/guardian/index.ts index d5fc6cebdf..780c09a5f2 100644 --- a/packages/passport/sdk/src/guardian/index.ts +++ b/packages/wallet/src/guardian/index.ts @@ -1,24 +1,19 @@ import * as GeneratedClients from '@imtbl/generated-clients'; import { BigNumberish, ZeroAddress } from 'ethers'; import axios from 'axios'; -import AuthManager from '../authManager'; -import { ConfirmationScreen } from '../confirmation'; -import { retryWithDelay } from '../network/retry'; +import { Auth, IAuthConfiguration } from '@imtbl/auth'; +import ConfirmationScreen from '../confirmation/confirmation'; import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from '../zkEvm/JsonRpcError'; import { MetaTransaction, TypedDataPayload } from '../zkEvm/types'; -import { PassportConfiguration } from '../config'; +import { WalletConfiguration } from '../config'; import { getEip155ChainId } from '../zkEvm/walletHelpers'; -import { PassportError, PassportErrorType } from '../errors/passportError'; +import { WalletError, WalletErrorType } from '../errors'; export type GuardianClientParams = { - confirmationScreen: ConfirmationScreen; - config: PassportConfiguration; - authManager: AuthManager; + config: WalletConfiguration; + auth: Auth; guardianApi: GeneratedClients.mr.GuardianApi; -}; - -export type GuardianEvaluateImxTransactionParams = { - payloadHash: string; + authConfig: IAuthConfiguration; }; type GuardianEVMTxnEvaluationParams = { @@ -73,15 +68,15 @@ export default class GuardianClient { private readonly crossSdkBridgeEnabled: boolean; - private readonly authManager: AuthManager; + private readonly auth: Auth; constructor({ - confirmationScreen, config, authManager, guardianApi, + config, auth, guardianApi, authConfig, }: GuardianClientParams) { - this.confirmationScreen = confirmationScreen; + this.confirmationScreen = new ConfirmationScreen(authConfig); this.crossSdkBridgeEnabled = config.crossSdkBridgeEnabled; this.guardianApi = guardianApi; - this.authManager = authManager; + this.auth = auth; } /** @@ -105,7 +100,7 @@ export default class GuardianClient { try { return await task(); } catch (err) { - if (err instanceof PassportError && err.type === PassportErrorType.SERVICE_UNAVAILABLE_ERROR) { + if (err instanceof WalletError && err.type === WalletErrorType.SERVICE_UNAVAILABLE_ERROR) { await this.confirmationScreen.showServiceUnavailable(); throw err; } @@ -120,65 +115,12 @@ export default class GuardianClient { return this.withConfirmationScreenTask()(task); } - public async evaluateImxTransaction({ payloadHash }: GuardianEvaluateImxTransactionParams): Promise { - try { - const finallyFn = () => { - this.confirmationScreen.closeWindow(); - }; - const user = await this.authManager.getUserImx(); - - const headers = { Authorization: `Bearer ${user.accessToken}` }; - const transactionRes = await retryWithDelay( - async () => this.guardianApi.getTransactionByID({ - transactionID: payloadHash, - chainType: 'starkex', - }, { headers }), - { finallyFn }, - ); - - if (!transactionRes.data.id) { - throw new Error("Transaction doesn't exists"); - } - - const evaluateImxRes = await this.guardianApi.evaluateTransaction({ - id: payloadHash, - transactionEvaluationRequest: { - chainType: 'starkex', - }, - }, { headers }); - - const { confirmationRequired } = evaluateImxRes.data; - if (confirmationRequired) { - if (this.crossSdkBridgeEnabled) { - throw new Error(transactionRejectedCrossSdkBridgeError); - } - - const confirmationResult = await this.confirmationScreen.requestConfirmation( - payloadHash, - user.imx.ethAddress, - GeneratedClients.mr.TransactionApprovalRequestChainTypeEnum.Starkex, - ); - - if (!confirmationResult.confirmed) { - throw new Error('Transaction rejected by user'); - } - } else { - this.confirmationScreen.closeWindow(); - } - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 403) { - throw new PassportError('Service unavailable', PassportErrorType.SERVICE_UNAVAILABLE_ERROR); - } - throw error; - } - } - private async evaluateEVMTransaction({ chainId, nonce, metaTransactions, }: GuardianEVMTxnEvaluationParams): Promise { - const user = await this.authManager.getUserZkEvm(); + const user = await this.auth.getUserZkEvm(); const headers = { Authorization: `Bearer ${user.accessToken}` }; const guardianTransactions = transformGuardianTransactions(metaTransactions); try { @@ -201,7 +143,7 @@ export default class GuardianClient { return response.data; } catch (error) { if (axios.isAxiosError(error) && error.response?.status === 403) { - throw new PassportError('Service unavailable', PassportErrorType.SERVICE_UNAVAILABLE_ERROR); + throw new WalletError('Service unavailable', WalletErrorType.SERVICE_UNAVAILABLE_ERROR); } const errorMessage = error instanceof Error ? error.message : String(error); @@ -233,7 +175,7 @@ export default class GuardianClient { } if (confirmationRequired && !!transactionId) { - const user = await this.authManager.getUserZkEvm(); + const user = await this.auth.getUserZkEvm(); const confirmationResult = await this.confirmationScreen.requestConfirmation( transactionId, user.zkEvm.ethAddress, @@ -258,7 +200,7 @@ export default class GuardianClient { { chainID, payload }: GuardianEIP712MessageEvaluationParams, ): Promise { try { - const user = await this.authManager.getUserZkEvm(); + const user = await this.auth.getUserZkEvm(); if (user === null) { throw new JsonRpcError( ProviderErrorCode.UNAUTHORIZED, @@ -285,7 +227,7 @@ export default class GuardianClient { throw new JsonRpcError(RpcErrorCode.TRANSACTION_REJECTED, transactionRejectedCrossSdkBridgeError); } if (confirmationRequired && !!messageId) { - const user = await this.authManager.getUserZkEvm(); + const user = await this.auth.getUserZkEvm(); const confirmationResult = await this.confirmationScreen.requestMessageConfirmation( messageId, user.zkEvm.ethAddress, @@ -307,7 +249,7 @@ export default class GuardianClient { { chainID, payload }: GuardianERC191MessageEvaluationParams, ): Promise { try { - const user = await this.authManager.getUserZkEvm(); + const user = await this.auth.getUserZkEvm(); if (user === null) { throw new JsonRpcError( ProviderErrorCode.UNAUTHORIZED, @@ -339,7 +281,7 @@ export default class GuardianClient { throw new JsonRpcError(RpcErrorCode.TRANSACTION_REJECTED, transactionRejectedCrossSdkBridgeError); } if (confirmationRequired && !!messageId) { - const user = await this.authManager.getUserZkEvm(); + const user = await this.auth.getUserZkEvm(); const confirmationResult = await this.confirmationScreen.requestMessageConfirmation( messageId, user.zkEvm.ethAddress, diff --git a/packages/wallet/src/index.ts b/packages/wallet/src/index.ts new file mode 100644 index 0000000000..039026270e --- /dev/null +++ b/packages/wallet/src/index.ts @@ -0,0 +1,53 @@ +// Export main connection function (public API) +export { connectWallet } from './connectWallet'; + +// Export chain ID constants (public API) +export { + IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID, + IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID, +} from './constants'; + +// Export presets (public API) +export { + IMMUTABLE_ZKEVM_MAINNET, + IMMUTABLE_ZKEVM_TESTNET, + IMMUTABLE_ZKEVM_MULTICHAIN, + IMMUTABLE_ZKEVM_MAINNET_CHAIN, + IMMUTABLE_ZKEVM_TESTNET_CHAIN, + DEFAULT_CHAINS, +} from './presets'; + +// Export main wallet provider +export { ZkEvmProvider } from './zkEvm/zkEvmProvider'; + +// Export internal configuration (for Passport/advanced usage) +export { WalletConfiguration } from './config'; + +// Export types +export * from './types'; + +// Export errors +export { WalletError, WalletErrorType } from './errors'; +export { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './zkEvm/JsonRpcError'; + +// Export zkEvm utilities +export { RelayerClient } from './zkEvm/relayerClient'; +export * as walletHelpers from './zkEvm/walletHelpers'; + +// Export guardian and magic (for advanced usage) +export { default as GuardianClient } from './guardian'; +export { default as MagicTEESigner } from './magic/magicTEESigner'; + +// Export utilities +export { TypedEventEmitter } from '@imtbl/auth'; +export { retryWithDelay } from './network/retry'; + +// Export EIP-6963 provider announcement +export { announceProvider, passportProviderInfo } from './provider/eip6963'; + +// Export confirmation screen (for transaction confirmations) +export { default as ConfirmationScreen } from './confirmation/confirmation'; + +// Export wallet linking +export { linkExternalWallet, getLinkedAddresses } from './linkExternalWallet'; +export type { LinkWalletParams, LinkedWallet } from './linkExternalWallet'; diff --git a/packages/wallet/src/linkExternalWallet.ts b/packages/wallet/src/linkExternalWallet.ts new file mode 100644 index 0000000000..3c9c5c6fba --- /dev/null +++ b/packages/wallet/src/linkExternalWallet.ts @@ -0,0 +1,147 @@ +import { Auth, isUserZkEvm } from '@imtbl/auth'; +import { MultiRollupApiClients } from '@imtbl/generated-clients'; +import { isAxiosError } from 'axios'; +import { trackFlow, trackError } from '@imtbl/metrics'; +import { WalletError, WalletErrorType } from './errors'; + +export type LinkWalletParams = { + type: string; + walletAddress: string; + signature: string; + nonce: string; +}; + +export type LinkedWallet = { + address: string; + type: string; + created_at: string; + updated_at: string; + name?: string; + clientName: string; +}; + +type APIError = { + code: string; + message: string; + details?: unknown; +}; + +function isAPIError(error: unknown): error is APIError { + return ( + typeof error === 'object' + && error !== null + && 'code' in error + && 'message' in error + ); +} + +/** + * Get all addresses linked to the current user's account + * @param auth - Auth instance to get current user + * @param apiClient - MultiRollupApiClients instance for API calls + * @returns Array of linked addresses + * @throws WalletError if user is not logged in + */ +export async function getLinkedAddresses( + auth: Auth, + apiClient: MultiRollupApiClients, +): Promise { + const user = await auth.getUser(); + if (!user?.profile.sub) { + return []; + } + + const headers = { + Authorization: `Bearer ${user.accessToken}`, + }; + + const { data } = await apiClient.passportProfileApi.getUserInfo({ headers }); + return data.linked_addresses; +} + +/** + * Link an external wallet to the current user's account + * @param auth - Auth instance to get current user + * @param apiClient - MultiRollupApiClients instance for API calls + * @param params - Link wallet parameters + * @returns Linked wallet information + * @throws WalletError if user is not logged in, not registered, or linking fails + */ +export async function linkExternalWallet( + auth: Auth, + apiClient: MultiRollupApiClients, + params: LinkWalletParams, +): Promise { + const flowInit = trackFlow('wallet', 'linkExternalWallet'); + + try { + const user = await auth.getUser(); + if (!user) { + throw new WalletError('User is not logged in', WalletErrorType.NOT_LOGGED_IN_ERROR); + } + + const isZkEvmUser = isUserZkEvm(user); + if (!isZkEvmUser) { + throw new WalletError('User has not been registered on Immutable zkEVM', WalletErrorType.WALLET_CONNECTION_ERROR); + } + + const headers = { + Authorization: `Bearer ${user.accessToken}`, + }; + + const linkWalletV2Request = { + type: params.type, + wallet_address: params.walletAddress, + signature: params.signature, + nonce: params.nonce, + }; + + const linkWalletV2Result = await apiClient + .passportProfileApi.linkWalletV2({ linkWalletV2Request }, { headers }); + return { ...linkWalletV2Result.data }; + } catch (error) { + // Track error + if (error instanceof Error) { + trackError('wallet', 'linkExternalWallet', error); + } else { + flowInit.addEvent('errored'); + } + + // Handle and rethrow + if (isAxiosError(error) && error.response) { + if (error.response.data && isAPIError(error.response.data)) { + const { code, message } = error.response.data; + + switch (code) { + case 'ALREADY_LINKED': + throw new WalletError(message, WalletErrorType.WALLET_CONNECTION_ERROR); + case 'MAX_WALLETS_LINKED': + throw new WalletError(message, WalletErrorType.WALLET_CONNECTION_ERROR); + case 'DUPLICATE_NONCE': + throw new WalletError(message, WalletErrorType.WALLET_CONNECTION_ERROR); + case 'VALIDATION_ERROR': + throw new WalletError(message, WalletErrorType.WALLET_CONNECTION_ERROR); + default: + throw new WalletError(message, WalletErrorType.WALLET_CONNECTION_ERROR); + } + } else if (error.response.status) { + throw new WalletError( + `Link wallet request failed with status code ${error.response.status}`, + WalletErrorType.WALLET_CONNECTION_ERROR, + ); + } + } + + let message: string = 'Link wallet request failed'; + if (error instanceof Error) { + message += `: ${error.message}`; + } + + throw new WalletError( + message, + WalletErrorType.WALLET_CONNECTION_ERROR, + ); + } finally { + flowInit.addEvent('End'); + } +} diff --git a/packages/passport/sdk/src/magic/index.ts b/packages/wallet/src/magic/index.ts similarity index 100% rename from packages/passport/sdk/src/magic/index.ts rename to packages/wallet/src/magic/index.ts diff --git a/packages/passport/sdk/src/magic/magicTEESigner.ts b/packages/wallet/src/magic/magicTEESigner.ts similarity index 86% rename from packages/passport/sdk/src/magic/magicTEESigner.ts rename to packages/wallet/src/magic/magicTEESigner.ts index f71ce9754b..aa7b159f84 100644 --- a/packages/passport/sdk/src/magic/magicTEESigner.ts +++ b/packages/wallet/src/magic/magicTEESigner.ts @@ -2,10 +2,10 @@ import { AbstractSigner, Signer } from 'ethers'; import { MagicTeeApiClients } from '@imtbl/generated-clients'; import { isAxiosError } from 'axios'; import { Flow, trackDuration } from '@imtbl/metrics'; -import { PassportError, PassportErrorType } from '../errors/passportError'; -import AuthManager from '../authManager'; +import { WalletError, WalletErrorType } from '../errors'; +import { Auth } from '@imtbl/auth'; import { withMetricsAsync } from '../utils/metrics'; -import { isUserImx, isUserZkEvm, User } from '../types'; +import { isUserZkEvm, User } from '../types'; const CHAIN_IDENTIFIER = 'ETH'; @@ -15,7 +15,7 @@ interface UserWallet { } export default class MagicTEESigner extends AbstractSigner { - private readonly authManager: AuthManager; + private readonly auth: Auth; private readonly magicTeeApiClient: MagicTeeApiClients; @@ -23,9 +23,9 @@ export default class MagicTEESigner extends AbstractSigner { private createWalletPromise: Promise | null = null; - constructor(authManager: AuthManager, magicTeeApiClient: MagicTeeApiClients) { + constructor(auth: Auth, magicTeeApiClient: MagicTeeApiClients) { super(); - this.authManager = authManager; + this.auth = auth; this.magicTeeApiClient = magicTeeApiClient; } @@ -41,19 +41,11 @@ export default class MagicTEESigner extends AbstractSigner { userWallet = await this.createWallet(user); } - if (isUserImx(user) && user.imx.userAdminAddress.toLowerCase() !== userWallet.walletAddress.toLowerCase()) { - throw new PassportError( - 'Wallet address mismatch.' - + `Rollup: IMX, TEE address: ${userWallet.walletAddress}, profile address: ${user.imx.userAdminAddress}`, - PassportErrorType.WALLET_CONNECTION_ERROR, - ); - } - if (isUserZkEvm(user) && user.zkEvm.userAdminAddress.toLowerCase() !== userWallet.walletAddress.toLowerCase()) { - throw new PassportError( + throw new WalletError( 'Wallet address mismatch.' + `Rollup: zkEVM, TEE address: ${userWallet.walletAddress}, profile address: ${user.zkEvm.userAdminAddress}`, - PassportErrorType.WALLET_CONNECTION_ERROR, + WalletErrorType.WALLET_CONNECTION_ERROR, ); } @@ -126,11 +118,11 @@ export default class MagicTEESigner extends AbstractSigner { } private async getUserOrThrow(): Promise { - const user = await this.authManager.getUser(); + const user = await this.auth.getUser(); if (!user) { - throw new PassportError( + throw new WalletError( 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, + WalletErrorType.NOT_LOGGED_IN_ERROR, ); } return user; @@ -138,9 +130,9 @@ export default class MagicTEESigner extends AbstractSigner { private static getHeaders(user: User): Record { if (!user) { - throw new PassportError( + throw new WalletError( 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, + WalletErrorType.NOT_LOGGED_IN_ERROR, ); } return { diff --git a/packages/passport/sdk/src/network/chains.ts b/packages/wallet/src/network/chains.ts similarity index 100% rename from packages/passport/sdk/src/network/chains.ts rename to packages/wallet/src/network/chains.ts diff --git a/packages/passport/sdk/src/network/constants.ts b/packages/wallet/src/network/constants.ts similarity index 100% rename from packages/passport/sdk/src/network/constants.ts rename to packages/wallet/src/network/constants.ts diff --git a/packages/passport/sdk/src/network/retry.ts b/packages/wallet/src/network/retry.ts similarity index 100% rename from packages/passport/sdk/src/network/retry.ts rename to packages/wallet/src/network/retry.ts diff --git a/packages/passport/sdk/src/overlay/confirmationOverlay.ts b/packages/wallet/src/overlay/confirmationOverlay.ts similarity index 91% rename from packages/passport/sdk/src/overlay/confirmationOverlay.ts rename to packages/wallet/src/overlay/confirmationOverlay.ts index fe3808525f..1ebe99f944 100644 --- a/packages/passport/sdk/src/overlay/confirmationOverlay.ts +++ b/packages/wallet/src/overlay/confirmationOverlay.ts @@ -1,4 +1,4 @@ -import { PopupOverlayOptions } from '../types'; +import { PopupOverlayOptions } from '@imtbl/auth'; import { PASSPORT_OVERLAY_CLOSE_ID, PASSPORT_OVERLAY_TRY_AGAIN_ID } from './constants'; import { addLink, getBlockedOverlay, getGenericOverlay } from './elements'; @@ -50,7 +50,9 @@ export default class ConfirmationOverlay { if (!this.overlay) { addLink({ id: 'link-googleapis', href: 'https://fonts.googleapis.com' }); addLink({ id: 'link-gstatic', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' }); - addLink({ id: 'link-roboto', href: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&display=swap', rel: 'stylesheet' }); + const robotoFontUrl = 'https://fonts.googleapis.com/css2?' + + 'family=Roboto:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&display=swap'; + addLink({ id: 'link-roboto', href: robotoFontUrl, rel: 'stylesheet' }); const overlay = document.createElement('div'); overlay.innerHTML = this.isBlockedOverlay ? getBlockedOverlay() : getGenericOverlay(); diff --git a/packages/wallet/src/overlay/constants.ts b/packages/wallet/src/overlay/constants.ts new file mode 100644 index 0000000000..3cebc8a883 --- /dev/null +++ b/packages/wallet/src/overlay/constants.ts @@ -0,0 +1,221 @@ +/* eslint-disable max-len */ + +export const PASSPORT_OVERLAY_ID = 'passport-overlay'; +export const PASSPORT_OVERLAY_CONTENTS_ID = 'passport-overlay-contents'; +export const PASSPORT_OVERLAY_CLOSE_ID = `${PASSPORT_OVERLAY_ID}-close`; +export const PASSPORT_OVERLAY_TRY_AGAIN_ID = `${PASSPORT_OVERLAY_ID}-try-again`; + +export const CLOSE_BUTTON_SVG = ` + + + +`; + +export const POPUP_BLOCKED_SVG = ` + + + +`; + +export const IMMUTABLE_LOGO_SVG = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/wallet/src/overlay/elements.ts b/packages/wallet/src/overlay/elements.ts new file mode 100644 index 0000000000..73e9d7dd15 --- /dev/null +++ b/packages/wallet/src/overlay/elements.ts @@ -0,0 +1,187 @@ +import { + CLOSE_BUTTON_SVG, + POPUP_BLOCKED_SVG, + IMMUTABLE_LOGO_SVG, + PASSPORT_OVERLAY_CLOSE_ID, + PASSPORT_OVERLAY_ID, + PASSPORT_OVERLAY_TRY_AGAIN_ID, + PASSPORT_OVERLAY_CONTENTS_ID, +} from './constants'; + +const getCloseButton = (): string => ` + + `; + +const getTryAgainButton = () => ` + +`; + +const getBlockedContents = () => ` + ${IMMUTABLE_LOGO_SVG} +
+ ${POPUP_BLOCKED_SVG} + Pop-up blocked +
+

+ Please try again below.
+ If the problem continues, adjust your
+ browser settings. +

+ ${getTryAgainButton()} + `; + +const getGenericContents = () => ` + ${IMMUTABLE_LOGO_SVG} +

+ Secure pop-up not showing?
We'll help you re-launch +

+ ${getTryAgainButton()} + `; + +export const getOverlay = (contents: string): string => ` +
+ ${getCloseButton()} +
+ ${contents ?? ''} +
+
+ `; + +export const getEmbeddedLoginPromptOverlay = (): string => ` +
+
+
+ `; + +type LinkParams = { + id: string; + href: string; + rel?: string; + crossOrigin?: string; +}; +export function addLink({ + id, + href, + rel, + crossOrigin, +}: LinkParams): void { + const fullId = `${PASSPORT_OVERLAY_ID}-${id}`; + if (!document.getElementById(fullId)) { + const link: HTMLLinkElement = document.createElement('link'); + link.id = fullId; + link.href = href; + if (rel) link.rel = rel; + if (crossOrigin) link.crossOrigin = crossOrigin; + document.head.appendChild(link); + } +} + +export const getBlockedOverlay = () => getOverlay(getBlockedContents()); +export const getGenericOverlay = () => getOverlay(getGenericContents()); diff --git a/packages/wallet/src/presets.ts b/packages/wallet/src/presets.ts new file mode 100644 index 0000000000..b2ead4c04a --- /dev/null +++ b/packages/wallet/src/presets.ts @@ -0,0 +1,92 @@ +import { ChainConfig } from './types'; +import { + IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID, + IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID, + MAGIC_CONFIG, +} from './constants'; + +/** + * Immutable zkEVM Mainnet chain configuration + */ +export const IMMUTABLE_ZKEVM_MAINNET_CHAIN: ChainConfig = { + chainId: IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID, + name: 'Immutable zkEVM', + rpcUrl: 'https://rpc.immutable.com', + relayerUrl: 'https://api.immutable.com/relayer-mr', + apiUrl: 'https://api.immutable.com', + passportDomain: 'https://passport.immutable.com', + magicPublishableApiKey: MAGIC_CONFIG[IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID].magicPublishableApiKey, + magicProviderId: MAGIC_CONFIG[IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID].magicProviderId, + magicTeeBasePath: 'https://tee.express.magiclabs.com', +}; + +/** + * Immutable zkEVM Testnet chain configuration + */ +export const IMMUTABLE_ZKEVM_TESTNET_CHAIN: ChainConfig = { + chainId: IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID, + name: 'Immutable zkEVM Testnet', + rpcUrl: 'https://rpc.testnet.immutable.com', + relayerUrl: 'https://api.sandbox.immutable.com/relayer-mr', + apiUrl: 'https://api.sandbox.immutable.com', + passportDomain: 'https://passport.sandbox.immutable.com', + magicPublishableApiKey: MAGIC_CONFIG[IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID].magicPublishableApiKey, + magicProviderId: MAGIC_CONFIG[IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID].magicProviderId, + magicTeeBasePath: 'https://tee.express.magiclabs.com', +}; + +/** + * Default chains (testnet + mainnet) + * Testnet is first (default initial chain) + */ +export const DEFAULT_CHAINS: ChainConfig[] = [ + IMMUTABLE_ZKEVM_TESTNET_CHAIN, + IMMUTABLE_ZKEVM_MAINNET_CHAIN, +]; + +/** + * Mainnet only preset + * + * @example + * ```typescript + * const provider = await connectWallet({ + * ...IMMUTABLE_ZKEVM_MAINNET, + * auth, + * }); + * ``` + */ +export const IMMUTABLE_ZKEVM_MAINNET = { + chains: [IMMUTABLE_ZKEVM_MAINNET_CHAIN], +}; + +/** + * Testnet only preset + * + * @example + * ```typescript + * const provider = await connectWallet({ + * ...IMMUTABLE_ZKEVM_TESTNET, + * auth, + * }); + * ``` + */ +export const IMMUTABLE_ZKEVM_TESTNET = { + chains: [IMMUTABLE_ZKEVM_TESTNET_CHAIN], +}; + +/** + * Multi-chain preset (testnet + mainnet) + * Defaults to testnet as initial chain + * + * @example + * ```typescript + * const provider = await connectWallet({ + * ...IMMUTABLE_ZKEVM_MULTICHAIN, + * auth, + * initialChainId: IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID, // Optional: start on mainnet + * }); + * ``` + */ +export const IMMUTABLE_ZKEVM_MULTICHAIN = { + chains: DEFAULT_CHAINS, +}; diff --git a/packages/passport/sdk/src/zkEvm/provider/eip6963.ts b/packages/wallet/src/provider/eip6963.ts similarity index 98% rename from packages/passport/sdk/src/zkEvm/provider/eip6963.ts rename to packages/wallet/src/provider/eip6963.ts index 6bad4e23dc..62198a802d 100644 --- a/packages/passport/sdk/src/zkEvm/provider/eip6963.ts +++ b/packages/wallet/src/provider/eip6963.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from 'uuid'; -import { EIP6963AnnounceProviderEvent, EIP6963ProviderDetail, EIP6963ProviderInfo } from '../types'; +import { EIP6963ProviderDetail, EIP6963ProviderInfo } from '../types'; export const passportProviderInfo = { // eslint-disable-next-line max-len @@ -16,7 +16,7 @@ export function announceProvider( const event: CustomEvent = new CustomEvent( 'eip6963:announceProvider', { detail: Object.freeze(detail) }, - ) as EIP6963AnnounceProviderEvent; + ); window.dispatchEvent(event); diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts new file mode 100644 index 0000000000..4d27e60a6b --- /dev/null +++ b/packages/wallet/src/types.ts @@ -0,0 +1,242 @@ +import { Flow } from '@imtbl/metrics'; +import { + Auth, TypedEventEmitter, type AuthEventMap, +} from '@imtbl/auth'; +import { BigNumberish } from 'ethers'; +import { JsonRpcError } from './zkEvm/JsonRpcError'; + +// Re-export auth types for convenience +export type { + User, UserProfile, UserZkEvm, DirectLoginMethod, AuthEventMap, +} from '@imtbl/auth'; +export { isUserZkEvm } from '@imtbl/auth'; +export type { RollupType } from '@imtbl/auth'; +export { AuthEvents } from '@imtbl/auth'; + +// Wallet-specific event (in addition to AuthEvents) +export enum WalletEvents { + ACCOUNTS_REQUESTED = 'accountsRequested', +} + +export type AccountsRequestedEvent = { + sessionActivityApiUrl: string; + sendTransaction: (params: Array, flow: Flow) => Promise; + walletAddress: string; + passportClient: string; + flow?: Flow; +}; + +// PassportEventMap combines auth events and wallet-specific events +export interface PassportEventMap extends AuthEventMap { + [WalletEvents.ACCOUNTS_REQUESTED]: [AccountsRequestedEvent]; +} + +// Re-export zkEVM Provider type for public API +export type { Provider } from './zkEvm/types'; + +export interface RequestArguments { + method: string; + params?: Array; +} + +export type JsonRpcRequestPayload = RequestArguments & { + jsonrpc?: string; + id?: string | number; +}; + +export interface JsonRpcRequestCallback { + ( + err: JsonRpcError | null, + result?: JsonRpcResponsePayload | (JsonRpcResponsePayload | null)[] | null + ): void; +} + +export interface JsonRpcResponsePayload { + result?: Array | null; + error?: JsonRpcError | null; + jsonrpc?: string; + id?: string | number; +} + +// https://eips.ethereum.org/EIPS/eip-712 +export interface TypedDataPayload { + types: { + EIP712Domain: Array<{ name: string; type: string }>; + [key: string]: Array<{ name: string; type: string }>; + }; + domain: { + name?: string; + version?: string; + chainId?: number | string; + verifyingContract?: string; + salt?: string; + } | { + name?: string; + version?: string; + chainId?: number; + verifyingContract?: string; + salt?: string; + }; + primaryType: string; + message: Record; +} + +export interface MetaTransaction { + to: string; + value?: BigNumberish | null; + data?: string | null; + nonce?: BigNumberish; + gasLimit?: BigNumberish; + delegateCall?: boolean; + revertOnError?: boolean; +} + +export interface MetaTransactionNormalised { + delegateCall: boolean; + revertOnError: boolean; + gasLimit: BigNumberish; + target: string; + value: BigNumberish; + data: string; +} + +export enum ProviderEvent { + ACCOUNTS_CHANGED = 'accountsChanged', +} + +export type AccountsChangedEvent = Array; + +export interface ProviderEventMap extends Record { + [ProviderEvent.ACCOUNTS_CHANGED]: [AccountsChangedEvent]; +} + +export enum RelayerTransactionStatus { + PENDING = 'PENDING', + SUBMITTED = 'SUBMITTED', + SUCCESSFUL = 'SUCCESSFUL', + REVERTED = 'REVERTED', + FAILED = 'FAILED', + CANCELLED = 'CANCELLED', +} + +export interface RelayerTransaction { + status: RelayerTransactionStatus; + chainId: string; + relayerId: string; + hash: string; + statusMessage?: string; +} + +export interface FeeOption { + tokenPrice: string; + tokenSymbol: string; + tokenDecimals: number; + tokenAddress: string; + recipientAddress: string; +} + +// Re-export EIP-6963 types from zkEvm for public API +export type { + EIP6963ProviderDetail, + EIP6963ProviderInfo, + EIP6963AnnounceProviderEvent, +} from './zkEvm/types'; + +/** + * Configuration for a single blockchain network + */ +export interface ChainConfig { + /** Chain ID (e.g., 13371 for mainnet, 13473 for testnet) */ + chainId: number; + + /** RPC URL for the chain */ + rpcUrl: string; + + /** Relayer URL for transaction submission */ + relayerUrl: string; + + /** API URL for Passport APIs (guardian, user registration) */ + apiUrl: string; + + /** Chain name (e.g., 'Immutable zkEVM') */ + name: string; + + /** Passport domain (optional, defaults based on apiUrl) */ + passportDomain?: string; + + /** + * Magic publishable API key (optional, for dev/custom environments) + * If not provided, will use default based on chainId + */ + magicPublishableApiKey?: string; + + /** + * Magic provider ID (optional, for dev/custom environments) + * If not provided, will use default based on chainId + */ + magicProviderId?: string; + + /** + * Magic TEE base path (optional, for dev/custom environments) + * Defaults to 'https://tee.express.magiclabs.com' + */ + magicTeeBasePath?: string; +} + +/** + * Popup overlay options for wallet UI + */ +export interface PopupOverlayOptions { + /** Disable the generic popup overlay */ + disableGenericPopupOverlay?: boolean; + + /** Disable the blocked popup overlay */ + disableBlockedPopupOverlay?: boolean; +} + +/** + * Options for connecting a wallet via connectWallet() + * High-level configuration that gets transformed into internal WalletConfiguration + */ +export interface ConnectWalletOptions { + /** + * Auth instance. Optional – if omitted, a default Auth instance + * configured with Immutable hosted defaults will be created. + */ + auth?: Auth; + + /** + * Chain configurations (supports multi-chain) + * Defaults to [IMMUTABLE_ZKEVM_TESTNET_CHAIN, IMMUTABLE_ZKEVM_MAINNET_CHAIN] if not provided + */ + chains?: ChainConfig[]; + + /** + * Initial chain ID (defaults to first chain in chains array) + * Use IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID or IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID + */ + initialChainId?: number; + + /** Optional popup overlay options */ + popupOverlayOptions?: PopupOverlayOptions; + + /** Announce provider via EIP-6963 (default: true) */ + announceProvider?: boolean; + + /** Enable cross-SDK bridge mode (default: false) */ + crossSdkBridgeEnabled?: boolean; + + /** Optional referrer URL to be sent with JSON-RPC requests */ + jsonRpcReferrer?: string; + + /** Preferred token symbol for relayer fees (default: 'IMX') */ + feeTokenSymbol?: string; + + /** If true, forces SCW deployment before allowing message signature */ + forceScwDeployBeforeMessageSignature?: boolean; + + /** + * @internal - Only used by Passport for internal event communication + */ + passportEventEmitter?: TypedEventEmitter; +} diff --git a/packages/wallet/src/utils/metrics.ts b/packages/wallet/src/utils/metrics.ts new file mode 100644 index 0000000000..3198a51d09 --- /dev/null +++ b/packages/wallet/src/utils/metrics.ts @@ -0,0 +1,57 @@ +import { Flow, trackError, trackFlow } from '@imtbl/metrics'; + +export const withMetrics = ( + fn: (flow: Flow) => T, + flowName: string, + trackStartEvent: boolean = true, + trackEndEvent: boolean = true, +): T => { + const flow: Flow = trackFlow( + 'passport', + flowName, + trackStartEvent, + ); + + try { + return fn(flow); + } catch (error) { + if (error instanceof Error) { + trackError('passport', flowName, error, { flowId: flow.details.flowId }); + } else { + flow.addEvent('errored'); + } + throw error; + } finally { + if (trackEndEvent) { + flow.addEvent('End'); + } + } +}; + +export const withMetricsAsync = async ( + fn: (flow: Flow) => Promise, + flowName: string, + trackStartEvent: boolean = true, + trackEndEvent: boolean = true, +): Promise => { + const flow: Flow = trackFlow( + 'passport', + flowName, + trackStartEvent, + ); + + try { + return await fn(flow); + } catch (error) { + if (error instanceof Error) { + trackError('passport', flowName, error, { flowId: flow.details.flowId }); + } else { + flow.addEvent('errored'); + } + throw error; + } finally { + if (trackEndEvent) { + flow.addEvent('End'); + } + } +}; diff --git a/packages/passport/sdk/src/utils/string.ts b/packages/wallet/src/utils/string.ts similarity index 100% rename from packages/passport/sdk/src/utils/string.ts rename to packages/wallet/src/utils/string.ts diff --git a/packages/passport/sdk/src/zkEvm/JsonRpcError.ts b/packages/wallet/src/zkEvm/JsonRpcError.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/JsonRpcError.ts rename to packages/wallet/src/zkEvm/JsonRpcError.ts diff --git a/packages/passport/sdk/src/zkEvm/index.ts b/packages/wallet/src/zkEvm/index.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/index.ts rename to packages/wallet/src/zkEvm/index.ts diff --git a/packages/passport/sdk/src/zkEvm/personalSign.ts b/packages/wallet/src/zkEvm/personalSign.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/personalSign.ts rename to packages/wallet/src/zkEvm/personalSign.ts diff --git a/packages/passport/sdk/src/zkEvm/relayerClient.ts b/packages/wallet/src/zkEvm/relayerClient.ts similarity index 91% rename from packages/passport/sdk/src/zkEvm/relayerClient.ts rename to packages/wallet/src/zkEvm/relayerClient.ts index 8ce076d259..65ac9c93a9 100644 --- a/packages/passport/sdk/src/zkEvm/relayerClient.ts +++ b/packages/wallet/src/zkEvm/relayerClient.ts @@ -1,13 +1,13 @@ import { BytesLike, JsonRpcProvider } from 'ethers'; -import AuthManager from '../authManager'; -import { PassportConfiguration } from '../config'; +import { Auth } from '@imtbl/auth'; +import { WalletConfiguration } from '../config'; import { FeeOption, RelayerTransaction, TypedDataPayload } from './types'; import { getEip155ChainId } from './walletHelpers'; export type RelayerClientInput = { - config: PassportConfiguration, + config: WalletConfiguration, rpcProvider: JsonRpcProvider, - authManager: AuthManager + auth: Auth }; // JsonRpc base Types @@ -90,16 +90,16 @@ export type RelayerTransactionRequest = | ImSignRequest; export class RelayerClient { - private readonly config: PassportConfiguration; + private readonly config: WalletConfiguration; private readonly rpcProvider: JsonRpcProvider; - private readonly authManager: AuthManager; + private readonly auth: Auth; - constructor({ config, rpcProvider, authManager }: RelayerClientInput) { + constructor({ config, rpcProvider, auth }: RelayerClientInput) { this.config = config; this.rpcProvider = rpcProvider; - this.authManager = authManager; + this.auth = auth; } private static getResponsePreview(text: string): string { @@ -115,7 +115,7 @@ export class RelayerClient { ...request, }; - const user = await this.authManager.getUserZkEvm(); + const user = await this.auth.getUserZkEvm(); const response = await fetch(`${this.config.relayerUrl}/v1/transactions`, { method: 'POST', @@ -138,6 +138,7 @@ export class RelayerClient { jsonResponse = JSON.parse(responseText); } catch (parseError) { const preview = RelayerClient.getResponsePreview(responseText); + // eslint-disable-next-line max-len throw new Error(`Relayer JSON parse error: ${parseError instanceof Error ? parseError.message : 'Unknown error'}. Content: "${preview}"`); } @@ -148,6 +149,10 @@ export class RelayerClient { return jsonResponse; } + public getPreferredFeeTokenSymbol(): string { + return this.config.feeTokenSymbol; + } + public async ethSendTransaction(to: string, data: BytesLike): Promise { const { chainId } = await this.rpcProvider.getNetwork(); const payload: EthSendTransactionRequest = { diff --git a/packages/passport/sdk/src/zkEvm/sendDeployTransactionAndPersonalSign.ts b/packages/wallet/src/zkEvm/sendDeployTransactionAndPersonalSign.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/sendDeployTransactionAndPersonalSign.ts rename to packages/wallet/src/zkEvm/sendDeployTransactionAndPersonalSign.ts diff --git a/packages/passport/sdk/src/zkEvm/sendTransaction.ts b/packages/wallet/src/zkEvm/sendTransaction.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/sendTransaction.ts rename to packages/wallet/src/zkEvm/sendTransaction.ts diff --git a/packages/passport/sdk/src/zkEvm/sessionActivity/errorBoundary.ts b/packages/wallet/src/zkEvm/sessionActivity/errorBoundary.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/sessionActivity/errorBoundary.ts rename to packages/wallet/src/zkEvm/sessionActivity/errorBoundary.ts diff --git a/packages/passport/sdk/src/zkEvm/sessionActivity/request.ts b/packages/wallet/src/zkEvm/sessionActivity/request.ts similarity index 58% rename from packages/passport/sdk/src/zkEvm/sessionActivity/request.ts rename to packages/wallet/src/zkEvm/sessionActivity/request.ts index c3757fa179..3de78395dc 100644 --- a/packages/passport/sdk/src/zkEvm/sessionActivity/request.ts +++ b/packages/wallet/src/zkEvm/sessionActivity/request.ts @@ -1,33 +1,16 @@ -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) => { +export const setupClient = (sessionActivityApiUrl: string) => { if (client) { return; } client = axios.create({ - baseURL: getBaseUrl(environment), + baseURL: sessionActivityApiUrl, }); }; diff --git a/packages/passport/sdk/src/zkEvm/sessionActivity/sessionActivity.ts b/packages/wallet/src/zkEvm/sessionActivity/sessionActivity.ts similarity index 96% rename from packages/passport/sdk/src/zkEvm/sessionActivity/sessionActivity.ts rename to packages/wallet/src/zkEvm/sessionActivity/sessionActivity.ts index 992f1c8848..fbbada6a54 100644 --- a/packages/passport/sdk/src/zkEvm/sessionActivity/sessionActivity.ts +++ b/packages/wallet/src/zkEvm/sessionActivity/sessionActivity.ts @@ -65,15 +65,15 @@ const trackSessionActivityFn = async (args: AccountsRequestedEvent) => { } currentSessionTrackCall[clientId] = true; - const { sendTransaction, environment } = args; + const { sendTransaction, sessionActivityApiUrl } = 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'); + if (!sessionActivityApiUrl) { + throw new Error('No session activity API URL provided'); } - setupClient(environment); + setupClient(sessionActivityApiUrl); const from = args.walletAddress; if (!from) { diff --git a/packages/passport/sdk/src/zkEvm/signEjectionTransaction.ts b/packages/wallet/src/zkEvm/signEjectionTransaction.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/signEjectionTransaction.ts rename to packages/wallet/src/zkEvm/signEjectionTransaction.ts diff --git a/packages/passport/sdk/src/zkEvm/signTypedDataV4.ts b/packages/wallet/src/zkEvm/signTypedDataV4.ts similarity index 98% rename from packages/passport/sdk/src/zkEvm/signTypedDataV4.ts rename to packages/wallet/src/zkEvm/signTypedDataV4.ts index 16ccbbcaa6..c9b2c63a6f 100644 --- a/packages/passport/sdk/src/zkEvm/signTypedDataV4.ts +++ b/packages/wallet/src/zkEvm/signTypedDataV4.ts @@ -39,6 +39,7 @@ const transformTypedData = (typedData: string | object, chainId: bigint): TypedD if (!isValidTypedDataPayload(transformedTypedData)) { throw new JsonRpcError( RpcErrorCode.INVALID_PARAMS, + // eslint-disable-next-line max-len `Invalid typed data argument. The following properties are required: ${REQUIRED_TYPED_DATA_PROPERTIES.join(', ')}`, ); } diff --git a/packages/passport/sdk/src/zkEvm/transactionHelpers.ts b/packages/wallet/src/zkEvm/transactionHelpers.ts similarity index 96% rename from packages/passport/sdk/src/zkEvm/transactionHelpers.ts rename to packages/wallet/src/zkEvm/transactionHelpers.ts index 8ddaec4055..77c84ab650 100644 --- a/packages/passport/sdk/src/zkEvm/transactionHelpers.ts +++ b/packages/wallet/src/zkEvm/transactionHelpers.ts @@ -59,14 +59,15 @@ const getFeeOption = async ( throw new Error('Invalid fee options received from relayer'); } - const imxFeeOption = feeOptions.find( - (feeOption) => feeOption.tokenSymbol === 'IMX', + const preferredFeeTokenSymbol = relayerClient.getPreferredFeeTokenSymbol(); + const preferredFeeOption = feeOptions.find( + (feeOption) => feeOption.tokenSymbol === preferredFeeTokenSymbol, ); - if (!imxFeeOption) { - throw new Error('Failed to retrieve fees for IMX token'); + if (!preferredFeeOption) { + throw new Error(`Failed to retrieve fees for ${preferredFeeTokenSymbol} token`); } - return imxFeeOption; + return preferredFeeOption; }; /** diff --git a/packages/passport/sdk/src/zkEvm/types.ts b/packages/wallet/src/zkEvm/types.ts similarity index 97% rename from packages/passport/sdk/src/zkEvm/types.ts rename to packages/wallet/src/zkEvm/types.ts index b013685712..0fedc0d4b3 100644 --- a/packages/passport/sdk/src/zkEvm/types.ts +++ b/packages/wallet/src/zkEvm/types.ts @@ -92,6 +92,10 @@ export interface JsonRpcResponsePayload { id?: string | number; } +/** + * EIP-1193 Provider Interface + * Standard Ethereum provider interface + */ export type Provider = { request: (request: RequestArguments) => Promise; on: (event: string, listener: (...args: any[]) => void) => void; diff --git a/packages/passport/sdk/src/zkEvm/user/index.ts b/packages/wallet/src/zkEvm/user/index.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/user/index.ts rename to packages/wallet/src/zkEvm/user/index.ts diff --git a/packages/passport/sdk/src/zkEvm/user/registerZkEvmUser.ts b/packages/wallet/src/zkEvm/user/registerZkEvmUser.ts similarity index 94% rename from packages/passport/sdk/src/zkEvm/user/registerZkEvmUser.ts rename to packages/wallet/src/zkEvm/user/registerZkEvmUser.ts index bd82be822e..6898390a29 100644 --- a/packages/passport/sdk/src/zkEvm/user/registerZkEvmUser.ts +++ b/packages/wallet/src/zkEvm/user/registerZkEvmUser.ts @@ -3,11 +3,11 @@ import { signRaw } from '@imtbl/toolkit'; import { Flow } from '@imtbl/metrics'; import { Signer, JsonRpcProvider } from 'ethers'; import { getEip155ChainId } from '../walletHelpers'; -import AuthManager from '../../authManager'; +import { Auth } from '@imtbl/auth'; import { JsonRpcError, RpcErrorCode } from '../JsonRpcError'; export type RegisterZkEvmUserInput = { - authManager: AuthManager; + auth: Auth; ethSigner: Signer, multiRollupApiClients: MultiRollupApiClients, accessToken: string; @@ -18,7 +18,7 @@ export type RegisterZkEvmUserInput = { const MESSAGE_TO_SIGN = 'Only sign this message from Immutable Passport'; export async function registerZkEvmUser({ - authManager, + auth, ethSigner, multiRollupApiClients, accessToken, @@ -66,7 +66,7 @@ export async function registerZkEvmUser({ }); flow.addEvent('endCreateCounterfactualAddress'); - authManager.forceUserRefreshInBackground(); + auth.forceUserRefreshInBackground(); return registrationResponse.data.counterfactual_address; } catch (error) { diff --git a/packages/passport/sdk/src/zkEvm/walletHelpers.ts b/packages/wallet/src/zkEvm/walletHelpers.ts similarity index 100% rename from packages/passport/sdk/src/zkEvm/walletHelpers.ts rename to packages/wallet/src/zkEvm/walletHelpers.ts diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts b/packages/wallet/src/zkEvm/zkEvmProvider.ts similarity index 91% rename from packages/passport/sdk/src/zkEvm/zkEvmProvider.ts rename to packages/wallet/src/zkEvm/zkEvmProvider.ts index beba2623c0..f2a5fac138 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts +++ b/packages/wallet/src/zkEvm/zkEvmProvider.ts @@ -11,11 +11,10 @@ import { ProviderEventMap, RequestArguments, } from './types'; -import AuthManager from '../authManager'; -import TypedEventEmitter from '../utils/typedEventEmitter'; -import { PassportConfiguration } from '../config'; +import { Auth, TypedEventEmitter } from '@imtbl/auth'; +import { WalletConfiguration } from '../config'; import { - PassportEventMap, PassportEvents, User, UserZkEvm, + PassportEventMap, AuthEvents, WalletEvents, User, UserZkEvm, } from '../types'; import { RelayerClient } from './relayerClient'; import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './JsonRpcError'; @@ -30,21 +29,24 @@ import { sendDeployTransactionAndPersonalSign } from './sendDeployTransactionAnd import { signEjectionTransaction } from './signEjectionTransaction'; export type ZkEvmProviderInput = { - authManager: AuthManager; - config: PassportConfiguration; + auth: Auth; + config: WalletConfiguration; multiRollupApiClients: MultiRollupApiClients; passportEventEmitter: TypedEventEmitter; guardianClient: GuardianClient; ethSigner: Signer; user: User | null; + sessionActivityApiUrl: string | null; }; const isZkEvmUser = (user: User): user is UserZkEvm => 'zkEvm' in user; export class ZkEvmProvider implements Provider { - readonly #authManager: AuthManager; + readonly #auth: Auth; - readonly #config: PassportConfiguration; + readonly #config: WalletConfiguration; + + readonly #sessionActivityApiUrl: string | null; /** * intended to emit EIP-1193 events @@ -69,28 +71,32 @@ export class ZkEvmProvider implements Provider { public readonly isPassport: boolean = true; constructor({ - authManager, + auth, config, multiRollupApiClients, passportEventEmitter, guardianClient, ethSigner, user, + sessionActivityApiUrl, }: ZkEvmProviderInput) { - this.#authManager = authManager; + this.#auth = auth; this.#config = config; this.#guardianClient = guardianClient; this.#passportEventEmitter = passportEventEmitter; + this.#sessionActivityApiUrl = sessionActivityApiUrl; this.#ethSigner = ethSigner; + // Create JsonRpcProvider for reading from the chain this.#rpcProvider = new JsonRpcProvider(this.#config.zkEvmRpcUrl, undefined, { staticNetwork: true, }); + // Create RelayerClient for transaction submission this.#relayerClient = new RelayerClient({ config: this.#config, rpcProvider: this.#rpcProvider, - authManager: this.#authManager, + auth: this.#auth, }); this.#multiRollupApiClients = multiRollupApiClients; @@ -100,14 +106,14 @@ export class ZkEvmProvider implements Provider { this.#callSessionActivity(user.zkEvm.ethAddress); } - passportEventEmitter.on(PassportEvents.LOGGED_IN, (loggedInUser: User) => { + passportEventEmitter.on(AuthEvents.LOGGED_IN, (loggedInUser: User) => { if (isZkEvmUser(loggedInUser)) { this.#callSessionActivity(loggedInUser.zkEvm.ethAddress); } }); - passportEventEmitter.on(PassportEvents.LOGGED_OUT, this.#handleLogout); + passportEventEmitter.on(AuthEvents.LOGGED_OUT, this.#handleLogout); passportEventEmitter.on( - PassportEvents.ACCOUNTS_REQUESTED, + WalletEvents.ACCOUNTS_REQUESTED, trackSessionActivity, ); } @@ -117,6 +123,11 @@ export class ZkEvmProvider implements Provider { }; async #callSessionActivity(zkEvmAddress: string, clientId?: string) { + // Only emit session activity event for supported chains (mainnet, testnet, devnet) + if (!this.#sessionActivityApiUrl) { + return; + } + // SessionActivity requests are processed in nonce space 1, where as all // other sendTransaction requests are processed in nonce space 0. This means // we can submit a session activity request per SCW in parallel without a SCW @@ -133,18 +144,18 @@ export class ZkEvmProvider implements Provider { nonceSpace, isBackgroundTransaction: true, }); - this.#passportEventEmitter.emit(PassportEvents.ACCOUNTS_REQUESTED, { - environment: this.#config.baseConfig.environment, + this.#passportEventEmitter.emit(WalletEvents.ACCOUNTS_REQUESTED, { + sessionActivityApiUrl: this.#sessionActivityApiUrl, sendTransaction: sendTransactionClosure, walletAddress: zkEvmAddress, - passportClient: clientId || this.#config.oidcConfiguration.clientId, + passportClient: clientId || await this.#auth.getClientId(), }); } // Used to get the registered zkEvm address from the User session async #getZkEvmAddress() { try { - const user = await this.#authManager.getUser(); + const user = await this.#auth.getUser(); if (user && isZkEvmUser(user)) { return user.zkEvm.ethAddress; } @@ -165,7 +176,7 @@ export class ZkEvmProvider implements Provider { const flow = trackFlow('passport', 'ethRequestAccounts'); try { - const user = await this.#authManager.getUserOrLogin(); + const user = await this.#auth.getUserOrLogin(); flow.addEvent('endGetUserOrLogin'); let userZkEvmEthAddress: string | undefined; @@ -175,7 +186,7 @@ export class ZkEvmProvider implements Provider { userZkEvmEthAddress = await registerZkEvmUser({ ethSigner: this.#ethSigner, - authManager: this.#authManager, + auth: this.#auth, multiRollupApiClients: this.#multiRollupApiClients, accessToken: user.accessToken, rpcProvider: this.#rpcProvider, diff --git a/packages/wallet/tsconfig.eslint.json b/packages/wallet/tsconfig.eslint.json new file mode 100644 index 0000000000..fee686ba0f --- /dev/null +++ b/packages/wallet/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "exclude": [], + "include": ["src"] +} + diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json new file mode 100644 index 0000000000..94a7d835ce --- /dev/null +++ b/packages/wallet/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDirs": ["src"], + "customConditions": ["development"], + "types": ["node"] + }, + "include": ["src", "src/types.ts"], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ] +} + diff --git a/packages/webhook/sdk/package.json b/packages/webhook/sdk/package.json index 24d389e30f..16c9edea38 100644 --- a/packages/webhook/sdk/package.json +++ b/packages/webhook/sdk/package.json @@ -12,12 +12,12 @@ "devDependencies": { "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/sns-validator": "^0.3.3", "eslint": "^8.40.0", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "exports": { diff --git a/packages/x-client/package.json b/packages/x-client/package.json index 906aaf098d..ed0da35318 100644 --- a/packages/x-client/package.json +++ b/packages/x-client/package.json @@ -19,12 +19,12 @@ "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", "@types/bn.js": "^5.1.6", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "crypto": "^1.0.1", "eslint": "^8.40.0", "jest": "^29.4.3", "jest-environment-jsdom": "^29.4.3", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "engines": { diff --git a/packages/x-provider/package.json b/packages/x-provider/package.json index e28289ecef..dca80662ab 100644 --- a/packages/x-provider/package.json +++ b/packages/x-provider/package.json @@ -21,7 +21,7 @@ "@swc/core": "^1.3.36", "@swc/jest": "^0.2.37", "@types/axios": "^0.14.0", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", @@ -32,7 +32,7 @@ "jest-environment-jsdom": "^29.4.3", "prettier": "^2.8.7", "ts-node": "^10.9.1", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "engines": { diff --git a/packages/x-provider/src/sample-app/package.json b/packages/x-provider/src/sample-app/package.json index b9ba6f3bdc..1d9a033130 100644 --- a/packages/x-provider/src/sample-app/package.json +++ b/packages/x-provider/src/sample-app/package.json @@ -19,7 +19,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", - "@types/jest": "^29.4.3", + "@types/jest": "^29.5.12", "@types/node": "^18.14.2", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2abee74e1..155388ce84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@emotion/react': specifier: ^11.11.3 version: 11.11.3(react@19.0.0-rc-66855b96-20241106) + '@jest/test-sequencer': + specifier: ^29.7.0 + version: 29.7.0 '@nx/js': specifier: ^19.7.2 version: 19.7.3(@babel/traverse@7.27.0)(@swc-node/register@1.10.9(@swc/core@1.9.3(@swc/helpers@0.5.13))(@swc/types@0.1.17)(typescript@5.6.2))(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@22.7.5)(nx@19.7.3(@swc-node/register@1.10.9(@swc/core@1.9.3(@swc/helpers@0.5.13))(@swc/types@0.1.17)(typescript@5.6.2))(@swc/core@1.9.3(@swc/helpers@0.5.13)))(typescript@5.6.2) @@ -65,7 +68,7 @@ importers: version: 17.1.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0)(typescript@5.6.2))(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint-plugin-import@2.29.1(@typescript-eslint/parser@5.62.0(eslint@8.57.0)(typescript@5.6.2))(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-react-refresh: specifier: latest - version: 0.4.19(eslint@8.57.0) + version: 0.4.24(eslint@8.57.0) events: specifier: ^3.1.0 version: 3.3.0 @@ -132,7 +135,7 @@ importers: version: 0.25.21(@emotion/react@11.11.3(@types/react@18.3.12)(react@18.3.1))(@rive-app/react-canvas-lite@4.9.0(react@18.3.1))(embla-carousel-react@8.1.5(react@18.3.1))(framer-motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@imtbl/sdk': specifier: latest - version: 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) + version: 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) next: specifier: 14.2.25 version: 14.2.25(@babel/core@7.26.9)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -994,6 +997,64 @@ importers: specifier: ^5 version: 5.6.2 + packages/auth: + dependencies: + '@imtbl/config': + specifier: workspace:* + version: link:../config + '@imtbl/metrics': + specifier: workspace:* + version: link:../internal/metrics + axios: + specifier: ^1.6.5 + version: 1.7.7 + jwt-decode: + specifier: ^3.1.2 + version: 3.1.2 + localforage: + specifier: ^1.10.0 + version: 1.10.0 + oidc-client-ts: + specifier: 3.4.1 + version: 3.4.1 + uuid: + specifier: ^9.0.1 + version: 9.0.1 + devDependencies: + '@imtbl/toolkit': + specifier: workspace:* + version: link:../internal/toolkit + '@jest/test-sequencer': + specifier: ^29.7.0 + version: 29.7.0 + '@swc/core': + specifier: ^1.3.36 + version: 1.9.3(@swc/helpers@0.5.13) + '@swc/jest': + specifier: ^0.2.37 + version: 0.2.37(@swc/core@1.9.3(@swc/helpers@0.5.13)) + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^18.14.2 + version: 18.15.13 + jest: + specifier: ^29.4.3 + version: 29.7.0(@types/node@18.15.13)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2)) + jest-environment-jsdom: + specifier: ^29.4.3 + version: 29.6.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2) + tsup: + specifier: ^8.3.0 + version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) + typescript: + specifier: ^5.6.2 + version: 5.6.2 + packages/blockchain-data/sdk: dependencies: '@imtbl/config': @@ -1013,8 +1074,8 @@ importers: specifier: ^0.2.37 version: 0.2.37(@swc/core@1.9.3(@swc/helpers@0.5.13)) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 eslint: specifier: ^8.40.0 version: 8.57.0 @@ -1025,7 +1086,7 @@ importers: specifier: ^29.4.3 version: 29.6.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -1070,8 +1131,8 @@ importers: specifier: ^7.4.0 version: 7.6.3 uuid: - specifier: ^8.3.2 - version: 8.3.2 + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@swc/core': specifier: ^1.3.36 @@ -1080,8 +1141,8 @@ importers: specifier: ^0.2.37 version: 0.2.37(@swc/core@1.9.3(@swc/helpers@0.5.13)) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1107,7 +1168,7 @@ importers: specifier: ^0.7.0 version: 0.7.0 tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typedoc: specifier: ^0.26.5 @@ -1174,8 +1235,8 @@ importers: specifier: ^13.5.0 version: 13.5.0(@testing-library/dom@10.4.0) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1288,8 +1349,8 @@ importers: specifier: ^7.0.2 version: 7.0.2 uuid: - specifier: ^8.3.2 - version: 8.3.2 + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@0xsquid/squid-types': specifier: ^0.1.108 @@ -1334,8 +1395,8 @@ importers: specifier: ^13.5.0 version: 13.5.0(@testing-library/dom@10.4.0) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/lodash.debounce': specifier: ^4.0.9 version: 4.0.9 @@ -1443,8 +1504,8 @@ importers: specifier: ^13.5.0 version: 13.5.0(@testing-library/dom@10.4.0) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1483,8 +1544,8 @@ importers: specifier: ^0.2.37 version: 0.2.37(@swc/core@1.9.3(@swc/helpers@0.5.13)) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1510,7 +1571,7 @@ importers: specifier: ^10.9.1 version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -1603,8 +1664,8 @@ importers: specifier: ^0.5.1 version: 0.5.1(ethers@6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10))(typechain@8.3.0(typescript@5.6.2))(typescript@5.6.2) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1618,7 +1679,7 @@ importers: specifier: ^10.9.1 version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typechain: specifier: ^8.1.1 @@ -1646,8 +1707,8 @@ importers: specifier: ^0.2.37 version: 0.2.37(@swc/core@1.9.3(@swc/helpers@0.5.13)) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1679,7 +1740,7 @@ importers: specifier: ^10.9.1 version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -1713,8 +1774,8 @@ importers: specifier: ^0.5.1 version: 0.5.1(ethers@6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10))(typechain@8.3.0(typescript@5.6.2))(typescript@5.6.2) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1731,7 +1792,7 @@ importers: specifier: ^10.9.1 version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typechain: specifier: ^8.1.1 @@ -1798,8 +1859,8 @@ importers: specifier: ^1.3.36 version: 1.9.3(@swc/helpers@0.5.13) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1810,7 +1871,7 @@ importers: specifier: ^6.0.1 version: 6.0.1 tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -1835,8 +1896,8 @@ importers: specifier: ^0.2.37 version: 0.2.37(@swc/core@1.9.3(@swc/helpers@0.5.13)) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1853,7 +1914,7 @@ importers: specifier: ^29.1.0 version: 29.2.5(@babel/core@7.26.10)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.10))(esbuild@0.23.1)(jest@29.7.0(@types/node@18.15.13)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2))(typescript@5.6.2) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -1902,8 +1963,8 @@ importers: specifier: ^5.1.6 version: 5.1.6 '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -1935,7 +1996,7 @@ importers: specifier: ^10.9.1 version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -1959,8 +2020,8 @@ importers: specifier: workspace:* version: link:../../webhook/sdk uuid: - specifier: ^8.3.2 - version: 8.3.2 + specifier: ^9.0.1 + version: 9.0.1 optionalDependencies: pg: specifier: ^8.11.5 @@ -1979,8 +2040,8 @@ importers: specifier: ^10.9.0 version: 10.9.0(encoding@0.1.13) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/pg': specifier: ^8.11.5 version: 8.11.6 @@ -2000,7 +2061,7 @@ importers: specifier: ^10.9.0 version: 10.9.0(encoding@0.1.13) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -2040,8 +2101,8 @@ importers: specifier: ^0.5.1 version: 0.5.1(ethers@6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10))(typechain@8.3.0(typescript@5.6.2))(typescript@5.6.2) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 dotenv: specifier: ^16.0.3 version: 16.4.5 @@ -2058,7 +2119,7 @@ importers: specifier: ^2.6.1 version: 2.6.1 tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typechain: specifier: ^8.1.1 @@ -2075,6 +2136,9 @@ importers: '@0xsequence/core': specifier: ^2.0.25 version: 2.2.13(ethers@6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + '@imtbl/auth': + specifier: workspace:* + version: link:../../auth '@imtbl/config': specifier: workspace:* version: link:../../config @@ -2087,21 +2151,15 @@ importers: '@imtbl/toolkit': specifier: workspace:* version: link:../../internal/toolkit + '@imtbl/wallet': + specifier: workspace:* + version: link:../../wallet '@imtbl/x-client': specifier: workspace:* version: link:../../x-client '@imtbl/x-provider': specifier: workspace:* version: link:../../x-provider - '@magic-ext/oidc': - specifier: 12.0.5 - version: 12.0.5 - '@magic-sdk/provider': - specifier: ^29.0.5 - version: 29.0.5(localforage@1.10.0) - '@metamask/detect-provider': - specifier: ^2.0.0 - version: 2.0.0 axios: specifier: ^1.6.5 version: 1.7.7 @@ -2124,9 +2182,12 @@ importers: specifier: 3.4.1 version: 3.4.1 uuid: - specifier: ^8.3.2 - version: 8.3.2 + specifier: ^9.0.1 + version: 9.0.1 devDependencies: + '@jest/test-sequencer': + specifier: ^29.7.0 + version: 29.7.0 '@swc/core': specifier: ^1.3.36 version: 1.9.3(@swc/helpers@0.5.13) @@ -2137,8 +2198,8 @@ importers: specifier: ^0.14.0 version: 0.14.0 '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/jwt-encode': specifier: ^1.0.1 version: 1.0.1 @@ -2182,7 +2243,7 @@ importers: specifier: ^10.9.1 version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@22.7.5)(typescript@5.6.2) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -2211,6 +2272,9 @@ importers: '@imtbl/passport': specifier: workspace:* version: link:../sdk + '@imtbl/wallet': + specifier: workspace:* + version: link:../../wallet '@imtbl/x-client': specifier: workspace:* version: link:../../x-client @@ -2285,6 +2349,82 @@ importers: specifier: ^5.6.2 version: 5.6.2 + packages/wallet: + dependencies: + '@0xsequence/abi': + specifier: ^2.0.25 + version: 2.2.13 + '@0xsequence/core': + specifier: ^2.0.25 + version: 2.2.13(ethers@6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + '@imtbl/auth': + specifier: workspace:* + version: link:../auth + '@imtbl/config': + specifier: workspace:* + version: link:../config + '@imtbl/generated-clients': + specifier: workspace:* + version: link:../internal/generated-clients + '@imtbl/metrics': + specifier: workspace:* + version: link:../internal/metrics + '@magic-ext/oidc': + specifier: 12.0.5 + version: 12.0.5 + '@magic-sdk/provider': + specifier: ^29.0.5 + version: 29.0.5(localforage@1.10.0) + '@metamask/detect-provider': + specifier: ^2.0.0 + version: 2.0.0 + axios: + specifier: ^1.6.5 + version: 1.7.7 + ethers: + specifier: ^6.13.4 + version: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) + uuid: + specifier: ^9.0.1 + version: 9.0.1 + devDependencies: + '@imtbl/toolkit': + specifier: workspace:* + version: link:../internal/toolkit + '@jest/test-sequencer': + specifier: ^29.7.0 + version: 29.7.0 + '@swc/core': + specifier: ^1.3.36 + version: 1.9.3(@swc/helpers@0.5.13) + '@swc/jest': + specifier: ^0.2.37 + version: 0.2.37(@swc/core@1.9.3(@swc/helpers@0.5.13)) + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + '@types/node': + specifier: ^18.14.2 + version: 18.15.13 + '@types/uuid': + specifier: ^8.3.4 + version: 8.3.4 + jest: + specifier: ^29.4.3 + version: 29.7.0(@types/node@18.15.13)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2)) + jest-environment-jsdom: + specifier: ^29.4.3 + version: 29.6.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2) + tsup: + specifier: ^8.3.0 + version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) + typescript: + specifier: ^5.6.2 + version: 5.6.2 + packages/webhook/sdk: dependencies: '@imtbl/config': @@ -2304,8 +2444,8 @@ importers: specifier: ^0.2.37 version: 0.2.37(@swc/core@1.9.3(@swc/helpers@0.5.13)) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/sns-validator': specifier: ^0.3.3 version: 0.3.3 @@ -2319,7 +2459,7 @@ importers: specifier: ^29.4.3 version: 29.6.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -2365,8 +2505,8 @@ importers: specifier: ^5.1.6 version: 5.1.6 '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 crypto: specifier: ^1.0.1 version: 1.0.1 @@ -2380,7 +2520,7 @@ importers: specifier: ^29.4.3 version: 29.6.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -2432,8 +2572,8 @@ importers: specifier: ^0.14.0 version: 0.14.0 '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -2465,7 +2605,7 @@ importers: specifier: ^10.9.1 version: 10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2) tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -2489,8 +2629,8 @@ importers: specifier: ^13.5.0 version: 13.5.0(@testing-library/dom@10.4.0) '@types/jest': - specifier: ^29.4.3 - version: 29.5.3 + specifier: ^29.5.12 + version: 29.5.14 '@types/node': specifier: ^18.14.2 version: 18.15.13 @@ -2522,6 +2662,9 @@ importers: sdk: dependencies: + '@imtbl/auth': + specifier: workspace:* + version: link:../packages/auth '@imtbl/blockchain-data': specifier: workspace:* version: link:../packages/blockchain-data/sdk @@ -2540,6 +2683,9 @@ importers: '@imtbl/passport': specifier: workspace:* version: link:../packages/passport/sdk + '@imtbl/wallet': + specifier: workspace:* + version: link:../packages/wallet '@imtbl/webhook': specifier: workspace:* version: link:../packages/webhook/sdk @@ -2554,7 +2700,7 @@ importers: specifier: ^8.40.0 version: 8.57.0 tsup: - specifier: 8.3.0 + specifier: ^8.3.0 version: 8.3.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) typescript: specifier: ^5.6.2 @@ -3634,9 +3780,11 @@ packages: '@cosmjs/crypto@0.31.3': resolution: {integrity: sha512-vRbvM9ZKR2017TO73dtJ50KxoGcFzKtKI7C8iO302BQ5p+DuB+AirUg1952UpSoLfv5ki9O416MFANNg8UN/EQ==} + deprecated: This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk. '@cosmjs/crypto@0.32.4': resolution: {integrity: sha512-zicjGU051LF1V9v7bp8p7ovq+VyC91xlaHdsFOTo2oVry3KQikp8L/81RkXmUIT8FxMwdx1T7DmFwVQikcSDIw==} + deprecated: This uses elliptic for cryptographic operations, which contains several security-relevant bugs. To what degree this affects your application is something you need to carefully investigate. See https://github.com/cosmos/cosmjs/issues/1708 for further pointers. Starting with version 0.34.0 the cryptographic library has been replaced. However, private keys might still be at risk. '@cosmjs/encoding@0.31.3': resolution: {integrity: sha512-6IRtG0fiVYwyP7n+8e54uTx2pLYijO48V3t9TLiROERm5aUAIzIlz6Wp0NYaI5he9nh1lcEGJ1lkquVKFw3sUg==} @@ -4364,18 +4512,18 @@ packages: resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} engines: {node: '>=18.18'} - '@imtbl/blockchain-data@2.1.11': - resolution: {integrity: sha512-FZtCxgBoDwNONdbLT61MiOzTG1+rMHC/Zt3ed0K79elISf73/v65SzhyHgumngOWkcUs25TiHw+jm2uU52JyBw==} + '@imtbl/blockchain-data@2.10.6': + resolution: {integrity: sha512-bYYsU3UzLn5gfTntKelk6QEcTcWnFLlUW9EmAS7wXJRq7YUOBpQa+bUiL7E0oNf7zFjRP3zhPqMsayg0AS3fcw==} - '@imtbl/bridge-sdk@2.1.11': - resolution: {integrity: sha512-EaXeMG+Ge17CT089wHipwYoJsGz/EeCLcEztTgIYpijA6R+4wb/jOtwnoCnOAWLHJE4Ep7wi366Xf7be5jTJWQ==} + '@imtbl/bridge-sdk@2.10.6': + resolution: {integrity: sha512-Wynb0Ze5IrFn1K9u0D0fm7p1ijmE+c5FwPfyA1rMe/jiHHeUNRkCngPfwAzuf0fwYRx8NeWtYzdfB/5xVgQyMg==} engines: {node: '>=20.11.0'} - '@imtbl/checkout-sdk@2.1.11': - resolution: {integrity: sha512-8PvuLX7T/3fGygug6fGGnAWRXFHpXkzV3wHgBcBekOe4K16Dl1wobgzyHoal6K90N/LId/ox38Hu3gPwe/yR6Q==} + '@imtbl/checkout-sdk@2.10.6': + resolution: {integrity: sha512-sPyeYobNmk3BZ4qNWucIhPKcrJJTRy+Nb25wrh5Pb2XJNYj7czbAkn+idrMKVpahlhx5PYFhbHmdFiJyfGcvcw==} - '@imtbl/config@2.1.11': - resolution: {integrity: sha512-6qJ579F6teAGn8Rsdi+lIHejh4KoyYoG5QPiykMwFV+vbX7rok4pT6cNkNDLHFu/ZGcNMP+w5+W3vzaOnG0pcQ==} + '@imtbl/config@2.10.6': + resolution: {integrity: sha512-ctRqrnT4r+5S62IZWqsMAXs1wCf/Ir48k+DrEf6B/VDF5+VZSEVtROrXT4urLuzR9B2VXE4EnnZtNLFpzu3k6w==} engines: {node: '>=20.11.0'} '@imtbl/contracts@2.2.17': @@ -4384,51 +4532,51 @@ packages: '@imtbl/contracts@2.2.6': resolution: {integrity: sha512-2cfE3Tojfp4GnxwVKSwoZY1CWd+/drCIbCKawyH9Nh2zASXd7VC71lo27aD5RnCweXHkZVhPzjqwQf/xrtnmIQ==} - '@imtbl/dex-sdk@2.1.11': - resolution: {integrity: sha512-Neo2/ZaeT/DW6xm9xJ4GCFAvVOuBDjawKpWu2jRcu2t15Kmjj0qHHv1yKF5DHlSRq20fktytd+uJQyqtnx/+WA==} + '@imtbl/dex-sdk@2.10.6': + resolution: {integrity: sha512-01z7n0nu5KeYB3G45zWwMd4Kx5VYHFDcPTciZ632NO8wThKbV3H7cUxJjxwbV0cSbZjYIxYGf1qzVwi24yJsGQ==} engines: {node: '>=20.11.0'} - '@imtbl/generated-clients@2.1.11': - resolution: {integrity: sha512-r0xEwQiLYE9hOYCB/q37yPIkREpvRF+JeqQ3tXELQcqMwgH7Rb30ISAN2dMuxXMgvLa9pG2P9rSEisQXLemjJQ==} + '@imtbl/generated-clients@2.10.6': + resolution: {integrity: sha512-5CGynaSzjz2q/nthzn7rE9wYAEqUT1putvOE2DXElBnod+nOBgfOO1XcYQOawMADv9bE7Q74RhEctcSOwyRCkw==} engines: {node: '>=20.11.0'} '@imtbl/image-resizer-utils@0.0.3': resolution: {integrity: sha512-/EOJKMJF4gD/Dv0qNhpUTpp2AgWeQ7XgYK9Xjl+xP2WWgaarNv1SHe1aeqSb8aZT5W7wSXdUGzn6TIxNsuCWGw==} - '@imtbl/metrics@2.1.11': - resolution: {integrity: sha512-d+WYjjbV4ufYL1xKr5mmxnbbkgWS5LKsJbZ8dTF0O7pICrsH2WY5J74R2RGjCVgfoWk28E67WTjsTJYwP+M5CA==} + '@imtbl/metrics@2.10.6': + resolution: {integrity: sha512-8uDkrTw4scfHBlztZbiD8HpTuTkkZNG6iyVjv28JpW9KbbktDEm41RboW6excbe1NDynsDFtHHZHsRDO94xjrA==} engines: {node: '>=20.11.0'} - '@imtbl/minting-backend@2.1.11': - resolution: {integrity: sha512-SgfOT+4nDMAxj5dq0pIrPyaXZ5fhUVgbfOGDoYGJd6x8jJ7utADFemLWWxZHII1/gTe5hg3xSkYR7uluzxvv+Q==} + '@imtbl/minting-backend@2.10.6': + resolution: {integrity: sha512-eEqBZVDPZXBK98lHqkDNBmwfYpYNreGE9lyG8m0QjgvNawemE23fvB/ccnSO5b2tJTq55PkUqOi5mzfomM2KYg==} - '@imtbl/orderbook@2.1.11': - resolution: {integrity: sha512-QKt+oc0AU4kQYCzRSBc0BRwkioZ30cfsmqzthtKU4OLg8H2ngjtt7qN9f6fylflJfHCI3T8spMJPvurH9qsK+w==} + '@imtbl/orderbook@2.10.6': + resolution: {integrity: sha512-fBhqH/r6E9HADyfggCnpTXC3lak0eSfYDLBHvA5SlegHnA/s5sbVaKLiGVCbbGrV0SrSmx5I7G39dxAAb2gTjQ==} - '@imtbl/passport@2.1.11': - resolution: {integrity: sha512-62bc8Dn/RwLJBQtGC8rR+UJ9wEPNUr1z9OlOK/YOezHR2RR9EAVyXaDkhquCN4LkZuw+iqYbu2OWWJ0ST3K8Eg==} + '@imtbl/passport@2.10.6': + resolution: {integrity: sha512-sy+67xSO2udtyXrP4tdjPhuWZprD5BxuGRdRFlRR5ofoKXVvwx7dgU4Ig/0eL0fkL9E6Jv7KXIdlTqLIHzr6jw==} engines: {node: '>=20.11.0'} '@imtbl/react-analytics@0.3.4-alpha': resolution: {integrity: sha512-4VWvfm8RZtpLub7+x2D2wNQ507nIVBCSAPA7B5lxdb0cKrHEujM6Y/HScMImHZHvgjUFQT1jiD9b2BL/DS43Pg==} - '@imtbl/sdk@2.1.11': - resolution: {integrity: sha512-w3oYF+THX6kL3kV/gRsAa9ca18QXb66jlGUPt//rEwOqu6M2mcpWb5V4R+SzR/gKp79OuSCzkPFKYF7kNqQOJw==} + '@imtbl/sdk@2.10.6': + resolution: {integrity: sha512-iLbxFlQgB3g298p5k7VxRVwPlddi78ujHKh2aROCtPc4WRfQyTyUgRQu0KJEv4UjiEDdvUami+NY+aHUdHWydQ==} engines: {node: '>=20.0.0'} - '@imtbl/toolkit@2.1.11': - resolution: {integrity: sha512-krQRFKF+UL7qDca2eWBwRwDLWv0p+JlNs/bCO8q9xvv5gOkUvTplbm0hA+SfTsacboDJ13MekN96n83TRvvCPg==} + '@imtbl/toolkit@2.10.6': + resolution: {integrity: sha512-UgPdxnRrdAFKkRqog4yXweqz8StQkz/RPfHu/33dHQvuOOE+dummEqcqdEiw09PDqZD6LY64b9fe9bsCbjfUgg==} engines: {node: '>=20.11.0'} - '@imtbl/webhook@2.1.11': - resolution: {integrity: sha512-0uoXONxwroH1VYuNwKbqxxyLE83EZRZSUz1Gvya7uZk4RG8vAmSFqsPnEfqca64B4SYePoa0qeu0Bq8+P24AJg==} + '@imtbl/webhook@2.10.6': + resolution: {integrity: sha512-g0a53tHSLHrfSu+qzy+qvCiGlBXnprQGe4CROlG7MPM9mEUDhSYYXCf8OmmbuOrDTWOB4SXv8MVK5qY9uCF/2A==} - '@imtbl/x-client@2.1.11': - resolution: {integrity: sha512-HLYbj6dqfFvry5xfI1d+Q2GF+w5w9UTmAD4G29vTW6rsQQGXeBtJE5R8foY+c1OIz4+kDuTZrrfXxiGnkQ4DRQ==} + '@imtbl/x-client@2.10.6': + resolution: {integrity: sha512-oNG1aI9e1q/GnkW3X72HZvrIb29h7T6OC6l/XdvqezI+1K4g4v/tPbHthu28nX2TyxAzBrxrN0xIZ3izuSN5QQ==} engines: {node: '>=20.11.0'} - '@imtbl/x-provider@2.1.11': - resolution: {integrity: sha512-MdAv353DLWJf2S4JeBJpbLsfDbBjRcEc7baLTLxZUesfVTO6Mh0KLvuZ2U0vPtyOv37rG0oeXzcmWaq8a3CGgQ==} + '@imtbl/x-provider@2.10.6': + resolution: {integrity: sha512-CJCmOPICd9vSRXs+7XmDrtS7VXrSVNNI5SpMicUhXx/MIO8eJTaAVnPitwws0qlYmCTP0fcIgNPfUoMSMBZ2nw==} engines: {node: '>=20.11.0'} '@ioredis/commands@1.2.0': @@ -4718,11 +4866,9 @@ packages: resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} engines: {node: '>=8'} - '@magic-ext/oidc@12.0.2': - resolution: {integrity: sha512-k7KdSprnOFQjyjO24qJX4qnrhZJjZBva2f32REpvo5sb37AbWaYcmA4F+FfhWhMXxwdHlzFwSkeWHgFvzInEgw==} - '@magic-ext/oidc@12.0.5': resolution: {integrity: sha512-EAmmRRZn/c5jmxHZ1H3IHtEqUKHYrsRtH9O+WuMFOZMv0llef/9MBa4DiRZkpnB0EPKb2hwsY7us8qk/LaFRNA==} + deprecated: 'Deprecation Notice: The OIDC extension will be deprecated soon. Please migrate to API Wallet, which offers improved performance and faster response times. Learn more: https://docs.magic.link/api-wallets/introduction' '@magic-sdk/commons@25.0.5': resolution: {integrity: sha512-/qXYCAs4Y8XISyTHzytoWf4CDejLOynW53X9XFnGJt9c6jFV7FoeuN0n/+TIngjHVUu3v+wbQoJNeFzzCE2y5g==} @@ -7526,6 +7672,7 @@ packages: '@uniswap/swap-router-contracts@1.3.1': resolution: {integrity: sha512-mh/YNbwKb7Mut96VuEtL+Z5bRe0xVIbjjiryn+iMMrK2sFKhR4duk/86mEz0UO5gSx4pQIw9G5276P5heY/7Rg==} engines: {node: '>=10'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@uniswap/v2-core@1.0.1': resolution: {integrity: sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q==} @@ -7588,6 +7735,7 @@ packages: '@walletconnect/ethereum-provider@2.13.0': resolution: {integrity: sha512-dnpW8mmLpWl1AZUYGYZpaAfGw1HFkL0WSlhk5xekx3IJJKn4pLacX2QeIOo0iNkzNQxZfux1AK4Grl1DvtzZEA==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/events@1.0.1': resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} @@ -7629,6 +7777,7 @@ packages: '@walletconnect/modal@2.6.2': resolution: {integrity: sha512-eFopgKi8AjKf/0U4SemvcYw9zlLpx9njVN8sf6DAkowC2Md0gPU/UNEbH1Wwj407pEKnEds98pKWib1NN1ACoA==} + deprecated: Please follow the migration guide on https://docs.reown.com/appkit/upgrade/wcm '@walletconnect/relay-api@1.0.10': resolution: {integrity: sha512-tqrdd4zU9VBNqUaXXQASaexklv6A54yEyQQEXYOCr+Jz8Ket0dmPBDyg19LVSNUN2cipAghQc45/KVmfFJ0cYw==} @@ -7641,6 +7790,7 @@ packages: '@walletconnect/sign-client@2.13.0': resolution: {integrity: sha512-En7KSvNUlQFx20IsYGsFgkNJ2lpvDvRsSFOT5PTdGskwCkUfOpB33SQJ6nCrN19gyoKPNvWg80Cy6MJI0TjNYA==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/time@1.0.2': resolution: {integrity: sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==} @@ -7650,6 +7800,7 @@ packages: '@walletconnect/universal-provider@2.13.0': resolution: {integrity: sha512-B5QvO8pnk5Bqn4aIt0OukGEQn2Auk9VbHfhQb9cGwgmSCd1GlprX/Qblu4gyT5+TjHMb1Gz5UssUaZWTWbDhBg==} + deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/utils@2.13.0': resolution: {integrity: sha512-q1eDCsRHj5iLe7fF8RroGoPZpdo2CYMZzQSrw1iqL+2+GOeqapxxuJ1vaJkmDUkwgklfB22ufqG6KQnz78sD4w==} @@ -10081,8 +10232,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - eslint-plugin-react-refresh@0.4.19: - resolution: {integrity: sha512-eyy8pcr/YxSYjBoqIFSrlbn9i/xvxUFa8CjzAYo9cFjgGXqq1hyjihcpZvxRLalpaWmueWR81xn7vuKmAFijDQ==} + eslint-plugin-react-refresh@0.4.24: + resolution: {integrity: sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==} peerDependencies: eslint: '>=8.40' @@ -13348,9 +13499,9 @@ packages: ohash@1.1.3: resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} - oidc-client-ts@2.4.0: - resolution: {integrity: sha512-WijhkTrlXK2VvgGoakWJiBdfIsVGz6CFzgjNNqZU1hPKV2kyeEaJgLs7RwuiSp2WhLfWBQuLvr2SxVlZnk3N1w==} - engines: {node: '>=12.13.0'} + oidc-client-ts@3.3.0: + resolution: {integrity: sha512-t13S540ZwFOEZKLYHJwSfITugupW4uYLwuQSSXyKH/wHwZ+7FvgHE7gnNJh1YQIZ1Yd1hKSRjqeXGSUtS0r9JA==} + engines: {node: '>=18'} oidc-client-ts@3.4.1: resolution: {integrity: sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==} @@ -14433,7 +14584,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qr-code-styling@1.6.0-rc.1: @@ -15407,6 +15557,7 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + deprecated: The work that was done in this beta branch won't be included in future versions sourcemap-codec@1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} @@ -20041,17 +20192,17 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} - '@imtbl/blockchain-data@2.1.11': + '@imtbl/blockchain-data@2.10.6': dependencies: - '@imtbl/config': 2.1.11 - '@imtbl/generated-clients': 2.1.11 + '@imtbl/config': 2.10.6 + '@imtbl/generated-clients': 2.10.6 axios: 1.7.7 transitivePeerDependencies: - debug - '@imtbl/bridge-sdk@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/bridge-sdk@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: - '@imtbl/config': 2.1.11 + '@imtbl/config': 2.10.6 '@jest/globals': 29.7.0 axios: 1.7.7 ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20061,16 +20212,16 @@ snapshots: - supports-color - utf-8-validate - '@imtbl/checkout-sdk@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/checkout-sdk@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: - '@imtbl/blockchain-data': 2.1.11 - '@imtbl/bridge-sdk': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/config': 2.1.11 - '@imtbl/dex-sdk': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/generated-clients': 2.1.11 - '@imtbl/metrics': 2.1.11 - '@imtbl/orderbook': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/passport': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/blockchain-data': 2.10.6 + '@imtbl/bridge-sdk': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/config': 2.10.6 + '@imtbl/dex-sdk': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/generated-clients': 2.10.6 + '@imtbl/metrics': 2.10.6 + '@imtbl/orderbook': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/passport': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@metamask/detect-provider': 2.0.0 axios: 1.7.7 ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20083,9 +20234,9 @@ snapshots: - supports-color - utf-8-validate - '@imtbl/config@2.1.11': + '@imtbl/config@2.10.6': dependencies: - '@imtbl/metrics': 2.1.11 + '@imtbl/metrics': 2.10.6 transitivePeerDependencies: - debug @@ -20132,9 +20283,9 @@ snapshots: - typescript - utf-8-validate - '@imtbl/dex-sdk@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/dex-sdk@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: - '@imtbl/config': 2.1.11 + '@imtbl/config': 2.10.6 '@uniswap/sdk-core': 3.2.3 '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) '@uniswap/v3-sdk': 3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@18.15.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) @@ -20145,7 +20296,7 @@ snapshots: - hardhat - utf-8-validate - '@imtbl/generated-clients@2.1.11': + '@imtbl/generated-clients@2.10.6': dependencies: axios: 1.7.7 transitivePeerDependencies: @@ -20155,7 +20306,7 @@ snapshots: dependencies: buffer: 6.0.3 - '@imtbl/metrics@2.1.11': + '@imtbl/metrics@2.10.6': dependencies: axios: 1.7.7 global-const: 0.1.2 @@ -20163,13 +20314,13 @@ snapshots: transitivePeerDependencies: - debug - '@imtbl/minting-backend@2.1.11': + '@imtbl/minting-backend@2.10.6': dependencies: - '@imtbl/blockchain-data': 2.1.11 - '@imtbl/config': 2.1.11 - '@imtbl/generated-clients': 2.1.11 - '@imtbl/metrics': 2.1.11 - '@imtbl/webhook': 2.1.11 + '@imtbl/blockchain-data': 2.10.6 + '@imtbl/config': 2.10.6 + '@imtbl/generated-clients': 2.10.6 + '@imtbl/metrics': 2.10.6 + '@imtbl/webhook': 2.10.6 uuid: 8.3.2 optionalDependencies: pg: 8.11.5 @@ -20178,10 +20329,10 @@ snapshots: - debug - pg-native - '@imtbl/orderbook@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/orderbook@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: - '@imtbl/config': 2.1.11 - '@imtbl/metrics': 2.1.11 + '@imtbl/config': 2.10.6 + '@imtbl/metrics': 2.10.6 '@opensea/seaport-js': 4.0.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) axios: 1.7.7 ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20192,17 +20343,17 @@ snapshots: - debug - utf-8-validate - '@imtbl/passport@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/passport@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@0xsequence/abi': 2.2.13 '@0xsequence/core': 2.2.13(ethers@6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10)) - '@imtbl/config': 2.1.11 - '@imtbl/generated-clients': 2.1.11 - '@imtbl/metrics': 2.1.11 - '@imtbl/toolkit': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/x-client': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/x-provider': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@magic-ext/oidc': 12.0.2 + '@imtbl/config': 2.10.6 + '@imtbl/generated-clients': 2.10.6 + '@imtbl/metrics': 2.10.6 + '@imtbl/toolkit': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-client': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-provider': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@magic-ext/oidc': 12.0.5 '@magic-sdk/provider': 29.0.5(localforage@1.10.0) '@metamask/detect-provider': 2.0.0 axios: 1.7.7 @@ -20211,7 +20362,7 @@ snapshots: jwt-decode: 3.1.2 localforage: 1.10.0 magic-sdk: 29.0.5 - oidc-client-ts: 2.4.0 + oidc-client-ts: 3.3.0 uuid: 8.3.2 transitivePeerDependencies: - bufferutil @@ -20226,17 +20377,17 @@ snapshots: - encoding - supports-color - '@imtbl/sdk@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/sdk@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: - '@imtbl/blockchain-data': 2.1.11 - '@imtbl/checkout-sdk': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/config': 2.1.11 - '@imtbl/minting-backend': 2.1.11 - '@imtbl/orderbook': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/passport': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/webhook': 2.1.11 - '@imtbl/x-client': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/x-provider': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/blockchain-data': 2.10.6 + '@imtbl/checkout-sdk': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/config': 2.10.6 + '@imtbl/minting-backend': 2.10.6 + '@imtbl/orderbook': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/passport': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/webhook': 2.10.6 + '@imtbl/x-client': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-provider': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - debug @@ -20245,35 +20396,35 @@ snapshots: - supports-color - utf-8-validate - '@imtbl/toolkit@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/toolkit@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: - '@imtbl/x-client': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@magic-ext/oidc': 12.0.2 + '@imtbl/x-client': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@magic-ext/oidc': 12.0.5 '@metamask/detect-provider': 2.0.0 axios: 1.7.7 bn.js: 5.2.1 enc-utils: 3.0.0 ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) magic-sdk: 29.0.5 - oidc-client-ts: 2.4.0 + oidc-client-ts: 3.3.0 transitivePeerDependencies: - bufferutil - debug - utf-8-validate - '@imtbl/webhook@2.1.11': + '@imtbl/webhook@2.10.6': dependencies: - '@imtbl/config': 2.1.11 - '@imtbl/generated-clients': 2.1.11 + '@imtbl/config': 2.10.6 + '@imtbl/generated-clients': 2.10.6 sns-validator: 0.3.5 transitivePeerDependencies: - debug - '@imtbl/x-client@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/x-client@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@ethereumjs/wallet': 2.0.4 - '@imtbl/config': 2.1.11 - '@imtbl/generated-clients': 2.1.11 + '@imtbl/config': 2.10.6 + '@imtbl/generated-clients': 2.10.6 axios: 1.7.7 bn.js: 5.2.1 elliptic: 6.6.1 @@ -20285,19 +20436,19 @@ snapshots: - debug - utf-8-validate - '@imtbl/x-provider@2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/x-provider@2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: - '@imtbl/config': 2.1.11 - '@imtbl/generated-clients': 2.1.11 - '@imtbl/toolkit': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/x-client': 2.1.11(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@magic-ext/oidc': 12.0.2 + '@imtbl/config': 2.10.6 + '@imtbl/generated-clients': 2.10.6 + '@imtbl/toolkit': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-client': 2.10.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@magic-ext/oidc': 12.0.5 '@metamask/detect-provider': 2.0.0 axios: 1.7.7 enc-utils: 3.0.0 ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) magic-sdk: 29.0.5 - oidc-client-ts: 2.4.0 + oidc-client-ts: 3.3.0 transitivePeerDependencies: - bufferutil - debug @@ -20978,8 +21129,6 @@ snapshots: dependencies: '@lukeed/csprng': 1.1.0 - '@magic-ext/oidc@12.0.2': {} - '@magic-ext/oidc@12.0.5': {} '@magic-sdk/commons@25.0.5(@magic-sdk/provider@29.0.5(localforage@1.10.0))(@magic-sdk/types@24.18.1)': @@ -21581,7 +21730,7 @@ snapshots: '@nrwl/tao@19.7.3(@swc-node/register@1.10.9(@swc/core@1.9.3(@swc/helpers@0.5.13))(@swc/types@0.1.17)(typescript@5.6.2))(@swc/core@1.9.3(@swc/helpers@0.5.13))': dependencies: nx: 19.7.3(@swc-node/register@1.10.9(@swc/core@1.9.3(@swc/helpers@0.5.13))(@swc/types@0.1.17)(typescript@5.6.2))(@swc/core@1.9.3(@swc/helpers@0.5.13)) - tslib: 2.6.3 + tslib: 2.7.0 transitivePeerDependencies: - '@swc-node/register' - '@swc/core' @@ -21613,7 +21762,7 @@ snapshots: nx: 19.7.3(@swc-node/register@1.10.9(@swc/core@1.9.3(@swc/helpers@0.5.13))(@swc/types@0.1.17)(typescript@5.6.2))(@swc/core@1.9.3(@swc/helpers@0.5.13)) semver: 7.7.1 tmp: 0.2.3 - tslib: 2.6.3 + tslib: 2.7.0 yargs-parser: 21.1.1 '@nx/js@19.7.3(@babel/traverse@7.27.0)(@swc-node/register@1.10.9(@swc/core@1.9.3(@swc/helpers@0.5.13))(@swc/types@0.1.17)(typescript@5.6.2))(@swc/core@1.9.3(@swc/helpers@0.5.13))(@types/node@22.7.5)(nx@19.7.3(@swc-node/register@1.10.9(@swc/core@1.9.3(@swc/helpers@0.5.13))(@swc/types@0.1.17)(typescript@5.6.2))(@swc/core@1.9.3(@swc/helpers@0.5.13)))(typescript@5.6.2)': @@ -21696,7 +21845,7 @@ snapshots: chalk: 4.1.2 enquirer: 2.3.6 nx: 19.7.3(@swc-node/register@1.10.9(@swc/core@1.9.3(@swc/helpers@0.5.13))(@swc/types@0.1.17)(typescript@5.6.2))(@swc/core@1.9.3(@swc/helpers@0.5.13)) - tslib: 2.6.3 + tslib: 2.7.0 yargs-parser: 21.1.1 transitivePeerDependencies: - '@swc-node/register' @@ -22939,7 +23088,7 @@ snapshots: dependencies: '@lukeed/uuid': 2.0.1 dset: 3.1.2 - tslib: 2.6.3 + tslib: 2.7.0 '@segment/analytics-next@1.54.0(encoding@0.1.13)': dependencies: @@ -23974,7 +24123,7 @@ snapshots: '@swc-node/sourcemap-support@0.5.1': dependencies: source-map-support: 0.5.21 - tslib: 2.6.3 + tslib: 2.7.0 '@swc/cli@0.4.0(@swc/core@1.9.3(@swc/helpers@0.5.13))(chokidar@3.6.0)': dependencies: @@ -25248,7 +25397,7 @@ snapshots: '@yarnpkg/parsers@3.0.0-rc.46': dependencies: js-yaml: 3.14.1 - tslib: 2.6.3 + tslib: 2.7.0 '@zkochan/js-yaml@0.0.7': dependencies: @@ -28378,7 +28527,7 @@ snapshots: dependencies: eslint: 8.57.0 - eslint-plugin-react-refresh@0.4.19(eslint@8.57.0): + eslint-plugin-react-refresh@0.4.24(eslint@8.57.0): dependencies: eslint: 8.57.0 @@ -33563,10 +33712,9 @@ snapshots: ohash@1.1.3: {} - oidc-client-ts@2.4.0: + oidc-client-ts@3.3.0: dependencies: - crypto-js: 4.2.0 - jwt-decode: 3.1.2 + jwt-decode: 4.0.0 oidc-client-ts@3.4.1: dependencies: @@ -35622,7 +35770,7 @@ snapshots: rxjs@7.8.1: dependencies: - tslib: 2.6.3 + tslib: 2.7.0 safe-array-concat@1.1.2: dependencies: @@ -36006,7 +36154,7 @@ snapshots: snake-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.6.3 + tslib: 2.7.0 snapdragon-node@2.1.1: dependencies: @@ -36607,7 +36755,7 @@ snapshots: synckit@0.8.5: dependencies: '@pkgr/utils': 2.4.2 - tslib: 2.6.3 + tslib: 2.7.0 syncpack@13.0.0(typescript@5.6.2): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9249cc5540..59f7047925 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,7 @@ packages: - "sdk" + - "packages/auth" + - "packages/wallet" - "packages/config" - "packages/x-client" - "packages/x-provider" diff --git a/sdk/package.json b/sdk/package.json index 94b0a1980b..7708f843bd 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -5,19 +5,21 @@ "author": "Immutable", "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", "dependencies": { + "@imtbl/auth": "workspace:*", "@imtbl/blockchain-data": "workspace:*", "@imtbl/checkout-sdk": "workspace:*", "@imtbl/config": "workspace:*", "@imtbl/minting-backend": "workspace:*", "@imtbl/orderbook": "workspace:*", "@imtbl/passport": "workspace:*", + "@imtbl/wallet": "workspace:*", "@imtbl/webhook": "workspace:*", "@imtbl/x-client": "workspace:*", "@imtbl/x-provider": "workspace:*" }, "devDependencies": { "eslint": "^8.40.0", - "tsup": "8.3.0", + "tsup": "^8.3.0", "typescript": "^5.6.2" }, "engines": { diff --git a/tsup.config.js b/tsup.config.js index 91eb602d97..b36b1b864f 100644 --- a/tsup.config.js +++ b/tsup.config.js @@ -13,6 +13,18 @@ export default defineConfig((options) => { target: 'es2022', platform: 'browser', bundle: true, + esbuildPlugins: [ + nodeModulesPolyfillPlugin({ + globals: { + Buffer: true, + process: true, + }, + modules: ['crypto', 'buffer', 'process'], + }), + replace({ + '__SDK_VERSION__': pkg.version === '0.0.0' ? '2.0.0' : pkg.version + }), + ], } }