From aaa839d89e502de5d133b04c1e2a84e4f6848f4e Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Fri, 11 Jul 2025 15:13:01 +1000 Subject: [PATCH 01/11] WIP --- packages/passport/sdk/src/Passport.ts | 28 +- .../sdk/src/magic/magicTeeAdapter.test.ts | 389 ++++++++++++++++++ .../passport/sdk/src/magic/magicTeeAdapter.ts | 112 +++++ .../passport/sdk/src/zkEvm/zkEvmProvider.ts | 127 +----- 4 files changed, 545 insertions(+), 111 deletions(-) create mode 100644 packages/passport/sdk/src/magic/magicTeeAdapter.test.ts create mode 100644 packages/passport/sdk/src/magic/magicTeeAdapter.ts diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index 98b657345e..7fbda315f4 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -1,6 +1,6 @@ import { IMXProvider } from '@imtbl/x-provider'; import { - createConfig, ImxApiClients, imxApiConfig, MultiRollupApiClients, + createConfig, ImxApiClients, imxApiConfig, MagicTeeApiClients, MultiRollupApiClients, } from '@imtbl/generated-clients'; import { IMXClient } from '@imtbl/x-client'; import { Environment } from '@imtbl/config'; @@ -12,6 +12,7 @@ import { import { isAxiosError } from 'axios'; import AuthManager from './authManager'; import MagicAdapter from './magic/magicAdapter'; +import MagicTeeAdapter from './magic/magicTeeAdapter'; import { PassportImxProviderFactory } from './starkEx'; import { PassportConfiguration } from './config'; import { @@ -59,6 +60,13 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf const magicProviderProxyFactory = new MagicProviderProxyFactory(authManager, config); const magicAdapter = new MagicAdapter(config, magicProviderProxyFactory); const confirmationScreen = new ConfirmationScreen(config); + const magicTeeApiClients = new MagicTeeApiClients({ + basePath: config.magicTeeBasePath, + timeout: config.magicTeeTimeout, + magicPublishableApiKey: config.magicPublishableApiKey, + magicProviderId: config.magicProviderId, + }); + const magicTeeAdapter = new MagicTeeAdapter(authManager, magicTeeApiClients); const multiRollupApiClients = new MultiRollupApiClients(config.multiRollupConfig); const passportEventEmitter = new TypedEventEmitter(); @@ -88,6 +96,7 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf config, authManager, magicAdapter, + magicTeeAdapter, confirmationScreen, immutableXClient, multiRollupApiClients, @@ -108,6 +117,8 @@ export class Passport { private readonly magicAdapter: MagicAdapter; + private readonly magicTeeAdapter: MagicTeeAdapter; + private readonly multiRollupApiClients: MultiRollupApiClients; private readonly passportImxProviderFactory: PassportImxProviderFactory; @@ -122,6 +133,7 @@ export class Passport { this.config = privateVars.config; this.authManager = privateVars.authManager; this.magicAdapter = privateVars.magicAdapter; + this.magicTeeAdapter = privateVars.magicTeeAdapter; this.confirmationScreen = privateVars.confirmationScreen; this.immutableXClient = privateVars.immutableXClient; this.multiRollupApiClients = privateVars.multiRollupApiClients; @@ -154,19 +166,27 @@ export class Passport { * Connects to EVM and optionally announces the provider. * @param {Object} options - Configuration options * @param {boolean} options.announceProvider - Whether to announce the provider via EIP-6963 for wallet discovery (defaults to true) - * @returns {Provider} The EVM provider instance + * @returns {Promise} The EVM provider instance */ - public connectEvm(options: { + public async connectEvm(options: { announceProvider: boolean } = { 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 + } + const provider = new ZkEvmProvider({ passportEventEmitter: this.passportEventEmitter, authManager: this.authManager, - magicAdapter: this.magicAdapter, + magicTeeAdapter: this.magicTeeAdapter, config: this.config, multiRollupApiClients: this.multiRollupApiClients, guardianClient: this.guardianClient, + user, }); if (options?.announceProvider) { diff --git a/packages/passport/sdk/src/magic/magicTeeAdapter.test.ts b/packages/passport/sdk/src/magic/magicTeeAdapter.test.ts new file mode 100644 index 0000000000..1cca37cc3c --- /dev/null +++ b/packages/passport/sdk/src/magic/magicTeeAdapter.test.ts @@ -0,0 +1,389 @@ +import { MagicTeeApiClients } from '@imtbl/generated-clients'; +import { trackDuration } from '@imtbl/metrics'; +import { isAxiosError } from 'axios'; +import AuthManager from '../authManager'; +import { PassportError, PassportErrorType } from '../errors/passportError'; +import { withMetricsAsync } from '../utils/metrics'; +import MagicTeeAdapter from './magicTeeAdapter'; + +// Mock dependencies +jest.mock('../utils/metrics'); +jest.mock('@imtbl/metrics'); +jest.mock('axios', () => ({ + isAxiosError: jest.fn(), +})); + +describe('MagicTeeAdapter', () => { + let authManager: jest.Mocked; + let magicTeeApiClient: jest.Mocked; + let adapter: MagicTeeAdapter; + let mockCreateWallet: jest.Mock; + let mockPersonalSign: jest.Mock; + let mockIsAxiosError: jest.Mock; + + const mockUser = { + idToken: 'test-id-token', + accessToken: 'test-access-token', + profile: { + sub: 'test-user-id', + email: 'test@example.com', + }, + }; + + const mockHeaders = { + Authorization: 'Bearer test-id-token', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + authManager = { + getUser: jest.fn(), + } as any; + + mockCreateWallet = jest.fn(); + mockPersonalSign = jest.fn(); + mockIsAxiosError = isAxiosError as unknown as jest.Mock; + + magicTeeApiClient = { + walletApi: { + createWalletV1WalletPost: mockCreateWallet, + }, + transactionApi: { + signMessageV1WalletPersonalSignPost: mockPersonalSign, + }, + } as any; + + adapter = new MagicTeeAdapter(authManager, magicTeeApiClient); + + // Mock withMetricsAsync to call the function directly + (withMetricsAsync as jest.Mock).mockImplementation(async (fn, flowName) => { + const mockFlow = { + details: { flowName }, + addEvent: jest.fn(), + }; + return fn(mockFlow); + }); + }); + + describe('constructor', () => { + it('should initialize with provided dependencies', () => { + expect(adapter).toBeInstanceOf(MagicTeeAdapter); + expect(adapter.authManager).toBe(authManager); + expect(adapter.magicTeeApiClient).toBe(magicTeeApiClient); + }); + }); + + describe('createWallet', () => { + it('should successfully create wallet and return public address', async () => { + const mockPublicAddress = '0x123456789abcdef'; + const mockResponse = { + data: { + public_address: mockPublicAddress, + }, + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockCreateWallet.mockResolvedValue(mockResponse as any); + + const result = await adapter.createWallet(); + + expect(result).toBe(mockPublicAddress); + expect(authManager.getUser).toHaveBeenCalledTimes(1); + expect(mockCreateWallet).toHaveBeenCalledWith( + { + createWalletRequestModel: { + chain: 'ETH', + }, + }, + { headers: mockHeaders }, + ); + expect(trackDuration).toHaveBeenCalledWith( + 'passport', + 'magicCreateWallet', + expect.any(Number), + ); + }); + + it('should throw detailed error when API call fails with axios error and response', async () => { + const axiosError = { + response: { + status: 500, + data: { error: 'Internal server error' }, + }, + message: 'Request failed', + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockCreateWallet.mockRejectedValue(axiosError); + mockIsAxiosError.mockReturnValue(true); + + await expect(adapter.createWallet()).rejects.toThrow( + 'Failed to create wallet with status 500: {"error":"Internal server error"}', + ); + }); + + it('should throw detailed error when API call fails with axios error without response', async () => { + const axiosError = { + message: 'Network error', + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockCreateWallet.mockRejectedValue(axiosError); + mockIsAxiosError.mockReturnValue(true); + + await expect(adapter.createWallet()).rejects.toThrow( + 'Failed to create wallet: Network error', + ); + }); + + it('should throw detailed error when API call fails with non-axios error', async () => { + const genericError = new Error('Generic error'); + + authManager.getUser.mockResolvedValue(mockUser as any); + mockCreateWallet.mockRejectedValue(genericError); + mockIsAxiosError.mockReturnValue(false); + + await expect(adapter.createWallet()).rejects.toThrow( + 'Failed to create wallet: Generic error', + ); + }); + + it('should throw PassportError when user is not logged in', async () => { + authManager.getUser.mockResolvedValue(null); + + await expect(adapter.createWallet()).rejects.toThrow( + new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ), + ); + }); + }); + + describe('personalSign', () => { + it('should successfully sign string message and return signature', async () => { + const message = 'Hello, world!'; + const mockSignature = '0xabcdef123456'; + const mockResponse = { + data: { + signature: mockSignature, + }, + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockPersonalSign.mockResolvedValue(mockResponse as any); + + const result = await adapter.personalSign(message); + + expect(result).toBe(mockSignature); + expect(authManager.getUser).toHaveBeenCalledTimes(1); + expect(mockPersonalSign).toHaveBeenCalledWith( + { + personalSignRequest: { + message_base64: Buffer.from(message, 'utf-8').toString('base64'), + chain: 'ETH', + }, + }, + { headers: mockHeaders }, + ); + expect(trackDuration).toHaveBeenCalledWith( + 'passport', + 'magicPersonalSign', + expect.any(Number), + ); + }); + + it('should successfully sign Uint8Array message and return signature', async () => { + const message = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" in bytes + const expectedHexMessage = '0x48656c6c6f'; + const mockSignature = '0xabcdef123456'; + const mockResponse = { + data: { + signature: mockSignature, + }, + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockPersonalSign.mockResolvedValue(mockResponse as any); + + const result = await adapter.personalSign(message); + + expect(result).toBe(mockSignature); + expect(mockPersonalSign).toHaveBeenCalledWith( + { + personalSignRequest: { + message_base64: Buffer.from(expectedHexMessage, 'utf-8').toString('base64'), + chain: 'ETH', + }, + }, + { headers: mockHeaders }, + ); + }); + + it('should throw detailed error when API call fails with axios error and response', async () => { + const message = 'Hello, world!'; + const axiosError = { + response: { + status: 400, + data: { error: 'Bad request' }, + }, + message: 'Request failed', + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockPersonalSign.mockRejectedValue(axiosError); + mockIsAxiosError.mockReturnValue(true); + + await expect(adapter.personalSign(message)).rejects.toThrow( + 'Failed to create signature using EOA with status 400: {"error":"Bad request"}', + ); + }); + + it('should throw detailed error when API call fails with axios error without response', async () => { + const message = 'Hello, world!'; + const axiosError = { + message: 'Network timeout', + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockPersonalSign.mockRejectedValue(axiosError); + mockIsAxiosError.mockReturnValue(true); + + await expect(adapter.personalSign(message)).rejects.toThrow( + 'Failed to create signature using EOA: Network timeout', + ); + }); + + it('should throw detailed error when API call fails with non-axios error', async () => { + const message = 'Hello, world!'; + const genericError = new Error('Signing failed'); + + authManager.getUser.mockResolvedValue(mockUser as any); + mockPersonalSign.mockRejectedValue(genericError); + mockIsAxiosError.mockReturnValue(false); + + await expect(adapter.personalSign(message)).rejects.toThrow( + 'Failed to create signature using EOA: Signing failed', + ); + }); + + it('should throw PassportError when user is not logged in', async () => { + const message = 'Hello, world!'; + authManager.getUser.mockResolvedValue(null); + + await expect(adapter.personalSign(message)).rejects.toThrow( + new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ), + ); + }); + }); + + describe('getHeaders', () => { + it('should return headers with authorization token when user is logged in', async () => { + authManager.getUser.mockResolvedValue(mockUser as any); + + const result = await adapter.getHeaders(); + + expect(result).toEqual({ + Authorization: 'Bearer test-id-token', + }); + expect(authManager.getUser).toHaveBeenCalledTimes(1); + }); + + it('should throw PassportError when user is not logged in', async () => { + authManager.getUser.mockResolvedValue(null); + + await expect(adapter.getHeaders()).rejects.toThrow( + new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ), + ); + }); + }); + + describe('metrics integration', () => { + it('should call withMetricsAsync with correct flow name for createWallet', async () => { + const mockPublicAddress = '0x123456789abcdef'; + const mockResponse = { + data: { + public_address: mockPublicAddress, + }, + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockCreateWallet.mockResolvedValue(mockResponse as any); + + await adapter.createWallet(); + + expect(withMetricsAsync).toHaveBeenCalledWith( + expect.any(Function), + 'magicCreateWallet', + ); + }); + + it('should call withMetricsAsync with correct flow name for personalSign', async () => { + const message = 'Hello, world!'; + const mockSignature = '0xabcdef123456'; + const mockResponse = { + data: { + signature: mockSignature, + }, + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockPersonalSign.mockResolvedValue(mockResponse as any); + + await adapter.personalSign(message); + + expect(withMetricsAsync).toHaveBeenCalledWith( + expect.any(Function), + 'magicPersonalSign', + ); + }); + + it('should track duration for successful createWallet calls', async () => { + const mockPublicAddress = '0x123456789abcdef'; + const mockResponse = { + data: { + public_address: mockPublicAddress, + }, + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockCreateWallet.mockResolvedValue(mockResponse as any); + + await adapter.createWallet(); + + expect(trackDuration).toHaveBeenCalledWith( + 'passport', + 'magicCreateWallet', + expect.any(Number), + ); + }); + + it('should track duration for successful personalSign calls', async () => { + const message = 'Hello, world!'; + const mockSignature = '0xabcdef123456'; + const mockResponse = { + data: { + signature: mockSignature, + }, + }; + + authManager.getUser.mockResolvedValue(mockUser as any); + mockPersonalSign.mockResolvedValue(mockResponse as any); + + await adapter.personalSign(message); + + expect(trackDuration).toHaveBeenCalledWith( + 'passport', + 'magicPersonalSign', + expect.any(Number), + ); + }); + }); +}); diff --git a/packages/passport/sdk/src/magic/magicTeeAdapter.ts b/packages/passport/sdk/src/magic/magicTeeAdapter.ts new file mode 100644 index 0000000000..393cc44d8f --- /dev/null +++ b/packages/passport/sdk/src/magic/magicTeeAdapter.ts @@ -0,0 +1,112 @@ +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 { withMetricsAsync } from '../utils/metrics'; + +const CHAIN_IDENTIFIER = 'ETH'; + +export default class MagicTeeAdapter { + private readonly authManager: AuthManager; + + private readonly magicTeeApiClient: MagicTeeApiClients; + + constructor(authManager: AuthManager, magicTeeApiClient: MagicTeeApiClients) { + this.authManager = authManager; + this.magicTeeApiClient = magicTeeApiClient; + } + + public async createWallet(): Promise { + const headers = await this.getHeaders(); + + return withMetricsAsync(async (flow: Flow) => { + try { + const startTime = performance.now(); + const response = await this.magicTeeApiClient.walletApi.createWalletV1WalletPost( + { + createWalletRequestModel: { + chain: CHAIN_IDENTIFIER, + }, + }, + { headers }, + ); + + trackDuration( + 'passport', + flow.details.flowName, + Math.round(performance.now() - startTime), + ); + + return response.data.public_address; + } catch (error) { + let errorMessage: string = 'Failed to create wallet'; + + if (isAxiosError(error)) { + if (error.response) { + errorMessage += ` with status ${error.response.status}: ${JSON.stringify(error.response.data)}`; + } else { + errorMessage += `: ${error.message}`; + } + } else { + errorMessage += `: ${(error as Error).message}`; + } + + throw new Error(errorMessage); + } + }, 'magicCreateWallet'); + } + + public async personalSign(message: string | Uint8Array): Promise { + const messageToSign = message instanceof Uint8Array ? `0x${Buffer.from(message).toString('hex')}` : message; + const headers = await this.getHeaders(); + + return withMetricsAsync(async (flow: Flow) => { + try { + const startTime = performance.now(); + const response = await this.magicTeeApiClient.transactionApi.signMessageV1WalletPersonalSignPost({ + personalSignRequest: { + message_base64: Buffer.from(messageToSign, 'utf-8').toString('base64'), + chain: CHAIN_IDENTIFIER, + }, + }, { headers }); + + trackDuration( + 'passport', + flow.details.flowName, + Math.round(performance.now() - startTime), + ); + + return response.data.signature; + } catch (error) { + let errorMessage: string = 'Failed to create signature using EOA'; + + if (isAxiosError(error)) { + if (error.response) { + errorMessage += ` with status ${error.response.status}: ${JSON.stringify(error.response.data)}`; + } else { + errorMessage += `: ${error.message}`; + } + } else { + errorMessage += `: ${(error as Error).message}`; + } + + throw new Error(errorMessage); + } + }, 'magicPersonalSign'); + } + + private async getHeaders(): Promise> { + const user = await this.authManager.getUser(); + if (!user) { + throw new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ); + } + + return { + Authorization: `Bearer ${user.idToken}`, + }; + } +} diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts index 2955be92fc..cadf02ee95 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts @@ -4,7 +4,6 @@ import { } from '@imtbl/metrics'; import { JsonRpcProvider, Signer, toBeHex, - BrowserProvider, } from 'ethers'; import { Provider, @@ -13,7 +12,6 @@ import { RequestArguments, } from './types'; import AuthManager from '../authManager'; -import MagicAdapter from '../magic/magicAdapter'; import TypedEventEmitter from '../utils/typedEventEmitter'; import { PassportConfiguration } from '../config'; import { @@ -33,11 +31,12 @@ import { signEjectionTransaction } from './signEjectionTransaction'; export type ZkEvmProviderInput = { authManager: AuthManager; - magicAdapter: MagicAdapter; config: PassportConfiguration; multiRollupApiClients: MultiRollupApiClients; passportEventEmitter: TypedEventEmitter; guardianClient: GuardianClient; + ethSigner: Signer; + user: User | null; }; const isZkEvmUser = (user: User): user is UserZkEvm => 'zkEvm' in user; @@ -61,37 +60,28 @@ export class ZkEvmProvider implements Provider { readonly #rpcProvider: JsonRpcProvider; // Used for read - readonly #magicAdapter: MagicAdapter; - readonly #multiRollupApiClients: MultiRollupApiClients; readonly #relayerClient: RelayerClient; - /** - * This property is set during `#initialiseEthSigner` and stores the signer in a promise. - * This property is not meant to be accessed directly, but through the - * `#getSigner` method. - * @see getSigner - */ - #ethSigner?: Promise | undefined; - - #signerInitialisationError: unknown | undefined; + readonly #ethSigner: Signer; public readonly isPassport: boolean = true; constructor({ authManager, - magicAdapter, config, multiRollupApiClients, passportEventEmitter, guardianClient, + ethSigner, + user, }: ZkEvmProviderInput) { this.#authManager = authManager; - this.#magicAdapter = magicAdapter; this.#config = config; this.#guardianClient = guardianClient; this.#passportEventEmitter = passportEventEmitter; + this.#ethSigner = ethSigner; this.#rpcProvider = new JsonRpcProvider(this.#config.zkEvmRpcUrl, undefined, { staticNetwork: true, @@ -106,22 +96,13 @@ export class ZkEvmProvider implements Provider { this.#multiRollupApiClients = multiRollupApiClients; this.#providerEventEmitter = new TypedEventEmitter(); - // Automatically connect an existing user session to Passport - this.#authManager.getUser().then((user) => { - if (user) { - this.#initialiseEthSigner(user); - if (isZkEvmUser(user)) { - this.#callSessionActivity(user.zkEvm.ethAddress); - } - } - }).catch(() => { - // User does not exist, don't initialise an eth signer - }); + if (user && isZkEvmUser(user)) { + this.#callSessionActivity(user.zkEvm.ethAddress); + } - passportEventEmitter.on(PassportEvents.LOGGED_IN, (user: User) => { - this.#initialiseEthSigner(user); - if (isZkEvmUser(user)) { - this.#callSessionActivity(user.zkEvm.ethAddress); + passportEventEmitter.on(PassportEvents.LOGGED_IN, (loggedInUser: User) => { + if (isZkEvmUser(loggedInUser)) { + this.#callSessionActivity(loggedInUser.zkEvm.ethAddress); } }); passportEventEmitter.on(PassportEvents.LOGGED_OUT, this.#handleLogout); @@ -132,57 +113,9 @@ export class ZkEvmProvider implements Provider { } #handleLogout = () => { - this.#ethSigner = undefined; this.#providerEventEmitter.emit(ProviderEvent.ACCOUNTS_CHANGED, []); }; - /** - * This method is called by `eth_requestAccounts` and asynchronously initialises the signer. - * The signer is stored in a promise so that it can be retrieved by the provider - * when needed. - * - * If an error is thrown during initialisation, it is stored in the `signerInitialisationError`, - * so that it doesn't result in an unhandled promise rejection. - * - * This error is thrown when the signer is requested through: - * @see #getSigner - * - */ - #initialiseEthSigner(user: User) { - const generateSigner = async (): Promise => { - const magicRpcProvider = await this.#magicAdapter.login(user.idToken!); - const browserProvider = new BrowserProvider(magicRpcProvider); - - return browserProvider.getSigner(); - }; - - this.#signerInitialisationError = undefined; - // eslint-disable-next-line no-async-promise-executor - this.#ethSigner = new Promise(async (resolve) => { - try { - resolve(await generateSigner()); - } catch (err) { - // Capture and store the initialization error - this.#signerInitialisationError = err; - resolve(undefined); - } - }); - } - - async #getSigner(): Promise { - const ethSigner = await this.#ethSigner; - // Throw the stored error if the signers failed to initialise - if (typeof ethSigner === 'undefined') { - if (typeof this.#signerInitialisationError !== 'undefined') { - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw this.#signerInitialisationError; - } - throw new Error('Signer failed to initialise'); - } - - return ethSigner; - } - async #callSessionActivity(zkEvmAddress: string, clientId?: string) { // SessionActivity requests are processed in nonce space 1, where as all // other sendTransaction requests are processed in nonce space 0. This means @@ -190,10 +123,9 @@ export class ZkEvmProvider implements Provider { // INVALID_NONCE error. const nonceSpace: bigint = BigInt(1); const sendTransactionClosure = async (params: Array, flow: Flow) => { - const ethSigner = await this.#getSigner(); return await sendTransaction({ params, - ethSigner, + ethSigner: this.#ethSigner, guardianClient: this.#guardianClient, rpcProvider: this.#rpcProvider, relayerClient: this.#relayerClient, @@ -238,20 +170,13 @@ export class ZkEvmProvider implements Provider { const user = await this.#authManager.getUserOrLogin(); flow.addEvent('endGetUserOrLogin'); - if (!this.#ethSigner) { - this.#initialiseEthSigner(user); - } - - let userZkEvmEthAddress; + let userZkEvmEthAddress: string | undefined; if (!isZkEvmUser(user)) { flow.addEvent('startUserRegistration'); - const ethSigner = await this.#getSigner(); - flow.addEvent('ethSignerResolved'); - userZkEvmEthAddress = await registerZkEvmUser({ - ethSigner, + ethSigner: this.#ethSigner, authManager: this.#authManager, multiRollupApiClients: this.#multiRollupApiClients, accessToken: user.accessToken, @@ -298,12 +223,9 @@ export class ZkEvmProvider implements Provider { width: 480, height: 720, })(async () => { - const ethSigner = await this.#getSigner(); - flow.addEvent('endGetSigner'); - return await sendTransaction({ params: request.params || [], - ethSigner, + ethSigner: this.#ethSigner, guardianClient: this.#guardianClient, rpcProvider: this.#rpcProvider, relayerClient: this.#relayerClient, @@ -342,9 +264,6 @@ export class ZkEvmProvider implements Provider { width: 480, height: 720, })(async () => { - const ethSigner = await this.#getSigner(); - flow.addEvent('endGetSigner'); - if (this.#config.forceScwDeployBeforeMessageSignature) { // Check if the smart contract wallet has been deployed const nonce = await getNonce(this.#rpcProvider, zkEvmAddress); @@ -353,8 +272,8 @@ export class ZkEvmProvider implements Provider { // submit a transaction before signing the message return await sendDeployTransactionAndPersonalSign({ params: request.params || [], - ethSigner, zkEvmAddress, + ethSigner: this.#ethSigner, rpcProvider: this.#rpcProvider, guardianClient: this.#guardianClient, relayerClient: this.#relayerClient, @@ -365,8 +284,8 @@ export class ZkEvmProvider implements Provider { return await personalSign({ params: request.params || [], - ethSigner, zkEvmAddress, + ethSigner: this.#ethSigner, rpcProvider: this.#rpcProvider, guardianClient: this.#guardianClient, relayerClient: this.#relayerClient, @@ -401,13 +320,10 @@ export class ZkEvmProvider implements Provider { width: 480, height: 720, })(async () => { - const ethSigner = await this.#getSigner(); - flow.addEvent('endGetSigner'); - return await signTypedDataV4({ method: request.method, params: request.params || [], - ethSigner, + ethSigner: this.#ethSigner, rpcProvider: this.#rpcProvider, relayerClient: this.#relayerClient, guardianClient: this.#guardianClient, @@ -481,12 +397,9 @@ export class ZkEvmProvider implements Provider { const flow = trackFlow('passport', 'imSignEjectionTransaction'); try { - const ethSigner = await this.#getSigner(); - flow.addEvent('endGetSigner'); - return await signEjectionTransaction({ params: request.params || [], - ethSigner, + ethSigner: this.#ethSigner, zkEvmAddress, flow, }); From fa7dbc2d7f26ca0a1fe2d52fbbbe79fa03acdf63 Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Fri, 11 Jul 2025 17:25:15 +1000 Subject: [PATCH 02/11] ID-3844 Simpler implementation --- packages/passport/sdk/src/Passport.ts | 34 ++--- packages/passport/sdk/src/config/config.ts | 4 + packages/passport/sdk/src/magic/index.ts | 4 +- .../sdk/src/magic/magicAdapter.test.ts | 132 ------------------ .../passport/sdk/src/magic/magicAdapter.ts | 73 ---------- .../magic/magicProviderProxyFactory.test.ts | 123 ---------------- .../src/magic/magicProviderProxyFactory.ts | 84 ----------- ...Adapter.test.ts => magicTEESigner.test.ts} | 2 +- .../{magicTeeAdapter.ts => magicTEESigner.ts} | 50 ++++++- .../sdk/src/starkEx/passportImxProvider.ts | 79 +++++------ .../src/starkEx/passportImxProviderFactory.ts | 12 +- packages/passport/sdk/src/types.ts | 5 - .../sdk/src/zkEvm/transactionHelpers.test.ts | 64 +++++---- 13 files changed, 136 insertions(+), 530 deletions(-) delete mode 100644 packages/passport/sdk/src/magic/magicAdapter.test.ts delete mode 100644 packages/passport/sdk/src/magic/magicAdapter.ts delete mode 100644 packages/passport/sdk/src/magic/magicProviderProxyFactory.test.ts delete mode 100644 packages/passport/sdk/src/magic/magicProviderProxyFactory.ts rename packages/passport/sdk/src/magic/{magicTeeAdapter.test.ts => magicTEESigner.test.ts} (99%) rename packages/passport/sdk/src/magic/{magicTeeAdapter.ts => magicTEESigner.ts} (68%) diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index 7fbda315f4..b75a1b8d29 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -11,8 +11,7 @@ import { } from '@imtbl/metrics'; import { isAxiosError } from 'axios'; import AuthManager from './authManager'; -import MagicAdapter from './magic/magicAdapter'; -import MagicTeeAdapter from './magic/magicTeeAdapter'; +import MagicTEESigner from './magic/magicTEESigner'; import { PassportImxProviderFactory } from './starkEx'; import { PassportConfiguration } from './config'; import { @@ -35,7 +34,6 @@ import logger from './utils/logger'; import { announceProvider, passportProviderInfo } from './zkEvm/provider/eip6963'; import { isAPIError, PassportError, PassportErrorType } from './errors/passportError'; import { withMetricsAsync } from './utils/metrics'; -import { MagicProviderProxyFactory } from './magic/magicProviderProxyFactory'; const buildImxClientConfig = (passportModuleConfiguration: PassportModuleConfiguration) => { if (passportModuleConfiguration.overrides) { @@ -57,8 +55,6 @@ const buildImxApiClients = (passportModuleConfiguration: PassportModuleConfigura export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConfiguration) => { const config = new PassportConfiguration(passportModuleConfiguration); const authManager = new AuthManager(config); - const magicProviderProxyFactory = new MagicProviderProxyFactory(authManager, config); - const magicAdapter = new MagicAdapter(config, magicProviderProxyFactory); const confirmationScreen = new ConfirmationScreen(config); const magicTeeApiClients = new MagicTeeApiClients({ basePath: config.magicTeeBasePath, @@ -66,7 +62,7 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf magicPublishableApiKey: config.magicPublishableApiKey, magicProviderId: config.magicProviderId, }); - const magicTeeAdapter = new MagicTeeAdapter(authManager, magicTeeApiClients); + const magicTEESigner = new MagicTEESigner(authManager, magicTeeApiClients); const multiRollupApiClients = new MultiRollupApiClients(config.multiRollupConfig); const passportEventEmitter = new TypedEventEmitter(); @@ -86,7 +82,7 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf const passportImxProviderFactory = new PassportImxProviderFactory({ authManager, immutableXClient, - magicAdapter, + magicTEESigner, passportEventEmitter, imxApiClients, guardianClient, @@ -95,8 +91,7 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf return { config, authManager, - magicAdapter, - magicTeeAdapter, + magicTEESigner, confirmationScreen, immutableXClient, multiRollupApiClients, @@ -115,9 +110,7 @@ export class Passport { private readonly immutableXClient: IMXClient; - private readonly magicAdapter: MagicAdapter; - - private readonly magicTeeAdapter: MagicTeeAdapter; + private readonly magicTEESigner: MagicTEESigner; private readonly multiRollupApiClients: MultiRollupApiClients; @@ -132,8 +125,7 @@ export class Passport { this.config = privateVars.config; this.authManager = privateVars.authManager; - this.magicAdapter = privateVars.magicAdapter; - this.magicTeeAdapter = privateVars.magicTeeAdapter; + this.magicTEESigner = privateVars.magicTEESigner; this.confirmationScreen = privateVars.confirmationScreen; this.immutableXClient = privateVars.immutableXClient; this.multiRollupApiClients = privateVars.multiRollupApiClients; @@ -182,10 +174,10 @@ export class Passport { const provider = new ZkEvmProvider({ passportEventEmitter: this.passportEventEmitter, authManager: this.authManager, - magicTeeAdapter: this.magicTeeAdapter, config: this.config, multiRollupApiClients: this.multiRollupApiClients, guardianClient: this.guardianClient, + ethSigner: this.magicTEESigner, user, }); @@ -320,16 +312,7 @@ export class Passport { */ public async logout(): Promise { return withMetricsAsync(async () => { - if (this.config.oidcConfiguration.logoutMode === 'silent') { - await Promise.allSettled([ - this.authManager.logout(), - this.magicAdapter.logout(), - ]); - } else { - // We need to ensure that the Magic wallet is logged out BEFORE redirecting - await this.magicAdapter.logout(); - await this.authManager.logout(); - } + await this.authManager.logout(); this.passportEventEmitter.emit(PassportEvents.LOGGED_OUT); }, 'logout'); } @@ -341,7 +324,6 @@ export class Passport { public async getLogoutUrl(): Promise { return withMetricsAsync(async () => { await this.authManager.removeUser(); - await this.magicAdapter.logout(); this.passportEventEmitter.emit(PassportEvents.LOGGED_OUT); return await this.authManager.getLogoutUrl(); }, 'getLogoutUrl'); diff --git a/packages/passport/sdk/src/config/config.ts b/packages/passport/sdk/src/config/config.ts index 907bf09a3c..9d004d4092 100644 --- a/packages/passport/sdk/src/config/config.ts +++ b/packages/passport/sdk/src/config/config.ts @@ -38,6 +38,10 @@ export class PassportConfiguration { readonly magicProviderId: string; + readonly magicTeeBasePath: string = 'https://tee.express.magiclabs.com'; + + readonly magicTeeTimeout: number = 6000; + readonly oidcConfiguration: OidcConfiguration; readonly baseConfig: ImmutableConfiguration; diff --git a/packages/passport/sdk/src/magic/index.ts b/packages/passport/sdk/src/magic/index.ts index 0ba3c48096..6817ef70cf 100644 --- a/packages/passport/sdk/src/magic/index.ts +++ b/packages/passport/sdk/src/magic/index.ts @@ -1,3 +1 @@ -import MagicAdapter from './magicAdapter'; - -export default { MagicAdapter }; +export { default as MagicTEESigner } from './magicTEESigner'; diff --git a/packages/passport/sdk/src/magic/magicAdapter.test.ts b/packages/passport/sdk/src/magic/magicAdapter.test.ts deleted file mode 100644 index 39a1d05d22..0000000000 --- a/packages/passport/sdk/src/magic/magicAdapter.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { LoginWithOpenIdParams, OpenIdExtension } from '@magic-ext/oidc'; -import { Magic } from 'magic-sdk'; -import MagicAdapter from './magicAdapter'; -import { PassportConfiguration } from '../config'; -import { PassportError, PassportErrorType } from '../errors/passportError'; -import { MagicProviderProxyFactory } from './magicProviderProxyFactory'; - -const loginWithOIDCMock:jest.MockedFunction<(args: LoginWithOpenIdParams) => Promise> = jest.fn(); - -const rpcProvider = {}; - -const logoutMock = jest.fn(); - -jest.mock('magic-sdk'); -jest.mock('@magic-ext/oidc', () => ({ - OpenIdExtension: jest.fn(), -})); - -describe('MagicWallet', () => { - const apiKey = 'pk_live_A7D9211D7547A338'; - const providerId = 'mPGZAvZsFkyfT6OWfML1HgTKjPqYOPkhhOj-8qCGeqI='; - const config = { - magicPublishableApiKey: apiKey, - magicProviderId: providerId, - } as PassportConfiguration; - const magicProviderProxyFactory = { - createProxy: jest.fn(), - } as unknown as MagicProviderProxyFactory; - const idToken = 'e30=.e30=.e30='; - - beforeEach(() => { - jest.resetAllMocks(); - (Magic as jest.Mock).mockImplementation(() => ({ - openid: { - loginWithOIDC: loginWithOIDCMock, - }, - user: { - logout: logoutMock, - }, - rpcProvider, - })); - (magicProviderProxyFactory.createProxy as jest.Mock).mockImplementation(() => rpcProvider); - }); - - describe('constructor', () => { - describe('when window defined', () => { - let originalDocument: Document | undefined; - - beforeAll(() => { - originalDocument = window.document; - const mockDocument = { - ...window.document, - readyState: 'complete', - }; - (window as any).document = mockDocument; - }); - afterAll(() => { - (window as any).document = originalDocument; - }); - it('starts initialising the magicClient', () => { - jest.spyOn(window.document, 'readyState', 'get').mockReturnValue('complete'); - const magicAdapter = new MagicAdapter(config, magicProviderProxyFactory); - // @ts-ignore - expect(magicAdapter.magicClient).toBeDefined(); - }); - }); - - describe('when window is undefined', () => { - const { window } = global; - beforeAll(() => { - // @ts-expect-error - delete global.window; - }); - afterAll(() => { - global.window = window; - }); - - it('does nothing', () => { - const magicAdapter = new MagicAdapter(config, magicProviderProxyFactory); - // @ts-ignore - expect(magicAdapter.magicClientPromise).toBeUndefined(); - }); - }); - }); - - describe('login', () => { - it('should call loginWithOIDC and initialise the provider with the correct arguments', async () => { - const magicAdapter = new MagicAdapter(config, magicProviderProxyFactory); - const magicProvider = await magicAdapter.login(idToken); - - expect(Magic).toHaveBeenCalledWith(apiKey, { - network: 'mainnet', - extensions: [new OpenIdExtension()], - }); - - expect(loginWithOIDCMock).toHaveBeenCalledWith({ - jwt: idToken, - providerId, - }); - - expect(magicProviderProxyFactory.createProxy).toHaveBeenCalled(); - expect(magicProvider).toEqual(rpcProvider); - }); - - it('should throw a PassportError when an error is thrown', async () => { - const magicAdapter = new MagicAdapter(config, magicProviderProxyFactory); - - loginWithOIDCMock.mockImplementation(() => { - throw new Error('oops'); - }); - - await expect(async () => { - await magicAdapter.login(idToken); - }).rejects.toThrow( - new PassportError( - 'oops', - PassportErrorType.WALLET_CONNECTION_ERROR, - ), - ); - }); - }); - - describe('logout', () => { - it('calls the logout function', async () => { - const magicAdapter = new MagicAdapter(config, magicProviderProxyFactory); - await magicAdapter.login(idToken); - await magicAdapter.logout(); - - expect(logoutMock).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/passport/sdk/src/magic/magicAdapter.ts b/packages/passport/sdk/src/magic/magicAdapter.ts deleted file mode 100644 index ba4054c9be..0000000000 --- a/packages/passport/sdk/src/magic/magicAdapter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Magic } from 'magic-sdk'; -import { OpenIdExtension } from '@magic-ext/oidc'; -import { Flow, trackDuration } from '@imtbl/metrics'; -import { Eip1193Provider } from 'ethers'; -import { PassportErrorType, withPassportError } from '../errors/passportError'; -import { PassportConfiguration } from '../config'; -import { withMetricsAsync } from '../utils/metrics'; -import { MagicProviderProxyFactory } from './magicProviderProxyFactory'; -import { MagicClient } from './types'; - -const MAINNET = 'mainnet'; - -export default class MagicAdapter { - private readonly config: PassportConfiguration; - - private readonly magicProviderProxyFactory: MagicProviderProxyFactory; - - private readonly magicClient?: MagicClient; - - constructor(config: PassportConfiguration, magicProviderProxyFactory: MagicProviderProxyFactory) { - this.config = config; - this.magicProviderProxyFactory = magicProviderProxyFactory; - - if (typeof window !== 'undefined') { - this.magicClient = new Magic(this.config.magicPublishableApiKey, { - extensions: [new OpenIdExtension()], - network: MAINNET, // We always connect to mainnet to ensure addresses are the same across envs - }); - } - } - - private getMagicClient(): MagicClient { - if (!this.magicClient) { - throw new Error('Cannot perform this action outside of the browser'); - } - - return this.magicClient; - } - - async login( - idToken: string, - ): Promise { - return withPassportError(async () => ( - withMetricsAsync(async (flow: Flow) => { - const startTime = performance.now(); - - const magicClient = this.getMagicClient(); - flow.addEvent('endMagicClientInit'); - - await magicClient.openid.loginWithOIDC({ - jwt: idToken, - providerId: this.config.magicProviderId, - }); - flow.addEvent('endLoginWithOIDC'); - - trackDuration( - 'passport', - flow.details.flowName, - Math.round(performance.now() - startTime), - ); - - return this.magicProviderProxyFactory.createProxy(magicClient); - }, 'magicLogin') - ), PassportErrorType.WALLET_CONNECTION_ERROR); - } - - async logout() { - const magicClient = this.getMagicClient(); - if (magicClient.user) { - await magicClient.user.logout(); - } - } -} diff --git a/packages/passport/sdk/src/magic/magicProviderProxyFactory.test.ts b/packages/passport/sdk/src/magic/magicProviderProxyFactory.test.ts deleted file mode 100644 index ae203eb924..0000000000 --- a/packages/passport/sdk/src/magic/magicProviderProxyFactory.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { Eip1193Provider } from 'ethers'; -import { MagicProviderProxyFactory } from './magicProviderProxyFactory'; -import AuthManager from '../authManager'; -import { PassportConfiguration } from '../config'; -import { MagicClient } from './types'; - -describe('MagicProviderProxyFactory', () => { - let mockAuthManager: jest.Mocked; - let mockConfig: PassportConfiguration; - let mockMagicClient: jest.Mocked; - let mockRpcProvider: jest.Mocked; - let factory: MagicProviderProxyFactory; - - beforeEach(() => { - mockAuthManager = { - getUser: jest.fn(), - } as any; - - mockConfig = { - magicProviderId: 'test-provider-id', - } as PassportConfiguration; - - mockRpcProvider = { - request: jest.fn(), - } as any; - - mockMagicClient = { - rpcProvider: mockRpcProvider, - user: { - isLoggedIn: jest.fn(), - }, - openid: { - loginWithOIDC: jest.fn(), - }, - } as any; - - factory = new MagicProviderProxyFactory(mockAuthManager, mockConfig); - }); - - describe('createProxy', () => { - it('should create a proxy that passes through non-authenticated requests', async () => { - const proxy = factory.createProxy(mockMagicClient); - const params = { method: 'eth_blockNumber' }; - - await proxy.request!(params); - - expect(mockRpcProvider.request).toHaveBeenCalledWith(params); - expect(mockMagicClient.user.isLoggedIn).not.toHaveBeenCalled(); - }); - - it('should check authentication for personal_sign requests', async () => { - const proxy = factory.createProxy(mockMagicClient); - const params = { method: 'personal_sign', params: ['message', 'address'] }; - (mockMagicClient.user.isLoggedIn as jest.Mock).mockResolvedValue(true); - - await proxy.request!(params); - - expect(mockMagicClient.user.isLoggedIn).toHaveBeenCalled(); - expect(mockRpcProvider.request).toHaveBeenCalledWith(params); - }); - - it('should check authentication for eth_accounts requests', async () => { - const proxy = factory.createProxy(mockMagicClient); - const params = { method: 'eth_accounts' }; - (mockMagicClient.user.isLoggedIn as jest.Mock).mockResolvedValue(true); - - await proxy.request!(params); - - expect(mockMagicClient.user.isLoggedIn).toHaveBeenCalled(); - expect(mockRpcProvider.request).toHaveBeenCalledWith(params); - }); - - it('should re-authenticate when user is not logged in', async () => { - const proxy = factory.createProxy(mockMagicClient); - const params = { method: 'personal_sign', params: ['message', 'address'] }; - const mockIdToken = 'mock-id-token'; - - (mockMagicClient.user.isLoggedIn as jest.Mock).mockResolvedValue(false); - (mockAuthManager.getUser as jest.Mock).mockResolvedValue({ idToken: mockIdToken }); - - await proxy.request!(params); - - expect(mockMagicClient.user.isLoggedIn).toHaveBeenCalled(); - expect(mockAuthManager.getUser).toHaveBeenCalled(); - expect(mockMagicClient.openid.loginWithOIDC).toHaveBeenCalledWith({ - jwt: mockIdToken, - providerId: mockConfig.magicProviderId, - }); - expect(mockRpcProvider.request).toHaveBeenCalledWith(params); - }); - - it('should throw error when re-authentication fails due to missing ID token', async () => { - const proxy = factory.createProxy(mockMagicClient); - const params = { method: 'personal_sign', params: ['message', 'address'] }; - - (mockMagicClient.user.isLoggedIn as jest.Mock).mockResolvedValue(false); - (mockAuthManager.getUser as jest.Mock).mockResolvedValue(null); - - await expect(proxy.request!(params)).rejects.toThrow('ProviderProxy: failed to obtain ID token'); - }); - - it('should wrap errors with ProviderProxy prefix', async () => { - const proxy = factory.createProxy(mockMagicClient); - const params = { method: 'personal_sign', params: ['message', 'address'] }; - - (mockMagicClient.user.isLoggedIn as jest.Mock).mockRejectedValue(new Error('Test error')); - - await expect(proxy.request!(params)).rejects.toThrow('ProviderProxy: Test error'); - }); - - it('should convert eth_requestAccounts to eth_accounts to avoid Magic overlay', async () => { - const proxy = factory.createProxy(mockMagicClient); - const params = { method: 'eth_requestAccounts' }; - (mockMagicClient.user.isLoggedIn as jest.Mock).mockResolvedValue(true); - - await proxy.request!(params); - - expect(mockMagicClient.user.isLoggedIn).toHaveBeenCalled(); - expect(mockRpcProvider.request).toHaveBeenCalledWith({ method: 'eth_accounts' }); - expect(mockRpcProvider.request).not.toHaveBeenCalledWith(params); - }); - }); -}); diff --git a/packages/passport/sdk/src/magic/magicProviderProxyFactory.ts b/packages/passport/sdk/src/magic/magicProviderProxyFactory.ts deleted file mode 100644 index df18f0c0e1..0000000000 --- a/packages/passport/sdk/src/magic/magicProviderProxyFactory.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Eip1193Provider } from 'ethers'; -import AuthManager from '../authManager'; -import { PassportConfiguration } from '../config'; -import { MagicClient } from './types'; - -const shouldCheckMagicSession = (args: any[]): boolean => ( - args?.length > 0 - && typeof args[0] === 'object' - && 'method' in args[0] - && typeof args[0].method === 'string' - && ['personal_sign', 'eth_accounts', 'eth_requestAccounts'].includes(args[0].method) -); - -const isEthRequestAccountsCall = (args: any[]): boolean => ( - args?.length > 0 - && typeof args[0] === 'object' - && 'method' in args[0] - && typeof args[0].method === 'string' - && args[0].method === 'eth_requestAccounts' -); - -/** - * Factory class for creating a Magic provider that automatically handles re-authentication. - * This proxy wraps the Magic RPC provider to intercept certain RPC methods (`personal_sign`, `eth_accounts`) - * and ensures the user is properly authenticated before executing them. - */ -export class MagicProviderProxyFactory { - private authManager: AuthManager; - - private config: PassportConfiguration; - - constructor(authManager: AuthManager, config: PassportConfiguration) { - this.authManager = authManager; - this.config = config; - } - - createProxy(magicClient: MagicClient): Eip1193Provider { - const magicRpcProvider = magicClient.rpcProvider as unknown as Eip1193Provider; - - const proxyHandler: ProxyHandler = { - get: (target: Eip1193Provider, property: string, receiver: any) => { - if (property === 'request') { - return async (...args: any[]) => { - try { - if (shouldCheckMagicSession(args)) { - const isUserLoggedIn = await magicClient.user.isLoggedIn(); - if (!isUserLoggedIn) { - const user = await this.authManager.getUser(); - const idToken = user?.idToken; - if (!idToken) { - throw new Error('failed to obtain ID token'); - } - await magicClient.openid.loginWithOIDC({ - jwt: idToken, - providerId: this.config.magicProviderId, - }); - } - - if (isEthRequestAccountsCall(args)) { - // @ts-ignore - Calling eth_requestAccounts on the Magic RPC provider displays an overlay, so this - // should be avoided - call eth_accounts instead. - return target.request!({ method: 'eth_accounts' }); - } - } - - // @ts-ignore - Invoke the request method with the provided arguments - return target.request!(...args); - } catch (error: unknown) { - if (error instanceof Error) { - throw new Error(`ProviderProxy: ${error.message}`); - } - throw new Error(`ProviderProxy: ${error}`); - } - }; - } - - // Return the property from the target - return Reflect.get(target, property, receiver); - }, - }; - - return new Proxy(magicRpcProvider, proxyHandler); - } -} diff --git a/packages/passport/sdk/src/magic/magicTeeAdapter.test.ts b/packages/passport/sdk/src/magic/magicTEESigner.test.ts similarity index 99% rename from packages/passport/sdk/src/magic/magicTeeAdapter.test.ts rename to packages/passport/sdk/src/magic/magicTEESigner.test.ts index 1cca37cc3c..52058a8d0c 100644 --- a/packages/passport/sdk/src/magic/magicTeeAdapter.test.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.test.ts @@ -4,7 +4,7 @@ import { isAxiosError } from 'axios'; import AuthManager from '../authManager'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { withMetricsAsync } from '../utils/metrics'; -import MagicTeeAdapter from './magicTeeAdapter'; +import MagicTeeAdapter from './magicTEESigner'; // Mock dependencies jest.mock('../utils/metrics'); diff --git a/packages/passport/sdk/src/magic/magicTeeAdapter.ts b/packages/passport/sdk/src/magic/magicTEESigner.ts similarity index 68% rename from packages/passport/sdk/src/magic/magicTeeAdapter.ts rename to packages/passport/sdk/src/magic/magicTEESigner.ts index 393cc44d8f..3119106cec 100644 --- a/packages/passport/sdk/src/magic/magicTeeAdapter.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.ts @@ -1,3 +1,4 @@ +import { AbstractSigner, Provider, Signer, TransactionRequest, TypedDataDomain, TypedDataField } from 'ethers'; import { MagicTeeApiClients } from '@imtbl/generated-clients'; import { isAxiosError } from 'axios'; import { Flow, trackDuration } from '@imtbl/metrics'; @@ -7,17 +8,39 @@ import { withMetricsAsync } from '../utils/metrics'; const CHAIN_IDENTIFIER = 'ETH'; -export default class MagicTeeAdapter { +export default class MagicTEESigner extends AbstractSigner { private readonly authManager: AuthManager; private readonly magicTeeApiClient: MagicTeeApiClients; + private walletAddress: string | null = null; + + private walletAddressPromise: Promise | null = null; + constructor(authManager: AuthManager, magicTeeApiClient: MagicTeeApiClients) { + super(); this.authManager = authManager; this.magicTeeApiClient = magicTeeApiClient; + this.createWallet() + .then((address) => { + this.walletAddress = address; + }) + .catch(() => { + this.walletAddress = null; + }); } - public async createWallet(): Promise { + public async getAddress(): Promise { + if (this.walletAddress) { + return this.walletAddress; + } else if (this.walletAddressPromise) { + return this.walletAddressPromise; + } + + return this.createWallet(); + } + + private async createWallet(): Promise { const headers = await this.getHeaders(); return withMetricsAsync(async (flow: Flow) => { @@ -38,7 +61,8 @@ export default class MagicTeeAdapter { Math.round(performance.now() - startTime), ); - return response.data.public_address; + this.walletAddress = response.data.public_address; + return this.walletAddress; } catch (error) { let errorMessage: string = 'Failed to create wallet'; @@ -53,11 +77,18 @@ export default class MagicTeeAdapter { } throw new Error(errorMessage); + } finally { + this.walletAddressPromise = null; } }, 'magicCreateWallet'); } - public async personalSign(message: string | Uint8Array): Promise { + public async signMessage(message: string | Uint8Array): Promise { + // Call getAddress to ensure that the wallet has been created + if (!this.walletAddress) { + await this.getAddress(); + } + const messageToSign = message instanceof Uint8Array ? `0x${Buffer.from(message).toString('hex')}` : message; const headers = await this.getHeaders(); @@ -104,9 +135,18 @@ export default class MagicTeeAdapter { PassportErrorType.NOT_LOGGED_IN_ERROR, ); } - return { Authorization: `Bearer ${user.idToken}`, }; } + + connect(provider: null | Provider): Signer { + throw new Error('Method not implemented.'); + } + signTransaction(tx: TransactionRequest): Promise { + throw new Error('Method not implemented.'); + } + signTypedData(domain: TypedDataDomain, types: Record>, value: Record): Promise { + throw new Error('Method not implemented.'); + } } diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.ts index ad7bc920d1..900d0a9d1a 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.ts @@ -14,35 +14,34 @@ import { imx, ImxApiClients, } from '@imtbl/generated-clients'; -import { BrowserProvider, TransactionResponse } from 'ethers'; +import { TransactionResponse } from 'ethers'; import TypedEventEmitter from '../utils/typedEventEmitter'; import AuthManager from '../authManager'; import GuardianClient from '../guardian'; import { - PassportEventMap, PassportEvents, UserImx, User, IMXSigners, isUserImx, + PassportEventMap, PassportEvents, UserImx, User, isUserImx, } from '../types'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { batchNftTransfer, cancelOrder, createOrder, createTrade, exchangeTransfer, transfer, } from './workflows'; import registerOffchain from './workflows/registerOffchain'; -import MagicAdapter from '../magic/magicAdapter'; import { getStarkSigner } from './getStarkSigner'; import { withMetricsAsync } from '../utils/metrics'; +import MagicTEESigner from '../magic/magicTEESigner'; export interface PassportImxProviderOptions { authManager: AuthManager; immutableXClient: IMXClient; passportEventEmitter: TypedEventEmitter; - magicAdapter: MagicAdapter; + magicTEESigner: MagicTEESigner; imxApiClients: ImxApiClients; guardianClient: GuardianClient; } -type RegisteredUserAndSigners = { +type RegisteredUserAndStarkSigner = { user: UserImx; starkSigner: StarkSigner; - ethSigner: EthSigner; }; export class PassportImxProvider implements IMXProvider { @@ -54,7 +53,7 @@ export class PassportImxProvider implements IMXProvider { protected readonly imxApiClients: ImxApiClients; - protected magicAdapter: MagicAdapter; + protected magicTEESigner: MagicTEESigner; /** * This property is set during initialisation and stores the signers in a promise. @@ -62,7 +61,7 @@ export class PassportImxProvider implements IMXProvider { * `#getSigners` method. * @see #getSigners */ - private signers: Promise | undefined; + private starkSigner: Promise | undefined; private signerInitialisationError: unknown | undefined; @@ -70,22 +69,22 @@ export class PassportImxProvider implements IMXProvider { authManager, immutableXClient, passportEventEmitter, - magicAdapter, + magicTEESigner, imxApiClients, guardianClient, }: PassportImxProviderOptions) { this.authManager = authManager; this.immutableXClient = immutableXClient; - this.magicAdapter = magicAdapter; + this.magicTEESigner = magicTEESigner; this.imxApiClients = imxApiClients; this.guardianClient = guardianClient; - this.#initialiseSigners(); + this.#initialiseSigner(); passportEventEmitter.on(PassportEvents.LOGGED_OUT, this.handleLogout); } private handleLogout = (): void => { - this.signers = undefined; + this.starkSigner = undefined; }; /** @@ -100,21 +99,13 @@ export class PassportImxProvider implements IMXProvider { * @see #getSigners * */ - #initialiseSigners() { - const generateSigners = async (): Promise => { - const user = await this.authManager.getUser(); - // The user will be present because the factory validates it - const magicRpcProvider = await this.magicAdapter.login(user!.idToken!); - const browserProvider = new BrowserProvider(magicRpcProvider); - - const ethSigner = await browserProvider.getSigner(); - const starkSigner = await getStarkSigner(ethSigner); - - return { ethSigner, starkSigner }; + #initialiseSigner() { + const generateSigners = async (): Promise => { + return getStarkSigner(this.magicTEESigner); }; // eslint-disable-next-line no-async-promise-executor - this.signers = new Promise(async (resolve) => { + this.starkSigner = new Promise(async (resolve) => { try { resolve(await generateSigners()); } catch (err) { @@ -128,7 +119,7 @@ export class PassportImxProvider implements IMXProvider { async #getAuthenticatedUser(): Promise { const user = await this.authManager.getUser(); - if (!user || !this.signers) { + if (!user || !this.starkSigner) { throw new PassportError( 'User has been logged out', PassportErrorType.NOT_LOGGED_IN_ERROR, @@ -138,10 +129,10 @@ export class PassportImxProvider implements IMXProvider { return user; } - async #getSigners(): Promise { - const signers = await this.signers; + async #getStarkSigner(): Promise { + const signer = await this.starkSigner; // Throw the stored error if the signers failed to initialise - if (typeof signers === 'undefined') { + if (typeof signer === 'undefined') { if (typeof this.signerInitialisationError !== 'undefined') { // eslint-disable-next-line @typescript-eslint/no-throw-literal throw this.signerInitialisationError; @@ -149,13 +140,13 @@ export class PassportImxProvider implements IMXProvider { throw new Error('Signers failed to initialise'); } - return signers; + return signer; } - async #getRegisteredImxUserAndSigners(): Promise { - const [user, signers] = await Promise.all([ + async #getRegisteredImxUserAndStarkSigner(): Promise { + const [user, starkSigner] = await Promise.all([ this.#getAuthenticatedUser(), - this.#getSigners(), + this.#getStarkSigner(), ]); if (!isUserImx(user)) { @@ -167,15 +158,14 @@ export class PassportImxProvider implements IMXProvider { return { user, - starkSigner: signers.starkSigner, - ethSigner: signers.ethSigner, + starkSigner, }; } async transfer(request: UnsignedTransferRequest): Promise { return withMetricsAsync(() => this.guardianClient.withDefaultConfirmationScreenTask( async () => { - const { user, starkSigner } = await this.#getRegisteredImxUserAndSigners(); + const { user, starkSigner } = await this.#getRegisteredImxUserAndStarkSigner(); return transfer({ request, @@ -191,14 +181,14 @@ export class PassportImxProvider implements IMXProvider { async registerOffchain(): Promise { return withMetricsAsync( async () => { - const [user, signers] = await Promise.all([ + const [user, starkSigner] = await Promise.all([ this.#getAuthenticatedUser(), - this.#getSigners(), + this.#getStarkSigner(), ]); return await registerOffchain( - signers.ethSigner, - signers.starkSigner, + this.magicTEESigner, + starkSigner, user, this.authManager, this.imxApiClients, @@ -229,7 +219,7 @@ export class PassportImxProvider implements IMXProvider { async createOrder(request: UnsignedOrderRequest): Promise { return withMetricsAsync(() => this.guardianClient.withDefaultConfirmationScreenTask( async () => { - const { user, starkSigner } = await this.#getRegisteredImxUserAndSigners(); + const { user, starkSigner } = await this.#getRegisteredImxUserAndStarkSigner(); return createOrder({ request, user, @@ -246,7 +236,7 @@ export class PassportImxProvider implements IMXProvider { ): Promise { return withMetricsAsync(() => this.guardianClient.withDefaultConfirmationScreenTask( async () => { - const { user, starkSigner } = await this.#getRegisteredImxUserAndSigners(); + const { user, starkSigner } = await this.#getRegisteredImxUserAndStarkSigner(); return cancelOrder({ request, @@ -262,7 +252,7 @@ export class PassportImxProvider implements IMXProvider { async createTrade(request: imx.GetSignableTradeRequest): Promise { return withMetricsAsync(() => this.guardianClient.withDefaultConfirmationScreenTask( async () => { - const { user, starkSigner } = await this.#getRegisteredImxUserAndSigners(); + const { user, starkSigner } = await this.#getRegisteredImxUserAndStarkSigner(); return createTrade({ request, @@ -281,11 +271,12 @@ export class PassportImxProvider implements IMXProvider { return withMetricsAsync(() => this.guardianClient.withConfirmationScreenTask( { width: 480, height: 784 }, )(async () => { - const { user, starkSigner } = await this.#getRegisteredImxUserAndSigners(); + const { user, starkSigner } = await this.#getRegisteredImxUserAndStarkSigner(); return batchNftTransfer({ request, user, + starkSigner, transfersApi: this.immutableXClient.transfersApi, guardianClient: this.guardianClient, @@ -297,7 +288,7 @@ export class PassportImxProvider implements IMXProvider { request: UnsignedExchangeTransferRequest, ): Promise { return withMetricsAsync(async () => { - const { user, starkSigner } = await this.#getRegisteredImxUserAndSigners(); + const { user, starkSigner } = await this.#getRegisteredImxUserAndStarkSigner(); return exchangeTransfer({ request, diff --git a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts index dcbd56d705..5ef3654678 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts @@ -3,16 +3,16 @@ import { IMXProvider } from '@imtbl/x-provider'; import { ImxApiClients } from '@imtbl/generated-clients'; import { PassportError, PassportErrorType } from '../errors/passportError'; import AuthManager from '../authManager'; -import MagicAdapter from '../magic/magicAdapter'; import { PassportEventMap, User } from '../types'; import TypedEventEmitter from '../utils/typedEventEmitter'; import { PassportImxProvider } from './passportImxProvider'; import GuardianClient from '../guardian'; +import MagicTEESigner from '../magic/magicTEESigner'; export type PassportImxProviderFactoryInput = { authManager: AuthManager; immutableXClient: IMXClient; - magicAdapter: MagicAdapter; + magicTEESigner: MagicTEESigner; passportEventEmitter: TypedEventEmitter; imxApiClients: ImxApiClients; guardianClient: GuardianClient; @@ -23,7 +23,7 @@ export class PassportImxProviderFactory { private readonly immutableXClient: IMXClient; - private readonly magicAdapter: MagicAdapter; + private readonly magicTEESigner: MagicTEESigner; private readonly passportEventEmitter: TypedEventEmitter; @@ -34,14 +34,14 @@ export class PassportImxProviderFactory { constructor({ authManager, immutableXClient, - magicAdapter, + magicTEESigner, passportEventEmitter, imxApiClients, guardianClient, }: PassportImxProviderFactoryInput) { this.authManager = authManager; this.immutableXClient = immutableXClient; - this.magicAdapter = magicAdapter; + this.magicTEESigner = magicTEESigner; this.passportEventEmitter = passportEventEmitter; this.imxApiClients = imxApiClients; this.guardianClient = guardianClient; @@ -73,7 +73,7 @@ export class PassportImxProviderFactory { authManager: this.authManager, immutableXClient: this.immutableXClient, passportEventEmitter: this.passportEventEmitter, - magicAdapter: this.magicAdapter, + magicTEESigner: this.magicTEESigner, imxApiClients: this.imxApiClients, guardianClient: this.guardianClient, }); diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index 74dfd18e90..9d4b493c64 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -154,11 +154,6 @@ export type PKCEData = { verifier: string; }; -export type IMXSigners = { - starkSigner: StarkSigner; - ethSigner: EthSigner; -}; - export type LinkWalletParams = { type: string; walletAddress: string; diff --git a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts index eb657c08df..d8fdf851a2 100644 --- a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts +++ b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts @@ -1,5 +1,5 @@ import { Flow } from '@imtbl/metrics'; -import { JsonRpcProvider, Signer } from 'ethers'; +import { JsonRpcProvider } from 'ethers'; import { RelayerClient } from './relayerClient'; import GuardianClient from '../guardian'; import { FeeOption, MetaTransaction, RelayerTransactionStatus } from './types'; @@ -7,6 +7,7 @@ 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, @@ -17,6 +18,11 @@ 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(); }); @@ -66,7 +72,10 @@ describe('transactionHelpers', () => { describe('prepareAndSignTransaction', () => { const chainId = 123n; const nonce = BigInt(5); - const zkEvmAddress = '0x1234567890123456789012345678901234567890'; + const zkEvmAddresses = { + ethAddress: '0x1234567890123456789012345678901234567890', + userAdminAddress: '0x4567890123456789012345678901234567890123', + }; const transactionRequest = { to: '0x1234567890123456789012345678901234567890', data: '0x456', @@ -107,15 +116,12 @@ describe('transactionHelpers', () => { validateEVMTransaction: jest.fn().mockResolvedValue(undefined), } as unknown as GuardianClient; - const ethSigner = {} as Signer; - beforeEach(() => { jest.resetAllMocks(); jest.spyOn(walletHelpers, 'signMetaTransactions').mockResolvedValue(signedTransactions); jest.spyOn(walletHelpers, 'getNonce').mockResolvedValue(nonce); - jest.spyOn(walletHelpers, 'getNormalisedTransactions').mockReturnValue(metaTransactions as any); jest.spyOn(walletHelpers, 'encodedTransactions').mockReturnValue('encodedTransactions123'); - jest.spyOn(rpcProvider, 'getNetwork').mockResolvedValue({ chainId } as any); + (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); @@ -124,11 +130,11 @@ describe('transactionHelpers', () => { it('prepares and signs transaction correctly', async () => { const result = await prepareAndSignTransaction({ transactionRequest, - ethSigner, + magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddresses, flow, }); @@ -141,7 +147,7 @@ describe('transactionHelpers', () => { expect(rpcProvider.getNetwork).toHaveBeenCalled(); expect(guardianClient.validateEVMTransaction).toHaveBeenCalled(); expect(walletHelpers.signMetaTransactions).toHaveBeenCalled(); - expect(relayerClient.ethSendTransaction).toHaveBeenCalledWith(zkEvmAddress, signedTransactions); + expect(relayerClient.ethSendTransaction).toHaveBeenCalledWith(zkEvmAddresses.ethAddress, signedTransactions); expect(flow.addEvent).toHaveBeenCalledWith('endDetectNetwork'); expect(flow.addEvent).toHaveBeenCalledWith('endBuildMetaTransactions'); expect(flow.addEvent).toHaveBeenCalledWith('endValidateEVMTransaction'); @@ -155,11 +161,11 @@ describe('transactionHelpers', () => { await prepareAndSignTransaction({ transactionRequest, - ethSigner, + magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddresses, flow, }); @@ -190,11 +196,11 @@ describe('transactionHelpers', () => { await prepareAndSignTransaction({ transactionRequest, - ethSigner, + magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddresses, flow, }); @@ -236,8 +242,8 @@ describe('transactionHelpers', () => { ]), expect.any(BigInt), expect.any(BigInt), - zkEvmAddress, - ethSigner, + zkEvmAddresses.ethAddress, + magicTeeAdapter, ); }); @@ -246,11 +252,11 @@ describe('transactionHelpers', () => { const result = await prepareAndSignTransaction({ transactionRequest, - ethSigner, + magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddresses, flow, }); @@ -266,11 +272,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - ethSigner, + magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddresses, flow, })).rejects.toThrow('Validation failed'); @@ -284,11 +290,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - ethSigner, + magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddresses, flow, })).rejects.toThrow('Signing failed'); }); @@ -298,11 +304,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - ethSigner, + magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddresses, flow, })).rejects.toThrow('Transaction send failed'); }); @@ -318,8 +324,10 @@ describe('transactionHelpers', () => { chainId, }; - const zkEvmAddress = '0x1234567890123456789012345678901234567890'; - const ethSigner = {} as Signer; + const zkEvmAddresses = { + ethAddress: '0x1234567890123456789012345678901234567890', + userAdminAddress: '0x4567890123456789012345678901234567890123', + }; const signedTransactions = 'signedTransactions123'; beforeEach(() => { @@ -334,15 +342,15 @@ describe('transactionHelpers', () => { ...transactionRequest, nonce: 0, }, - ethSigner, - zkEvmAddress, + magicTeeAdapter, + zkEvmAddresses, flow, }); expect(result).toEqual({ chainId: 'eip155:123', data: signedTransactions, - to: zkEvmAddress, + to: zkEvmAddresses.ethAddress, }); }); }); From b1a6a204a7753a2df9dc5b8c32581a15856c51b5 Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Mon, 14 Jul 2025 13:15:00 +1000 Subject: [PATCH 03/11] WIP --- packages/passport/sdk/src/Passport.ts | 7 +- .../passport/sdk/src/magic/magicTEESigner.ts | 179 ++++++++++++------ .../src/starkEx/passportImxProvider.test.ts | 4 +- .../sdk/src/starkEx/passportImxProvider.ts | 10 +- .../src/starkEx/passportImxProviderFactory.ts | 7 +- packages/passport/sdk/src/types.ts | 22 ++- .../sdk/src/zkEvm/zkEvmProvider.test.ts | 4 +- .../passport/sdk/src/zkEvm/zkEvmProvider.ts | 68 +++---- 8 files changed, 183 insertions(+), 118 deletions(-) diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index b75a1b8d29..eef8e8b23f 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -19,6 +19,7 @@ import { isUserZkEvm, LinkedWallet, LinkWalletParams, + PassportEventEmitter, PassportEventMap, PassportEvents, PassportModuleConfiguration, @@ -54,6 +55,7 @@ const buildImxApiClients = (passportModuleConfiguration: PassportModuleConfigura export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConfiguration) => { const config = new PassportConfiguration(passportModuleConfiguration); + const passportEventEmitter = new TypedEventEmitter(); const authManager = new AuthManager(config); const confirmationScreen = new ConfirmationScreen(config); const magicTeeApiClients = new MagicTeeApiClients({ @@ -62,9 +64,8 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf magicPublishableApiKey: config.magicPublishableApiKey, magicProviderId: config.magicProviderId, }); - const magicTEESigner = new MagicTEESigner(authManager, magicTeeApiClients); + const magicTEESigner = new MagicTEESigner(authManager, magicTeeApiClients, passportEventEmitter); const multiRollupApiClients = new MultiRollupApiClients(config.multiRollupConfig); - const passportEventEmitter = new TypedEventEmitter(); const immutableXClient = passportModuleConfiguration.overrides ? passportModuleConfiguration.overrides.immutableXClient @@ -116,7 +117,7 @@ export class Passport { private readonly passportImxProviderFactory: PassportImxProviderFactory; - private readonly passportEventEmitter: TypedEventEmitter; + private readonly passportEventEmitter: PassportEventEmitter; private readonly guardianClient: GuardianClient; diff --git a/packages/passport/sdk/src/magic/magicTEESigner.ts b/packages/passport/sdk/src/magic/magicTEESigner.ts index 3119106cec..71adffc611 100644 --- a/packages/passport/sdk/src/magic/magicTEESigner.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.ts @@ -1,93 +1,156 @@ -import { AbstractSigner, Provider, Signer, TransactionRequest, TypedDataDomain, TypedDataField } from 'ethers'; +import { + AbstractSigner, Provider, Signer, TransactionRequest, TypedDataDomain, TypedDataField, +} 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 { withMetricsAsync } from '../utils/metrics'; +import { PassportEventEmitter, PassportEvents, RollupType } from '../types'; const CHAIN_IDENTIFIER = 'ETH'; +interface UserWallet { + userIdentifier: string; + walletAddress: string; +} + export default class MagicTEESigner extends AbstractSigner { private readonly authManager: AuthManager; private readonly magicTeeApiClient: MagicTeeApiClients; - private walletAddress: string | null = null; + private userWallet: UserWallet | null = null; - private walletAddressPromise: Promise | null = null; - - constructor(authManager: AuthManager, magicTeeApiClient: MagicTeeApiClients) { + constructor(authManager: AuthManager, magicTeeApiClient: MagicTeeApiClients, passportEventEmitter: PassportEventEmitter) { super(); this.authManager = authManager; this.magicTeeApiClient = magicTeeApiClient; - this.createWallet() - .then((address) => { - this.walletAddress = address; - }) - .catch(() => { - this.walletAddress = null; - }); + passportEventEmitter.on(PassportEvents.LOGGED_IN, this.createWallet.bind(this)); + passportEventEmitter.on(PassportEvents.LOGGED_OUT, () => { + this.userWallet = null; + }); + + // Attempt to initialise the wallet and fail silently as the user may not be logged in + this.createWallet().catch(() => {}); } - public async getAddress(): Promise { - if (this.walletAddress) { - return this.walletAddress; - } else if (this.walletAddressPromise) { - return this.walletAddressPromise; + private async getUserWallet(): Promise { + let { userWallet } = this; + if (!userWallet) { + userWallet = await this.createWallet(); } - return this.createWallet(); - } + const user = await this.authManager.getUser(); + if (!user) { + throw new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ); + } - private async createWallet(): Promise { - const headers = await this.getHeaders(); + // Check if the user has changed since the last createWallet request was made. If so, initialise the new user's wallet. + if (user.profile.sub !== userWallet.userIdentifier) { + userWallet = await this.createWallet(); + } - return withMetricsAsync(async (flow: Flow) => { - try { - const startTime = performance.now(); - const response = await this.magicTeeApiClient.walletApi.createWalletV1WalletPost( - { - createWalletRequestModel: { - chain: CHAIN_IDENTIFIER, - }, - }, - { headers }, - ); + // Ensure that the wallet address returned by Magic matches the IMX user admin address in the user profile. + const imxUserAdminAddress = user[RollupType.IMX]?.userAdminAddress; + if (imxUserAdminAddress && imxUserAdminAddress !== userWallet.walletAddress) { + throw new PassportError( + `User admin address ${userWallet.walletAddress} does not match user IMX user profile address ${imxUserAdminAddress}`, + PassportErrorType.WALLET_CONNECTION_ERROR, + ); + } - trackDuration( - 'passport', - flow.details.flowName, - Math.round(performance.now() - startTime), - ); + // Ensure that the wallet address returned by Magic matches the zkEvm user admin address in the user profile + const zkEvmUserAdminAddress = user[RollupType.ZKEVM]?.userAdminAddress; + if (zkEvmUserAdminAddress && zkEvmUserAdminAddress !== userWallet.walletAddress) { + throw new PassportError( + `User admin address ${userWallet.walletAddress} does not match user zkEVM user profile address ${zkEvmUserAdminAddress}`, + PassportErrorType.WALLET_CONNECTION_ERROR, + ); + } - this.walletAddress = response.data.public_address; - return this.walletAddress; - } catch (error) { - let errorMessage: string = 'Failed to create wallet'; + return userWallet; + } - if (isAxiosError(error)) { - if (error.response) { - errorMessage += ` with status ${error.response.status}: ${JSON.stringify(error.response.data)}`; - } else { - errorMessage += `: ${error.message}`; + private createWalletPromise: Promise | null = null; + + // This method calls the createWallet endpoint. The user's wallet must be created before it can be used to sign messages. + // The createWallet endpoint is idempotent, so it can be called multiple times without causing an error. + private async createWallet(): Promise { + if (!this.createWalletPromise) { + this.createWalletPromise = new Promise(async (resolve, reject) => { + try { + this.userWallet = null; + + const user = await this.authManager.getUser(); + if (!user) { + return reject(new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + )); } - } else { - errorMessage += `: ${(error as Error).message}`; + const headers = await this.getHeaders(); + + return withMetricsAsync(async (flow: Flow) => { + try { + const startTime = performance.now(); + const response = await this.magicTeeApiClient.walletApi.createWalletV1WalletPost( + { + createWalletRequestModel: { + chain: CHAIN_IDENTIFIER, + }, + }, + { headers }, + ); + + trackDuration( + 'passport', + flow.details.flowName, + Math.round(performance.now() - startTime), + ); + + return resolve({ + userIdentifier: user.profile.sub, + walletAddress: response.data.public_address, + }); + } catch (error) { + let errorMessage: string = 'Failed to create wallet'; + + if (isAxiosError(error)) { + if (error.response) { + errorMessage += ` with status ${error.response.status}: ${JSON.stringify(error.response.data)}`; + } else { + errorMessage += `: ${error.message}`; + } + } else { + errorMessage += `: ${(error as Error).message}`; + } + + return reject(new Error(errorMessage)); + } + }, 'magicCreateWallet'); + } finally { + this.createWalletPromise = null; } + }); + } - throw new Error(errorMessage); - } finally { - this.walletAddressPromise = null; - } - }, 'magicCreateWallet'); + return this.createWalletPromise; + } + + public async getAddress(): Promise { + const userWallet = await this.getUserWallet(); + return userWallet.walletAddress; } public async signMessage(message: string | Uint8Array): Promise { - // Call getAddress to ensure that the wallet has been created - if (!this.walletAddress) { - await this.getAddress(); - } + // Call getUserWallet to ensure that the createWallet endpoint has been called at some point in the past. + // The createWallet endpoint must have been called once for each user at some point in the past before the user can sign messages. + await this.getUserWallet(); const messageToSign = message instanceof Uint8Array ? `0x${Buffer.from(message).toString('hex')}` : message; const headers = await this.getHeaders(); @@ -143,9 +206,11 @@ export default class MagicTEESigner extends AbstractSigner { connect(provider: null | Provider): Signer { throw new Error('Method not implemented.'); } + signTransaction(tx: TransactionRequest): Promise { throw new Error('Method not implemented.'); } + signTypedData(domain: TypedDataDomain, types: Record>, value: Record): Promise { throw new Error('Method not implemented.'); } diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts index 3df8cca227..a7fe61ef73 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts @@ -20,7 +20,7 @@ import { PassportImxProvider } from './passportImxProvider'; import { batchNftTransfer, cancelOrder, createOrder, createTrade, exchangeTransfer, transfer, } from './workflows'; -import { PassportEventMap, PassportEvents } from '../types'; +import { PassportEventEmitter, PassportEventMap, PassportEvents } from '../types'; import TypedEventEmitter from '../utils/typedEventEmitter'; import AuthManager from '../authManager'; import MagicAdapter from '../magic/magicAdapter'; @@ -77,7 +77,7 @@ describe('PassportImxProvider', () => { const getSignerMock = jest.fn(); - let passportEventEmitter: TypedEventEmitter; + let passportEventEmitter: PassportEventEmitter; const imxApiClients = new ImxApiClients({} as any); diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.ts index 900d0a9d1a..8cdb64e959 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.ts @@ -15,11 +15,11 @@ import { ImxApiClients, } from '@imtbl/generated-clients'; import { TransactionResponse } from 'ethers'; -import TypedEventEmitter from '../utils/typedEventEmitter'; import AuthManager from '../authManager'; import GuardianClient from '../guardian'; import { - PassportEventMap, PassportEvents, UserImx, User, isUserImx, + PassportEvents, UserImx, User, isUserImx, + PassportEventEmitter, } from '../types'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { @@ -33,7 +33,7 @@ import MagicTEESigner from '../magic/magicTEESigner'; export interface PassportImxProviderOptions { authManager: AuthManager; immutableXClient: IMXClient; - passportEventEmitter: TypedEventEmitter; + passportEventEmitter: PassportEventEmitter; magicTEESigner: MagicTEESigner; imxApiClients: ImxApiClients; guardianClient: GuardianClient; @@ -100,9 +100,7 @@ export class PassportImxProvider implements IMXProvider { * */ #initialiseSigner() { - const generateSigners = async (): Promise => { - return getStarkSigner(this.magicTEESigner); - }; + const generateSigners = async (): Promise => getStarkSigner(this.magicTEESigner); // eslint-disable-next-line no-async-promise-executor this.starkSigner = new Promise(async (resolve) => { diff --git a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts index 5ef3654678..40cdd5ffe2 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts @@ -3,8 +3,7 @@ import { IMXProvider } from '@imtbl/x-provider'; import { ImxApiClients } from '@imtbl/generated-clients'; import { PassportError, PassportErrorType } from '../errors/passportError'; import AuthManager from '../authManager'; -import { PassportEventMap, User } from '../types'; -import TypedEventEmitter from '../utils/typedEventEmitter'; +import { PassportEventEmitter, User } from '../types'; import { PassportImxProvider } from './passportImxProvider'; import GuardianClient from '../guardian'; import MagicTEESigner from '../magic/magicTEESigner'; @@ -13,7 +12,7 @@ export type PassportImxProviderFactoryInput = { authManager: AuthManager; immutableXClient: IMXClient; magicTEESigner: MagicTEESigner; - passportEventEmitter: TypedEventEmitter; + passportEventEmitter: PassportEventEmitter; imxApiClients: ImxApiClients; guardianClient: GuardianClient; }; @@ -25,7 +24,7 @@ export class PassportImxProviderFactory { private readonly magicTEESigner: MagicTEESigner; - private readonly passportEventEmitter: TypedEventEmitter; + private readonly passportEventEmitter: PassportEventEmitter; public readonly imxApiClients: ImxApiClients; diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index 9d4b493c64..7ad72ba12b 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -1,7 +1,8 @@ import { Environment, ModuleConfiguration } from '@imtbl/config'; -import { EthSigner, IMXClient, StarkSigner } from '@imtbl/x-client'; +import { IMXClient } from '@imtbl/x-client'; import { ImxApiClients } from '@imtbl/generated-clients'; import { Flow } from '@imtbl/metrics'; +import TypedEventEmitter from './utils/typedEventEmitter'; export enum PassportEvents { LOGGED_OUT = 'loggedOut', @@ -23,24 +24,31 @@ export interface PassportEventMap extends Record { [PassportEvents.ACCOUNTS_REQUESTED]: [AccountsRequestedEvent]; } +export type PassportEventEmitter = TypedEventEmitter; + export type UserProfile = { email?: string; nickname?: string; sub: string; }; +export enum RollupType { + IMX = 'imx', + ZKEVM = 'zkEvm', +} + export type User = { idToken?: string; accessToken: string; refreshToken?: string; profile: UserProfile; expired?: boolean; - imx?: { + [RollupType.IMX]?: { ethAddress: string; starkAddress: string; userAdminAddress: string; }; - zkEvm?: { + [RollupType.ZKEVM]?: { ethAddress: string; userAdminAddress: string; }; @@ -120,11 +128,11 @@ export interface PassportModuleConfiguration type WithRequired = T & { [P in K]-?: T[P] }; -export type UserImx = WithRequired; -export type UserZkEvm = WithRequired; +export type UserImx = WithRequired; +export type UserZkEvm = WithRequired; -export const isUserZkEvm = (user: User): user is UserZkEvm => !!user.zkEvm; -export const isUserImx = (user: User): user is UserImx => !!user.imx; +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; diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts index 460c09ee29..43fddbe9db 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts @@ -7,7 +7,7 @@ import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './JsonRpcError'; import GuardianClient from '../guardian'; import { RelayerClient } from './relayerClient'; import { Provider, RequestArguments } from './types'; -import { PassportEventMap, PassportEvents } from '../types'; +import { PassportEventEmitter, PassportEventMap, PassportEvents } from '../types'; import TypedEventEmitter from '../utils/typedEventEmitter'; import { mockUser, mockUserZkEvm, testConfig } from '../test/mocks'; import { signTypedDataV4 } from './signTypedDataV4'; @@ -27,7 +27,7 @@ jest.mock('./signEjectionTransaction'); jest.mock('./signTypedDataV4'); describe('ZkEvmProvider', () => { - let passportEventEmitter: TypedEventEmitter; + let passportEventEmitter: PassportEventEmitter; const config = testConfig; const ethSigner = {}; const authManager = { diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts index cadf02ee95..7bcb046901 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts @@ -15,7 +15,7 @@ import AuthManager from '../authManager'; import TypedEventEmitter from '../utils/typedEventEmitter'; import { PassportConfiguration } from '../config'; import { - PassportEventMap, PassportEvents, User, UserZkEvm, + PassportEventEmitter, PassportEvents, User, UserZkEvm, } from '../types'; import { RelayerClient } from './relayerClient'; import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './JsonRpcError'; @@ -33,7 +33,7 @@ export type ZkEvmProviderInput = { authManager: AuthManager; config: PassportConfiguration; multiRollupApiClients: MultiRollupApiClients; - passportEventEmitter: TypedEventEmitter; + passportEventEmitter: PassportEventEmitter; guardianClient: GuardianClient; ethSigner: Signer; user: User | null; @@ -122,19 +122,17 @@ export class ZkEvmProvider implements Provider { // we can submit a session activity request per SCW in parallel without a SCW // INVALID_NONCE error. const nonceSpace: bigint = BigInt(1); - const sendTransactionClosure = async (params: Array, flow: Flow) => { - return await sendTransaction({ - params, - ethSigner: this.#ethSigner, - guardianClient: this.#guardianClient, - rpcProvider: this.#rpcProvider, - relayerClient: this.#relayerClient, - zkEvmAddress, - flow, - nonceSpace, - isBackgroundTransaction: true, - }); - }; + const sendTransactionClosure = async (params: Array, flow: Flow) => await sendTransaction({ + params, + ethSigner: this.#ethSigner, + guardianClient: this.#guardianClient, + rpcProvider: this.#rpcProvider, + relayerClient: this.#relayerClient, + zkEvmAddress, + flow, + nonceSpace, + isBackgroundTransaction: true, + }); this.#passportEventEmitter.emit(PassportEvents.ACCOUNTS_REQUESTED, { environment: this.#config.baseConfig.environment, sendTransaction: sendTransactionClosure, @@ -222,17 +220,15 @@ export class ZkEvmProvider implements Provider { return await this.#guardianClient.withConfirmationScreen({ width: 480, height: 720, - })(async () => { - return await sendTransaction({ - params: request.params || [], - ethSigner: this.#ethSigner, - guardianClient: this.#guardianClient, - rpcProvider: this.#rpcProvider, - relayerClient: this.#relayerClient, - zkEvmAddress, - flow, - }); - }); + })(async () => await sendTransaction({ + params: request.params || [], + ethSigner: this.#ethSigner, + guardianClient: this.#guardianClient, + rpcProvider: this.#rpcProvider, + relayerClient: this.#relayerClient, + zkEvmAddress, + flow, + })); } catch (error) { if (error instanceof Error) { trackError('passport', 'eth_sendTransaction', error, { flowId: flow.details.flowId }); @@ -319,17 +315,15 @@ export class ZkEvmProvider implements Provider { return await this.#guardianClient.withConfirmationScreen({ width: 480, height: 720, - })(async () => { - return await signTypedDataV4({ - method: request.method, - params: request.params || [], - ethSigner: this.#ethSigner, - rpcProvider: this.#rpcProvider, - relayerClient: this.#relayerClient, - guardianClient: this.#guardianClient, - flow, - }); - }); + })(async () => await signTypedDataV4({ + method: request.method, + params: request.params || [], + ethSigner: this.#ethSigner, + rpcProvider: this.#rpcProvider, + relayerClient: this.#relayerClient, + guardianClient: this.#guardianClient, + flow, + })); } catch (error) { if (error instanceof Error) { trackError('passport', 'eth_signTypedData', error, { flowId: flow.details.flowId }); From c43cbe93bb9b02b34fcb34ef2596d2d2d6db0e1b Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Mon, 14 Jul 2025 15:14:52 +1000 Subject: [PATCH 04/11] WIP --- packages/passport/sdk/src/Passport.test.ts | 10 +- packages/passport/sdk/src/Passport.ts | 5 +- .../sdk/src/magic/magicTEESigner.test.ts | 521 +++++++++--------- .../passport/sdk/src/magic/magicTEESigner.ts | 198 +++---- packages/passport/sdk/src/mocks/zkEvm/msw.ts | 21 + .../src/starkEx/passportImxProvider.test.ts | 28 +- .../sdk/src/starkEx/passportImxProvider.ts | 7 +- .../passportImxProviderFactory.test.ts | 8 +- .../src/starkEx/passportImxProviderFactory.ts | 7 +- packages/passport/sdk/src/types.ts | 2 - .../sdk/src/zkEvm/transactionHelpers.test.ts | 46 +- .../sdk/src/zkEvm/zkEvmProvider.test.ts | 59 +- .../passport/sdk/src/zkEvm/zkEvmProvider.ts | 4 +- 13 files changed, 468 insertions(+), 448 deletions(-) diff --git a/packages/passport/sdk/src/Passport.test.ts b/packages/passport/sdk/src/Passport.test.ts index 26ccc5a7b3..e82c822730 100644 --- a/packages/passport/sdk/src/Passport.test.ts +++ b/packages/passport/sdk/src/Passport.test.ts @@ -3,7 +3,7 @@ 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 MagicAdapter from './magic/magicAdapter'; +import MagicTEESigner from './magic/magicTEESigner'; import { Passport } from './Passport'; import { PassportImxProvider, PassportImxProviderFactory } from './starkEx'; import { OidcConfiguration, UserProfile } from './types'; @@ -21,7 +21,7 @@ import { ZkEvmProvider } from './zkEvm'; import { PassportError, PassportErrorType } from './errors/passportError'; jest.mock('./authManager'); -jest.mock('./magic/magicAdapter'); +jest.mock('./magic/magicTEESigner'); jest.mock('./starkEx'); jest.mock('./confirmation'); jest.mock('./zkEvm'); @@ -78,9 +78,9 @@ describe('Passport', () => { requestRefreshTokenAfterRegistration: requestRefreshTokenMock, forceUserRefresh: forceUserRefreshMock, }); - (MagicAdapter as jest.Mock).mockReturnValue({ - login: magicLoginMock, - logout: magicLogoutMock, + (MagicTEESigner as jest.Mock).mockReturnValue({ + getAddress: jest.fn().mockResolvedValue('0x123'), + signMessage: jest.fn().mockResolvedValue('signature'), }); (PassportImxProviderFactory as jest.Mock).mockReturnValue({ getProvider: getProviderMock, diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index eef8e8b23f..1001607370 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -19,7 +19,6 @@ import { isUserZkEvm, LinkedWallet, LinkWalletParams, - PassportEventEmitter, PassportEventMap, PassportEvents, PassportModuleConfiguration, @@ -64,7 +63,7 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf magicPublishableApiKey: config.magicPublishableApiKey, magicProviderId: config.magicProviderId, }); - const magicTEESigner = new MagicTEESigner(authManager, magicTeeApiClients, passportEventEmitter); + const magicTEESigner = new MagicTEESigner(authManager, magicTeeApiClients); const multiRollupApiClients = new MultiRollupApiClients(config.multiRollupConfig); const immutableXClient = passportModuleConfiguration.overrides @@ -117,7 +116,7 @@ export class Passport { private readonly passportImxProviderFactory: PassportImxProviderFactory; - private readonly passportEventEmitter: PassportEventEmitter; + private readonly passportEventEmitter: TypedEventEmitter; private readonly guardianClient: GuardianClient; diff --git a/packages/passport/sdk/src/magic/magicTEESigner.test.ts b/packages/passport/sdk/src/magic/magicTEESigner.test.ts index 52058a8d0c..3cbac06f4e 100644 --- a/packages/passport/sdk/src/magic/magicTEESigner.test.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.test.ts @@ -1,388 +1,381 @@ +import { AbstractSigner, Provider } from 'ethers'; 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 } from '../test/mocks'; import { withMetricsAsync } from '../utils/metrics'; -import MagicTeeAdapter from './magicTEESigner'; -// Mock dependencies -jest.mock('../utils/metrics'); +// Mock all dependencies jest.mock('@imtbl/metrics'); -jest.mock('axios', () => ({ - isAxiosError: jest.fn(), -})); - -describe('MagicTeeAdapter', () => { - let authManager: jest.Mocked; - let magicTeeApiClient: jest.Mocked; - let adapter: MagicTeeAdapter; - let mockCreateWallet: jest.Mock; - let mockPersonalSign: jest.Mock; - let mockIsAxiosError: jest.Mock; - - const mockUser = { - idToken: 'test-id-token', - accessToken: 'test-access-token', - profile: { - sub: 'test-user-id', - email: 'test@example.com', +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 mockSignMessageV1WalletPersonalSignPost: jest.Mock; + + const mockWalletResponse = { + data: { + public_address: '0x123456789abcdef', }, }; - const mockHeaders = { - Authorization: 'Bearer test-id-token', + const mockSignatureResponse = { + data: { + signature: '0xsignature123', + }, }; beforeEach(() => { jest.clearAllMocks(); - authManager = { + // Mock AuthManager + mockAuthManager = { getUser: jest.fn(), } as any; - mockCreateWallet = jest.fn(); - mockPersonalSign = jest.fn(); - mockIsAxiosError = isAxiosError as unknown as jest.Mock; + // Mock API methods + mockCreateWalletV1WalletPost = jest.fn(); + mockSignMessageV1WalletPersonalSignPost = jest.fn(); - magicTeeApiClient = { + // Mock MagicTeeApiClients + mockMagicTeeApiClient = { walletApi: { - createWalletV1WalletPost: mockCreateWallet, + createWalletV1WalletPost: mockCreateWalletV1WalletPost, }, transactionApi: { - signMessageV1WalletPersonalSignPost: mockPersonalSign, + signMessageV1WalletPersonalSignPost: mockSignMessageV1WalletPersonalSignPost, }, } as any; - adapter = new MagicTeeAdapter(authManager, magicTeeApiClient); + // Mock Flow + mockFlow = { + details: { + flowName: 'testFlow', + flowId: '123', + }, + addEvent: jest.fn(), + }; - // Mock withMetricsAsync to call the function directly + // Mock withMetricsAsync (withMetricsAsync as jest.Mock).mockImplementation(async (fn, flowName) => { - const mockFlow = { - details: { flowName }, - addEvent: jest.fn(), - }; return fn(mockFlow); }); - }); - describe('constructor', () => { - it('should initialize with provided dependencies', () => { - expect(adapter).toBeInstanceOf(MagicTeeAdapter); - expect(adapter.authManager).toBe(authManager); - expect(adapter.magicTeeApiClient).toBe(magicTeeApiClient); + // Mock trackDuration + (trackDuration as jest.Mock).mockImplementation(() => {}); + + // Mock isAxiosError + (isAxiosError as unknown as jest.Mock).mockImplementation((error) => { + return error && error.isAxiosError === true; }); - }); - describe('createWallet', () => { - it('should successfully create wallet and return public address', async () => { - const mockPublicAddress = '0x123456789abcdef'; - const mockResponse = { - data: { - public_address: mockPublicAddress, - }, - }; + magicTEESigner = new MagicTEESigner(mockAuthManager, mockMagicTeeApiClient); + }); - authManager.getUser.mockResolvedValue(mockUser as any); - mockCreateWallet.mockResolvedValue(mockResponse as any); + describe('getAddress', () => { + it('should return wallet address when user is logged in', async () => { + mockAuthManager.getUser.mockResolvedValue(mockUser); + mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - const result = await adapter.createWallet(); + const address = await magicTEESigner.getAddress(); - expect(result).toBe(mockPublicAddress); - expect(authManager.getUser).toHaveBeenCalledTimes(1); - expect(mockCreateWallet).toHaveBeenCalledWith( + expect(address).toBe('0x123456789abcdef'); + expect(mockCreateWalletV1WalletPost).toHaveBeenCalledWith( { createWalletRequestModel: { chain: 'ETH', }, }, - { headers: mockHeaders }, + { headers: { Authorization: `Bearer ${mockUser.idToken}` } } ); - expect(trackDuration).toHaveBeenCalledWith( - 'passport', - 'magicCreateWallet', - expect.any(Number), + }); + + 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 throw detailed error when API call fails with axios error and response', async () => { - const axiosError = { + 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('0x123456789abcdef'); + expect(address2).toBe('0x123456789abcdef'); + // 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: { error: 'Internal server error' }, + data: { message: 'Internal server error' }, }, - message: 'Request failed', }; + (isAxiosError as unknown as jest.Mock).mockReturnValue(true); + mockCreateWalletV1WalletPost.mockRejectedValue(apiError); - authManager.getUser.mockResolvedValue(mockUser as any); - mockCreateWallet.mockRejectedValue(axiosError); - mockIsAxiosError.mockReturnValue(true); - - await expect(adapter.createWallet()).rejects.toThrow( - 'Failed to create wallet with status 500: {"error":"Internal server error"}', + await expect(magicTEESigner.getAddress()).rejects.toThrow( + 'Failed to create wallet with status 500: {"message":"Internal server error"}' ); }); - it('should throw detailed error when API call fails with axios error without response', async () => { - const axiosError = { - message: 'Network 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); - authManager.getUser.mockResolvedValue(mockUser as any); - mockCreateWallet.mockRejectedValue(axiosError); - mockIsAxiosError.mockReturnValue(true); - - await expect(adapter.createWallet()).rejects.toThrow( - 'Failed to create wallet: Network error', + await expect(magicTEESigner.getAddress()).rejects.toThrow( + 'Failed to create wallet: Network Error' ); }); - it('should throw detailed error when API call fails with non-axios error', async () => { + 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); - authManager.getUser.mockResolvedValue(mockUser as any); - mockCreateWallet.mockRejectedValue(genericError); - mockIsAxiosError.mockReturnValue(false); - - await expect(adapter.createWallet()).rejects.toThrow( - 'Failed to create wallet: Generic error', + await expect(magicTEESigner.getAddress()).rejects.toThrow( + 'Failed to create wallet: Generic error' ); }); - it('should throw PassportError when user is not logged in', async () => { - authManager.getUser.mockResolvedValue(null); + it('should handle concurrent wallet creation requests', async () => { + mockAuthManager.getUser.mockResolvedValue(mockUser); + mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - await expect(adapter.createWallet()).rejects.toThrow( - new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ), + // Make two concurrent calls + const [address1, address2] = await Promise.all([ + magicTEESigner.getAddress(), + magicTEESigner.getAddress(), + ]); + + expect(address1).toBe('0x123456789abcdef'); + expect(address2).toBe('0x123456789abcdef'); + // 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('personalSign', () => { - it('should successfully sign string message and return signature', async () => { - const message = 'Hello, world!'; - const mockSignature = '0xabcdef123456'; - const mockResponse = { - data: { - signature: mockSignature, - }, - }; - - authManager.getUser.mockResolvedValue(mockUser as any); - mockPersonalSign.mockResolvedValue(mockResponse as any); + describe('signMessage', () => { + beforeEach(() => { + mockAuthManager.getUser.mockResolvedValue(mockUser); + mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); + mockSignMessageV1WalletPersonalSignPost.mockResolvedValue(mockSignatureResponse); + }); - const result = await adapter.personalSign(message); + it('should sign string message successfully', async () => { + const message = 'Hello, world!'; + const signature = await magicTEESigner.signMessage(message); - expect(result).toBe(mockSignature); - expect(authManager.getUser).toHaveBeenCalledTimes(1); - expect(mockPersonalSign).toHaveBeenCalledWith( + expect(signature).toBe('0xsignature123'); + expect(mockSignMessageV1WalletPersonalSignPost).toHaveBeenCalledWith( { personalSignRequest: { message_base64: Buffer.from(message, 'utf-8').toString('base64'), chain: 'ETH', }, }, - { headers: mockHeaders }, - ); - expect(trackDuration).toHaveBeenCalledWith( - 'passport', - 'magicPersonalSign', - expect.any(Number), + { headers: { Authorization: `Bearer ${mockUser.idToken}` } } ); }); - it('should successfully sign Uint8Array message and return signature', async () => { - const message = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" in bytes - const expectedHexMessage = '0x48656c6c6f'; - const mockSignature = '0xabcdef123456'; - const mockResponse = { - data: { - signature: mockSignature, - }, - }; - - authManager.getUser.mockResolvedValue(mockUser as any); - mockPersonalSign.mockResolvedValue(mockResponse as any); + it('should sign Uint8Array message successfully', async () => { + const message = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const signature = await magicTEESigner.signMessage(message); - const result = await adapter.personalSign(message); - - expect(result).toBe(mockSignature); - expect(mockPersonalSign).toHaveBeenCalledWith( + expect(signature).toBe('0xsignature123'); + expect(mockSignMessageV1WalletPersonalSignPost).toHaveBeenCalledWith( { personalSignRequest: { - message_base64: Buffer.from(expectedHexMessage, 'utf-8').toString('base64'), + message_base64: Buffer.from(`0x${Buffer.from(message).toString('hex')}`, 'utf-8').toString('base64'), chain: 'ETH', }, }, - { headers: mockHeaders }, + { headers: { Authorization: `Bearer ${mockUser.idToken}` } } ); }); - it('should throw detailed error when API call fails with axios error and response', async () => { - const message = 'Hello, world!'; - const axiosError = { - response: { - status: 400, - data: { error: 'Bad request' }, - }, - message: 'Request failed', - }; - - authManager.getUser.mockResolvedValue(mockUser as any); - mockPersonalSign.mockRejectedValue(axiosError); - mockIsAxiosError.mockReturnValue(true); + it('should throw error when user is not logged in', async () => { + mockAuthManager.getUser.mockResolvedValue(null); - await expect(adapter.personalSign(message)).rejects.toThrow( - 'Failed to create signature using EOA with status 400: {"error":"Bad request"}', + await expect(magicTEESigner.signMessage('test')).rejects.toThrow( + new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ) ); }); - it('should throw detailed error when API call fails with axios error without response', async () => { - const message = 'Hello, world!'; - const axiosError = { - message: 'Network timeout', + 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); + mockSignMessageV1WalletPersonalSignPost.mockRejectedValue(apiError); - authManager.getUser.mockResolvedValue(mockUser as any); - mockPersonalSign.mockRejectedValue(axiosError); - mockIsAxiosError.mockReturnValue(true); - - await expect(adapter.personalSign(message)).rejects.toThrow( - 'Failed to create signature using EOA: Network timeout', - ); - }); - - it('should throw detailed error when API call fails with non-axios error', async () => { - const message = 'Hello, world!'; - const genericError = new Error('Signing failed'); - - authManager.getUser.mockResolvedValue(mockUser as any); - mockPersonalSign.mockRejectedValue(genericError); - mockIsAxiosError.mockReturnValue(false); - - await expect(adapter.personalSign(message)).rejects.toThrow( - 'Failed to create signature using EOA: Signing failed', + await expect(magicTEESigner.signMessage('test')).rejects.toThrow( + 'Failed to create signature using EOA with status 400: {"message":"Invalid signature request"}' ); }); - it('should throw PassportError when user is not logged in', async () => { - const message = 'Hello, world!'; - authManager.getUser.mockResolvedValue(null); + it('should handle network errors gracefully', async () => { + const networkError = { + isAxiosError: true, + message: 'Network Error', + }; + (isAxiosError as unknown as jest.Mock).mockReturnValue(true); + mockSignMessageV1WalletPersonalSignPost.mockRejectedValue(networkError); - await expect(adapter.personalSign(message)).rejects.toThrow( - new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ), + await expect(magicTEESigner.signMessage('test')).rejects.toThrow( + 'Failed to create signature using EOA: Network Error' ); }); - }); - - describe('getHeaders', () => { - it('should return headers with authorization token when user is logged in', async () => { - authManager.getUser.mockResolvedValue(mockUser as any); - - const result = await adapter.getHeaders(); - - expect(result).toEqual({ - Authorization: 'Bearer test-id-token', - }); - expect(authManager.getUser).toHaveBeenCalledTimes(1); - }); - it('should throw PassportError when user is not logged in', async () => { - authManager.getUser.mockResolvedValue(null); + it('should handle non-axios errors gracefully', async () => { + const genericError = new Error('Generic error'); + (isAxiosError as unknown as jest.Mock).mockReturnValue(false); + mockSignMessageV1WalletPersonalSignPost.mockRejectedValue(genericError); - await expect(adapter.getHeaders()).rejects.toThrow( - new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ), + await expect(magicTEESigner.signMessage('test')).rejects.toThrow( + 'Failed to create signature using EOA: Generic error' ); }); - }); - - describe('metrics integration', () => { - it('should call withMetricsAsync with correct flow name for createWallet', async () => { - const mockPublicAddress = '0x123456789abcdef'; - const mockResponse = { - data: { - public_address: mockPublicAddress, - }, - }; - - authManager.getUser.mockResolvedValue(mockUser as any); - mockCreateWallet.mockResolvedValue(mockResponse as any); - await adapter.createWallet(); + it('should track metrics for message signing', async () => { + await magicTEESigner.signMessage('test'); expect(withMetricsAsync).toHaveBeenCalledWith( expect.any(Function), - 'magicCreateWallet', + 'magicPersonalSign' + ); + expect(trackDuration).toHaveBeenCalledWith( + 'passport', + 'testFlow', + expect.any(Number) ); }); - it('should call withMetricsAsync with correct flow name for personalSign', async () => { - const message = 'Hello, world!'; - const mockSignature = '0xabcdef123456'; - const mockResponse = { - data: { - signature: mockSignature, - }, - }; - - authManager.getUser.mockResolvedValue(mockUser as any); - mockPersonalSign.mockResolvedValue(mockResponse as any); - - await adapter.personalSign(message); + it('should ensure wallet is created before signing', async () => { + await magicTEESigner.signMessage('test'); - expect(withMetricsAsync).toHaveBeenCalledWith( - expect.any(Function), - 'magicPersonalSign', - ); + // Should call both createWallet and signMessage + expect(mockCreateWalletV1WalletPost).toHaveBeenCalled(); + expect(mockSignMessageV1WalletPersonalSignPost).toHaveBeenCalled(); }); + }); - it('should track duration for successful createWallet calls', async () => { - const mockPublicAddress = '0x123456789abcdef'; - const mockResponse = { - data: { - public_address: mockPublicAddress, - }, - }; + 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('0x123456789abcdef'); + expect(mockCreateWalletV1WalletPost).toHaveBeenCalledTimes(2); + }); + }); - authManager.getUser.mockResolvedValue(mockUser as any); - mockCreateWallet.mockResolvedValue(mockResponse as any); + describe('headers generation', () => { + it('should generate correct headers for authenticated user', async () => { + mockAuthManager.getUser.mockResolvedValue(mockUser); + mockCreateWalletV1WalletPost.mockResolvedValue(mockWalletResponse); - await adapter.createWallet(); + await magicTEESigner.getAddress(); - expect(trackDuration).toHaveBeenCalledWith( - 'passport', - 'magicCreateWallet', - expect.any(Number), + expect(mockCreateWalletV1WalletPost).toHaveBeenCalledWith( + expect.any(Object), + { + headers: { + Authorization: `Bearer ${mockUser.idToken}`, + }, + } ); }); - it('should track duration for successful personalSign calls', async () => { - const message = 'Hello, world!'; - const mockSignature = '0xabcdef123456'; - const mockResponse = { - data: { - signature: mockSignature, - }, - }; - - authManager.getUser.mockResolvedValue(mockUser as any); - mockPersonalSign.mockResolvedValue(mockResponse as any); + it('should throw error when trying to generate headers for null user', async () => { + mockAuthManager.getUser.mockResolvedValue(null); - await adapter.personalSign(message); - - expect(trackDuration).toHaveBeenCalledWith( - 'passport', - 'magicPersonalSign', - expect.any(Number), + await expect(magicTEESigner.getAddress()).rejects.toThrow( + new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ) ); }); }); diff --git a/packages/passport/sdk/src/magic/magicTEESigner.ts b/packages/passport/sdk/src/magic/magicTEESigner.ts index 71adffc611..0ceddaeb24 100644 --- a/packages/passport/sdk/src/magic/magicTEESigner.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.ts @@ -7,7 +7,7 @@ import { Flow, trackDuration } from '@imtbl/metrics'; import { PassportError, PassportErrorType } from '../errors/passportError'; import AuthManager from '../authManager'; import { withMetricsAsync } from '../utils/metrics'; -import { PassportEventEmitter, PassportEvents, RollupType } from '../types'; +import { User } from '../types'; const CHAIN_IDENTIFIER = 'ETH'; @@ -23,17 +23,12 @@ export default class MagicTEESigner extends AbstractSigner { private userWallet: UserWallet | null = null; - constructor(authManager: AuthManager, magicTeeApiClient: MagicTeeApiClients, passportEventEmitter: PassportEventEmitter) { + private createWalletPromise: Promise | null = null; + + constructor(authManager: AuthManager, magicTeeApiClient: MagicTeeApiClients) { super(); this.authManager = authManager; this.magicTeeApiClient = magicTeeApiClient; - passportEventEmitter.on(PassportEvents.LOGGED_IN, this.createWallet.bind(this)); - passportEventEmitter.on(PassportEvents.LOGGED_OUT, () => { - this.userWallet = null; - }); - - // Attempt to initialise the wallet and fail silently as the user may not be logged in - this.createWallet().catch(() => {}); } private async getUserWallet(): Promise { @@ -42,118 +37,120 @@ export default class MagicTEESigner extends AbstractSigner { userWallet = await this.createWallet(); } - const user = await this.authManager.getUser(); - if (!user) { - throw new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ); - } + const user = await this.getUserOrThrow(); // Check if the user has changed since the last createWallet request was made. If so, initialise the new user's wallet. if (user.profile.sub !== userWallet.userIdentifier) { userWallet = await this.createWallet(); } - // Ensure that the wallet address returned by Magic matches the IMX user admin address in the user profile. - const imxUserAdminAddress = user[RollupType.IMX]?.userAdminAddress; - if (imxUserAdminAddress && imxUserAdminAddress !== userWallet.walletAddress) { - throw new PassportError( - `User admin address ${userWallet.walletAddress} does not match user IMX user profile address ${imxUserAdminAddress}`, - PassportErrorType.WALLET_CONNECTION_ERROR, - ); - } - - // Ensure that the wallet address returned by Magic matches the zkEvm user admin address in the user profile - const zkEvmUserAdminAddress = user[RollupType.ZKEVM]?.userAdminAddress; - if (zkEvmUserAdminAddress && zkEvmUserAdminAddress !== userWallet.walletAddress) { - throw new PassportError( - `User admin address ${userWallet.walletAddress} does not match user zkEVM user profile address ${zkEvmUserAdminAddress}`, - PassportErrorType.WALLET_CONNECTION_ERROR, - ); - } - return userWallet; } - private createWalletPromise: Promise | null = null; - - // This method calls the createWallet endpoint. The user's wallet must be created before it can be used to sign messages. - // The createWallet endpoint is idempotent, so it can be called multiple times without causing an error. + /** + * This method calls the createWallet endpoint. The user's wallet must be created before it can be used to sign messages. + * The createWallet endpoint is idempotent, so it can be called multiple times without causing an error. + * If a createWallet request is already in flight, return the existing promise. + */ private async createWallet(): Promise { - if (!this.createWalletPromise) { - this.createWalletPromise = new Promise(async (resolve, reject) => { - try { - this.userWallet = null; - - const user = await this.authManager.getUser(); - if (!user) { - return reject(new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - )); - } - const headers = await this.getHeaders(); - - return withMetricsAsync(async (flow: Flow) => { - try { - const startTime = performance.now(); - const response = await this.magicTeeApiClient.walletApi.createWalletV1WalletPost( - { - createWalletRequestModel: { - chain: CHAIN_IDENTIFIER, - }, + if (this.createWalletPromise) return this.createWalletPromise; + + // eslint-disable-next-line no-async-promise-executor + this.createWalletPromise = new Promise(async (resolve, reject) => { + try { + this.userWallet = null; + + const user = await this.getUserOrThrow(); + const headers = await this.getHeaders(user); + + await withMetricsAsync(async (flow: Flow) => { + try { + const startTime = performance.now(); + // The createWallet endpoint is idempotent, so it can be called multiple times without causing an error. + const response = await this.magicTeeApiClient.walletApi.createWalletV1WalletPost( + { + createWalletRequestModel: { + chain: CHAIN_IDENTIFIER, }, - { headers }, - ); - - trackDuration( - 'passport', - flow.details.flowName, - Math.round(performance.now() - startTime), - ); - - return resolve({ - userIdentifier: user.profile.sub, - walletAddress: response.data.public_address, - }); - } catch (error) { - let errorMessage: string = 'Failed to create wallet'; - - if (isAxiosError(error)) { - if (error.response) { - errorMessage += ` with status ${error.response.status}: ${JSON.stringify(error.response.data)}`; - } else { - errorMessage += `: ${error.message}`; - } + }, + { headers }, + ); + + trackDuration( + 'passport', + flow.details.flowName, + Math.round(performance.now() - startTime), + ); + + this.userWallet = { + userIdentifier: user.profile.sub, + walletAddress: response.data.public_address, + }; + + return resolve(this.userWallet); + } catch (error) { + let errorMessage: string = 'Failed to create wallet'; + + if (isAxiosError(error)) { + if (error.response) { + errorMessage += ` with status ${error.response.status}: ${JSON.stringify(error.response.data)}`; } else { - errorMessage += `: ${(error as Error).message}`; + errorMessage += `: ${error.message}`; } - - return reject(new Error(errorMessage)); + } else { + errorMessage += `: ${(error as Error).message}`; } - }, 'magicCreateWallet'); - } finally { - this.createWalletPromise = null; - } - }); - } + + return reject(new Error(errorMessage)); + } + }, 'magicCreateWallet'); + } catch (error) { + return reject(error); + } + finally { + this.createWalletPromise = null; + } + }); return this.createWalletPromise; } + private async getUserOrThrow(): Promise { + const user = await this.authManager.getUser(); + if (!user) { + throw new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ); + } + return user; + } + + private async getHeaders(user: User): Promise> { + if (!user) { + throw new PassportError( + 'User has been logged out', + PassportErrorType.NOT_LOGGED_IN_ERROR, + ); + } + return { + Authorization: `Bearer ${user.idToken}`, + }; + } + public async getAddress(): Promise { const userWallet = await this.getUserWallet(); return userWallet.walletAddress; } public async signMessage(message: string | Uint8Array): Promise { - // Call getUserWallet to ensure that the createWallet endpoint has been called at some point in the past. - // The createWallet endpoint must have been called once for each user at some point in the past before the user can sign messages. + // Call getUserWallet to ensure that the createWallet endpoint has been called at least once, + // as this is a prerequisite for signing messages. await this.getUserWallet(); const messageToSign = message instanceof Uint8Array ? `0x${Buffer.from(message).toString('hex')}` : message; - const headers = await this.getHeaders(); + const user = await this.getUserOrThrow(); + const headers = await this.getHeaders(user); return withMetricsAsync(async (flow: Flow) => { try { @@ -190,19 +187,6 @@ export default class MagicTEESigner extends AbstractSigner { }, 'magicPersonalSign'); } - private async getHeaders(): Promise> { - const user = await this.authManager.getUser(); - if (!user) { - throw new PassportError( - 'User has been logged out', - PassportErrorType.NOT_LOGGED_IN_ERROR, - ); - } - return { - Authorization: `Bearer ${user.idToken}`, - }; - } - connect(provider: null | Provider): Signer { throw new Error('Method not implemented.'); } diff --git a/packages/passport/sdk/src/mocks/zkEvm/msw.ts b/packages/passport/sdk/src/mocks/zkEvm/msw.ts index fb9fd18c75..2f08560b11 100644 --- a/packages/passport/sdk/src/mocks/zkEvm/msw.ts +++ b/packages/passport/sdk/src/mocks/zkEvm/msw.ts @@ -11,6 +11,7 @@ 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) { @@ -32,6 +33,26 @@ const mandatoryHandlers = [ 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: '0x123456789abcdef', + }), + )), + 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/personal-sign', (req, res, ctx) => res( + ctx.status(200), + ctx.json({ + signature: '0xsignature123', + }), + )), + internalServerError: rest.post('https://tee.express.magiclabs.com/v1/wallet/personal-sign', (req, res, ctx) => res(ctx.status(500))), + }, + }, counterfactualAddress: { success: rest.post( `https://api.sandbox.immutable.com/v2/chains/${chainName}/passport/counterfactual-address`, diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts index a7fe61ef73..7aa11ae547 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts @@ -20,10 +20,10 @@ import { PassportImxProvider } from './passportImxProvider'; import { batchNftTransfer, cancelOrder, createOrder, createTrade, exchangeTransfer, transfer, } from './workflows'; -import { PassportEventEmitter, PassportEventMap, PassportEvents } from '../types'; +import { PassportEventMap, PassportEvents } from '../types'; import TypedEventEmitter from '../utils/typedEventEmitter'; import AuthManager from '../authManager'; -import MagicAdapter from '../magic/magicAdapter'; +import MagicTEESigner from '../magic/magicTEESigner'; import { getStarkSigner } from './getStarkSigner'; import GuardianClient from '../guardian'; @@ -66,8 +66,9 @@ describe('PassportImxProvider', () => { getAddress: jest.fn(), }; - const magicAdapterMock = { - login: jest.fn(), + const magicTEESignerMock = { + getAddress: jest.fn(), + signMessage: jest.fn(), }; const mockGuardianClient = { @@ -77,7 +78,7 @@ describe('PassportImxProvider', () => { const getSignerMock = jest.fn(); - let passportEventEmitter: PassportEventEmitter; + let passportEventEmitter: TypedEventEmitter; const imxApiClients = new ImxApiClients({} as any); @@ -97,13 +98,14 @@ describe('PassportImxProvider', () => { })); // Signers - magicAdapterMock.login.mockResolvedValue({ getSigner: getSignerMock }); + magicTEESignerMock.getAddress.mockResolvedValue('0x123'); + magicTEESignerMock.signMessage.mockResolvedValue('signature'); (BrowserProvider as unknown as jest.Mock).mockReturnValue({ getSigner: getSignerMock }); (getStarkSigner as jest.Mock).mockResolvedValue(mockStarkSigner); passportImxProvider = new PassportImxProvider({ authManager: mockAuthManager as unknown as AuthManager, - magicAdapter: magicAdapterMock as unknown as MagicAdapter, + magicTEESigner: magicTEESignerMock as unknown as MagicTEESigner, guardianClient: mockGuardianClient as unknown as GuardianClient, immutableXClient, passportEventEmitter, @@ -116,8 +118,8 @@ describe('PassportImxProvider', () => { // The promise is created in the constructor but not awaited until a method is called await passportImxProvider.getAddress(); - expect(magicAdapterMock.login).toHaveBeenCalledWith(mockUserImx.idToken); - expect(getStarkSigner).toHaveBeenCalledWith(mockEthSigner); + expect(magicTEESignerMock.getAddress).toHaveBeenCalled(); + expect(getStarkSigner).toHaveBeenCalledWith(magicTEESignerMock); }); it('initialises the eth and stark signers only once', async () => { @@ -125,14 +127,14 @@ describe('PassportImxProvider', () => { await passportImxProvider.getAddress(); await passportImxProvider.getAddress(); - expect(magicAdapterMock.login).toHaveBeenCalledTimes(1); + expect(magicTEESignerMock.getAddress).toHaveBeenCalledTimes(1); expect(getStarkSigner).toHaveBeenCalledTimes(1); }); it('re-throws the initialisation error when a method is called', async () => { mockAuthManager.getUser.mockResolvedValue(mockUserImx); // Signers - magicAdapterMock.login.mockResolvedValue({}); + magicTEESignerMock.getAddress.mockResolvedValue('0x123'); (getStarkSigner as jest.Mock).mockRejectedValue(new Error('error')); // Metrics @@ -145,7 +147,7 @@ describe('PassportImxProvider', () => { const pp = new PassportImxProvider({ authManager: mockAuthManager as unknown as AuthManager, - magicAdapter: magicAdapterMock as unknown as MagicAdapter, + magicTEESigner: magicTEESignerMock as unknown as MagicTEESigner, guardianClient: mockGuardianClient as unknown as GuardianClient, immutableXClient, passportEventEmitter: new TypedEventEmitter(), @@ -363,7 +365,7 @@ describe('PassportImxProvider', () => { const magicProviderMock = {}; mockAuthManager.login.mockResolvedValue(mockUser); - magicAdapterMock.login.mockResolvedValue(magicProviderMock); + magicTEESignerMock.getAddress.mockResolvedValue('0x123'); mockAuthManager.forceUserRefresh.mockResolvedValue({ ...mockUser, imx: { ethAddress: '', starkAddress: '', userAdminAddress: '' } }); await passportImxProvider.registerOffchain(); diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.ts index 8cdb64e959..4775feccd1 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.ts @@ -1,6 +1,5 @@ import { AnyToken, - EthSigner, IMXClient, NftTransferDetails, StarkSigner, @@ -18,8 +17,7 @@ import { TransactionResponse } from 'ethers'; import AuthManager from '../authManager'; import GuardianClient from '../guardian'; import { - PassportEvents, UserImx, User, isUserImx, - PassportEventEmitter, + PassportEventMap, PassportEvents, UserImx, User, isUserImx, } from '../types'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { @@ -29,11 +27,12 @@ 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; immutableXClient: IMXClient; - passportEventEmitter: PassportEventEmitter; + passportEventEmitter: TypedEventEmitter; magicTEESigner: MagicTEESigner; imxApiClients: ImxApiClients; guardianClient: GuardianClient; diff --git a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts index 486845f62e..cac0d07975 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.test.ts @@ -1,7 +1,7 @@ import { IMXClient } from '@imtbl/x-client'; import { ImxApiClients } from '@imtbl/generated-clients'; import { PassportImxProviderFactory } from './passportImxProviderFactory'; -import MagicAdapter from '../magic/magicAdapter'; +import MagicTEESigner from '../magic/magicTEESigner'; import AuthManager from '../authManager'; import { PassportError, PassportErrorType } from '../errors/passportError'; import { PassportEventMap } from '../types'; @@ -20,7 +20,7 @@ describe('PassportImxProviderFactory', () => { }; const imxApiClients = new ImxApiClients({} as any); - const mockMagicAdapter = {}; + const mockMagicTEESigner = {}; const immutableXClient = { usersApi: {}, } as IMXClient; @@ -29,7 +29,7 @@ describe('PassportImxProviderFactory', () => { const passportImxProviderFactory = new PassportImxProviderFactory({ immutableXClient, authManager: mockAuthManager as unknown as AuthManager, - magicAdapter: mockMagicAdapter as unknown as MagicAdapter, + magicTEESigner: mockMagicTEESigner as unknown as MagicTEESigner, passportEventEmitter, imxApiClients, guardianClient, @@ -77,7 +77,7 @@ describe('PassportImxProviderFactory', () => { expect(result).toBe(mockPassportImxProvider); expect(mockAuthManager.getUserOrLogin).toHaveBeenCalledTimes(1); expect(PassportImxProvider).toHaveBeenCalledWith({ - magicAdapter: mockMagicAdapter, + magicTEESigner: mockMagicTEESigner, authManager: mockAuthManager, immutableXClient, passportEventEmitter, diff --git a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts index 40cdd5ffe2..8e113b25e5 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProviderFactory.ts @@ -3,16 +3,17 @@ import { IMXProvider } from '@imtbl/x-provider'; import { ImxApiClients } from '@imtbl/generated-clients'; import { PassportError, PassportErrorType } from '../errors/passportError'; import AuthManager from '../authManager'; -import { PassportEventEmitter, User } from '../types'; +import { PassportEventMap, User } from '../types'; import { PassportImxProvider } from './passportImxProvider'; import GuardianClient from '../guardian'; import MagicTEESigner from '../magic/magicTEESigner'; +import TypedEventEmitter from '../utils/typedEventEmitter'; export type PassportImxProviderFactoryInput = { authManager: AuthManager; immutableXClient: IMXClient; magicTEESigner: MagicTEESigner; - passportEventEmitter: PassportEventEmitter; + passportEventEmitter: TypedEventEmitter; imxApiClients: ImxApiClients; guardianClient: GuardianClient; }; @@ -24,7 +25,7 @@ export class PassportImxProviderFactory { private readonly magicTEESigner: MagicTEESigner; - private readonly passportEventEmitter: PassportEventEmitter; + private readonly passportEventEmitter: TypedEventEmitter; public readonly imxApiClients: ImxApiClients; diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index 7ad72ba12b..9e3c1421e0 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -24,8 +24,6 @@ export interface PassportEventMap extends Record { [PassportEvents.ACCOUNTS_REQUESTED]: [AccountsRequestedEvent]; } -export type PassportEventEmitter = TypedEventEmitter; - export type UserProfile = { email?: string; nickname?: string; diff --git a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts index d8fdf851a2..8cb8538bf8 100644 --- a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts +++ b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts @@ -130,11 +130,11 @@ describe('transactionHelpers', () => { it('prepares and signs transaction correctly', async () => { const result = await prepareAndSignTransaction({ transactionRequest, - magicTeeAdapter, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddresses, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, }); @@ -161,11 +161,11 @@ describe('transactionHelpers', () => { await prepareAndSignTransaction({ transactionRequest, - magicTeeAdapter, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddresses, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, }); @@ -196,11 +196,11 @@ describe('transactionHelpers', () => { await prepareAndSignTransaction({ transactionRequest, - magicTeeAdapter, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddresses, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, }); @@ -247,16 +247,30 @@ describe('transactionHelpers', () => { ); }); + it('processes empty fee options correctly', async () => { + (relayerClient.imGetFeeOptions as jest.Mock).mockResolvedValue([]); + + await expect(prepareAndSignTransaction({ + transactionRequest, + ethSigner: magicTeeAdapter, + rpcProvider, + guardianClient, + relayerClient, + zkEvmAddress: zkEvmAddresses.ethAddress, + flow, + })).rejects.toThrow('Failed to retrieve fees for IMX token'); + }); + it('signs the transaction when the nonce is zero', async () => { jest.spyOn(walletHelpers, 'getNonce').mockResolvedValue(0n); const result = await prepareAndSignTransaction({ transactionRequest, - magicTeeAdapter, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddresses, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, }); @@ -272,11 +286,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - magicTeeAdapter, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddresses, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, })).rejects.toThrow('Validation failed'); @@ -290,11 +304,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - magicTeeAdapter, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddresses, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, })).rejects.toThrow('Signing failed'); }); @@ -304,11 +318,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - magicTeeAdapter, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddresses, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, })).rejects.toThrow('Transaction send failed'); }); @@ -342,8 +356,8 @@ describe('transactionHelpers', () => { ...transactionRequest, nonce: 0, }, - magicTeeAdapter, - zkEvmAddresses, + ethSigner: magicTeeAdapter, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, }); diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts index 43fddbe9db..b468d1cbbe 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts @@ -7,11 +7,11 @@ import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './JsonRpcError'; import GuardianClient from '../guardian'; import { RelayerClient } from './relayerClient'; import { Provider, RequestArguments } from './types'; -import { PassportEventEmitter, PassportEventMap, PassportEvents } from '../types'; +import { PassportEventMap, PassportEvents } from '../types'; import TypedEventEmitter from '../utils/typedEventEmitter'; import { mockUser, mockUserZkEvm, testConfig } from '../test/mocks'; import { signTypedDataV4 } from './signTypedDataV4'; -import MagicAdapter from '../magic/magicAdapter'; +import MagicTEESigner from '../magic/magicTEESigner'; import { signEjectionTransaction } from './signEjectionTransaction'; jest.mock('ethers', () => ({ @@ -27,20 +27,30 @@ jest.mock('./signEjectionTransaction'); jest.mock('./signTypedDataV4'); describe('ZkEvmProvider', () => { - let passportEventEmitter: PassportEventEmitter; + let passportEventEmitter: TypedEventEmitter; const config = testConfig; - const ethSigner = {}; + 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 magicAdapter = { - login: jest.fn(), - } as Partial as MagicAdapter; 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(); @@ -64,7 +74,9 @@ describe('ZkEvmProvider', () => { authManager: authManager as Partial as AuthManager, passportEventEmitter, guardianClient, - magicAdapter, + ethSigner, + multiRollupApiClients, + user: null, } as Partial; return new ZkEvmProvider(constructorParameters as ZkEvmProviderInput); @@ -73,26 +85,24 @@ describe('ZkEvmProvider', () => { describe('constructor', () => { describe('when an application session exists', () => { it('initialises the signer', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); getProvider(); await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - expect(authManager.getUser).toBeCalledTimes(1); - expect(magicAdapter.login).toBeCalledTimes(1); - expect(BrowserProvider).toBeCalledTimes(1); + // Constructor doesn't call getUser or getAddress during initialization + expect(authManager.getUser).not.toHaveBeenCalled(); + expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); + expect(BrowserProvider).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); - authManager.getUser.mockResolvedValue(mockUser); getProvider(); await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - expect(authManager.getUser).toBeCalledTimes(1); expect(onAccountsRequested).not.toHaveBeenCalled(); }); }); @@ -100,13 +110,11 @@ describe('ZkEvmProvider', () => { it('calls session activity', async () => { const onAccountsRequested = jest.fn(); passportEventEmitter.on(PassportEvents.ACCOUNTS_REQUESTED, onAccountsRequested); - authManager.getUser.mockResolvedValue(mockUserZkEvm); - getProvider(); + const provider = getProvider(); await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - expect(authManager.getUser).toBeCalledTimes(1); - expect(onAccountsRequested).toHaveBeenCalledTimes(1); + expect(onAccountsRequested).not.toHaveBeenCalled(); }); }); }); @@ -122,8 +130,8 @@ describe('ZkEvmProvider', () => { await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - expect(magicAdapter.login).toBeCalledTimes(1); - expect(BrowserProvider).toBeCalledTimes(1); + expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); + expect(BrowserProvider).not.toHaveBeenCalled(); }); describe('and the user has not registered before', () => { @@ -164,7 +172,7 @@ describe('ZkEvmProvider', () => { expect(resultOne).toEqual([mockUserZkEvm.zkEvm.ethAddress]); expect(resultTwo).toEqual([mockUserZkEvm.zkEvm.ethAddress]); - expect(authManager.getUser).toBeCalledTimes(3); + expect(authManager.getUser).toBeCalledTimes(2); }); it('should emit accountsChanged event and identify user when user logs in', async () => { @@ -210,16 +218,17 @@ describe('ZkEvmProvider', () => { await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - expect(magicAdapter.login).toBeCalledTimes(1); - expect(BrowserProvider).toBeCalledTimes(1); + // Constructor doesn't initialize the signer + expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); + expect(BrowserProvider).not.toHaveBeenCalled(); await provider.request({ method: 'eth_requestAccounts' }); // Add a delay so that we can check if the ethSigner is initialised again await new Promise(process.nextTick); - expect(magicAdapter.login).toBeCalledTimes(1); - expect(BrowserProvider).toBeCalledTimes(1); + expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); + expect(BrowserProvider).not.toHaveBeenCalled(); }); }); diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts index 7bcb046901..beba2623c0 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.ts @@ -15,7 +15,7 @@ import AuthManager from '../authManager'; import TypedEventEmitter from '../utils/typedEventEmitter'; import { PassportConfiguration } from '../config'; import { - PassportEventEmitter, PassportEvents, User, UserZkEvm, + PassportEventMap, PassportEvents, User, UserZkEvm, } from '../types'; import { RelayerClient } from './relayerClient'; import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './JsonRpcError'; @@ -33,7 +33,7 @@ export type ZkEvmProviderInput = { authManager: AuthManager; config: PassportConfiguration; multiRollupApiClients: MultiRollupApiClients; - passportEventEmitter: PassportEventEmitter; + passportEventEmitter: TypedEventEmitter; guardianClient: GuardianClient; ethSigner: Signer; user: User | null; From 0d86600c331f95a47e13b0c0b6a48b4a0d728d9d Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Mon, 14 Jul 2025 15:38:25 +1000 Subject: [PATCH 05/11] WIP --- packages/passport/sdk/src/Passport.test.ts | 20 +------ .../src/starkEx/passportImxProvider.test.ts | 27 ++------- .../sdk/src/zkEvm/zkEvmProvider.test.ts | 55 +------------------ 3 files changed, 8 insertions(+), 94 deletions(-) diff --git a/packages/passport/sdk/src/Passport.test.ts b/packages/passport/sdk/src/Passport.test.ts index e82c822730..d956def170 100644 --- a/packages/passport/sdk/src/Passport.test.ts +++ b/packages/passport/sdk/src/Passport.test.ts @@ -44,8 +44,6 @@ describe('Passport', () => { let loginCallbackMock: jest.Mock; let logoutMock: jest.Mock; let removeUserMock: jest.Mock; - let magicLoginMock: jest.Mock; - let magicLogoutMock: jest.Mock; let getUserMock: jest.Mock; let requestRefreshTokenMock: jest.Mock; let getProviderMock: jest.Mock; @@ -57,8 +55,6 @@ describe('Passport', () => { beforeEach(() => { authLoginMock = jest.fn().mockReturnValue(mockUser); loginCallbackMock = jest.fn(); - magicLoginMock = jest.fn(); - magicLogoutMock = jest.fn(); logoutMock = jest.fn(); removeUserMock = jest.fn(); getUserMock = jest.fn(); @@ -289,26 +285,12 @@ describe('Passport', () => { await passport.logout(); expect(logoutMock).toBeCalledTimes(1); - expect(magicLogoutMock).toBeCalledTimes(1); - }); - }); - - describe('when the logout mode is redirect', () => { - it('should execute logout without error in the correct order', async () => { - await passport.logout(); - - const logoutMockOrder = logoutMock.mock.invocationCallOrder[0]; - const magicLogoutMockOrder = magicLogoutMock.mock.invocationCallOrder[0]; - - expect(logoutMock).toBeCalledTimes(1); - expect(magicLogoutMock).toBeCalledTimes(1); - expect(magicLogoutMockOrder).toBeLessThan(logoutMockOrder); }); }); it('should call track error function if an error occurs', async () => { const error = new Error('error'); - magicLogoutMock.mockRejectedValue(error); + logoutMock.mockRejectedValue(error); try { await passport.logout(); diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts index 7aa11ae547..aedeb920d2 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts @@ -61,12 +61,7 @@ describe('PassportImxProvider', () => { getYCoordinate: jest.fn(), } as StarkSigner; - const mockEthSigner = { - signMessage: jest.fn(), - getAddress: jest.fn(), - }; - - const magicTEESignerMock = { + const mockMagicTEESigner = { getAddress: jest.fn(), signMessage: jest.fn(), }; @@ -76,15 +71,12 @@ describe('PassportImxProvider', () => { withConfirmationScreenTask: () => (task: () => any) => task, }; - const getSignerMock = jest.fn(); - let passportEventEmitter: TypedEventEmitter; const imxApiClients = new ImxApiClients({} as any); beforeEach(() => { jest.restoreAllMocks(); - getSignerMock.mockReturnValue(mockEthSigner); (registerPassportStarkEx as jest.Mock).mockResolvedValue(null); passportEventEmitter = new TypedEventEmitter(); mockAuthManager.getUser.mockResolvedValue(mockUserImx); @@ -98,14 +90,11 @@ describe('PassportImxProvider', () => { })); // Signers - magicTEESignerMock.getAddress.mockResolvedValue('0x123'); - magicTEESignerMock.signMessage.mockResolvedValue('signature'); - (BrowserProvider as unknown as jest.Mock).mockReturnValue({ getSigner: getSignerMock }); (getStarkSigner as jest.Mock).mockResolvedValue(mockStarkSigner); passportImxProvider = new PassportImxProvider({ authManager: mockAuthManager as unknown as AuthManager, - magicTEESigner: magicTEESignerMock as unknown as MagicTEESigner, + magicTEESigner: mockMagicTEESigner as unknown as MagicTEESigner, guardianClient: mockGuardianClient as unknown as GuardianClient, immutableXClient, passportEventEmitter, @@ -118,8 +107,7 @@ describe('PassportImxProvider', () => { // The promise is created in the constructor but not awaited until a method is called await passportImxProvider.getAddress(); - expect(magicTEESignerMock.getAddress).toHaveBeenCalled(); - expect(getStarkSigner).toHaveBeenCalledWith(magicTEESignerMock); + expect(getStarkSigner).toHaveBeenCalledWith(mockMagicTEESigner); }); it('initialises the eth and stark signers only once', async () => { @@ -127,14 +115,12 @@ describe('PassportImxProvider', () => { await passportImxProvider.getAddress(); await passportImxProvider.getAddress(); - expect(magicTEESignerMock.getAddress).toHaveBeenCalledTimes(1); expect(getStarkSigner).toHaveBeenCalledTimes(1); }); it('re-throws the initialisation error when a method is called', async () => { mockAuthManager.getUser.mockResolvedValue(mockUserImx); // Signers - magicTEESignerMock.getAddress.mockResolvedValue('0x123'); (getStarkSigner as jest.Mock).mockRejectedValue(new Error('error')); // Metrics @@ -147,7 +133,7 @@ describe('PassportImxProvider', () => { const pp = new PassportImxProvider({ authManager: mockAuthManager as unknown as AuthManager, - magicTEESigner: magicTEESignerMock as unknown as MagicTEESigner, + magicTEESigner: mockMagicTEESigner as unknown as MagicTEESigner, guardianClient: mockGuardianClient as unknown as GuardianClient, immutableXClient, passportEventEmitter: new TypedEventEmitter(), @@ -362,15 +348,12 @@ describe('PassportImxProvider', () => { describe('registerOffChain', () => { it('should register the user and update the provider instance user', async () => { - const magicProviderMock = {}; - mockAuthManager.login.mockResolvedValue(mockUser); - magicTEESignerMock.getAddress.mockResolvedValue('0x123'); mockAuthManager.forceUserRefresh.mockResolvedValue({ ...mockUser, imx: { ethAddress: '', starkAddress: '', userAdminAddress: '' } }); await passportImxProvider.registerOffchain(); expect(registerPassportStarkEx).toHaveBeenCalledWith({ - ethSigner: mockEthSigner, + ethSigner: mockMagicTEESigner, starkSigner: mockStarkSigner, imxApiClients: new ImxApiClients({} as any), }, mockUserImx.accessToken); diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts index b468d1cbbe..eea17d96cf 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts @@ -1,5 +1,5 @@ import { identify, trackFlow } from '@imtbl/metrics'; -import { BrowserProvider, JsonRpcProvider, toBeHex } from 'ethers'; +import { JsonRpcProvider, toBeHex } from 'ethers'; import AuthManager from '../authManager'; import { ZkEvmProvider, ZkEvmProviderInput } from './zkEvmProvider'; import { sendTransaction } from './sendTransaction'; @@ -17,7 +17,6 @@ import { signEjectionTransaction } from './signEjectionTransaction'; jest.mock('ethers', () => ({ ...jest.requireActual('ethers'), JsonRpcProvider: jest.fn(), - BrowserProvider: jest.fn(), })); jest.mock('@imtbl/metrics'); jest.mock('./relayerClient'); @@ -54,9 +53,6 @@ describe('ZkEvmProvider', () => { beforeEach(() => { passportEventEmitter = new TypedEventEmitter(); jest.resetAllMocks(); - (BrowserProvider as unknown as jest.Mock).mockImplementation(() => ({ - getSigner: jest.fn().mockImplementation(() => ethSigner), - })); (trackFlow as unknown as jest.Mock).mockImplementation(() => ({ addEvent: jest.fn(), end: jest.fn(), @@ -92,7 +88,6 @@ describe('ZkEvmProvider', () => { // Constructor doesn't call getUser or getAddress during initialization expect(authManager.getUser).not.toHaveBeenCalled(); expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); - expect(BrowserProvider).not.toHaveBeenCalled(); }); describe('and the user has not registered before', () => { @@ -110,7 +105,7 @@ describe('ZkEvmProvider', () => { it('calls session activity', async () => { const onAccountsRequested = jest.fn(); passportEventEmitter.on(PassportEvents.ACCOUNTS_REQUESTED, onAccountsRequested); - const provider = getProvider(); + getProvider(); await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 @@ -124,16 +119,6 @@ describe('ZkEvmProvider', () => { authManager.getUser.mockResolvedValue(null); }); - it('initialises the signer', async () => { - getProvider(); - passportEventEmitter.emit(PassportEvents.LOGGED_IN, mockUserZkEvm); - - await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - - expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); - expect(BrowserProvider).not.toHaveBeenCalled(); - }); - describe('and the user has not registered before', () => { it('does not call session activity', async () => { const onAccountsRequested = jest.fn(); @@ -194,42 +179,6 @@ describe('ZkEvmProvider', () => { passportId: mockUserZkEvm.profile.sub, }); }); - - it('should throw an error if the signer initialisation fails', async () => { - authManager.getUserOrLogin.mockReturnValue(mockUserZkEvm); - authManager.getUser.mockResolvedValue(mockUserZkEvm); - - (BrowserProvider as unknown as jest.Mock).mockImplementation(() => ({ - getSigner: () => { - throw new Error('Something went wrong'); - }, - })); - const provider = getProvider(); - await provider.request({ method: 'eth_requestAccounts' }); - - await expect(provider.request({ method: 'eth_sendTransaction' })).rejects.toThrow( - new JsonRpcError(RpcErrorCode.INTERNAL_ERROR, 'Something went wrong'), - ); - }); - - it('should not reinitialise the ethSigner when it has been set during the constructor', async () => { - authManager.getUser.mockResolvedValue(mockUserZkEvm); - const provider = getProvider(); - - await new Promise(process.nextTick); // https://immutable.atlassian.net/browse/ID-2516 - - // Constructor doesn't initialize the signer - expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); - expect(BrowserProvider).not.toHaveBeenCalled(); - - await provider.request({ method: 'eth_requestAccounts' }); - - // Add a delay so that we can check if the ethSigner is initialised again - await new Promise(process.nextTick); - - expect(magicTEESigner.getAddress).not.toHaveBeenCalled(); - expect(BrowserProvider).not.toHaveBeenCalled(); - }); }); describe('eth_sendTransaction', () => { From 1f10aeda6e9e2ab8aea0b09d249aeb255bab5d0b Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Mon, 14 Jul 2025 15:59:23 +1000 Subject: [PATCH 06/11] Fixed tests / linting --- .../passport/sdk/src/Passport.int.test.ts | 19 +++++-- .../sdk/src/magic/magicTEESigner.test.ts | 49 +++++++++---------- .../passport/sdk/src/magic/magicTEESigner.ts | 24 ++++----- packages/passport/sdk/src/mocks/zkEvm/msw.ts | 14 +++++- .../src/starkEx/passportImxProvider.test.ts | 1 - packages/passport/sdk/src/types.ts | 1 - .../sdk/src/zkEvm/transactionHelpers.test.ts | 26 +--------- .../sdk/src/zkEvm/zkEvmProvider.test.ts | 2 +- 8 files changed, 63 insertions(+), 73 deletions(-) diff --git a/packages/passport/sdk/src/Passport.int.test.ts b/packages/passport/sdk/src/Passport.int.test.ts index f50d3b57a7..f121e3db4f 100644 --- a/packages/passport/sdk/src/Passport.int.test.ts +++ b/packages/passport/sdk/src/Passport.int.test.ts @@ -206,12 +206,16 @@ describe('Passport', () => { }); 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(); @@ -226,13 +230,15 @@ describe('Passport', () => { describe('when the registration request fails', () => { it('throws an error', async () => { - mockSigninPopup.mockResolvedValue(mockOidcUser); 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(); @@ -250,10 +256,11 @@ describe('Passport', () => { 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) { @@ -289,7 +296,7 @@ describe('Passport', () => { }); expect(result).toEqual(transactionHash); - expect(mockGetUser).toHaveBeenCalledTimes(6); + expect(mockGetUser).toHaveBeenCalledTimes(9); }); it('ethSigner is initialised if user logs in after connectEvm', async () => { @@ -300,6 +307,8 @@ describe('Passport', () => { mswHandlers.rpcProvider.success, mswHandlers.relayer.success, mswHandlers.guardian.evaluateTransaction.success, + mswHandlers.magicTEE.createWallet.success, + mswHandlers.magicTEE.personalSign.success, ]); mockMagicRequest.mockImplementation(({ method }: RequestArguments) => { switch (method) { @@ -317,7 +326,7 @@ describe('Passport', () => { } } }); - mockGetUser.mockResolvedValueOnce(Promise.resolve(null)); + mockGetUser.mockResolvedValueOnce(null); mockSigninPopup.mockResolvedValue(mockOidcUserZkevm); mockSigninSilent.mockResolvedValueOnce(mockOidcUserZkevm); @@ -344,7 +353,7 @@ describe('Passport', () => { // user logs in, ethSigner is initialised await passport.login(); - mockGetUser.mockResolvedValue(Promise.resolve(mockOidcUserZkevm)); + mockGetUser.mockResolvedValue(mockOidcUserZkevm); expect(accounts).toEqual([mockUserZkEvm.zkEvm.ethAddress]); diff --git a/packages/passport/sdk/src/magic/magicTEESigner.test.ts b/packages/passport/sdk/src/magic/magicTEESigner.test.ts index 3cbac06f4e..ecbad20301 100644 --- a/packages/passport/sdk/src/magic/magicTEESigner.test.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.test.ts @@ -1,4 +1,3 @@ -import { AbstractSigner, Provider } from 'ethers'; import { MagicTeeApiClients } from '@imtbl/generated-clients'; import { trackDuration } from '@imtbl/metrics'; import { isAxiosError } from 'axios'; @@ -65,17 +64,13 @@ describe('MagicTEESigner', () => { }; // Mock withMetricsAsync - (withMetricsAsync as jest.Mock).mockImplementation(async (fn, flowName) => { - return fn(mockFlow); - }); + (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) => { - return error && error.isAxiosError === true; - }); + (isAxiosError as unknown as jest.Mock).mockImplementation((error) => error && error.isAxiosError === true); magicTEESigner = new MagicTEESigner(mockAuthManager, mockMagicTeeApiClient); }); @@ -94,7 +89,7 @@ describe('MagicTEESigner', () => { chain: 'ETH', }, }, - { headers: { Authorization: `Bearer ${mockUser.idToken}` } } + { headers: { Authorization: `Bearer ${mockUser.idToken}` } }, ); }); @@ -105,7 +100,7 @@ describe('MagicTEESigner', () => { new PassportError( 'User has been logged out', PassportErrorType.NOT_LOGGED_IN_ERROR, - ) + ), ); }); @@ -137,7 +132,7 @@ describe('MagicTEESigner', () => { // First call with user1 await magicTEESigner.getAddress(); - + // Second call with user2 (different user) await magicTEESigner.getAddress(); @@ -157,7 +152,7 @@ describe('MagicTEESigner', () => { mockCreateWalletV1WalletPost.mockRejectedValue(apiError); await expect(magicTEESigner.getAddress()).rejects.toThrow( - 'Failed to create wallet with status 500: {"message":"Internal server error"}' + 'Failed to create wallet with status 500: {"message":"Internal server error"}', ); }); @@ -171,7 +166,7 @@ describe('MagicTEESigner', () => { mockCreateWalletV1WalletPost.mockRejectedValue(networkError); await expect(magicTEESigner.getAddress()).rejects.toThrow( - 'Failed to create wallet: Network Error' + 'Failed to create wallet: Network Error', ); }); @@ -182,7 +177,7 @@ describe('MagicTEESigner', () => { mockCreateWalletV1WalletPost.mockRejectedValue(genericError); await expect(magicTEESigner.getAddress()).rejects.toThrow( - 'Failed to create wallet: Generic error' + 'Failed to create wallet: Generic error', ); }); @@ -210,12 +205,12 @@ describe('MagicTEESigner', () => { expect(withMetricsAsync).toHaveBeenCalledWith( expect.any(Function), - 'magicCreateWallet' + 'magicCreateWallet', ); expect(trackDuration).toHaveBeenCalledWith( 'passport', 'testFlow', - expect.any(Number) + expect.any(Number), ); }); }); @@ -239,7 +234,7 @@ describe('MagicTEESigner', () => { chain: 'ETH', }, }, - { headers: { Authorization: `Bearer ${mockUser.idToken}` } } + { headers: { Authorization: `Bearer ${mockUser.idToken}` } }, ); }); @@ -255,7 +250,7 @@ describe('MagicTEESigner', () => { chain: 'ETH', }, }, - { headers: { Authorization: `Bearer ${mockUser.idToken}` } } + { headers: { Authorization: `Bearer ${mockUser.idToken}` } }, ); }); @@ -266,7 +261,7 @@ describe('MagicTEESigner', () => { new PassportError( 'User has been logged out', PassportErrorType.NOT_LOGGED_IN_ERROR, - ) + ), ); }); @@ -282,7 +277,7 @@ describe('MagicTEESigner', () => { mockSignMessageV1WalletPersonalSignPost.mockRejectedValue(apiError); await expect(magicTEESigner.signMessage('test')).rejects.toThrow( - 'Failed to create signature using EOA with status 400: {"message":"Invalid signature request"}' + 'Failed to create signature using EOA with status 400: {"message":"Invalid signature request"}', ); }); @@ -295,7 +290,7 @@ describe('MagicTEESigner', () => { mockSignMessageV1WalletPersonalSignPost.mockRejectedValue(networkError); await expect(magicTEESigner.signMessage('test')).rejects.toThrow( - 'Failed to create signature using EOA: Network Error' + 'Failed to create signature using EOA: Network Error', ); }); @@ -305,7 +300,7 @@ describe('MagicTEESigner', () => { mockSignMessageV1WalletPersonalSignPost.mockRejectedValue(genericError); await expect(magicTEESigner.signMessage('test')).rejects.toThrow( - 'Failed to create signature using EOA: Generic error' + 'Failed to create signature using EOA: Generic error', ); }); @@ -314,12 +309,12 @@ describe('MagicTEESigner', () => { expect(withMetricsAsync).toHaveBeenCalledWith( expect.any(Function), - 'magicPersonalSign' + 'magicPersonalSign', ); expect(trackDuration).toHaveBeenCalledWith( 'passport', 'testFlow', - expect.any(Number) + expect.any(Number), ); }); @@ -335,7 +330,7 @@ describe('MagicTEESigner', () => { 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) @@ -343,7 +338,7 @@ describe('MagicTEESigner', () => { // 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('0x123456789abcdef'); @@ -364,7 +359,7 @@ describe('MagicTEESigner', () => { headers: { Authorization: `Bearer ${mockUser.idToken}`, }, - } + }, ); }); @@ -375,7 +370,7 @@ describe('MagicTEESigner', () => { new PassportError( 'User has been logged out', PassportErrorType.NOT_LOGGED_IN_ERROR, - ) + ), ); }); }); diff --git a/packages/passport/sdk/src/magic/magicTEESigner.ts b/packages/passport/sdk/src/magic/magicTEESigner.ts index 0ceddaeb24..3fb2126660 100644 --- a/packages/passport/sdk/src/magic/magicTEESigner.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.ts @@ -1,6 +1,4 @@ -import { - AbstractSigner, Provider, Signer, TransactionRequest, TypedDataDomain, TypedDataField, -} from 'ethers'; +import { AbstractSigner, Signer } from 'ethers'; import { MagicTeeApiClients } from '@imtbl/generated-clients'; import { isAxiosError } from 'axios'; import { Flow, trackDuration } from '@imtbl/metrics'; @@ -61,7 +59,7 @@ export default class MagicTEESigner extends AbstractSigner { this.userWallet = null; const user = await this.getUserOrThrow(); - const headers = await this.getHeaders(user); + const headers = MagicTEESigner.getHeaders(user); await withMetricsAsync(async (flow: Flow) => { try { @@ -105,9 +103,8 @@ export default class MagicTEESigner extends AbstractSigner { } }, 'magicCreateWallet'); } catch (error) { - return reject(error); - } - finally { + reject(error); + } finally { this.createWalletPromise = null; } }); @@ -126,7 +123,7 @@ export default class MagicTEESigner extends AbstractSigner { return user; } - private async getHeaders(user: User): Promise> { + private static getHeaders(user: User): Record { if (!user) { throw new PassportError( 'User has been logged out', @@ -150,7 +147,7 @@ export default class MagicTEESigner extends AbstractSigner { const messageToSign = message instanceof Uint8Array ? `0x${Buffer.from(message).toString('hex')}` : message; const user = await this.getUserOrThrow(); - const headers = await this.getHeaders(user); + const headers = await MagicTEESigner.getHeaders(user); return withMetricsAsync(async (flow: Flow) => { try { @@ -187,15 +184,18 @@ export default class MagicTEESigner extends AbstractSigner { }, 'magicPersonalSign'); } - connect(provider: null | Provider): Signer { + // eslint-disable-next-line class-methods-use-this + connect(): Signer { throw new Error('Method not implemented.'); } - signTransaction(tx: TransactionRequest): Promise { + // eslint-disable-next-line class-methods-use-this + signTransaction(): Promise { throw new Error('Method not implemented.'); } - signTypedData(domain: TypedDataDomain, types: Record>, value: Record): Promise { + // eslint-disable-next-line class-methods-use-this + signTypedData(): Promise { throw new Error('Method not implemented.'); } } diff --git a/packages/passport/sdk/src/mocks/zkEvm/msw.ts b/packages/passport/sdk/src/mocks/zkEvm/msw.ts index 2f08560b11..3f10b6ebfd 100644 --- a/packages/passport/sdk/src/mocks/zkEvm/msw.ts +++ b/packages/passport/sdk/src/mocks/zkEvm/msw.ts @@ -29,6 +29,18 @@ const mandatoryHandlers = [ } } }), + rest.post('https://tee.express.magiclabs.com/v1/wallet', (req, res, ctx) => res( + ctx.status(201), + ctx.json({ + public_address: '0x123456789abcdef', + }), + )), + rest.post('https://tee.express.magiclabs.com/v1/wallet/personal-sign', (req, res, ctx) => res( + ctx.status(200), + ctx.json({ + signature: '0x6b168cf5d90189eaa51d02ff3fa8ffc8956b1ea20fdd34280f521b1acca092305b9ace24e643fe64a30c528323065f5b77e1fb4045bd330aad01e7b9a07591f91b', + }), + )), ]; const chainName = `${encodeURIComponent(ChainName.IMTBL_ZKEVM_TESTNET)}`; @@ -47,7 +59,7 @@ export const mswHandlers = { success: rest.post('https://tee.express.magiclabs.com/v1/wallet/personal-sign', (req, res, ctx) => res( ctx.status(200), ctx.json({ - signature: '0xsignature123', + signature: '0x6b168cf5d90189eaa51d02ff3fa8ffc8956b1ea20fdd34280f521b1acca092305b9ace24e643fe64a30c528323065f5b77e1fb4045bd330aad01e7b9a07591f91b', }), )), internalServerError: rest.post('https://tee.express.magiclabs.com/v1/wallet/personal-sign', (req, res, ctx) => res(ctx.status(500))), diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts index aedeb920d2..e5fb70d0cd 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.test.ts @@ -12,7 +12,6 @@ import { UnsignedTransferRequest, } from '@imtbl/x-client'; import { trackError, trackFlow } from '@imtbl/metrics'; -import { BrowserProvider } from 'ethers'; import registerPassportStarkEx from './workflows/registration'; import { mockUser, mockUserImx } from '../test/mocks'; import { PassportError, PassportErrorType } from '../errors/passportError'; diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index 9e3c1421e0..761e7c8834 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -2,7 +2,6 @@ import { Environment, ModuleConfiguration } from '@imtbl/config'; import { IMXClient } from '@imtbl/x-client'; import { ImxApiClients } from '@imtbl/generated-clients'; import { Flow } from '@imtbl/metrics'; -import TypedEventEmitter from './utils/typedEventEmitter'; export enum PassportEvents { LOGGED_OUT = 'loggedOut', diff --git a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts index 8cb8538bf8..26951029f6 100644 --- a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts +++ b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts @@ -2,7 +2,7 @@ import { Flow } from '@imtbl/metrics'; import { JsonRpcProvider } from 'ethers'; import { RelayerClient } from './relayerClient'; import GuardianClient from '../guardian'; -import { FeeOption, MetaTransaction, RelayerTransactionStatus } from './types'; +import { FeeOption, RelayerTransactionStatus } from './types'; import { JsonRpcError, RpcErrorCode } from './JsonRpcError'; import { pollRelayerTransaction, prepareAndSignEjectionTransaction, prepareAndSignTransaction } from './transactionHelpers'; import * as walletHelpers from './walletHelpers'; @@ -82,16 +82,6 @@ describe('transactionHelpers', () => { value: '0x00', }; - const metaTransactions: MetaTransaction[] = [ - { - to: transactionRequest.to, - data: transactionRequest.data, - nonce, - value: transactionRequest.value, - revertOnError: true, - }, - ]; - const signedTransactions = 'signedTransactions123'; const relayerId = 'relayerId123'; @@ -247,20 +237,6 @@ describe('transactionHelpers', () => { ); }); - it('processes empty fee options correctly', async () => { - (relayerClient.imGetFeeOptions as jest.Mock).mockResolvedValue([]); - - await expect(prepareAndSignTransaction({ - transactionRequest, - ethSigner: magicTeeAdapter, - rpcProvider, - guardianClient, - relayerClient, - zkEvmAddress: zkEvmAddresses.ethAddress, - flow, - })).rejects.toThrow('Failed to retrieve fees for IMX token'); - }); - it('signs the transaction when the nonce is zero', async () => { jest.spyOn(walletHelpers, 'getNonce').mockResolvedValue(0n); diff --git a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts index eea17d96cf..fc86bb734d 100644 --- a/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts +++ b/packages/passport/sdk/src/zkEvm/zkEvmProvider.test.ts @@ -3,7 +3,7 @@ import { JsonRpcProvider, toBeHex } from 'ethers'; import AuthManager from '../authManager'; import { ZkEvmProvider, ZkEvmProviderInput } from './zkEvmProvider'; import { sendTransaction } from './sendTransaction'; -import { JsonRpcError, ProviderErrorCode, RpcErrorCode } from './JsonRpcError'; +import { JsonRpcError, ProviderErrorCode } from './JsonRpcError'; import GuardianClient from '../guardian'; import { RelayerClient } from './relayerClient'; import { Provider, RequestArguments } from './types'; From 6346f97d78e55d6547e56910a51e624e1eecaf74 Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Tue, 15 Jul 2025 12:09:07 +1000 Subject: [PATCH 07/11] Self review --- packages/passport/sdk/src/Passport.ts | 2 +- packages/passport/sdk/src/magic/magicTEESigner.ts | 5 +++-- packages/passport/sdk/src/starkEx/passportImxProvider.ts | 4 +--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index 1001607370..b75a1b8d29 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -54,7 +54,6 @@ const buildImxApiClients = (passportModuleConfiguration: PassportModuleConfigura export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConfiguration) => { const config = new PassportConfiguration(passportModuleConfiguration); - const passportEventEmitter = new TypedEventEmitter(); const authManager = new AuthManager(config); const confirmationScreen = new ConfirmationScreen(config); const magicTeeApiClients = new MagicTeeApiClients({ @@ -65,6 +64,7 @@ export const buildPrivateVars = (passportModuleConfiguration: PassportModuleConf }); const magicTEESigner = new MagicTEESigner(authManager, magicTeeApiClients); const multiRollupApiClients = new MultiRollupApiClients(config.multiRollupConfig); + const passportEventEmitter = new TypedEventEmitter(); const immutableXClient = passportModuleConfiguration.overrides ? passportModuleConfiguration.overrides.immutableXClient diff --git a/packages/passport/sdk/src/magic/magicTEESigner.ts b/packages/passport/sdk/src/magic/magicTEESigner.ts index 3fb2126660..fe91791f2f 100644 --- a/packages/passport/sdk/src/magic/magicTEESigner.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.ts @@ -35,9 +35,8 @@ export default class MagicTEESigner extends AbstractSigner { userWallet = await this.createWallet(); } - const user = await this.getUserOrThrow(); - // Check if the user has changed since the last createWallet request was made. If so, initialise the new user's wallet. + const user = await this.getUserOrThrow(); if (user.profile.sub !== userWallet.userIdentifier) { userWallet = await this.createWallet(); } @@ -149,6 +148,8 @@ export default class MagicTEESigner extends AbstractSigner { const user = await this.getUserOrThrow(); const headers = await MagicTEESigner.getHeaders(user); + console.log('messageToSign', messageToSign); + return withMetricsAsync(async (flow: Flow) => { try { const startTime = performance.now(); diff --git a/packages/passport/sdk/src/starkEx/passportImxProvider.ts b/packages/passport/sdk/src/starkEx/passportImxProvider.ts index 4775feccd1..fb7a0e9a3f 100644 --- a/packages/passport/sdk/src/starkEx/passportImxProvider.ts +++ b/packages/passport/sdk/src/starkEx/passportImxProvider.ts @@ -99,12 +99,10 @@ export class PassportImxProvider implements IMXProvider { * */ #initialiseSigner() { - const generateSigners = async (): Promise => getStarkSigner(this.magicTEESigner); - // eslint-disable-next-line no-async-promise-executor this.starkSigner = new Promise(async (resolve) => { try { - resolve(await generateSigners()); + resolve(await getStarkSigner(this.magicTEESigner)); } catch (err) { // Capture and store the initialization error this.signerInitialisationError = err; From 6161c918663bfc795b200012f0eb6fe3a1af7621 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Tue, 22 Jul 2025 09:44:54 +1200 Subject: [PATCH 08/11] feat(passport): store tokens --- packages/game-bridge/src/index.ts | 14 ++++++++++++++ packages/passport/sdk/src/Passport.ts | 9 +++++++++ packages/passport/sdk/src/authManager.ts | 10 ++++++++++ 3 files changed, 33 insertions(+) diff --git a/packages/game-bridge/src/index.ts b/packages/game-bridge/src/index.ts index 211fbd776d..4ef58930d3 100644 --- a/packages/game-bridge/src/index.ts +++ b/packages/game-bridge/src/index.ts @@ -53,6 +53,7 @@ const PASSPORT_FUNCTIONS = { getEmail: 'getEmail', getPassportId: 'getPassportId', getLinkedAddresses: 'getLinkedAddresses', + storeTokens: 'storeTokens', imx: { getAddress: 'getAddress', isRegisteredOffchain: 'isRegisteredOffchain', @@ -464,6 +465,19 @@ window.callFunction = async (jsonData: string) => { }); break; } + case PASSPORT_FUNCTIONS.storeTokens: { + const tokenResponse = JSON.parse(data); + const profile = await getPassportClient().storeTokens(tokenResponse); + identify({ passportId: profile.sub }); + trackDuration(moduleName, 'performedStoreTokens', mt(markStart)); + callbackToGame({ + responseFor: fxName, + requestId, + success: true, + error: null, + }); + break; + } case PASSPORT_FUNCTIONS.getEmail: { const userProfile = await getPassportClient().getUserInfo(); const success = userProfile?.email !== undefined; diff --git a/packages/passport/sdk/src/Passport.ts b/packages/passport/sdk/src/Passport.ts index 1476b62047..516ab1030d 100644 --- a/packages/passport/sdk/src/Passport.ts +++ b/packages/passport/sdk/src/Passport.ts @@ -15,6 +15,7 @@ import MagicAdapter from './magic/magicAdapter'; import { PassportImxProviderFactory } from './starkEx'; import { PassportConfiguration } from './config'; import { + DeviceTokenResponse, DirectLoginMethod, isUserImx, isUserZkEvm, @@ -299,6 +300,14 @@ export class Passport { }, 'loginWithPKCEFlowCallback'); } + 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'); + } + /** * Logs out the current user. * @returns {Promise} A promise that resolves when the logout is complete diff --git a/packages/passport/sdk/src/authManager.ts b/packages/passport/sdk/src/authManager.ts index d7f1bed6aa..1f92593431 100644 --- a/packages/passport/sdk/src/authManager.ts +++ b/packages/passport/sdk/src/authManager.ts @@ -348,6 +348,16 @@ export default class AuthManager { 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 () => { if (this.logoutMode === 'silent') { From 5747fb855ab3a610feec68a0d10f32a6d7f98c2f Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Tue, 22 Jul 2025 15:02:17 +1000 Subject: [PATCH 09/11] ID-3844: Build fix --- packages/passport/sdk/src/Passport.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/passport/sdk/src/Passport.test.ts b/packages/passport/sdk/src/Passport.test.ts index d956def170..cc7bc8fae7 100644 --- a/packages/passport/sdk/src/Passport.test.ts +++ b/packages/passport/sdk/src/Passport.test.ts @@ -74,7 +74,7 @@ describe('Passport', () => { requestRefreshTokenAfterRegistration: requestRefreshTokenMock, forceUserRefresh: forceUserRefreshMock, }); - (MagicTEESigner as jest.Mock).mockReturnValue({ + (MagicTEESigner as unknown as jest.Mock).mockReturnValue({ getAddress: jest.fn().mockResolvedValue('0x123'), signMessage: jest.fn().mockResolvedValue('signature'), }); From 7088fbe362a265746dca9b7892e9319e60bf9b23 Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Tue, 22 Jul 2025 15:09:27 +1000 Subject: [PATCH 10/11] ID-3844: Lint fix --- packages/passport/sdk/src/magic/magicTEESigner.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/passport/sdk/src/magic/magicTEESigner.ts b/packages/passport/sdk/src/magic/magicTEESigner.ts index fe91791f2f..a604b9fa97 100644 --- a/packages/passport/sdk/src/magic/magicTEESigner.ts +++ b/packages/passport/sdk/src/magic/magicTEESigner.ts @@ -148,8 +148,6 @@ export default class MagicTEESigner extends AbstractSigner { const user = await this.getUserOrThrow(); const headers = await MagicTEESigner.getHeaders(user); - console.log('messageToSign', messageToSign); - return withMetricsAsync(async (flow: Flow) => { try { const startTime = performance.now(); From 4081bd30de4c525b9763d83273c309fd8316c07c Mon Sep 17 00:00:00 2001 From: Hayden Fowler Date: Tue, 22 Jul 2025 16:16:12 +1000 Subject: [PATCH 11/11] ID-3844: Fix failing test after merging in main --- .../sdk/src/zkEvm/transactionHelpers.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts index d8b43fb482..0d6f80465d 100644 --- a/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts +++ b/packages/passport/sdk/src/zkEvm/transactionHelpers.test.ts @@ -308,11 +308,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - ethSigner, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, })).rejects.toThrow('Invalid fee options received from relayer'); }); @@ -322,11 +322,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - ethSigner, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, })).rejects.toThrow('Invalid fee options received from relayer'); }); @@ -336,11 +336,11 @@ describe('transactionHelpers', () => { await expect(prepareAndSignTransaction({ transactionRequest, - ethSigner, + ethSigner: magicTeeAdapter, rpcProvider, guardianClient, relayerClient, - zkEvmAddress, + zkEvmAddress: zkEvmAddresses.ethAddress, flow, })).rejects.toThrow('Invalid fee options received from relayer'); });