diff --git a/packages/sdk-multichain/package.json b/packages/sdk-multichain/package.json index b8a38e86c..c2849b001 100644 --- a/packages/sdk-multichain/package.json +++ b/packages/sdk-multichain/package.json @@ -18,9 +18,9 @@ "build": "yarn clean && npx tsup", "build:post-tsc": "echo 'N/A'", "build:pre-tsc": "echo 'N/A'", - "lint": "yarn biome lint ./src", - "lint:ci": "yarn biome ci ./src", - "lint:fix": "yarn biome format --write ./src", + "lint": "yarn biome lint --max-diagnostics 1000 ./src", + "lint:ci": "yarn biome ci --max-diagnostics 1000 ./src", + "lint:fix": "yarn biome format --max-diagnostics 1000 --write ./src", "lint:changelog": "../../scripts/validate-changelog.sh @metamask/multichain-sdk", "test": "vitest run", "test:unit": "vitest run", @@ -49,12 +49,14 @@ "license": "MIT", "dependencies": { "@metamask/multichain-api-client": "^0.6.4", + "@metamask/onboarding": "^1.0.1", "@metamask/sdk-analytics": "workspace:^", + "@metamask/sdk-install-modal-web": "workspace:^", "@metamask/utils": "^11.4.0", "@paulmillr/qr": "^0.2.1", "bowser": "^2.11.0", "cross-fetch": "^4.1.0", - "eventemitter2": "^6.4.9", + "eventemitter3": "^5.0.1", "uuid": "^11.1.0" } } diff --git a/packages/sdk-multichain/src/domain/errors/base.ts b/packages/sdk-multichain/src/domain/errors/base.ts index fac7a6622..a9174864b 100644 --- a/packages/sdk-multichain/src/domain/errors/base.ts +++ b/packages/sdk-multichain/src/domain/errors/base.ts @@ -1,10 +1,10 @@ import type { ErrorCodes } from "./types"; export abstract class BaseErr extends Error { - constructor( - public readonly message: `${C}Err${T}: ${string}`, - public readonly code: T, - ) { - super(message); - } + constructor( + public readonly message: `${C}Err${T}: ${string}`, + public readonly code: T, + ) { + super(message); + } } diff --git a/packages/sdk-multichain/src/domain/errors/index.ts b/packages/sdk-multichain/src/domain/errors/index.ts index 514b0ecde..17811d442 100644 --- a/packages/sdk-multichain/src/domain/errors/index.ts +++ b/packages/sdk-multichain/src/domain/errors/index.ts @@ -1,2 +1,2 @@ -export * from './rpc' -export * from './types'; +export * from "./rpc"; +export * from "./types"; diff --git a/packages/sdk-multichain/src/domain/errors/rpc.ts b/packages/sdk-multichain/src/domain/errors/rpc.ts index 8f46c6bc8..45e27930a 100644 --- a/packages/sdk-multichain/src/domain/errors/rpc.ts +++ b/packages/sdk-multichain/src/domain/errors/rpc.ts @@ -1,53 +1,34 @@ - import { BaseErr } from "./base"; import type { RPCErrorCodes } from "./types"; -export class RPCHttpErr extends BaseErr<'RPC', RPCErrorCodes> { - static readonly code = 50; - constructor( - readonly rpcEndpoint: string, - readonly method: string, - readonly httpStatus: number, - ) { - super( - `RPCErr${RPCHttpErr.code}: ${httpStatus} on ${rpcEndpoint} for method ${method}`, - RPCHttpErr.code - ); - } +export class RPCHttpErr extends BaseErr<"RPC", RPCErrorCodes> { + static readonly code = 50; + constructor( + readonly rpcEndpoint: string, + readonly method: string, + readonly httpStatus: number, + ) { + super(`RPCErr${RPCHttpErr.code}: ${httpStatus} on ${rpcEndpoint} for method ${method}`, RPCHttpErr.code); + } } -export class RPCReadonlyResponseErr extends BaseErr<'RPC', RPCErrorCodes> { - static readonly code = 51; - constructor( - public readonly reason: string, - ) { - super( - `RPCErr${RPCReadonlyResponseErr.code}: RPC Client response reason ${reason}`, - RPCReadonlyResponseErr.code - ); - } +export class RPCReadonlyResponseErr extends BaseErr<"RPC", RPCErrorCodes> { + static readonly code = 51; + constructor(public readonly reason: string) { + super(`RPCErr${RPCReadonlyResponseErr.code}: RPC Client response reason ${reason}`, RPCReadonlyResponseErr.code); + } } -export class RPCReadonlyRequestErr extends BaseErr<'RPC', RPCErrorCodes> { - static readonly code = 52; - constructor( - public readonly reason: string, - ) { - super( - `RPCErr${RPCReadonlyRequestErr.code}: RPC Client fetch reason ${reason}`, - RPCReadonlyRequestErr.code - ); - } +export class RPCReadonlyRequestErr extends BaseErr<"RPC", RPCErrorCodes> { + static readonly code = 52; + constructor(public readonly reason: string) { + super(`RPCErr${RPCReadonlyRequestErr.code}: RPC Client fetch reason ${reason}`, RPCReadonlyRequestErr.code); + } } -export class RPCInvokeMethodErr extends BaseErr<'RPC', RPCErrorCodes> { - static readonly code = 53; - constructor( - public readonly reason: string, - ) { - super( - `RPCErr${RPCInvokeMethodErr.code}: RPC Client invoke method reason ${reason}`, - RPCInvokeMethodErr.code - ); - } +export class RPCInvokeMethodErr extends BaseErr<"RPC", RPCErrorCodes> { + static readonly code = 53; + constructor(public readonly reason: string) { + super(`RPCErr${RPCInvokeMethodErr.code}: RPC Client invoke method reason ${reason}`, RPCInvokeMethodErr.code); + } } diff --git a/packages/sdk-multichain/src/domain/errors/storage.ts b/packages/sdk-multichain/src/domain/errors/storage.ts index 05fd8c6f7..c156d16c2 100644 --- a/packages/sdk-multichain/src/domain/errors/storage.ts +++ b/packages/sdk-multichain/src/domain/errors/storage.ts @@ -1,45 +1,35 @@ - import { BaseErr } from "./base"; -import type { StorageErrorCodes } from "./types"; +import type { StorageErrorCodes } from "./types"; -export class StorageGetErr extends BaseErr<'Storage', StorageErrorCodes> { - static readonly code = 60; - constructor( - public readonly platform: 'web' | 'rn' | 'node', - public readonly key: string, - public readonly reason: string, - ) { - super( - `StorageErr${StorageGetErr.code}: ${platform} storage get error in key: ${key} - ${reason}`, - StorageGetErr.code - ); - } +export class StorageGetErr extends BaseErr<"Storage", StorageErrorCodes> { + static readonly code = 60; + constructor( + public readonly platform: "web" | "rn" | "node", + public readonly key: string, + public readonly reason: string, + ) { + super(`StorageErr${StorageGetErr.code}: ${platform} storage get error in key: ${key} - ${reason}`, StorageGetErr.code); + } } -export class StorageSetErr extends BaseErr<'Storage', StorageErrorCodes> { - static readonly code = 61; - constructor( - public readonly platform: 'web' | 'rn' | 'node', - public readonly key: string, - public readonly reason: string, - ) { - super( - `StorageErr${StorageSetErr.code}: ${platform} storage set error in key: ${key} - ${reason}`, - StorageSetErr.code - ); - } +export class StorageSetErr extends BaseErr<"Storage", StorageErrorCodes> { + static readonly code = 61; + constructor( + public readonly platform: "web" | "rn" | "node", + public readonly key: string, + public readonly reason: string, + ) { + super(`StorageErr${StorageSetErr.code}: ${platform} storage set error in key: ${key} - ${reason}`, StorageSetErr.code); + } } -export class StorageDeleteErr extends BaseErr<'Storage', StorageErrorCodes> { - static readonly code = 62; - constructor( - public readonly platform: 'web' | 'rn' | 'node', - public readonly key: string, - public readonly reason: string, - ) { - super( - `StorageErr${StorageDeleteErr.code}: ${platform} storage delete error in key: ${key} - ${reason}`, - StorageDeleteErr.code - ); - } +export class StorageDeleteErr extends BaseErr<"Storage", StorageErrorCodes> { + static readonly code = 62; + constructor( + public readonly platform: "web" | "rn" | "node", + public readonly key: string, + public readonly reason: string, + ) { + super(`StorageErr${StorageDeleteErr.code}: ${platform} storage delete error in key: ${key} - ${reason}`, StorageDeleteErr.code); + } } diff --git a/packages/sdk-multichain/src/domain/errors/types.ts b/packages/sdk-multichain/src/domain/errors/types.ts index 27f1c2736..2453e4ca7 100644 --- a/packages/sdk-multichain/src/domain/errors/types.ts +++ b/packages/sdk-multichain/src/domain/errors/types.ts @@ -1,6 +1,4 @@ -export type Enumerate = Acc['length'] extends N -? Acc[number] -: Enumerate; +export type Enumerate = Acc["length"] extends N ? Acc[number] : Enumerate; export type ErrorCodeRange = Exclude, Enumerate>; @@ -8,7 +6,4 @@ export type DomainErrorCodes = ErrorCodeRange<1, 50>; export type RPCErrorCodes = ErrorCodeRange<50, 60>; export type StorageErrorCodes = ErrorCodeRange<60, 70>; -export type ErrorCodes = - | DomainErrorCodes - | RPCErrorCodes - | StorageErrorCodes; +export type ErrorCodes = DomainErrorCodes | RPCErrorCodes | StorageErrorCodes; diff --git a/packages/sdk-multichain/src/domain/events/index.ts b/packages/sdk-multichain/src/domain/events/index.ts index cb3c95584..43cb9b08a 100644 --- a/packages/sdk-multichain/src/domain/events/index.ts +++ b/packages/sdk-multichain/src/domain/events/index.ts @@ -1,7 +1,4 @@ -import { EventEmitter2 } from 'eventemitter2'; - -import type { EventTypes } from './types'; - +import { EventEmitter as EventEmitter3 } from "eventemitter3"; /** * A type-safe event emitter that provides a strongly-typed wrapper around EventEmitter2. @@ -12,66 +9,44 @@ import type { EventTypes } from './types'; * @template TEvents - A record type mapping event names to their argument types. * Each key represents an event name, and the value is a tuple of argument types. */ -export class EventEmitter< - TEvents extends Record = EventTypes, -> { - readonly #emitter = new EventEmitter2(); - - /** - * Emits an event with the specified name and arguments. - * - * @template TEventName - The name of the event to emit (must be a key of TEvents) - * @param eventName - The name of the event to emit - * @param eventArg - The arguments to pass to the event handlers - */ - emit( - eventName: TEventName, - ...eventArg: TEvents[TEventName] - ) { - this.#emitter.emit(eventName, ...eventArg); - } - - /** - * Registers an event handler for the specified event. - * - * @template TEventName - The name of the event to listen for (must be a key of TEvents) - * @param eventName - The name of the event to listen for - * @param handler - The function to call when the event is emitted - */ - on( - eventName: TEventName, - handler: (...eventArg: TEvents[TEventName]) => void, - ) { - this.#emitter.on(eventName, handler); - } - - /** - * Sets the maximum number of listeners that can be registered for any single event. - * - * This is useful for preventing memory leaks when many listeners are registered. - * By default, EventEmitter2 will warn if more than 10 listeners are registered - * for a single event. - * - * @param maxListeners - The maximum number of listeners per event (0 means unlimited) - */ - setMaxListeners(maxListeners: number) { - this.#emitter.setMaxListeners(maxListeners); - } - - /** - * Removes a specific event handler for the specified event. - * - * @template TEventName - The name of the event to remove the handler from (must be a key of TEvents) - * @param eventName - The name of the event to remove the handler from - * @param handler - The specific handler function to remove - */ - off( - eventName: TEventName, - handler: (...eventArg: TEvents[TEventName]) => void, - ) { - this.#emitter.off(eventName, handler); - } +export class EventEmitter> { + readonly #emitter = new EventEmitter3(); + + /** + * Emits an event with the specified name and arguments. + * + * @template TEventName - The name of the event to emit (must be a key of TEvents) + * @param eventName - The name of the event to emit + * @param eventArg - The arguments to pass to the event handlers + */ + emit(eventName: TEventName, ...eventArg: TEvents[TEventName]) { + this.#emitter.emit(eventName, ...eventArg); + } + + /** + * Registers an event handler for the specified event. + * + * @template TEventName - The name of the event to listen for (must be a key of TEvents) + * @param eventName - The name of the event to listen for + * @param handler - The function to call when the event is emitted + */ + on(eventName: TEventName, handler: (...eventArg: TEvents[TEventName]) => void) { + this.#emitter.on(eventName, handler); + return () => { + this.off(eventName, handler); + }; + } + + /** + * Removes a specific event handler for the specified event. + * + * @template TEventName - The name of the event to remove the handler from (must be a key of TEvents) + * @param eventName - The name of the event to remove the handler from + * @param handler - The specific handler function to remove + */ + off(eventName: TEventName, handler: (...eventArg: TEvents[TEventName]) => void) { + this.#emitter.off(eventName, handler); + } } - -export type * from './types'; +export type * from "./types"; diff --git a/packages/sdk-multichain/src/domain/events/types/extension.ts b/packages/sdk-multichain/src/domain/events/types/extension.ts deleted file mode 100644 index 08de33381..000000000 --- a/packages/sdk-multichain/src/domain/events/types/extension.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Extension native Events - */ -export type ExtensionEvents = { - chainChanged: [evt: unknown]; -}; diff --git a/packages/sdk-multichain/src/domain/events/types/index.ts b/packages/sdk-multichain/src/domain/events/types/index.ts index 1ff8a790d..2b176b722 100644 --- a/packages/sdk-multichain/src/domain/events/types/index.ts +++ b/packages/sdk-multichain/src/domain/events/types/index.ts @@ -1,5 +1,8 @@ -import type { ExtensionEvents } from './extension'; -import type { SDKEvents } from './sdk'; +import type { SessionData } from "@metamask/multichain-api-client"; -export type EventTypes = SDKEvents | ExtensionEvents; -export type { SDKEvents, ExtensionEvents }; +export type SDKEvents = { + display_uri: [evt: string]; + sessionChanged: [evt: SessionData | undefined]; +}; + +export type EventTypes = SDKEvents; diff --git a/packages/sdk-multichain/src/domain/events/types/sdk.ts b/packages/sdk-multichain/src/domain/events/types/sdk.ts deleted file mode 100644 index 46197c788..000000000 --- a/packages/sdk-multichain/src/domain/events/types/sdk.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type SDKEvents = { - display_uri: [evt: string]; -}; diff --git a/packages/sdk-multichain/src/domain/index.test.ts b/packages/sdk-multichain/src/domain/index.test.ts index f31baad84..f3ef59bb7 100644 --- a/packages/sdk-multichain/src/domain/index.test.ts +++ b/packages/sdk-multichain/src/domain/index.test.ts @@ -1,513 +1,419 @@ +import * as t from "vitest"; +import Bowser from "bowser"; - -import * as t from 'vitest' -import Bowser from 'bowser'; - -import { EventEmitter,type ExtensionEvents, type SDKEvents, getPlatformType, PlatformType, createLogger, enableDebug, isEnabled } from './'; - +import { EventEmitter, type SDKEvents, getPlatformType, PlatformType, createLogger, enableDebug, isEnabled } from "./"; const parseMock = t.vi.fn(); // Mock Bowser at the top level -t.vi.mock('bowser', () => ({ - default: { - parse: parseMock, - }, +t.vi.mock("bowser", () => ({ + default: { + parse: parseMock, + }, })); -t.describe('Platform Detection', () => { - let mockBowser: { parse: t.MockedFunction }; - - t.beforeEach(() => { - // Reset mocks - t.vi.clearAllMocks(); - t.vi.unstubAllGlobals(); - // Get the mocked instance - Bowser is a default export - mockBowser = t.vi.mocked(Bowser); - }); - - t.describe('getPlatformType', () => { - t.it('should return ReactNative when environment is React Native', () => { - // Mock React Native environment - t.vi.stubGlobal('window', { - navigator: { - product: 'ReactNative', - userAgent: 'ReactNative', - }, - }); - t.vi.stubGlobal('navigator', { - product: 'ReactNative', - userAgent: 'ReactNative', - }); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.ReactNative); - }); - - t.it('should return NonBrowser when window is undefined', () => { - t.vi.stubGlobal('window', undefined); - t.vi.stubGlobal('navigator', undefined); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.NonBrowser); - }); - - t.it('should return NonBrowser when window.navigator is undefined', () => { - t.vi.stubGlobal('window', {}); - t.vi.stubGlobal('navigator', undefined); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.NonBrowser); - }); - - t.it('should return MetaMaskMobileWebview when in MetaMask mobile webview', () => { - t.vi.stubGlobal('window', { - ReactNativeWebView: {}, - navigator: { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 MetaMaskMobile', - }, - }); - t.vi.stubGlobal('navigator', { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 MetaMaskMobile', - }); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.MetaMaskMobileWebview); - }); - - t.it('should return MobileWeb when platform type is mobile', () => { - const mockBrowserInfo = { - platform: { type: 'mobile' }, - }; - - t.vi.stubGlobal('window', { - navigator: { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15', - }, - }); - t.vi.stubGlobal('navigator', { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15', - }); - - parseMock.mockReturnValue(mockBrowserInfo); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.MobileWeb); - }); - - t.it('should return MobileWeb when platform type is tablet', () => { - const mockBrowserInfo = { - platform: { type: 'tablet' }, - }; - - t.vi.stubGlobal('window', { - navigator: { - userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15', - }, - }); - t.vi.stubGlobal('navigator', { - userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15', - }); - - parseMock.mockReturnValue(mockBrowserInfo); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.MobileWeb); - }); - - t.it('should return DesktopWeb when platform type is desktop', () => { - const mockBrowserInfo = { - platform: { type: 'desktop' }, - }; - - t.vi.stubGlobal('window', { - navigator: { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - }, - }); - t.vi.stubGlobal('navigator', { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - }); - - parseMock.mockReturnValue(mockBrowserInfo); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.DesktopWeb); - }); - - t.it('should return DesktopWeb when platform type is undefined', () => { - const mockBrowserInfo = { - platform: { type: undefined }, - }; - - t.vi.stubGlobal('window', { - navigator: { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - }, - }); - t.vi.stubGlobal('navigator', { - userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - }); - - parseMock.mockReturnValue(mockBrowserInfo); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.DesktopWeb); - }); - - t.it('should not return MetaMaskMobileWebview when ReactNativeWebView is missing', () => { - t.vi.stubGlobal('window', { - navigator: { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 MetaMaskMobile', - }, - }); - t.vi.stubGlobal('navigator', { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 MetaMaskMobile', - }); - - const mockBrowserInfo = { - platform: { type: 'mobile' }, - }; - parseMock.mockReturnValue(mockBrowserInfo); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.MobileWeb); - }); - - t.it('should not return MetaMaskMobileWebview when userAgent does not end with MetaMaskMobile', () => { - t.vi.stubGlobal('window', { - ReactNativeWebView: {}, - navigator: { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15', - }, - }); - t.vi.stubGlobal('navigator', { - userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15', - }); - - const mockBrowserInfo = { - platform: { type: 'mobile' }, - }; - parseMock.mockReturnValue(mockBrowserInfo); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.MobileWeb); - }); - - t.it('should handle global navigator.product being ReactNative', () => { - t.vi.stubGlobal('global', { - navigator: { - product: 'ReactNative', - }, - }); - t.vi.stubGlobal('navigator', { - product: 'ReactNative', - }); - - const result = getPlatformType(); - t.expect(result).toBe(PlatformType.NonBrowser); - }); - }); +t.describe("Platform Detection", () => { + let mockBowser: { parse: t.MockedFunction }; + + t.beforeEach(() => { + // Reset mocks + t.vi.clearAllMocks(); + t.vi.unstubAllGlobals(); + // Get the mocked instance - Bowser is a default export + mockBowser = t.vi.mocked(Bowser); + }); + + t.describe("getPlatformType", () => { + t.it("should return ReactNative when environment is React Native", () => { + // Mock React Native environment + t.vi.stubGlobal("window", { + navigator: { + product: "ReactNative", + userAgent: "ReactNative", + }, + }); + t.vi.stubGlobal("navigator", { + product: "ReactNative", + userAgent: "ReactNative", + }); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.ReactNative); + }); + + t.it("should return NonBrowser when window is undefined", () => { + t.vi.stubGlobal("window", undefined); + t.vi.stubGlobal("navigator", undefined); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.NonBrowser); + }); + + t.it("should return NonBrowser when window.navigator is undefined", () => { + t.vi.stubGlobal("window", {}); + t.vi.stubGlobal("navigator", undefined); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.NonBrowser); + }); + + t.it("should return MetaMaskMobileWebview when in MetaMask mobile webview", () => { + t.vi.stubGlobal("window", { + ReactNativeWebView: {}, + navigator: { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 MetaMaskMobile", + }, + }); + t.vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 MetaMaskMobile", + }); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.MetaMaskMobileWebview); + }); + + t.it("should return MobileWeb when platform type is mobile", () => { + const mockBrowserInfo = { + platform: { type: "mobile" }, + }; + + t.vi.stubGlobal("window", { + navigator: { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15", + }, + }); + t.vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15", + }); + + parseMock.mockReturnValue(mockBrowserInfo); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.MobileWeb); + }); + + t.it("should return MobileWeb when platform type is tablet", () => { + const mockBrowserInfo = { + platform: { type: "tablet" }, + }; + + t.vi.stubGlobal("window", { + navigator: { + userAgent: "Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15", + }, + }); + t.vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15", + }); + + parseMock.mockReturnValue(mockBrowserInfo); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.MobileWeb); + }); + + t.it("should return DesktopWeb when platform type is desktop", () => { + const mockBrowserInfo = { + platform: { type: "desktop" }, + }; + + t.vi.stubGlobal("window", { + navigator: { + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + }); + t.vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }); + + parseMock.mockReturnValue(mockBrowserInfo); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.DesktopWeb); + }); + + t.it("should return DesktopWeb when platform type is undefined", () => { + const mockBrowserInfo = { + platform: { type: undefined }, + }; + + t.vi.stubGlobal("window", { + navigator: { + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + }); + t.vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }); + + parseMock.mockReturnValue(mockBrowserInfo); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.DesktopWeb); + }); + + t.it("should not return MetaMaskMobileWebview when ReactNativeWebView is missing", () => { + t.vi.stubGlobal("window", { + navigator: { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 MetaMaskMobile", + }, + }); + t.vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 MetaMaskMobile", + }); + + const mockBrowserInfo = { + platform: { type: "mobile" }, + }; + parseMock.mockReturnValue(mockBrowserInfo); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.MobileWeb); + }); + + t.it("should not return MetaMaskMobileWebview when userAgent does not end with MetaMaskMobile", () => { + t.vi.stubGlobal("window", { + ReactNativeWebView: {}, + navigator: { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15", + }, + }); + t.vi.stubGlobal("navigator", { + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15", + }); + + const mockBrowserInfo = { + platform: { type: "mobile" }, + }; + parseMock.mockReturnValue(mockBrowserInfo); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.MobileWeb); + }); + + t.it("should handle global navigator.product being ReactNative", () => { + t.vi.stubGlobal("global", { + navigator: { + product: "ReactNative", + }, + }); + t.vi.stubGlobal("navigator", { + product: "ReactNative", + }); + + const result = getPlatformType(); + t.expect(result).toBe(PlatformType.NonBrowser); + }); + }); }); t.describe("Logger", () => { + t.vi.mock("debug", { spy: true }); - t.vi.mock('debug', { spy: true }); - - // Mock StoreClient - const mockStoreClient = { - getDebug: t.vi.fn(), - }; - - t.beforeEach(() => { - t.vi.clearAllMocks(); - delete process.env.DEBUG; - mockStoreClient.getDebug.mockClear(); - }); - - t.describe('createLogger', () => { - t.it('should create a logger with default namespace', () => { - const logger = createLogger(); - - t.expect(logger).toBeDefined(); - t.expect(typeof logger).toBe('function'); - }); - - t.it('should create a logger with custom namespace', () => { - const logger = createLogger('metamask-sdk:core'); - - t.expect(logger).toBeDefined(); - t.expect(typeof logger).toBe('function'); - }); - - t.it('should create a logger with custom namespace and color', () => { - const logger = createLogger('metamask-sdk:core', '123'); - - t.expect(logger).toBeDefined(); - t.expect(typeof logger).toBe('function'); - t.expect(logger.color).toBe('123'); - }); - }); - - t.describe('enableDebug', () => { - t.it('should enable debug with default namespace', () => { - t.expect(() => enableDebug()).not.toThrow(); - }); - - t.it('should enable debug with custom namespace', () => { - t.expect(() => enableDebug('metamask-sdk:provider')).not.toThrow(); - }); - }); + // Mock StoreClient + const mockStoreClient = { + getDebug: t.vi.fn(), + }; - t.describe('isEnabled', () => { - t.it('should return true when namespace is in process.env.DEBUG', async () => { - process.env.DEBUG = 'metamask-sdk:core'; + t.beforeEach(() => { + t.vi.clearAllMocks(); + delete process.env.DEBUG; + mockStoreClient.getDebug.mockClear(); + }); - const result = await isEnabled('metamask-sdk:core', mockStoreClient as any); + t.describe("createLogger", () => { + t.it("should create a logger with default namespace", () => { + const logger = createLogger(); - t.expect(result).toBe(true); - t.expect(mockStoreClient.getDebug).not.toHaveBeenCalled(); - }); + t.expect(logger).toBeDefined(); + t.expect(typeof logger).toBe("function"); + }); - t.it('should return true when wildcard matches namespace', async () => { - process.env.DEBUG = 'metamask-sdk:*'; + t.it("should create a logger with custom namespace", () => { + const logger = createLogger("metamask-sdk:core"); - const result = await isEnabled('metamask-sdk:core', mockStoreClient as any); + t.expect(logger).toBeDefined(); + t.expect(typeof logger).toBe("function"); + }); - t.expect(result).toBe(true); - }); + t.it("should create a logger with custom namespace and color", () => { + const logger = createLogger("metamask-sdk:core", "123"); - t.it('should return true when universal wildcard is used', async () => { - process.env.DEBUG = '*'; + t.expect(logger).toBeDefined(); + t.expect(typeof logger).toBe("function"); + t.expect(logger.color).toBe("123"); + }); + }); - const result = await isEnabled('metamask-sdk', mockStoreClient as any); + t.describe("enableDebug", () => { + t.it("should enable debug with default namespace", () => { + t.expect(() => enableDebug()).not.toThrow(); + }); - t.expect(result).toBe(true); - }); + t.it("should enable debug with custom namespace", () => { + t.expect(() => enableDebug("metamask-sdk:provider")).not.toThrow(); + }); + }); - t.it('should return false when namespace is not in process.env.DEBUG', async () => { - process.env.DEBUG = 'other-namespace'; + t.describe("isEnabled", () => { + t.it("should return true when namespace is in process.env.DEBUG", async () => { + process.env.DEBUG = "metamask-sdk:core"; - const result = await isEnabled('metamask-sdk:core', mockStoreClient as any); + const result = await isEnabled("metamask-sdk:core", mockStoreClient as any); - t.expect(result).toBe(false); - }); + t.expect(result).toBe(true); + t.expect(mockStoreClient.getDebug).not.toHaveBeenCalled(); + }); - t.it('should check storage when process.env.DEBUG is not set', async () => { - mockStoreClient.getDebug.mockResolvedValue('metamask-sdk:provider'); + t.it("should return true when wildcard matches namespace", async () => { + process.env.DEBUG = "metamask-sdk:*"; - const result = await isEnabled('metamask-sdk:provider', mockStoreClient as any); + const result = await isEnabled("metamask-sdk:core", mockStoreClient as any); - t.expect(result).toBe(true); - t.expect(mockStoreClient.getDebug).toHaveBeenCalled(); - }); + t.expect(result).toBe(true); + }); - t.it('should return false when storage debug is empty', async () => { - mockStoreClient.getDebug.mockResolvedValue(null); + t.it("should return true when universal wildcard is used", async () => { + process.env.DEBUG = "*"; - const result = await isEnabled('metamask-sdk', mockStoreClient as any); + const result = await isEnabled("metamask-sdk", mockStoreClient as any); - t.expect(result).toBe(false); - }); + t.expect(result).toBe(true); + }); - t.it('should prioritize process.env.DEBUG over storage', async () => { - process.env.DEBUG = 'metamask-sdk:core'; - mockStoreClient.getDebug.mockResolvedValue('metamask-sdk:provider'); + t.it("should return false when namespace is not in process.env.DEBUG", async () => { + process.env.DEBUG = "other-namespace"; - const result = await isEnabled('metamask-sdk:core', mockStoreClient as any); + const result = await isEnabled("metamask-sdk:core", mockStoreClient as any); - t.expect(result).toBe(true); - t.expect(mockStoreClient.getDebug).not.toHaveBeenCalled(); - }); - }); + t.expect(result).toBe(false); + }); -}) + t.it("should check storage when process.env.DEBUG is not set", async () => { + mockStoreClient.getDebug.mockResolvedValue("metamask-sdk:provider"); + const result = await isEnabled("metamask-sdk:provider", mockStoreClient as any); -t.describe('EventEmitter', () => { + t.expect(result).toBe(true); + t.expect(mockStoreClient.getDebug).toHaveBeenCalled(); + }); + t.it("should return false when storage debug is empty", async () => { + mockStoreClient.getDebug.mockResolvedValue(null); - t.describe('emit', () => { + const result = await isEnabled("metamask-sdk", mockStoreClient as any); - t.describe("SDKEvents", () => { - let eventEmitter: EventEmitter; + t.expect(result).toBe(false); + }); - t.beforeEach(() => { - eventEmitter = new EventEmitter(); - }); + t.it("should prioritize process.env.DEBUG over storage", async () => { + process.env.DEBUG = "metamask-sdk:core"; + mockStoreClient.getDebug.mockResolvedValue("metamask-sdk:provider"); - t.it('should emit display_uri event with string argument', () => { - const handler = t.vi.fn(); - eventEmitter.on('display_uri', handler); + const result = await isEnabled("metamask-sdk:core", mockStoreClient as any); - eventEmitter.emit('display_uri', 'uri://test-connection'); - - t.expect(handler).toHaveBeenCalledWith('uri://test-connection'); - t.expect(handler).toHaveBeenCalledTimes(1); - }); - - t.it('should emit to all registered handlers for the same event', () => { - const handler1 = t.vi.fn(); - const handler2 = t.vi.fn(); - const handler3 = t.vi.fn(); - - eventEmitter.on('display_uri', handler1); - eventEmitter.on('display_uri', handler2); - eventEmitter.on('display_uri', handler3); - - eventEmitter.emit('display_uri', 'broadcast-uri'); - - t.expect(handler1).toHaveBeenCalledWith('broadcast-uri'); - t.expect(handler2).toHaveBeenCalledWith('broadcast-uri'); - t.expect(handler3).toHaveBeenCalledWith('broadcast-uri'); - t.expect(handler1).toHaveBeenCalledTimes(1); - t.expect(handler2).toHaveBeenCalledTimes(1); - t.expect(handler3).toHaveBeenCalledTimes(1); - }); - - t.it('should register handlers for display_uri event', () => { - const handler = t.vi.fn(); - - eventEmitter.on('display_uri', handler); - eventEmitter.emit('display_uri', 'test-uri'); - - t.expect(handler).toHaveBeenCalledWith('test-uri'); - }); - - t.it('should register multiple handlers for the same event', () => { - const handler1 = t.vi.fn(); - const handler2 = t.vi.fn(); - - eventEmitter.on('display_uri', handler1); - eventEmitter.on('display_uri', handler2); - - eventEmitter.emit('display_uri', 'test'); - - t.expect(handler1).toHaveBeenCalledWith('test'); - t.expect(handler2).toHaveBeenCalledWith('test'); - }); + t.expect(result).toBe(true); + t.expect(mockStoreClient.getDebug).not.toHaveBeenCalled(); + }); + }); +}); - t.it('should remove specific handlers for display_uri event', () => { - const handler1 = t.vi.fn(); - const handler2 = t.vi.fn(); +t.describe("EventEmitter", () => { + t.describe("emit", () => { + t.describe("SDKEvents", () => { + let eventEmitter: EventEmitter; - eventEmitter.on('display_uri', handler1); - eventEmitter.on('display_uri', handler2); + t.beforeEach(() => { + eventEmitter = new EventEmitter(); + }); - eventEmitter.off('display_uri', handler1); - eventEmitter.emit('display_uri', 'test'); + t.it("should emit display_uri event with string argument", () => { + const handler = t.vi.fn(); + eventEmitter.on("display_uri", handler); - t.expect(handler1).not.toHaveBeenCalled(); - t.expect(handler2).toHaveBeenCalledWith('test'); - }); + eventEmitter.emit("display_uri", "uri://test-connection"); - t.it('should handle removing non-existent handlers gracefully', () => { - const handler = t.vi.fn(); - const nonExistentHandler = t.vi.fn(); + t.expect(handler).toHaveBeenCalledWith("uri://test-connection"); + t.expect(handler).toHaveBeenCalledTimes(1); + }); - eventEmitter.on('display_uri', handler); + t.it("should emit to all registered handlers for the same event", () => { + const handler1 = t.vi.fn(); + const handler2 = t.vi.fn(); + const handler3 = t.vi.fn(); - // This should not throw an error - t.expect(() => { - eventEmitter.off('display_uri', nonExistentHandler); - }).not.toThrow(); + eventEmitter.on("display_uri", handler1); + eventEmitter.on("display_uri", handler2); + eventEmitter.on("display_uri", handler3); - eventEmitter.emit('display_uri', 'still works'); - t.expect(handler).toHaveBeenCalledWith('still works'); - }); + eventEmitter.emit("display_uri", "broadcast-uri"); - t.it('should handle removing handlers from non-existent events gracefully', () => { - const handler = t.vi.fn(); + t.expect(handler1).toHaveBeenCalledWith("broadcast-uri"); + t.expect(handler2).toHaveBeenCalledWith("broadcast-uri"); + t.expect(handler3).toHaveBeenCalledWith("broadcast-uri"); + t.expect(handler1).toHaveBeenCalledTimes(1); + t.expect(handler2).toHaveBeenCalledTimes(1); + t.expect(handler3).toHaveBeenCalledTimes(1); + }); - // This should not throw an error - t.expect(() => { - eventEmitter.off('display_uri', handler); - }).not.toThrow(); - }); - t.it('should not leak memory when handlers are removed', () => { - const handlers: Array<() => void> = []; + t.it("should register handlers for display_uri event", () => { + const handler = t.vi.fn(); - // Set a higher limit to avoid warnings - eventEmitter.setMaxListeners(200); + eventEmitter.on("display_uri", handler); + eventEmitter.emit("display_uri", "test-uri"); - // Add many handlers - for (let i = 0; i < 100; i++) { - const handler = t.vi.fn(); - handlers.push(handler); - eventEmitter.on('display_uri', handler); - } + t.expect(handler).toHaveBeenCalledWith("test-uri"); + }); - // Remove all handlers - handlers.forEach(handler => { - eventEmitter.off('display_uri', handler); - }); + t.it("should register multiple handlers for the same event", () => { + const handler1 = t.vi.fn(); + const handler2 = t.vi.fn(); - // Emit should not call any handlers - eventEmitter.emit('display_uri', 'should not be handled'); + eventEmitter.on("display_uri", handler1); + eventEmitter.on("display_uri", handler2); - handlers.forEach(handler => { - t.expect(handler).not.toHaveBeenCalled(); - }); - }); - }) + eventEmitter.emit("display_uri", "test"); - t.describe("ExtensionEvents", () => { - let eventEmitter: EventEmitter; - t.beforeEach(() => { - eventEmitter = new EventEmitter(); - }); + t.expect(handler1).toHaveBeenCalledWith("test"); + t.expect(handler2).toHaveBeenCalledWith("test"); + }); - t.it('should emit chainChanged event with unknown argument', () => { - const handler = t.vi.fn(); - eventEmitter.on('chainChanged', handler); + t.it("should remove specific handlers for display_uri event", () => { + const handler1 = t.vi.fn(); + const handler2 = t.vi.fn(); - const chainData = { chainId: '0x1', networkName: 'mainnet' }; - eventEmitter.emit('chainChanged', chainData); + eventEmitter.on("display_uri", handler1); + eventEmitter.on("display_uri", handler2); - t.expect(handler).toHaveBeenCalledWith(chainData); - t.expect(handler).toHaveBeenCalledTimes(1); - }); + eventEmitter.off("display_uri", handler1); + eventEmitter.emit("display_uri", "test"); - t.it('should emit chainChanged event with various data types', () => { - const handler = t.vi.fn(); - eventEmitter.on('chainChanged', handler); + t.expect(handler1).not.toHaveBeenCalled(); + t.expect(handler2).toHaveBeenCalledWith("test"); + }); - // Test with string - eventEmitter.emit('chainChanged', 'chain-id'); - t.expect(handler).toHaveBeenCalledWith('chain-id'); + t.it("should handle removing non-existent handlers gracefully", () => { + const handler = t.vi.fn(); + const nonExistentHandler = t.vi.fn(); - // Test with number - eventEmitter.emit('chainChanged', 1); - t.expect(handler).toHaveBeenCalledWith(1); + eventEmitter.on("display_uri", handler); - // Test with null - eventEmitter.emit('chainChanged', null); - t.expect(handler).toHaveBeenCalledWith(null); - - t.expect(handler).toHaveBeenCalledTimes(3); - }); + // This should not throw an error + t.expect(() => { + eventEmitter.off("display_uri", nonExistentHandler); + }).not.toThrow(); - t.it('should register handlers for chainChanged event', () => { - const handler = t.vi.fn(); + eventEmitter.emit("display_uri", "still works"); + t.expect(handler).toHaveBeenCalledWith("still works"); + }); - eventEmitter.on('chainChanged', handler); - eventEmitter.emit('chainChanged', { chainId: '0x89' }); + t.it("should handle removing handlers from non-existent events gracefully", () => { + const handler = t.vi.fn(); - t.expect(handler).toHaveBeenCalledWith({ chainId: '0x89' }); - }); - - t.it('should remove specific handlers for chainChanged event', () => { - const handler1 = t.vi.fn(); - const handler2 = t.vi.fn(); - - eventEmitter.on('chainChanged', handler1); - eventEmitter.on('chainChanged', handler2); - - eventEmitter.off('chainChanged', handler1); - eventEmitter.emit('chainChanged', { chainId: '0x1' }); - - t.expect(handler1).not.toHaveBeenCalled(); - t.expect(handler2).toHaveBeenCalledWith({ chainId: '0x1' }); - }); - }) - }); + // This should not throw an error + t.expect(() => { + eventEmitter.off("display_uri", handler); + }).not.toThrow(); + }); + }); + }); }); diff --git a/packages/sdk-multichain/src/domain/index.ts b/packages/sdk-multichain/src/domain/index.ts index 2b86e96e0..51cd9199c 100644 --- a/packages/sdk-multichain/src/domain/index.ts +++ b/packages/sdk-multichain/src/domain/index.ts @@ -1,6 +1,8 @@ -export * from './errors'; -export * from './platform'; -export * from './multichain'; -export * from './events'; -export * from './logger'; -export * from './store'; +export * from "./errors"; +export * from "./platform"; +export * from "./multichain"; +export * from "./events"; +export * from "./logger"; +export * from "./store"; +export * from "./ui"; +export * from "./utils"; diff --git a/packages/sdk-multichain/src/domain/logger/index.ts b/packages/sdk-multichain/src/domain/logger/index.ts index 2f3607116..c05b690f8 100644 --- a/packages/sdk-multichain/src/domain/logger/index.ts +++ b/packages/sdk-multichain/src/domain/logger/index.ts @@ -1,15 +1,12 @@ -import debug from 'debug'; +import debug from "debug"; -import type { StoreClient } from '../store/client'; +import type { StoreClient } from "../store/client"; /** * Supported debug namespace types for the MetaMask SDK logger. * These namespaces help categorize and filter debug output. */ -export type LoggerNameSpaces = - | 'metamask-sdk' - | 'metamask-sdk:core' - | 'metamask-sdk:provider'; +export type LoggerNameSpaces = "metamask-sdk" | "metamask-sdk:core" | "metamask-sdk:provider" | "metamask-sdk:ui"; /** * Creates a debug logger instance with the specified namespace and color. @@ -21,13 +18,10 @@ export type LoggerNameSpaces = * @param color - The ANSI color code to use for log output (default: '214' for yellow) * @returns A configured debug logger instance */ -export const createLogger = ( - namespace: LoggerNameSpaces = 'metamask-sdk', - color = '214', -) => { - const logger = debug(namespace); - logger.color = color; // Yellow color (basic ANSI) - return logger; +export const createLogger = (namespace: LoggerNameSpaces = "metamask-sdk", color = "214") => { + const logger = debug(namespace); + logger.color = color; // Yellow color (basic ANSI) + return logger; }; /** @@ -38,8 +32,8 @@ export const createLogger = ( * * @param namespace - The debug namespace to enable */ -export const enableDebug = (namespace: LoggerNameSpaces = 'metamask-sdk') => { - debug.enable(namespace); +export const enableDebug = (namespace: LoggerNameSpaces = "metamask-sdk") => { + debug.enable(namespace); }; /** @@ -54,11 +48,7 @@ export const enableDebug = (namespace: LoggerNameSpaces = 'metamask-sdk') => { * @returns True if the namespace should have debug logging enabled, false otherwise */ function isNamespaceEnabled(debugValue: string, namespace: LoggerNameSpaces) { - return ( - debugValue.includes(namespace) || - debugValue.includes('metamask-sdk:*') || - debugValue.includes('*') - ); + return debugValue.includes(namespace) || debugValue.includes("metamask-sdk:*") || debugValue.includes("*"); } /** @@ -73,19 +63,16 @@ function isNamespaceEnabled(debugValue: string, namespace: LoggerNameSpaces) { * @param storage - The storage client to check for debug settings * @returns Promise that resolves to true if debug logging is enabled, false otherwise */ -export const isEnabled = async ( - namespace: LoggerNameSpaces, - storage: StoreClient, -) => { - if (process?.env?.DEBUG) { - const {DEBUG} = process.env - return isNamespaceEnabled(DEBUG, namespace); - } +export const isEnabled = async (namespace: LoggerNameSpaces, storage: StoreClient) => { + if ("process" in globalThis && process?.env?.DEBUG) { + const { DEBUG } = process.env; + return isNamespaceEnabled(DEBUG, namespace); + } - const storageDebug = await storage.getDebug(); - if (storageDebug) { - return isNamespaceEnabled(storageDebug, namespace); - } + const storageDebug = await storage.getDebug(); + if (storageDebug) { + return isNamespaceEnabled(storageDebug, namespace); + } - return false; + return false; }; diff --git a/packages/sdk-multichain/src/domain/multichain/api/constants.ts b/packages/sdk-multichain/src/domain/multichain/api/constants.ts index 9236c87db..1b1ef0864 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/constants.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/constants.ts @@ -2,70 +2,68 @@ import type { RPC_URLS_MAP } from "./types"; export const infuraRpcUrls: RPC_URLS_MAP = { - // ###### Ethereum ###### - // Mainnet - 'eip155:1': 'https://mainnet.infura.io/v3/', - // Goerli - 'eip155:5': 'https://goerli.infura.io/v3/', - // Sepolia 11155111 - 'eip155:11155111': 'https://sepolia.infura.io/v3/', - // ###### Linea ###### - // Mainnet Alpha - 'eip155:59144': 'https://linea-mainnet.infura.io/v3/', - // Testnet ( linea goerli ) - 'eip155:59140': 'https://linea-goerli.infura.io/v3/', - // ###### Polygon ###### - // Mainnet - 'eip155:137': 'https://polygon-mainnet.infura.io/v3/', - // Mumbai - 'eip155:80001': 'https://polygon-mumbai.infura.io/v3/', - // ###### Optimism ###### - // Mainnet - 'eip155:10': 'https://optimism-mainnet.infura.io/v3/', - // Goerli - 'eip155:420': 'https://optimism-goerli.infura.io/v3/', - // ###### Arbitrum ###### - // Mainnet - 'eip155:42161': 'https://arbitrum-mainnet.infura.io/v3/', - // Goerli - 'eip155:421613': 'https://arbitrum-goerli.infura.io/v3/', - // ###### Palm ###### - // Mainnet - 'eip155:11297108109': 'https://palm-mainnet.infura.io/v3/', - // Testnet - 'eip155:11297108099': 'https://palm-testnet.infura.io/v3/', - // ###### Avalanche C-Chain ###### - // Mainnet - 'eip155:43114': 'https://avalanche-mainnet.infura.io/v3/', - // Fuji - 'eip155:43113': 'https://avalanche-fuji.infura.io/v3/', - // // ###### NEAR ###### - // // Mainnet - // 'near:mainnet': `https://near-mainnet.infura.io/v3/`, - // // Testnet - // 'near:testnet': `https://near-testnet.infura.io/v3/`, - // ###### Aurora ###### - // Mainnet - 'eip155:1313161554': 'https://aurora-mainnet.infura.io/v3/', - // Testnet - 'eip155:1313161555': 'https://aurora-testnet.infura.io/v3/', - // ###### StarkNet ###### - // Mainnet - // - // 'starknet:SN_MAIN': `https://starknet-mainnet.infura.io/v3/`, - // // Goerli - // 'starknet:SN_GOERLI': `https://starknet-goerli.infura.io/v3/`, - // // Goerli 2 - // 'starknet:SN_GOERLI2': `https://starknet-goerli2.infura.io/v3/`, - // ###### Celo ###### - // Mainnet - 'eip155:42220': 'https://celo-mainnet.infura.io/v3/', - // Alfajores Testnet - 'eip155:44787': 'https://celo-alfajores.infura.io/v3/', + // ###### Ethereum ###### + // Mainnet + "eip155:1": "https://mainnet.infura.io/v3/", + // Goerli + "eip155:5": "https://goerli.infura.io/v3/", + // Sepolia 11155111 + "eip155:11155111": "https://sepolia.infura.io/v3/", + // ###### Linea ###### + // Mainnet Alpha + "eip155:59144": "https://linea-mainnet.infura.io/v3/", + // Testnet ( linea goerli ) + "eip155:59140": "https://linea-goerli.infura.io/v3/", + // ###### Polygon ###### + // Mainnet + "eip155:137": "https://polygon-mainnet.infura.io/v3/", + // Mumbai + "eip155:80001": "https://polygon-mumbai.infura.io/v3/", + // ###### Optimism ###### + // Mainnet + "eip155:10": "https://optimism-mainnet.infura.io/v3/", + // Goerli + "eip155:420": "https://optimism-goerli.infura.io/v3/", + // ###### Arbitrum ###### + // Mainnet + "eip155:42161": "https://arbitrum-mainnet.infura.io/v3/", + // Goerli + "eip155:421613": "https://arbitrum-goerli.infura.io/v3/", + // ###### Palm ###### + // Mainnet + "eip155:11297108109": "https://palm-mainnet.infura.io/v3/", + // Testnet + "eip155:11297108099": "https://palm-testnet.infura.io/v3/", + // ###### Avalanche C-Chain ###### + // Mainnet + "eip155:43114": "https://avalanche-mainnet.infura.io/v3/", + // Fuji + "eip155:43113": "https://avalanche-fuji.infura.io/v3/", + // // ###### NEAR ###### + // // Mainnet + // 'near:mainnet': `https://near-mainnet.infura.io/v3/`, + // // Testnet + // 'near:testnet': `https://near-testnet.infura.io/v3/`, + // ###### Aurora ###### + // Mainnet + "eip155:1313161554": "https://aurora-mainnet.infura.io/v3/", + // Testnet + "eip155:1313161555": "https://aurora-testnet.infura.io/v3/", + // ###### StarkNet ###### + // Mainnet + // + // 'starknet:SN_MAIN': `https://starknet-mainnet.infura.io/v3/`, + // // Goerli + // 'starknet:SN_GOERLI': `https://starknet-goerli.infura.io/v3/`, + // // Goerli 2 + // 'starknet:SN_GOERLI2': `https://starknet-goerli2.infura.io/v3/`, + // ###### Celo ###### + // Mainnet + "eip155:42220": "https://celo-mainnet.infura.io/v3/", + // Alfajores Testnet + "eip155:44787": "https://celo-alfajores.infura.io/v3/", }; - - /** * Standard RPC method names used in the MetaMask ecosystem. * @@ -74,50 +72,50 @@ export const infuraRpcUrls: RPC_URLS_MAP = { * throughout the codebase. */ export const RPC_METHODS = { - /** Get the current provider state */ - METAMASK_GETPROVIDERSTATE: 'metamask_getProviderState', - /** Connect and sign in a single operation */ - METAMASK_CONNECTSIGN: 'metamask_connectSign', - /** Connect with specific parameters */ - METAMASK_CONNECTWITH: 'metamask_connectWith', - /** Open MetaMask interface */ - METAMASK_OPEN: 'metamask_open', - /** Execute multiple operations in a batch */ - METAMASK_BATCH: 'metamask_batch', - /** Sign a message with personal_sign */ - PERSONAL_SIGN: 'personal_sign', - /** Request wallet permissions */ - WALLET_REQUESTPERMISSIONS: 'wallet_requestPermissions', - /** Revoke wallet permissions */ - WALLET_REVOKEPERMISSIONS: 'wallet_revokePermissions', - /** Get current wallet permissions */ - WALLET_GETPERMISSIONS: 'wallet_getPermissions', - /** Watch/add a token to the wallet */ - WALLET_WATCHASSET: 'wallet_watchAsset', - /** Add a new Ethereum chain */ - WALLET_ADDETHEREUMCHAIN: 'wallet_addEthereumChain', - /** Switch to a different Ethereum chain */ - WALLET_SWITCHETHEREUMCHAIN: 'wallet_switchEthereumChain', - /** Request account access */ - ETH_REQUESTACCOUNTS: 'eth_requestAccounts', - /** Get available accounts */ - ETH_ACCOUNTS: 'eth_accounts', - /** Get current chain ID */ - ETH_CHAINID: 'eth_chainId', - /** Send a transaction */ - ETH_SENDTRANSACTION: 'eth_sendTransaction', - /** Sign typed data */ - ETH_SIGNTYPEDDATA: 'eth_signTypedData', - /** Sign typed data v3 */ - ETH_SIGNTYPEDDATA_V3: 'eth_signTypedData_v3', - /** Sign typed data v4 */ - ETH_SIGNTYPEDDATA_V4: 'eth_signTypedData_v4', - /** Sign a transaction */ - ETH_SIGNTRANSACTION: 'eth_signTransaction', - /** Sign arbitrary data */ - ETH_SIGN: 'eth_sign', - /** Recover address from signature */ - PERSONAL_EC_RECOVER: 'personal_ecRecover', + /** Get the current provider state */ + METAMASK_GETPROVIDERSTATE: "metamask_getProviderState", + /** Connect and sign in a single operation */ + METAMASK_CONNECTSIGN: "metamask_connectSign", + /** Connect with specific parameters */ + METAMASK_CONNECTWITH: "metamask_connectWith", + /** Open MetaMask interface */ + METAMASK_OPEN: "metamask_open", + /** Execute multiple operations in a batch */ + METAMASK_BATCH: "metamask_batch", + /** Sign a message with personal_sign */ + PERSONAL_SIGN: "personal_sign", + /** Request wallet permissions */ + WALLET_REQUESTPERMISSIONS: "wallet_requestPermissions", + /** Revoke wallet permissions */ + WALLET_REVOKEPERMISSIONS: "wallet_revokePermissions", + /** Get current wallet permissions */ + WALLET_GETPERMISSIONS: "wallet_getPermissions", + /** Watch/add a token to the wallet */ + WALLET_WATCHASSET: "wallet_watchAsset", + /** Add a new Ethereum chain */ + WALLET_ADDETHEREUMCHAIN: "wallet_addEthereumChain", + /** Switch to a different Ethereum chain */ + WALLET_SWITCHETHEREUMCHAIN: "wallet_switchEthereumChain", + /** Request account access */ + ETH_REQUESTACCOUNTS: "eth_requestAccounts", + /** Get available accounts */ + ETH_ACCOUNTS: "eth_accounts", + /** Get current chain ID */ + ETH_CHAINID: "eth_chainId", + /** Send a transaction */ + ETH_SENDTRANSACTION: "eth_sendTransaction", + /** Sign typed data */ + ETH_SIGNTYPEDDATA: "eth_signTypedData", + /** Sign typed data v3 */ + ETH_SIGNTYPEDDATA_V3: "eth_signTypedData_v3", + /** Sign typed data v4 */ + ETH_SIGNTYPEDDATA_V4: "eth_signTypedData_v4", + /** Sign a transaction */ + ETH_SIGNTRANSACTION: "eth_signTransaction", + /** Sign arbitrary data */ + ETH_SIGN: "eth_sign", + /** Recover address from signature */ + PERSONAL_EC_RECOVER: "personal_ecRecover", }; /** @@ -128,26 +126,26 @@ export const RPC_METHODS = { * Methods marked as true will redirect to the wallet for user approval. */ export const METHODS_TO_REDIRECT: { [method: string]: boolean } = { - [RPC_METHODS.ETH_REQUESTACCOUNTS]: true, - [RPC_METHODS.ETH_SENDTRANSACTION]: true, - [RPC_METHODS.ETH_SIGNTRANSACTION]: true, - [RPC_METHODS.ETH_SIGN]: true, - [RPC_METHODS.PERSONAL_SIGN]: true, - // stop redirecting these as we are caching values in the provider - [RPC_METHODS.ETH_ACCOUNTS]: false, - [RPC_METHODS.ETH_CHAINID]: false, - // - [RPC_METHODS.ETH_SIGNTYPEDDATA]: true, - [RPC_METHODS.ETH_SIGNTYPEDDATA_V3]: true, - [RPC_METHODS.ETH_SIGNTYPEDDATA_V4]: true, - [RPC_METHODS.WALLET_REQUESTPERMISSIONS]: true, - [RPC_METHODS.WALLET_GETPERMISSIONS]: true, - [RPC_METHODS.WALLET_WATCHASSET]: true, - [RPC_METHODS.WALLET_ADDETHEREUMCHAIN]: true, - [RPC_METHODS.WALLET_SWITCHETHEREUMCHAIN]: true, - [RPC_METHODS.METAMASK_CONNECTSIGN]: true, - [RPC_METHODS.METAMASK_CONNECTWITH]: true, - [RPC_METHODS.PERSONAL_EC_RECOVER]: true, - [RPC_METHODS.METAMASK_BATCH]: true, - [RPC_METHODS.METAMASK_OPEN]: true, + [RPC_METHODS.ETH_REQUESTACCOUNTS]: true, + [RPC_METHODS.ETH_SENDTRANSACTION]: true, + [RPC_METHODS.ETH_SIGNTRANSACTION]: true, + [RPC_METHODS.ETH_SIGN]: true, + [RPC_METHODS.PERSONAL_SIGN]: true, + // stop redirecting these as we are caching values in the provider + [RPC_METHODS.ETH_ACCOUNTS]: false, + [RPC_METHODS.ETH_CHAINID]: false, + // + [RPC_METHODS.ETH_SIGNTYPEDDATA]: true, + [RPC_METHODS.ETH_SIGNTYPEDDATA_V3]: true, + [RPC_METHODS.ETH_SIGNTYPEDDATA_V4]: true, + [RPC_METHODS.WALLET_REQUESTPERMISSIONS]: true, + [RPC_METHODS.WALLET_GETPERMISSIONS]: true, + [RPC_METHODS.WALLET_WATCHASSET]: true, + [RPC_METHODS.WALLET_ADDETHEREUMCHAIN]: true, + [RPC_METHODS.WALLET_SWITCHETHEREUMCHAIN]: true, + [RPC_METHODS.METAMASK_CONNECTSIGN]: true, + [RPC_METHODS.METAMASK_CONNECTWITH]: true, + [RPC_METHODS.PERSONAL_EC_RECOVER]: true, + [RPC_METHODS.METAMASK_BATCH]: true, + [RPC_METHODS.METAMASK_OPEN]: true, }; diff --git a/packages/sdk-multichain/src/domain/multichain/api/eip155.ts b/packages/sdk-multichain/src/domain/multichain/api/eip155.ts index 7d28c0dee..6683f2e5c 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/eip155.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/eip155.ts @@ -1,47 +1,38 @@ -import type { RpcMethod } from './types'; +import type { RpcMethod } from "./types"; //TODO: We probably want to avoid having to declare this and use the types from somewhere else type EIP155 = { - methods: { - personal_sign: RpcMethod<{ message: string; account: string }, string>; - eth_accounts: RpcMethod; - eth_chainId: RpcMethod; - eth_sendTransaction: RpcMethod< - { to: string; value?: string; data?: string }, - string - >; - eth_call: RpcMethod<{ to: string; data?: string }, string>; - eth_getBalance: RpcMethod<{ address: string; blockNumber: string }, string>; - wallet_switchEthereumChain: RpcMethod<{ chainId: string }, void>; - wallet_addEthereumChain: RpcMethod< - { - chainId: string; - chainName: string; - nativeCurrency?: - | { - name: string; - symbol: string; - decimals: number; - } - | undefined; - rpcUrls: readonly string[]; - blockExplorerUrls?: string[] | undefined; - iconUrls?: string[] | undefined; - }, - void - >; - signAndSendTransaction: RpcMethod< - { to: string; value?: string; data?: string }, - string - >; - signTransaction: RpcMethod< - { to: string; value?: string; data?: string }, - string - >; - signMessage: RpcMethod<{ message: string }, string>; - signIn: RpcMethod<{ message: string }, string>; - }; - events: ['eth_subscription']; + methods: { + personal_sign: RpcMethod<{ message: string; account: string }, string>; + eth_accounts: RpcMethod; + eth_chainId: RpcMethod; + eth_sendTransaction: RpcMethod<{ to: string; value?: string; data?: string }, string>; + eth_call: RpcMethod<{ to: string; data?: string }, string>; + eth_getBalance: RpcMethod<{ address: string; blockNumber: string }, string>; + wallet_switchEthereumChain: RpcMethod<{ chainId: string }, void>; + wallet_addEthereumChain: RpcMethod< + { + chainId: string; + chainName: string; + nativeCurrency?: + | { + name: string; + symbol: string; + decimals: number; + } + | undefined; + rpcUrls: readonly string[]; + blockExplorerUrls?: string[] | undefined; + iconUrls?: string[] | undefined; + }, + void + >; + signAndSendTransaction: RpcMethod<{ to: string; value?: string; data?: string }, string>; + signTransaction: RpcMethod<{ to: string; value?: string; data?: string }, string>; + signMessage: RpcMethod<{ message: string }, string>; + signIn: RpcMethod<{ message: string }, string>; + }; + events: ["eth_subscription"]; }; export default EIP155; diff --git a/packages/sdk-multichain/src/domain/multichain/api/infura.ts b/packages/sdk-multichain/src/domain/multichain/api/infura.ts index 185ed668f..72a946e00 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/infura.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/infura.ts @@ -2,9 +2,9 @@ import { infuraRpcUrls } from "./constants"; import type { RPC_URLS_MAP } from "./types"; export function getInfuraRpcUrls(infuraAPIKey: string) { - return Object.keys(infuraRpcUrls).reduce((acc, key) => { - const typedKey = key as keyof typeof infuraRpcUrls; - acc[typedKey] = `${infuraRpcUrls[typedKey]}${infuraAPIKey}`; - return acc; - }, {} as RPC_URLS_MAP) + return Object.keys(infuraRpcUrls).reduce((acc, key) => { + const typedKey = key as keyof typeof infuraRpcUrls; + acc[typedKey] = `${infuraRpcUrls[typedKey]}${infuraAPIKey}`; + return acc; + }, {} as RPC_URLS_MAP); } diff --git a/packages/sdk-multichain/src/domain/multichain/api/types.ts b/packages/sdk-multichain/src/domain/multichain/api/types.ts index c03334f0e..1d369ae1b 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/types.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/types.ts @@ -1,4 +1,3 @@ -import type { Json } from "@metamask/utils"; import type EIP155 from "./eip155"; /** @@ -10,10 +9,7 @@ import type EIP155 from "./eip155"; * * @template T - The RPC API type to extract available scopes from */ -export type Scope = - | `eip155:${string}` - | `solana:${string}` - | `${Extract}:${string}`; +export type Scope = `eip155:${string}` | `solana:${string}` | `${Extract}:${string}`; /** * Represents a generic RPC (Remote Procedure Call) method function type. @@ -37,8 +33,8 @@ export type RpcMethod = (params: Params) => Promise | Re * blockchain standards to be added in the future. */ export type RPCAPI = { - /** EIP-155 compliant RPC methods for Ethereum-based chains */ - eip155: EIP155; + /** EIP-155 compliant RPC methods for Ethereum-based chains */ + eip155: EIP155; }; /** @@ -58,18 +54,17 @@ export type NotificationCallback = (notification: unknown) => void; * callers to specify both the blockchain scope and the specific request details. */ export type InvokeMethodOptions = { - /** The blockchain scope/standard to use for the method call */ - scope: Scope; - /** The request details including method name and parameters */ - request: { - /** The name of the RPC method to invoke */ - method: string; - /** The parameters to pass to the RPC method */ - params: unknown; - }; + /** The blockchain scope/standard to use for the method call */ + scope: Scope; + /** The request details including method name and parameters */ + request: { + /** The name of the RPC method to invoke */ + method: string; + /** The parameters to pass to the RPC method */ + params: unknown; + }; }; - /** * Mapping of CAIP chain IDs to their corresponding RPC URLs. * @@ -77,8 +72,8 @@ export type InvokeMethodOptions = { * for different blockchain networks using CAIP-2 format identifiers. */ export type RPC_URLS_MAP = { - /** CAIP-2 format chain ID mapped to its RPC URL (e.g., "eip155:1" -> "https://...") */ - [chainId: `${string}:${string}`]: string; + /** CAIP-2 format chain ID mapped to its RPC URL (e.g., "eip155:1" -> "https://...") */ + [chainId: `${string}:${string}`]: string; }; /** @@ -93,5 +88,7 @@ export type RPC_URLS_MAP = { * @property result - The result of the RPC call JSON */ export type RPCResponse = { - id: number, jsonrpc: string, result: unknown -} + id: number; + jsonrpc: string; + result: unknown; +}; diff --git a/packages/sdk-multichain/src/domain/multichain/index.ts b/packages/sdk-multichain/src/domain/multichain/index.ts index a16c55c97..474d3f3ee 100644 --- a/packages/sdk-multichain/src/domain/multichain/index.ts +++ b/packages/sdk-multichain/src/domain/multichain/index.ts @@ -1,50 +1,17 @@ -import type { SessionData } from '@metamask/multichain-api-client'; -import type { CaipAccountId, Json } from '@metamask/utils'; +import type { CaipAccountId, Json } from "@metamask/utils"; +import type { StoreClient } from "../store/client"; +import type { InvokeMethodOptions, RPCAPI, Scope } from "./api/types"; +import { EventEmitter, type SDKEvents } from "../events"; +import type { MultichainOptions } from "./types"; +import type { MultichainApiClient, Transport } from "@metamask/multichain-api-client"; -import type { StoreClient } from '../store/client'; -import type { InvokeMethodOptions, NotificationCallback, RPC_URLS_MAP, Scope } from './api/types'; +export type SDKState = "pending" | "loaded" | "disconnected" | "connected"; -/** - * Configuration settings for the dapp using the SDK. - * - * This type allows for two variants of dapp configuration: - * - Using a regular icon URL - * - Using a base64-encoded icon - */ -export type DappSettings = { - name?: string; - url?: string; -} & ({ iconUrl?: string } | { base64Icon?: string }); - -/** - * Constructor options for creating a Multichain SDK instance. - * - * This type defines all the configuration options available when - * initializing the SDK, including dapp settings, API configuration, - * analytics, storage, UI preferences, and transport options. - */ -export type MultichainSDKConstructor = { - /** Dapp identification and branding settings */ - dapp: DappSettings; - /** Optional API configuration for external services */ - api?: { - /** The Infura API key to use for RPC requests */ - infuraAPIKey?: string; - /** A map of RPC URLs to use for read-only requests */ - readonlyRPCMap?: RPC_URLS_MAP; - }; - /** Analytics configuration */ - analytics: { enabled: false } | { enabled: true; integrationType: string }; - /** Storage client for persisting SDK data */ - storage: StoreClient; - /** UI configuration options */ - ui: { headless: boolean }; - /** Optional transport configuration */ - transport?: { - /** Extension ID for browser extension transport */ - extensionId?: string; - }; -}; +export enum TransportType { + Browser = "browser", + MPW = "mwp", + UNKNOWN = "unknown", +} /** * Abstract base class for the Multichain SDK implementation. @@ -52,83 +19,51 @@ export type MultichainSDKConstructor = { * This class defines the core interface that all Multichain SDK implementations * must provide, including session management, connection handling, and method invocation. */ -/* c8 ignore start */ -export abstract class MultichainSDKBase { - public abstract isInitialized: boolean; - public abstract session: SessionData | undefined; - - /** - * Establishes a connection to the multichain provider, or re-use existing session - * - * @returns Promise that resolves to the session data - */ - abstract connect( - scopes: Scope[], - caipAccountIds: CaipAccountId[], - ): Promise; - - /** - * Disconnects from the multichain provider. - * - * @returns Promise that resolves when disconnection is complete - */ - abstract disconnect(): Promise; - /** - * Revokes the current session. - * - * @returns Promise that resolves when the session has been revoked - */ - abstract revokeSession(): Promise; - - /** - * Registers a listener for incoming notifications. - * - * @param listener - Callback function to handle notifications - * @returns Function to remove the listener - */ - abstract onNotification(listener: NotificationCallback): () => void; +export abstract class MultichainCore extends EventEmitter { + abstract storage: StoreClient; + abstract state: SDKState; + abstract provider: MultichainApiClient; + abstract transport: Transport; - /** - * Invokes an RPC method with the specified options. - * - * @param options - The method invocation options including scope and request details - * @returns Promise that resolves to the method result - */ - abstract invokeMethod(options: InvokeMethodOptions): Promise; + abstract init(): Promise; + /** + * Establishes a connection to the multichain provider, or re-use existing session + * + * @returns Promise that resolves to the session data + */ + abstract connect(scopes: Scope[], caipAccountIds: CaipAccountId[]): Promise; + /** + * Disconnects from the multichain provider. + * + * @returns Promise that resolves when disconnection is complete + */ + abstract disconnect(): Promise; + /** + * Invokes an RPC method with the specified options. + * + * @param options - The method invocation options including scope and request details + * @returns Promise that resolves to the method result + */ + abstract invokeMethod(options: InvokeMethodOptions): Promise; - /** - * Storage client instance for persisting SDK data. - */ - abstract readonly storage: StoreClient; + constructor(protected readonly options: MultichainOptions) { + super(); + } } /* c8 ignore end */ -export type { SessionData } from '@metamask/multichain-api-client'; - -/** - * Base options for Multichain SDK configuration. - * - * This type includes the core configuration options excluding storage, - * which is handled separately in the full SDK options. - */ -export type MultichainSDKBaseOptions = Pick< - MultichainSDKConstructor, - 'dapp' | 'analytics' | 'ui' | 'transport' ->; - -/** - * Complete options for Multichain SDK configuration. - * - * This type extends the base options with storage configuration, - * providing all necessary options for SDK initialization. - */ -export type MultichainSDKOptions = MultichainSDKBaseOptions & { - /** Storage client for persisting SDK data */ - storage: StoreClient; -}; - -export type CreateMultichainFN = ( options: MultichainSDKBaseOptions ) => Promise; +export function getTransportType(type: string): TransportType { + switch (type) { + case "browser": + return TransportType.Browser; + case "mwp": + return TransportType.MPW; + default: + return TransportType.UNKNOWN; + } +} -export type * from './api/types'; -export * from './api/constants'; -export * from './api/infura'; +export type * from "./api/types"; +export type * from "./types"; +export * from "./api/constants"; +export * from "./api/infura"; diff --git a/packages/sdk-multichain/src/domain/multichain/types.ts b/packages/sdk-multichain/src/domain/multichain/types.ts new file mode 100644 index 000000000..60fd509d7 --- /dev/null +++ b/packages/sdk-multichain/src/domain/multichain/types.ts @@ -0,0 +1,66 @@ +import type { MultichainCore } from "."; +import type { StoreClient } from "../store"; +import type { ModalFactory } from "../ui"; +import type { RPC_URLS_MAP } from "./api/types"; + +export type { SessionData } from "@metamask/multichain-api-client"; + +/** + * Configuration settings for the dapp using the SDK. + * + * This type allows for two variants of dapp configuration: + * - Using a regular icon URL + * - Using a base64-encoded icon + */ +export type DappSettings = { + name?: string; + url?: string; +} & ({ iconUrl?: string } | { base64Icon?: string }); + +/** + * Constructor options for creating a Multichain SDK instance. + * + * This type defines all the configuration options available when + * initializing the SDK, including dapp settings, API configuration, + * analytics, storage, UI preferences, and transport options. + */ +export type MultichainOptions = { + /** Dapp identification and branding settings */ + dapp: DappSettings; + /** Optional API configuration for external services */ + api?: { + /** The Infura API key to use for RPC requests */ + infuraAPIKey?: string; + /** A map of RPC URLs to use for read-only requests */ + readonlyRPCMap?: RPC_URLS_MAP; + }; + /** Analytics configuration */ + analytics?: { enabled: false } | { enabled: true; integrationType: string }; + /** Storage client for persisting SDK data */ + storage: StoreClient; + /** UI configuration options */ + ui: { + factory: ModalFactory; + headless?: boolean; + preferExtension?: boolean; + preferDesktop?: boolean; + }; + mobile?: { + useDeeplink?: boolean; + }; + /** Optional transport configuration */ + transport?: { + /** Extension ID for browser extension transport */ + extensionId?: string; + }; +}; + +export type MultiChainFNOptions = Omit & { ui?: Omit }; + +/** + * Complete options for Multichain SDK configuration. + * + * This type extends the base options with storage configuration, + * providing all necessary options for SDK initialization. + */ +export type CreateMultichainFN = (options: MultiChainFNOptions) => Promise; diff --git a/packages/sdk-multichain/src/domain/platform/index.ts b/packages/sdk-multichain/src/domain/platform/index.ts index c21c643c3..f28b872f8 100644 --- a/packages/sdk-multichain/src/domain/platform/index.ts +++ b/packages/sdk-multichain/src/domain/platform/index.ts @@ -1,75 +1,76 @@ -import Bowser from 'bowser'; +import Bowser from "bowser"; export enum PlatformType { - // React Native or Nodejs - NonBrowser = 'nodejs', - // MetaMask Mobile in-app browser - MetaMaskMobileWebview = 'in-app-browser', - // Desktop Browser - DesktopWeb = 'web-desktop', - // Mobile Browser - MobileWeb = 'web-mobile', - // ReactNative - ReactNative = 'react-native', + // React Native or Nodejs + NonBrowser = "nodejs", + // MetaMask Mobile in-app browser + MetaMaskMobileWebview = "in-app-browser", + // Desktop Browser + DesktopWeb = "web-desktop", + // Mobile Browser + MobileWeb = "web-mobile", + // ReactNative + ReactNative = "react-native", } function isNotBrowser() { - if (typeof window === 'undefined') { - return true; - } - if (!window?.navigator) { - return true - } - if (typeof global !== 'undefined' && - global?.navigator?.product === 'ReactNative') { - return true; - } - return navigator?.product === 'ReactNative' + if (typeof window === "undefined") { + return true; + } + if (!window?.navigator) { + return true; + } + if (typeof global !== "undefined" && global?.navigator?.product === "ReactNative") { + return true; + } + return navigator?.product === "ReactNative"; } function isReactNative() { - const hasWindowNavigator = typeof window !== 'undefined' && window.navigator !== undefined; - const navigator = hasWindowNavigator ? - window.navigator : - undefined; + const hasWindowNavigator = typeof window !== "undefined" && window.navigator !== undefined; + const navigator = hasWindowNavigator ? window.navigator : undefined; - if (!navigator) { - return false; - } + if (!navigator) { + return false; + } - return hasWindowNavigator && window.navigator?.product === 'ReactNative' + return hasWindowNavigator && window.navigator?.product === "ReactNative"; } function isMetaMaskMobileWebView() { - return ( - typeof window !== 'undefined' && - Boolean(window.ReactNativeWebView) && - Boolean(window.navigator.userAgent.endsWith('MetaMaskMobile')) - ); + return typeof window !== "undefined" && Boolean(window.ReactNativeWebView) && Boolean(window.navigator.userAgent.endsWith("MetaMaskMobile")); } function isMobile() { - const browser = Bowser.parse(window.navigator.userAgent); - return ( - browser?.platform?.type === 'mobile' || browser?.platform?.type === 'tablet' - ); + const browser = Bowser.parse(window.navigator.userAgent); + return browser?.platform?.type === "mobile" || browser?.platform?.type === "tablet"; } /** * */ export function getPlatformType() { - if (isReactNative()) { - return PlatformType.ReactNative; - } - if (isNotBrowser()) { - return PlatformType.NonBrowser; - } - if (isMetaMaskMobileWebView()) { - return PlatformType.MetaMaskMobileWebview; - } - if (isMobile()) { - return PlatformType.MobileWeb; - } - return PlatformType.DesktopWeb; + if (isReactNative()) { + return PlatformType.ReactNative; + } + if (isNotBrowser()) { + return PlatformType.NonBrowser; + } + if (isMetaMaskMobileWebView()) { + return PlatformType.MetaMaskMobileWebview; + } + if (isMobile()) { + return PlatformType.MobileWeb; + } + return PlatformType.DesktopWeb; +} + +/** + * Check if MetaMask extension is installed + */ +export function isMetamaskExtensionInstalled(): boolean { + if (typeof window === "undefined") { + return false; + } + return Boolean(window.ethereum?.isMetaMask); } diff --git a/packages/sdk-multichain/src/domain/store/adapter.ts b/packages/sdk-multichain/src/domain/store/adapter.ts index 4c5a4d71f..c3158bae5 100644 --- a/packages/sdk-multichain/src/domain/store/adapter.ts +++ b/packages/sdk-multichain/src/domain/store/adapter.ts @@ -1,15 +1,13 @@ /* c8 ignore start */ export type StoreOptions = Record; - - export abstract class StoreAdapter { - abstract platform: 'web' | 'rn' | 'node' - constructor(public options?: StoreOptions) {} + abstract platform: "web" | "rn" | "node"; + constructor(public options?: StoreOptions) {} - abstract getItem(key: string): Promise; + abstract getItem(key: string): Promise; - abstract setItem(key: string, value: string): Promise; + abstract setItem(key: string, value: string): Promise; - abstract deleteItem(key: string): Promise; + abstract deleteItem(key: string): Promise; } diff --git a/packages/sdk-multichain/src/domain/store/client.ts b/packages/sdk-multichain/src/domain/store/client.ts index 8a83ef490..04a99f087 100644 --- a/packages/sdk-multichain/src/domain/store/client.ts +++ b/packages/sdk-multichain/src/domain/store/client.ts @@ -1,11 +1,18 @@ /* c8 ignore start */ +import type { TransportType } from "../multichain"; export abstract class StoreClient { - abstract getAnonId(): Promise; - abstract getExtensionId(): Promise; - abstract setExtensionId(extensionId: string): Promise; - abstract setAnonId(anonId: string): Promise; - abstract removeExtensionId(): Promise; - abstract removeAnonId(): Promise; - abstract getDebug(): Promise; + abstract getAnonId(): Promise; + + abstract getExtensionId(): Promise; + abstract setExtensionId(extensionId: string): Promise; + + abstract getTransport(): Promise; + abstract setTransport(transport: TransportType): Promise; + abstract removeTransport(): Promise; + + abstract setAnonId(anonId: string): Promise; + abstract removeExtensionId(): Promise; + abstract removeAnonId(): Promise; + abstract getDebug(): Promise; } diff --git a/packages/sdk-multichain/src/domain/store/index.ts b/packages/sdk-multichain/src/domain/store/index.ts index 61bceef04..31745d5c4 100644 --- a/packages/sdk-multichain/src/domain/store/index.ts +++ b/packages/sdk-multichain/src/domain/store/index.ts @@ -1,3 +1,2 @@ - -export * from './adapter'; -export * from './client'; +export * from "./adapter"; +export * from "./client"; diff --git a/packages/sdk-multichain/src/domain/ui/factory.ts b/packages/sdk-multichain/src/domain/ui/factory.ts new file mode 100644 index 000000000..507d31fab --- /dev/null +++ b/packages/sdk-multichain/src/domain/ui/factory.ts @@ -0,0 +1,59 @@ +import type { MultichainOptions, Scope, SessionData } from "../multichain"; +import type { Modal } from "./types"; +import type { CaipAccountId } from "@metamask/utils"; +import type { Transport } from "@metamask/multichain-api-client"; + +export type ModalTypes = "installModal" | "selectModal" | "pendingModal"; +/** + * Record type that maps modal names to their corresponding Modal instances. + * Used to store different types of modals that can be created by the factory. + */ +export type FactoryModals = Record>; + +/** + * Options passed when establishing a connection through a modal. + * Contains the scopes (permissions) and account IDs involved in the connection. + */ +export type ModalFactoryConnectOptions = { scopes: Scope[]; caipAccountIds: CaipAccountId[] }; + +/** + * Configuration options for the modal factory. + * Combines mobile settings from SDK options with UI preferences and connection handling. + */ +export type ModalFactoryOptions = Pick & { + ui: { + headless?: boolean; // Whether to run without UI + preferExtension?: boolean; // Whether to prefer browser extension + preferDesktop?: boolean; // Whether to prefer desktop wallet + }; + onConnection: (transport: Transport, options: ModalFactoryConnectOptions) => Promise; + getCurrentSession: () => Promise; + connection?: ModalFactoryConnectOptions; +}; + +/** + * Abstract base class for creating and managing modals across different platforms. + * Provides platform detection and common functionality for modal management. + * + * @template T - Type of modals this factory can create, defaults to FactoryModals + */ +export abstract class ModalFactory { + abstract renderInstallModal(link: string, preferDesktop: boolean): Promise; + abstract renderSelectModal(link: string, preferDesktop: boolean, connect: () => Promise): Promise; + abstract renderPendingModal(): Promise; + /** + * Creates a new modal factory instance. + * @param options - The modals configuration object + */ + constructor(protected readonly options: T) { + this.validateModals(); + } + + private validateModals() { + const requiredModals = ["installModal", "selectModal", "pendingModal"]; + const missingModals = requiredModals.filter((modal) => !this.options[modal as ModalTypes]); + if (missingModals.length > 0) { + throw new Error(`Missing required modals: ${missingModals.join(", ")}`); + } + } +} diff --git a/packages/sdk-multichain/src/domain/ui/index.ts b/packages/sdk-multichain/src/domain/ui/index.ts new file mode 100644 index 000000000..fcf4427a9 --- /dev/null +++ b/packages/sdk-multichain/src/domain/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./factory"; diff --git a/packages/sdk-multichain/src/domain/ui/types.ts b/packages/sdk-multichain/src/domain/ui/types.ts new file mode 100644 index 000000000..b73de0b9a --- /dev/null +++ b/packages/sdk-multichain/src/domain/ui/types.ts @@ -0,0 +1,52 @@ +import type { Components } from "@metamask/sdk-install-modal-web"; + +export interface InstallWidgetProps extends Components.MmInstallModal { + parentElement?: Element; + onClose: (shouldTerminate?: boolean) => void; + metaMaskInstaller: { + startDesktopOnboarding: () => void; + }; + onAnalyticsEvent: (event: { event: any; params?: Record }) => void; +} + +export interface PendingWidgetProps extends Components.MmPendingModal { + parentElement?: Element; + onClose: () => void; + onDisconnect?: () => void; + updateOTPValue: (otpValue: string) => void; +} + +export interface SelectWidgetProps extends Components.MmSelectModal { + parentElement?: Element; + onClose: (shouldTerminate?: boolean) => void; + connect: () => void; +} + +export type RenderedModal = { + mount(qrcodeLink?: string): void; + unmount(shouldTerminate?: boolean): void; + sync?(...params: unknown[]): void; +}; + +/** + * Abstract Modal class with shared functionality across all models + */ +export abstract class Modal> { + abstract instance?: HTMLMmInstallModalElement | HTMLMmSelectModalElement | HTMLMmPendingModalElement; + abstract render(options: T): Promise; + + updateQRCode(link: string) { + const installModal = this.instance?.querySelector("mm-install-modal") as HTMLMmInstallModalElement | null; + if (installModal) { + installModal.link = link; + } else { + const selectModal = this.instance?.querySelector("mm-select-modal") as HTMLMmSelectModalElement | null; + if (selectModal) { + selectModal.link = link; + } + } + } +} + +export abstract class AbstractInstallModal extends Modal {} +export abstract class AbstractPendingModal extends Modal {} diff --git a/packages/sdk-multichain/src/domain/utils/base64/index.test.ts b/packages/sdk-multichain/src/domain/utils/base64/index.test.ts new file mode 100644 index 000000000..b011ab78c --- /dev/null +++ b/packages/sdk-multichain/src/domain/utils/base64/index.test.ts @@ -0,0 +1,57 @@ +import * as t from "vitest"; + +import { base64Encode } from "."; + +t.describe("base64Encode", () => { + t.it("should encode an empty string", () => { + t.expect(base64Encode("")).toBe(""); + }); + + t.it("should encode a simple ASCII string", () => { + t.expect(base64Encode("Hello, World!")).toBe("SGVsbG8sIFdvcmxkIQ=="); + }); + + t.it("should encode a string with special characters", () => { + t.expect(base64Encode("!@#$%^&*()_+")).toBe("IUAjJCVeJiooKV8r"); + }); + + t.it("should encode a Unicode string", () => { + t.expect(base64Encode("こんにちは")).toBe("44GT44KT44Gr44Gh44Gv"); + }); + + t.it("should encode a mixed ASCII and Unicode string", () => { + t.expect(base64Encode("Hello, 世界!")).toBe("SGVsbG8sIOS4lueVjCE="); + }); + + t.it("should encode using global btoa object if buffer is undefined or not available", () => { + const originalBuffer = global.Buffer; + global.Buffer = undefined as any; + + const btoa = t.vi.spyOn(global, "btoa"); + btoa.mockImplementation(() => "base64encoded"); + + t.expect(base64Encode("Hello, World!")).toBe("base64encoded"); + + // Restore Buffer + global.Buffer = originalBuffer; + btoa.mockRestore(); + }); + + t.it("should encode a long string", () => { + const longString = "a".repeat(1000); + const encodedString = base64Encode(longString); + + // Check the length (it should be consistent) + t.expect(encodedString).toHaveLength(1336); + + // Check the start and end of the string + t.expect(encodedString.startsWith("YWFhYWFhYWFh")).toBe(true); + t.expect(encodedString.endsWith("YWFhYWFhYWFhYQ==")).toBe(true); + + // Check that it only contains valid base64 characters + t.expect(encodedString).toMatch(/^[A-Za-z0-9+/]+=*$/u); + + // Optionally, you can check the number of 'Y' characters, which should be consistent + t.expect(encodedString.match(/Y/gu)?.length).toBe(334); + }); +}); diff --git a/packages/sdk-multichain/src/domain/utils/base64/index.ts b/packages/sdk-multichain/src/domain/utils/base64/index.ts new file mode 100644 index 000000000..7d175648e --- /dev/null +++ b/packages/sdk-multichain/src/domain/utils/base64/index.ts @@ -0,0 +1,30 @@ +/** + * Base64 encode string for URL params + */ +export function base64Encode(str: string): string { + let base64string: string; + + if (typeof Buffer !== "undefined") { + base64string = Buffer.from(str, "utf8").toString("base64"); + } else if (typeof btoa === "function") { + base64string = btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/gu, (_match, p1) => String.fromCharCode(parseInt(p1, 16)))); + } else if (typeof global === "object" && "Buffer" in global) { + base64string = global.Buffer.from(str, "utf8").toString("base64"); + } else { + throw new Error("Unable to base64 encode: No available method."); + } + return base64string; +} + +export const getBase64FromUrl = async (url: string): Promise => { + const data = await fetch(url); + const blob = await data.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => { + const base64data = reader.result as string; + resolve(base64data); + }; + }); +}; diff --git a/packages/sdk-multichain/src/domain/utils/index.ts b/packages/sdk-multichain/src/domain/utils/index.ts new file mode 100644 index 000000000..6b083512a --- /dev/null +++ b/packages/sdk-multichain/src/domain/utils/index.ts @@ -0,0 +1,6 @@ +export * from "./base64"; +import packageJson from "../../../package.json"; + +export function getVersion() { + return packageJson.version; +} diff --git a/packages/sdk-multichain/tsconfig.json b/packages/sdk-multichain/tsconfig.json index 29ca1ad9b..515b2c0c8 100644 --- a/packages/sdk-multichain/tsconfig.json +++ b/packages/sdk-multichain/tsconfig.json @@ -13,7 +13,7 @@ "resolveJsonModule": true, "useUnknownInCatchVariables": false, "incremental": false, - "lib": ["DOM", "ES2016"], + "lib": ["DOM", "ES2020"], "skipLibCheck": true, "types": ["node"], "paths": { diff --git a/packages/sdk-multichain/vitest.config.ts b/packages/sdk-multichain/vitest.config.ts new file mode 100644 index 000000000..e592a8664 --- /dev/null +++ b/packages/sdk-multichain/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/.{idea,git,cache,output,temp}/**', + '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', + '**/fixtures.test.ts', // Exclude fixtures helper file + ], + include: [ + '**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', + ], + }, +}); diff --git a/yarn.lock b/yarn.lock index b93b74871..cb19944f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11220,7 +11220,9 @@ __metadata: "@biomejs/biome": 2.0.0 "@metamask/auto-changelog": ^3.4.3 "@metamask/multichain-api-client": ^0.6.4 + "@metamask/onboarding": ^1.0.1 "@metamask/sdk-analytics": "workspace:^" + "@metamask/sdk-install-modal-web": "workspace:^" "@metamask/utils": ^11.4.0 "@paulmillr/qr": ^0.2.1 "@react-native-async-storage/async-storage": ^1.19.6 @@ -11228,7 +11230,7 @@ __metadata: bowser: ^2.11.0 cross-fetch: ^4.1.0 esbuild-plugin-umd-wrapper: ^3.0.0 - eventemitter2: ^6.4.9 + eventemitter3: ^5.0.1 jsdom: ^26.1.0 nock: ^14.0.4 prettier: ^3.3.3 @@ -11544,7 +11546,7 @@ __metadata: languageName: node linkType: hard -"@metamask/sdk-install-modal-web@workspace:*, @metamask/sdk-install-modal-web@workspace:packages/sdk-install-modal-web": +"@metamask/sdk-install-modal-web@workspace:*, @metamask/sdk-install-modal-web@workspace:^, @metamask/sdk-install-modal-web@workspace:packages/sdk-install-modal-web": version: 0.0.0-use.local resolution: "@metamask/sdk-install-modal-web@workspace:packages/sdk-install-modal-web" dependencies: @@ -22632,9 +22634,9 @@ __metadata: linkType: hard "agent-base@npm:^7.1.2": - version: 7.1.4 - resolution: "agent-base@npm:7.1.4" - checksum: 86a7f542af277cfbd77dd61e7df8422f90bac512953709003a1c530171a9d019d072e2400eab2b59f84b49ab9dd237be44315ca663ac73e82b3922d10ea5eafa + version: 7.1.3 + resolution: "agent-base@npm:7.1.3" + checksum: 87bb7ee54f5ecf0ccbfcba0b07473885c43ecd76cb29a8db17d6137a19d9f9cd443a2a7c5fd8a3f24d58ad8145f9eb49116344a66b107e1aeab82cf2383f4753 languageName: node linkType: hard