Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions packages/passport/sdk/src/Passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,11 @@ export class Passport {

// Use connectWallet to create the provider (it will create WalletConfiguration internally)
const provider = await connectWallet({
getUser: (forceRefresh) => (forceRefresh
? this.auth.forceUserRefresh()
: this.auth.getUserOrLogin()),
getUser: (forceRefresh, getUserOptions) => {
if (forceRefresh) return this.auth.forceUserRefresh();
if (getUserOptions?.silent) return this.auth.getUser();
return this.auth.getUserOrLogin();
},
clientId: this.passportConfig.oidcConfiguration.clientId,
chains: [chainConfig],
crossSdkBridgeEnabled: this.passportConfig.crossSdkBridgeEnabled,
Expand Down
35 changes: 31 additions & 4 deletions packages/wallet/src/connectWallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,38 @@ describe('connectWallet', () => {
);
});

it('uses getUserOrLogin from internal Auth', async () => {
Comment thread
rodrigo-fournier-immutable marked this conversation as resolved.
it('uses getUser from internal Auth when silent (avoids popup on page load)', async () => {
await connectWallet({ chains: [zkEvmChain] });

// Internal Auth's getUserOrLogin should be called during setup
expect(mockAuthInstance.getUserOrLogin).toHaveBeenCalled();
// Internal Auth's getUser should be called during setup (silent mode to avoid popup)
expect(mockAuthInstance.getUser).toHaveBeenCalled();
});

it('does not call getUserOrLogin during setup (silent flow avoids popup)', async () => {
await connectWallet({ chains: [zkEvmChain] });

// Silent flow uses getUser only; getUserOrLogin would trigger popup
expect(mockAuthInstance.getUserOrLogin).not.toHaveBeenCalled();
});

it('uses getUserOrLogin when non-silent (e.g. eth_requestAccounts triggers login)', async () => {
// When external getUser is provided, it may use getUserOrLogin for explicit login
const getUser = jest.fn()
.mockResolvedValueOnce(null) // setup (silent) - not used when we provide getUser
.mockResolvedValueOnce({ profile: { sub: 'user' }, accessToken: 'token' });
const getUserOrLogin = jest.fn().mockResolvedValue({ profile: { sub: 'user' }, accessToken: 'token' });

await connectWallet({
getUser: async (forceRefresh?, options?) => {
if (options?.silent) return getUser(forceRefresh, options);
return getUserOrLogin(forceRefresh, options);
},
chains: [zkEvmChain],
});

// Setup calls getUser with silent: true, so getUser (not getUserOrLogin) is used
expect(getUser).toHaveBeenCalledWith(undefined, { silent: true });
expect(getUserOrLogin).not.toHaveBeenCalled();
});

it('derives passportDomain from chain apiUrl', async () => {
Expand Down Expand Up @@ -491,7 +518,7 @@ describe('connectWallet', () => {

describe('error handling', () => {
it('handles auth failure gracefully', async () => {
mockAuthInstance.getUserOrLogin.mockRejectedValueOnce(new Error('Auth failed'));
mockAuthInstance.getUser.mockRejectedValueOnce(new Error('Auth failed'));

const provider = await connectWallet({ chains: [zkEvmChain] });

Expand Down
13 changes: 9 additions & 4 deletions packages/wallet/src/connectWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,13 @@ function createDefaultGetUser(initialChain: ChainConfig, options: ConnectWalletO
});
}

// Return getUser function that wraps Auth.getUserOrLogin
// Return getUser function that wraps Auth.getUserOrLogin/getUser based on options
return {
getUser: async () => auth.getUserOrLogin(),
getUser: async (forceRefresh?: boolean, getUserOptions?: { silent?: boolean }) => {
if (forceRefresh) return auth.forceUserRefresh();
if (getUserOptions?.silent) return auth.getUser();
return auth.getUserOrLogin();
},
clientId,
};
}
Expand Down Expand Up @@ -222,8 +226,9 @@ export async function connectWallet(
clientId = defaultAuth.clientId;
}

// 4. Get current user (may be null if not logged in)
const user = await getUser().catch(() => null);
// 4. Get current user (may be null if not logged in).
// Use silent: true to avoid triggering login popup on page load.
const user = await getUser(undefined, { silent: true }).catch(() => null);

// 5. Create wallet configuration with concrete URLs
const passportDomain = initialChain.passportDomain || initialChain.apiUrl.replace('api.', 'passport.');
Expand Down
20 changes: 19 additions & 1 deletion packages/wallet/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type { RollupType } from '@imtbl/auth';
// Wallet events
export enum WalletEvents {
ACCOUNTS_REQUESTED = 'accountsRequested',
LOGGED_IN = 'loggedIn',
Comment thread
rodrigo-fournier-immutable marked this conversation as resolved.
LOGGED_OUT = 'loggedOut',
}

Expand All @@ -37,6 +38,7 @@ export type AccountsRequestedEvent = {
// WalletEventMap for internal wallet events
export interface WalletEventMap extends Record<string, any> {
[WalletEvents.ACCOUNTS_REQUESTED]: [AccountsRequestedEvent];
[WalletEvents.LOGGED_IN]: [User];
[WalletEvents.LOGGED_OUT]: [];
}

Expand Down Expand Up @@ -214,6 +216,17 @@ export interface PopupOverlayOptions {
disableBlockedPopupOverlay?: boolean;
}

/**
* Options for GetUserFunction calls.
*/
export interface GetUserOptions {
/**
* When true, do not trigger login (e.g. open popup) if user is not authenticated.
* Returns null instead. Use during page load to avoid unwanted popups.
*/
silent?: boolean;
}

/**
* Function type for getting the current user.
* Used as an alternative to passing an Auth instance.
Expand All @@ -222,8 +235,13 @@ export interface PopupOverlayOptions {
* @param forceRefresh - When true, the auth layer should trigger a server-side
* token refresh to get updated claims (e.g., after zkEVM registration).
* This ensures the returned user has the latest data from the identity provider.
* @param options - Optional. When options.silent is true, return null if not
* authenticated instead of triggering login (avoids popup on page load).
*/
export type GetUserFunction = (forceRefresh?: boolean) => Promise<User | null>;
export type GetUserFunction = (
forceRefresh?: boolean,
options?: GetUserOptions,
) => Promise<User | null>;

/**
* Options for connecting a wallet via connectWallet()
Expand Down
74 changes: 74 additions & 0 deletions packages/wallet/src/zkEvm/zkEvmProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { TypedEventEmitter } from '@imtbl/auth';
import { WalletEvents, WalletEventMap } from '../types';
import { ProviderEvent } from './types';
import { ZkEvmProvider } from './zkEvmProvider';
import { WalletConfiguration } from '../config';

jest.mock('viem', () => {
const actual = jest.requireActual<typeof import('viem')>('viem');
return {
...actual,
createPublicClient: jest.fn(() => ({})),
http: jest.fn(() => ({})),
};
});

jest.mock('./relayerClient', () => ({
RelayerClient: jest.fn().mockImplementation(() => ({})),
}));

jest.mock('../guardian', () => jest.fn().mockImplementation(() => ({
withConfirmationScreen: () => (fn: () => Promise<any>) => fn(),
})));

jest.mock('./sessionActivity/sessionActivity', () => ({
trackSessionActivity: jest.fn(),
}));

const mockUserWithZkEvm = {
profile: { sub: 'user-123' },
accessToken: 'token',
zkEvm: { ethAddress: '0x1234567890123456789012345678901234567890' },
};

describe('ZkEvmProvider', () => {
const walletEventEmitter = new TypedEventEmitter<WalletEventMap>();
const config = new WalletConfiguration({
passportDomain: 'https://passport.immutable.com',
zkEvmRpcUrl: 'https://rpc.test.immutable.com',
relayerUrl: 'https://relayer.test.immutable.com',
indexerMrBasePath: 'https://api.test.immutable.com',
});

const mockGetUser = jest.fn().mockResolvedValue(null);
const mockEthSigner = {
getAddress: jest.fn().mockResolvedValue('0xabc'),
signMessage: jest.fn(),
signTypedData: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
});

it('emits accountsChanged when LOGGED_IN event is received with zkEvm user', () => {
const provider = new ZkEvmProvider({
getUser: mockGetUser,
clientId: 'test-client',
config,
multiRollupApiClients: {} as any,
walletEventEmitter,
guardianClient: {} as any,
ethSigner: mockEthSigner as any,
user: null,
sessionActivityApiUrl: null,
});

const accountsChangedHandler = jest.fn();
provider.on(ProviderEvent.ACCOUNTS_CHANGED, accountsChangedHandler);

walletEventEmitter.emit(WalletEvents.LOGGED_IN, mockUserWithZkEvm as any);

expect(accountsChangedHandler).toHaveBeenCalledWith([mockUserWithZkEvm.zkEvm.ethAddress]);
});
});
22 changes: 18 additions & 4 deletions packages/wallet/src/zkEvm/zkEvmProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ export class ZkEvmProvider implements Provider {
this.#callSessionActivity(user.zkEvm.ethAddress);
}

// Listen for logout events
// Listen for auth events
walletEventEmitter.on(WalletEvents.LOGGED_OUT, this.#handleLogout);
walletEventEmitter.on(WalletEvents.LOGGED_IN, this.#handleLoggedIn);
walletEventEmitter.on(
WalletEvents.ACCOUNTS_REQUESTED,
trackSessionActivity,
Expand All @@ -133,11 +134,24 @@ export class ZkEvmProvider implements Provider {
this.#providerEventEmitter.emit(ProviderEvent.ACCOUNTS_CHANGED, []);
};

#handleLoggedIn = (user: User) => {
if (user && isZkEvmUser(user)) {
this.#providerEventEmitter.emit(ProviderEvent.ACCOUNTS_CHANGED, [
user.zkEvm.ethAddress,
]);
}
// If user doesn't have zkEvm yet, app must call eth_requestAccounts to register
};

/**
* Get the current user using getUser function.
* @param silent - When true, use getUser(undefined, { silent: true }) to avoid
* triggering login popup on read-only checks (e.g. eth_accounts).
*/
async #getCurrentUser(): Promise<User | null> {
return this.#getUser();
async #getCurrentUser(silent = false): Promise<User | null> {
return silent
? this.#getUser(undefined, { silent: true })
: this.#getUser();
}

async #callSessionActivity(zkEvmAddress: string) {
Expand Down Expand Up @@ -174,7 +188,7 @@ export class ZkEvmProvider implements Provider {
// Used to get the registered zkEvm address from the User session
async #getZkEvmAddress() {
try {
const user = await this.#getCurrentUser();
const user = await this.#getCurrentUser(true); // silent: avoid popup on read-only checks
if (user && isZkEvmUser(user)) {
return user.zkEvm.ethAddress;
}
Expand Down
Loading