Skip to content

fix: add modal abstraction, platform detection and onboarding#1325

Merged
elribonazo merged 7 commits into
mainfrom
features/ui-modal-abstraction
Jul 24, 2025
Merged

fix: add modal abstraction, platform detection and onboarding#1325
elribonazo merged 7 commits into
mainfrom
features/ui-modal-abstraction

Conversation

@elribonazo

@elribonazo elribonazo commented Jul 23, 2025

Copy link
Copy Markdown
Contributor

Explanation

PR is introducing:

  1. The UI abstraction and the existing modals with no UI changes (install, pending, select)
  2. Checking platform detection when SDK starts
  3. Transport + Session restoration when SDK initialize

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've highlighted breaking changes using the "BREAKING" category above as appropriate

@elribonazo elribonazo changed the title Features/UI modal abstraction fix: add modal abstraction, platform detection and onboarding Jul 23, 2025
@codecov

codecov Bot commented Jul 23, 2025

Copy link
Copy Markdown

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 74.93%. Comparing base (0ad4c95) to head (18e08d2).
Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1325   +/-   ##
=======================================
  Coverage   74.93%   74.93%           
=======================================
  Files         184      184           
  Lines        4513     4513           
  Branches     1105     1105           
=======================================
  Hits         3382     3382           
  Misses       1131     1131           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@elribonazo elribonazo marked this pull request as ready for review July 23, 2025 15:38
@elribonazo elribonazo requested a review from a team as a code owner July 23, 2025 15:38

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Callback Context Issue and Global Variable Conflict

The onTransportNotification method is passed as a callback to __transport.onNotification without binding its this context, causing this.emit() to fail when invoked. Additionally, the SDK uses global __provider and __transport variables, which breaks encapsulation. A disconnect() call from one SDK instance will clear these globals, inadvertently breaking other active SDK instances.

packages/sdk-multichain/src/multichain/index.ts#L33-L337

