Skip to content
Draft
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
23 changes: 23 additions & 0 deletions packages/connect-evm/src/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ function createMockCore() {
},
};

// Track registered clients for testing (with scopes)
const registeredClients = new Map<string, { clientId: string; sdkType: string; scopes: string[] }>();

const mockCore: Partial<MultichainCore> = {
// Delegate event methods to the real emitter
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
Expand Down Expand Up @@ -63,6 +66,26 @@ function createMockCore() {

disconnect: vi.fn().mockResolvedValue(undefined),

// Client registration methods (for singleton pattern with scope tracking)
registerClient: vi.fn((clientId: string, sdkType: string, scopes: string[]) => {
registeredClients.set(clientId, { clientId, sdkType, scopes });
}),
unregisterClient: vi.fn((clientId: string) => {
registeredClients.delete(clientId);
return registeredClients.size === 0;
}),
getClientCount: vi.fn(() => registeredClients.size),
getUnionScopes: vi.fn(() => {
const allScopes = new Set<string>();
for (const client of registeredClients.values()) {
for (const scope of client.scopes) {
allScopes.add(scope);
}
}
return Array.from(allScopes);
}),
updateSessionScopes: vi.fn().mockResolvedValue(undefined),

transport: mockTransport as any,
storage: mockStorage as any,
status: 'connected' as const,
Expand Down
34 changes: 33 additions & 1 deletion packages/connect-evm/src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export class MetamaskConnectEVM {
/** The clean-up function for the notification handler */
#removeNotificationHandler?: () => void;

/** Unique identifier for this client instance */
readonly #clientId: string;

/** Whether this client is currently registered with the core */
#isRegistered = false;

/**
* Creates a new MetamaskConnectEVM instance.
* Use the static `create()` method instead to ensure proper async initialization.
Expand All @@ -112,6 +118,7 @@ export class MetamaskConnectEVM {
*/
private constructor({ core, eventHandlers }: MetamaskConnectEVMOptions) {
this.#core = core;
this.#clientId = `evm-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;

this.#provider = new EIP1193Provider(
core,
Expand Down Expand Up @@ -341,6 +348,12 @@ export class MetamaskConnectEVM {
forceRequest,
);

// Register this client with the core (for reference counting and scope tracking)
if (!this.#isRegistered) {
this.#core.registerClient(this.#clientId, 'evm', caipChainIds as Scope[]);
this.#isRegistered = true;
}

const hexPermittedChainIds = getPermittedEthChainIds(this.#sessionScopes);

const initialAccounts = await this.#core.transport.sendEip1193Message<
Expand Down Expand Up @@ -479,13 +492,32 @@ export class MetamaskConnectEVM {

/**
* Disconnects from the wallet by revoking the session and cleaning up event listeners.
* Only actually revokes the session if this is the last client using the shared core.
*
* @returns A promise that resolves when disconnection is complete
*/
async disconnect(): Promise<void> {
logger('request: disconnect');

await this.#core.disconnect();
// Unregister this client from the core
const isLastClient = this.#isRegistered
? this.#core.unregisterClient(this.#clientId)
: true;
this.#isRegistered = false;

// Only actually disconnect if this was the last client
if (isLastClient) {
logger('Last client disconnecting, revoking session');
await this.#core.disconnect();
} else {
// Other clients remain - update session to only have their scopes
const remainingScopes = this.#core.getUnionScopes();
logger(
`Other clients remain (${this.#core.getClientCount()}), updating session to scopes: ${remainingScopes.join(', ')}`,
);
await this.#core.updateSessionScopes(remainingScopes);
}

this.#onDisconnect();
this.#clearConnectionState();

Expand Down
61 changes: 61 additions & 0 deletions packages/connect-multichain/src/domain/multichain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ export enum TransportType {
UNKNOWN = 'unknown',
}

/**
* Information about a registered client.
*/
export type ClientInfo = {
/** Unique identifier for the client */
clientId: string;
/** The SDK type (e.g., 'evm', 'solana', 'multichain') */
sdkType: string;
/** When the client was registered */
registeredAt: number;
/** The scopes this client has requested */
scopes: Scope[];
};

/**
* Abstract base class for the Multichain SDK implementation.
*
Expand Down Expand Up @@ -70,6 +84,53 @@ export abstract class MultichainCore extends EventEmitter<SDKEvents> {

abstract openDeeplinkIfNeeded(): void;

/**
* Registers a client with the core.
* Call this when a thin client (EVM, Solana) connects.
*
* @param clientId - Unique identifier for the client
* @param sdkType - The SDK type (e.g., 'evm', 'solana')
* @param scopes - The scopes this client has requested
*/
abstract registerClient(clientId: string, sdkType: string, scopes: Scope[]): void;

/**
* Gets the union of all scopes from all registered clients.
*
* @returns Array of unique scopes from all clients
*/
abstract getUnionScopes(): Scope[];

/**
* Unregisters a client from the core.
* Call this when a thin client disconnects.
* Returns true if this was the last client (actual disconnect should happen).
*
* @param clientId - The client ID to unregister
* @returns True if this was the last client, false if others remain
*/
abstract unregisterClient(clientId: string): boolean;

/**
* Gets the number of currently registered clients.
*
* @returns The number of active clients
*/
abstract getClientCount(): number;

/**
* Updates the session scopes when a client disconnects but others remain.
*
* NOTE: There is no CAIP standard for partial scope revocation.
* The wallet keeps all previously granted scopes. This method updates
* the SDK's internal tracking only. Full disconnect requires
* wallet_revokeSession when all clients disconnect.
*
* @param scopes - The scopes that remaining clients need
* @returns Promise that resolves when complete
*/
abstract updateSessionScopes(scopes: Scope[]): Promise<void>;

constructor(protected readonly options: MultichainOptions) {
super();
}
Expand Down
56 changes: 43 additions & 13 deletions packages/connect-multichain/src/index.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,57 @@
// Buffer polyfill must be imported first to set up globalThis.Buffer
import './polyfills/buffer-shim';

import type { CreateMultichainFN } from './domain';
import type { CreateMultichainFN, MultichainCore } from './domain';
import { enableDebug } from './domain';
import { MetaMaskConnectMultichain } from './multichain';
import {
createIsolatedStorage,
generateInstanceId,
} from './store/create-storage';
import { createIsolatedStorage } from './store/create-storage';
import { ModalFactory } from './ui';

export * from './domain';

// Singleton key for the core instance (using globalThis for cross-environment support)
const CORE_KEY = '__metamaskCore';

/**
* Get the cached core instance (if available)
*/
export function getCachedCore(): MultichainCore | undefined {
return (globalThis as Record<string, unknown>)[CORE_KEY] as
| MultichainCore
| undefined;
}

/**
* Check if a core instance is cached
*/
export function hasCachedCore(): boolean {
return CORE_KEY in globalThis;
}

/**
* Clear the cached core (for testing)
* @internal
*/
export function _clearCoreForTesting(): void {
delete (globalThis as Record<string, unknown>)[CORE_KEY];
}

export const createMultichainClient: CreateMultichainFN = async (options) => {
if (options.debug) {
enableDebug('metamask-sdk:*');
}

const uiModules = await import('./ui/modals/web');
// Return existing singleton if available
const existingCore = getCachedCore();
if (existingCore) {
return existingCore;
}

// Generate deterministic instanceId if not provided
// Empty string means no prefixing (for backwards compatibility / testing)
const sdkType = options.sdkType ?? 'multichain';
const instanceId =
options.instanceId ?? generateInstanceId(options.dapp.name, sdkType);
// Create new core
const uiModules = await import('./ui/modals/web');

const storage = await createIsolatedStorage({
instanceId,
instanceId: options.instanceId ?? '',
userStorage: options.storage,
createAdapter: async () => {
const { StoreAdapterWeb } = await import('./store/adapters/web');
Expand All @@ -36,12 +61,17 @@ export const createMultichainClient: CreateMultichainFN = async (options) => {
});

const factory = new ModalFactory(uiModules);
return MetaMaskConnectMultichain.create({
const core = await MetaMaskConnectMultichain.create({
...options,
storage,
ui: {
...options.ui,
factory,
},
});

// Cache the singleton
(globalThis as Record<string, unknown>)[CORE_KEY] = core;

return core;
};
56 changes: 43 additions & 13 deletions packages/connect-multichain/src/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,57 @@
// Buffer polyfill must be imported first to set up global.Buffer
import './polyfills/buffer-shim';

import type { CreateMultichainFN } from './domain';
import type { CreateMultichainFN, MultichainCore } from './domain';
import { enableDebug } from './domain';
import { MetaMaskConnectMultichain } from './multichain';
import {
createIsolatedStorage,
generateInstanceId,
} from './store/create-storage';
import { createIsolatedStorage } from './store/create-storage';
import { ModalFactory } from './ui/index.native';

export * from './domain';

// Singleton key for the core instance (using globalThis for cross-environment support)
const CORE_KEY = '__metamaskCore';

/**
* Get the cached core instance (if available)
*/
export function getCachedCore(): MultichainCore | undefined {
return (globalThis as Record<string, unknown>)[CORE_KEY] as
| MultichainCore
| undefined;
}

/**
* Check if a core instance is cached
*/
export function hasCachedCore(): boolean {
return CORE_KEY in globalThis;
}

/**
* Clear the cached core (for testing)
* @internal
*/
export function _clearCoreForTesting(): void {
delete (globalThis as Record<string, unknown>)[CORE_KEY];
}

export const createMultichainClient: CreateMultichainFN = async (options) => {
if (options.debug) {
enableDebug('metamask-sdk:*');
}

const uiModules = await import('./ui/modals/rn');
// Return existing singleton if available
const existingCore = getCachedCore();
if (existingCore) {
return existingCore;
}

// Generate deterministic instanceId if not provided
// Empty string means no prefixing (for backwards compatibility / testing)
const sdkType = options.sdkType ?? 'multichain';
const instanceId =
options.instanceId ?? generateInstanceId(options.dapp.name, sdkType);
// Create new core
const uiModules = await import('./ui/modals/rn');

const storage = await createIsolatedStorage({
instanceId,
instanceId: options.instanceId ?? '',
userStorage: options.storage,
createAdapter: async () => {
const { StoreAdapterRN } = await import('./store/adapters/rn');
Expand All @@ -36,12 +61,17 @@ export const createMultichainClient: CreateMultichainFN = async (options) => {
});

const factory = new ModalFactory(uiModules);
return MetaMaskConnectMultichain.create({
const core = await MetaMaskConnectMultichain.create({
...options,
storage,
ui: {
...options.ui,
factory,
},
});

// Cache the singleton
(globalThis as Record<string, unknown>)[CORE_KEY] = core;

return core;
};
Loading
Loading