let __provider: MultichainApiClient<RPCAPI> | undefined;
let __transport: Transport | undefined;
export class MultichainSDK extends MultichainCore {
public state: SDKState;
private listeners: (() => void)[] = [];
/**
* Static method to reset global state - useful for testing
* @internal
*/
static resetGlobals() {
__provider = undefined;
__transport = undefined;
}
private get client() {
const platformType = getPlatformType();
const sdkInfo = `Sdk/Javascript SdkVersion/${packageJson.version} Platform/${platformType} dApp/${this.options.dapp.url ?? this.options.dapp.name} dAppTitle/${this.options.dapp.name}`;
return new RPCClient(this.provider, this.options.api, sdkInfo);
}
get provider() {
if (!__provider) {
throw new Error('Provider not initialized, establish connection first');
}
return __provider;
}
get transport() {
if (!__transport) {
throw new Error('Transport not initialized, establish connection first');
}
return __transport;
}
private async getCurrentSession(): Promise<SessionData | undefined> {
try {
//TODO: We should report to the multichain api team that when there's no extension installed
// getSession timeouts and should be just undefined
// Thats why we need this function, to compensate that
let validSession: SessionData | undefined;
const session = await this.provider.getSession();
if (Object.keys(session?.sessionScopes ?? {}).length > 0) {
validSession = session;
}
return validSession;
} catch (err) {
logger('MetaMaskSDK error during getCurrentSession', err);
return undefined;
}
}
get storage() {
return this.options.storage;
}
private constructor(options: MultichainOptions) {
const withInfuraRPCMethods = setupInfuraProvider(options);
const withDappMetadata = setupDappMetadata(withInfuraRPCMethods);
const allOptions = {
...withDappMetadata,
ui: {
...withDappMetadata.ui,
preferExtension: withDappMetadata.ui.preferExtension ?? true,
preferDesktop: withDappMetadata.ui.preferDesktop ?? false,
headless: withDappMetadata.ui.headless ?? false,
},
analytics: {
...(options.analytics ?? {}),
enabled: options.analytics?.enabled !== undefined ? options.analytics.enabled : true,
integrationType: 'unknown',
},
};
super(allOptions);
this.state = 'pending';
}
static async create(options: MultichainOptions) {
const instance = new MultichainSDK(options);
const isEnabled = await isLoggerEnabled('metamask-sdk:core', instance.options.storage);
if (isEnabled) {
enableDebug('metamask-sdk:core');
}
await instance.init();
return instance;
}
private async setupAnalytics() {
if (!this.options.analytics?.enabled) {
return;
}
const platform = getPlatformType();
const isBrowser = platform === PlatformType.MetaMaskMobileWebview || platform === PlatformType.DesktopWeb || platform === PlatformType.MobileWeb;
const isReactNative = platform === PlatformType.ReactNative;
if (!isBrowser && !isReactNative) {
return;
}
const version = getVersion();
const dappId = getDappId(this.options.dapp);
const anonId = await getAnonId(this.options.storage);
const integrationType = this.options.analytics.integrationType;
analytics.setGlobalProperty('sdk_version', version);
analytics.setGlobalProperty('dapp_id', dappId);
analytics.setGlobalProperty('anon_id', anonId);
analytics.setGlobalProperty('platform', platform);
analytics.setGlobalProperty('integration_type', integrationType);
analytics.enable();
analytics.track('sdk_initialized', {});
}
private async onTransportNotification(data: any) {
if (data.method === 'session_changed') {
const session = data.params.session;
//TODO: We also should report this as an issue, sessions with no sessionScopes should be undefined, is there any reason
//why the object comes empty?
if (Object.keys(session?.sessionScopes ?? {}).length > 0) {
this.emit('sessionChanged', session);
} else {
this.emit('sessionChanged', undefined);
}
}
}
private async initialTransport() {
const transportType = await this.storage.getTransport();
if (transportType) {
if (transportType === TransportType.Browser) {
return getDefaultTransport(this.options.transport);
} else if (transportType === TransportType.MPW) {
return MWPClientTransport;
} else {
await this.storage.removeTransport();
}
}
return undefined;
}
private async setupTransport() {
const initialTransport = await this.initialTransport();
if (initialTransport) {
__transport = initialTransport;
}
if (__transport) {
const listener = __transport.onNotification(this.onTransportNotification);
if (!__transport.isConnected()) {
await __transport.connect();
}
__provider = getMultichainClient({ transport: __transport });
this.listeners.push(listener);
const session = await this.getCurrentSession();
if (Object.keys(session?.sessionScopes ?? {}).length > 0) {
this.emit('sessionChanged', session);
}
}
}
async init() {
try {
if (typeof window !== 'undefined' && window.mmsdk?.isInitialized) {
logger('MetaMaskSDK: init already initialized');
} else {
await this.setupAnalytics();
await this.setupTransport();
this.state = 'loaded';
if (typeof window !== 'undefined') {
window.mmsdk = this;
}
}
} catch (error) {
logger('MetaMaskSDK error during initialization', error);
}
}
private get hasExtension() {
if (typeof window !== 'undefined') {
return window.ethereum?.isMetaMask ?? false;
}
return false;
}
private async onConnectionSuccess(type: TransportType, transport: Transport, params: ModalFactoryConnectOptions) {
if (!transport.isConnected()) {
await transport.connect();
}
__transport = transport;
__provider = getMultichainClient({ transport });
await this.storage.setTransport(type);
const session = await this.getCurrentSession();
const currentScopes = Object.keys(session?.sessionScopes ?? {}) as Scope[];
const proposedScopes = params.scopes;
const isSameScopes = currentScopes.every((scope) => proposedScopes.includes(scope)) && proposedScopes.every((scope) => currentScopes.includes(scope));
if (isSameScopes) {
this.emit('sessionChanged', session);
return;
}
if (session) {
await this.provider.revokeSession();
}
const { scopes, caipAccountIds } = params;
const optionalScopes = addValidAccounts(getOptionalScopes(scopes), getValidAccounts(caipAccountIds));
const sessionRequest: CreateSessionParams<RPCAPI> = { optionalScopes };
const newSession = await this.provider.createSession(sessionRequest);
this.emit('sessionChanged', newSession);
}
private getTransportForPlatformType(platformType: PlatformType) {
if (__transport) {
return __transport;
}
if (platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.DesktopWeb || platformType === PlatformType.MobileWeb) {
return getDefaultTransport(this.options.transport);
}
return MWPClientTransport;
}
async connect(scopes: Scope[], caipAccountIds: CaipAccountId[]): Promise<void> {
const {
ui: { factory, ...uiProperties },
} = this.options;
const { preferExtension = false, preferDesktop = false, headless = false } = uiProperties;
const platformType = getPlatformType();
const transport = await this.getTransportForPlatformType(platformType);
const isWeb = platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.DesktopWeb || platformType === PlatformType.MobileWeb;
const existingSession = await this.getCurrentSession();
if (isWeb) {
if (existingSession) {
return this.onConnectionSuccess(TransportType.Browser, transport, {
scopes,
caipAccountIds,
});
}
if (this.hasExtension && preferExtension) {
return this.onConnectionSuccess(TransportType.Browser, transport, {
scopes,
caipAccountIds,
});
}
const link = this.options.dapp.url ?? this.options.dapp.name ?? 'dummy';
if (!this.hasExtension) {
if (preferExtension) {
// render install modal with extension tab selected
return factory.renderInstallModal(link, false);
}
// Doesn't have extension so we show install modal in the preferDesktop value
return factory.renderInstallModal(link, preferDesktop);
}
if (!preferExtension) {
// Has extension but we don't automatically chooose extension so we should show
return factory.renderSelectModal(link, true, async () => {
//This callback is after the user clicked extension in the select tab
return this.onConnectionSuccess(TransportType.Browser, transport, {
scopes,
caipAccountIds,
});
});
}
//We have extension and extension is the prefferred
return this.onConnectionSuccess(TransportType.Browser, transport, {
scopes,
caipAccountIds,
});
} else if (platformType === PlatformType.NonBrowser) {
return this.onConnectionSuccess(TransportType.MPW, transport, {
scopes,
caipAccountIds,
});
}
throw new Error('Not implemented');
}
async disconnect(): Promise<void> {
await __transport?.disconnect();
await __provider?.revokeSession();
this.listeners.forEach((listener) => listener());
__transport = undefined;
__provider = undefined;
this.emit('sessionChanged', undefined);
this.listeners = [];
await this.storage.removeTransport();
}

Fix in CursorFix in Web


Bug: SDK Instances Share Global Variables

The global variables __provider and __transport are shared across all MultichainSDK instances. This causes multiple SDK instances to interfere, leading to race conditions and unexpected behavior, as connect and disconnect operations from one instance can overwrite the connection state of others.

packages/sdk-multichain/src/multichain/index.ts#L33-L35

let __provider: MultichainApiClient<RPCAPI> | undefined;
let __transport: Transport | undefined;

Fix in CursorFix in Web


Was this report helpful? Give feedback by reacting with 👍 or 👎

Comment thread packages/sdk-multichain/package.json Outdated
Comment thread packages/sdk-multichain/package.json Outdated
Comment thread packages/sdk-multichain/src/connect.test.ts
Comment thread packages/sdk-multichain/src/domain/events/types/index.ts
Comment thread packages/sdk-multichain/src/domain/multichain/types.ts
Comment thread packages/sdk-multichain/src/index.browser.ts
Comment thread packages/sdk-multichain/src/multichain/index.ts Outdated
Comment thread packages/sdk-multichain/src/multichain/utils/index.ts Outdated
Comment thread packages/sdk-multichain/src/ui/node/select.ts
Comment thread packages/sdk-multichain/src/ui/web/install.ts
chakra-guy
chakra-guy previously approved these changes Jul 24, 2025
@chakra-guy

Copy link
Copy Markdown
Collaborator
  • please fix the base64 and rpc stuff as well

}
if (__transport) {
const listener = __transport.onNotification(this.onTransportNotification);
if (!__transport.isConnected()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Callback Context Error in SDK Method

The onTransportNotification method is passed as a callback to __transport.onNotification without binding its this context. When the callback is invoked, this does not refer to the MultichainSDK instance, causing this.emit to fail.

Locations (1)

Fix in CursorFix in Web

enabled: options.analytics?.enabled !== undefined ? options.analytics.enabled : true,
integrationType: 'unknown',
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: SDK Overwrites User Integration Type

The MultichainSDK constructor unconditionally sets integrationType to 'unknown', overwriting any user-provided value in options.analytics. This prevents users from customizing the integration type. The implementation should prioritize the user's integrationType and default to 'unknown' only if not provided.

Locations (1)

Fix in CursorFix in Web

Comment thread packages/sdk-multichain/src/multichain/index.ts
return {
mount: () => {
parentElement?.appendChild(modal);
setTimeout(() => this.updateQRCode(link), 100);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Modal Initialization Timing Issue

A race condition exists in the modal mount logic due to a fragile 100ms setTimeout delay for the updateQRCode call. This timing-dependent approach can cause updateQRCode to execute before the modal element is fully initialized in the DOM or after the modal has been unmounted, potentially leading to display issues.

Locations (1)

Fix in CursorFix in Web

}
if (__transport) {
const listener = __transport.onNotification(this.onTransportNotification);
if (!__transport.isConnected()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Callback Method Fails Without Proper Binding

The onTransportNotification method is passed as a callback to __transport.onNotification() without binding its this context. This will cause this.emit() calls within the method to fail when invoked by the transport. The method should be bound (e.g., this.onTransportNotification.bind(this)) or defined as an arrow function.

Locations (1)

Fix in CursorFix in Web

@elribonazo

elribonazo commented Jul 24, 2025

Copy link
Copy Markdown
Contributor Author
  • please fix the base64 and rpc stuff as well

Duplicate RPC stuff has been removed.
Base64 files too, they were not in use yet so we're good to remove those

*/
export const isEnabled = async (namespace: LoggerNameSpaces, storage: StoreClient) => {
if (process?.env?.DEBUG) {
if ('process' in globalThis && process?.env?.DEBUG) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Unnecessary process Check Causes Compatibility Issues

The condition if ('process' in globalThis && process?.env?.DEBUG) is less robust than the original process?.env?.DEBUG. It introduces an unnecessary check that can cause issues in environments where the process object exists but is not directly accessible via globalThis (e.g., certain bundled environments). The original optional chaining was more broadly compatible and sufficient.

Locations (1)

Fix in CursorFix in Web

@sonarqubecloud

Copy link
Copy Markdown

@elribonazo elribonazo merged commit 38271d2 into main Jul 24, 2025
60 of 65 checks passed
@elribonazo elribonazo deleted the features/ui-modal-abstraction branch July 24, 2025 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants