From 4b0937df1c84dcc725c12ca6e44d5094e901a93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Tue, 22 Jul 2025 17:30:42 +0200 Subject: [PATCH] fix: linting issues from biome --- .../sdk-multichain/src/domain/errors/base.ts | 12 +- .../sdk-multichain/src/domain/errors/index.ts | 4 +- .../sdk-multichain/src/domain/errors/rpc.ts | 67 +- .../src/domain/errors/storage.ts | 66 +- .../sdk-multichain/src/domain/errors/types.ts | 9 +- .../sdk-multichain/src/domain/events/index.ts | 107 +- .../src/domain/events/types/extension.ts | 2 +- .../src/domain/events/types/index.ts | 4 +- .../src/domain/events/types/sdk.ts | 2 +- .../sdk-multichain/src/domain/index.test.ts | 851 +++++---- packages/sdk-multichain/src/domain/index.ts | 12 +- .../sdk-multichain/src/domain/logger/index.ts | 53 +- .../src/domain/multichain/api/constants.ts | 254 ++- .../src/domain/multichain/api/eip155.ts | 73 +- .../src/domain/multichain/api/infura.ts | 10 +- .../src/domain/multichain/api/types.ts | 39 +- .../src/domain/multichain/index.ts | 150 +- .../src/domain/platform/index.ts | 95 +- .../src/domain/store/adapter.ts | 12 +- .../sdk-multichain/src/domain/store/client.ts | 14 +- .../sdk-multichain/src/domain/store/index.ts | 5 +- packages/sdk-multichain/src/globals.d.ts | 28 +- packages/sdk-multichain/src/index.browser.ts | 28 +- packages/sdk-multichain/src/index.native.ts | 28 +- packages/sdk-multichain/src/index.node.ts | 28 +- packages/sdk-multichain/src/index.test.ts | 1591 ++++++++--------- .../sdk-multichain/src/multichain/index.ts | 383 ++-- .../sdk-multichain/src/store/adapters/node.ts | 80 +- .../sdk-multichain/src/store/adapters/rn.ts | 22 +- .../sdk-multichain/src/store/adapters/web.ts | 32 +- .../sdk-multichain/src/store/index.test.ts | 396 ++-- packages/sdk-multichain/src/store/index.ts | 137 +- .../src/utis/base64/index.test.ts | 83 +- .../sdk-multichain/src/utis/base64/index.ts | 47 +- .../sdk-multichain/src/utis/index.test.ts | 675 ++++--- packages/sdk-multichain/src/utis/index.ts | 208 +-- .../src/utis/rpc/client.test.ts | 583 +++--- .../sdk-multichain/src/utis/rpc/client.ts | 226 ++- 38 files changed, 3058 insertions(+), 3358 deletions(-) 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..91af4f495 100644 --- a/packages/sdk-multichain/src/domain/events/index.ts +++ b/packages/sdk-multichain/src/domain/events/index.ts @@ -1,7 +1,6 @@ -import { EventEmitter2 } from 'eventemitter2'; - -import type { EventTypes } from './types'; +import { EventEmitter2 } from "eventemitter2"; +import type { EventTypes } from "./types"; /** * A type-safe event emitter that provides a strongly-typed wrapper around EventEmitter2. @@ -12,66 +11,54 @@ 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(); +export class EventEmitter = 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); - } + /** + * 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); - } + /** + * 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); - } + /** + * 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); - } + /** + * 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 index 08de33381..1df0c99aa 100644 --- a/packages/sdk-multichain/src/domain/events/types/extension.ts +++ b/packages/sdk-multichain/src/domain/events/types/extension.ts @@ -2,5 +2,5 @@ * Extension native Events */ export type ExtensionEvents = { - chainChanged: [evt: unknown]; + 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..a87c9de59 100644 --- a/packages/sdk-multichain/src/domain/events/types/index.ts +++ b/packages/sdk-multichain/src/domain/events/types/index.ts @@ -1,5 +1,5 @@ -import type { ExtensionEvents } from './extension'; -import type { SDKEvents } from './sdk'; +import type { ExtensionEvents } from "./extension"; +import type { SDKEvents } from "./sdk"; export type EventTypes = SDKEvents | ExtensionEvents; export type { SDKEvents, ExtensionEvents }; diff --git a/packages/sdk-multichain/src/domain/events/types/sdk.ts b/packages/sdk-multichain/src/domain/events/types/sdk.ts index 46197c788..96422149a 100644 --- a/packages/sdk-multichain/src/domain/events/types/sdk.ts +++ b/packages/sdk-multichain/src/domain/events/types/sdk.ts @@ -1,3 +1,3 @@ export type SDKEvents = { - display_uri: [evt: string]; + 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..6d7b68336 100644 --- a/packages/sdk-multichain/src/domain/index.test.ts +++ b/packages/sdk-multichain/src/domain/index.test.ts @@ -1,513 +1,504 @@ +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 ExtensionEvents, 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(); - }); + // Mock StoreClient + const mockStoreClient = { + getDebug: t.vi.fn(), + }; - t.describe('createLogger', () => { - t.it('should create a logger with default namespace', () => { - const logger = createLogger(); + t.beforeEach(() => { + t.vi.clearAllMocks(); + delete process.env.DEBUG; + mockStoreClient.getDebug.mockClear(); + }); - t.expect(logger).toBeDefined(); - t.expect(typeof logger).toBe('function'); - }); + t.describe("createLogger", () => { + t.it("should create a logger with default namespace", () => { + const logger = createLogger(); - 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.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.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).toBeDefined(); - t.expect(typeof logger).toBe('function'); - t.expect(logger.color).toBe('123'); - }); - }); + t.it("should create a logger with custom namespace and color", () => { + const logger = createLogger("metamask-sdk:core", "123"); - t.describe('enableDebug', () => { - t.it('should enable debug with default namespace', () => { - t.expect(() => enableDebug()).not.toThrow(); - }); + t.expect(logger).toBeDefined(); + t.expect(typeof logger).toBe("function"); + t.expect(logger.color).toBe("123"); + }); + }); - t.it('should enable debug with custom namespace', () => { - t.expect(() => enableDebug('metamask-sdk:provider')).not.toThrow(); - }); - }); + t.describe("enableDebug", () => { + t.it("should enable debug with default namespace", () => { + t.expect(() => enableDebug()).not.toThrow(); + }); - t.describe('isEnabled', () => { - t.it('should return true when namespace is in process.env.DEBUG', async () => { - process.env.DEBUG = 'metamask-sdk:core'; + t.it("should enable debug with custom namespace", () => { + t.expect(() => enableDebug("metamask-sdk:provider")).not.toThrow(); + }); + }); - const result = await isEnabled('metamask-sdk:core', mockStoreClient as any); + t.describe("isEnabled", () => { + t.it("should return true when namespace is in process.env.DEBUG", async () => { + process.env.DEBUG = "metamask-sdk:core"; - t.expect(result).toBe(true); - t.expect(mockStoreClient.getDebug).not.toHaveBeenCalled(); - }); + const result = await isEnabled("metamask-sdk:core", mockStoreClient as any); - t.it('should return true when wildcard matches namespace', async () => { - process.env.DEBUG = 'metamask-sdk:*'; + t.expect(result).toBe(true); + t.expect(mockStoreClient.getDebug).not.toHaveBeenCalled(); + }); - const result = await isEnabled('metamask-sdk:core', mockStoreClient as any); + t.it("should return true when wildcard matches namespace", async () => { + process.env.DEBUG = "metamask-sdk:*"; - t.expect(result).toBe(true); - }); + const result = await isEnabled("metamask-sdk:core", mockStoreClient as any); - t.it('should return true when universal wildcard is used', async () => { - process.env.DEBUG = '*'; + t.expect(result).toBe(true); + }); - const result = await isEnabled('metamask-sdk', mockStoreClient as any); + t.it("should return true when universal wildcard is used", async () => { + process.env.DEBUG = "*"; - t.expect(result).toBe(true); - }); + const result = await isEnabled("metamask-sdk", mockStoreClient as any); - t.it('should return false when namespace is not in process.env.DEBUG', async () => { - process.env.DEBUG = 'other-namespace'; + t.expect(result).toBe(true); + }); - const result = await isEnabled('metamask-sdk:core', mockStoreClient as any); + t.it("should return false when namespace is not in process.env.DEBUG", async () => { + process.env.DEBUG = "other-namespace"; - t.expect(result).toBe(false); - }); + const result = await isEnabled("metamask-sdk:core", mockStoreClient as any); - t.it('should check storage when process.env.DEBUG is not set', async () => { - mockStoreClient.getDebug.mockResolvedValue('metamask-sdk:provider'); + t.expect(result).toBe(false); + }); - const result = await isEnabled('metamask-sdk:provider', mockStoreClient as any); + t.it("should check storage when process.env.DEBUG is not set", async () => { + mockStoreClient.getDebug.mockResolvedValue("metamask-sdk:provider"); - t.expect(result).toBe(true); - t.expect(mockStoreClient.getDebug).toHaveBeenCalled(); - }); + const result = await isEnabled("metamask-sdk:provider", mockStoreClient as any); - t.it('should return false when storage debug is empty', async () => { - mockStoreClient.getDebug.mockResolvedValue(null); + t.expect(result).toBe(true); + t.expect(mockStoreClient.getDebug).toHaveBeenCalled(); + }); - const result = await isEnabled('metamask-sdk', mockStoreClient as any); + t.it("should return false when storage debug is empty", async () => { + mockStoreClient.getDebug.mockResolvedValue(null); - t.expect(result).toBe(false); - }); + const result = await isEnabled("metamask-sdk", mockStoreClient as any); - t.it('should prioritize process.env.DEBUG over storage', async () => { - process.env.DEBUG = 'metamask-sdk:core'; - mockStoreClient.getDebug.mockResolvedValue('metamask-sdk:provider'); + t.expect(result).toBe(false); + }); - const result = await isEnabled('metamask-sdk:core', mockStoreClient as any); + t.it("should prioritize process.env.DEBUG over storage", async () => { + process.env.DEBUG = "metamask-sdk:core"; + mockStoreClient.getDebug.mockResolvedValue("metamask-sdk:provider"); - t.expect(result).toBe(true); - t.expect(mockStoreClient.getDebug).not.toHaveBeenCalled(); - }); - }); + const result = await isEnabled("metamask-sdk:core", mockStoreClient as any); -}) - - -t.describe('EventEmitter', () => { - - - t.describe('emit', () => { + t.expect(result).toBe(true); + t.expect(mockStoreClient.getDebug).not.toHaveBeenCalled(); + }); + }); +}); - t.describe("SDKEvents", () => { - let eventEmitter: EventEmitter; +t.describe("EventEmitter", () => { + t.describe("emit", () => { + t.describe("SDKEvents", () => { + let eventEmitter: EventEmitter; - t.beforeEach(() => { - eventEmitter = new EventEmitter(); - }); + t.beforeEach(() => { + eventEmitter = new EventEmitter(); + }); - t.it('should emit display_uri event with string argument', () => { - const handler = t.vi.fn(); - eventEmitter.on('display_uri', handler); + t.it("should emit display_uri event with string argument", () => { + const handler = t.vi.fn(); + eventEmitter.on("display_uri", handler); - eventEmitter.emit('display_uri', 'uri://test-connection'); + eventEmitter.emit("display_uri", "uri://test-connection"); - t.expect(handler).toHaveBeenCalledWith('uri://test-connection'); - t.expect(handler).toHaveBeenCalledTimes(1); - }); + 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(); + 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.on("display_uri", handler1); + eventEmitter.on("display_uri", handler2); + eventEmitter.on("display_uri", handler3); - eventEmitter.emit('display_uri', 'broadcast-uri'); + 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.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(); + 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'); + eventEmitter.on("display_uri", handler); + eventEmitter.emit("display_uri", "test-uri"); - t.expect(handler).toHaveBeenCalledWith('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(); + 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.on("display_uri", handler1); + eventEmitter.on("display_uri", handler2); - eventEmitter.emit('display_uri', 'test'); + eventEmitter.emit("display_uri", "test"); - t.expect(handler1).toHaveBeenCalledWith('test'); - t.expect(handler2).toHaveBeenCalledWith('test'); - }); + t.expect(handler1).toHaveBeenCalledWith("test"); + t.expect(handler2).toHaveBeenCalledWith("test"); + }); - t.it('should remove specific handlers for display_uri event', () => { - const handler1 = t.vi.fn(); - const handler2 = t.vi.fn(); + t.it("should remove specific handlers for display_uri event", () => { + const handler1 = t.vi.fn(); + const handler2 = t.vi.fn(); - eventEmitter.on('display_uri', handler1); - eventEmitter.on('display_uri', handler2); + eventEmitter.on("display_uri", handler1); + eventEmitter.on("display_uri", handler2); - eventEmitter.off('display_uri', handler1); - eventEmitter.emit('display_uri', 'test'); + eventEmitter.off("display_uri", handler1); + eventEmitter.emit("display_uri", "test"); - t.expect(handler1).not.toHaveBeenCalled(); - t.expect(handler2).toHaveBeenCalledWith('test'); - }); + t.expect(handler1).not.toHaveBeenCalled(); + t.expect(handler2).toHaveBeenCalledWith("test"); + }); - t.it('should handle removing non-existent handlers gracefully', () => { - const handler = t.vi.fn(); - const nonExistentHandler = t.vi.fn(); + t.it("should handle removing non-existent handlers gracefully", () => { + const handler = t.vi.fn(); + const nonExistentHandler = t.vi.fn(); - eventEmitter.on('display_uri', handler); + eventEmitter.on("display_uri", handler); - // This should not throw an error - t.expect(() => { - eventEmitter.off('display_uri', nonExistentHandler); - }).not.toThrow(); + // This should not throw an error + t.expect(() => { + eventEmitter.off("display_uri", nonExistentHandler); + }).not.toThrow(); - eventEmitter.emit('display_uri', 'still works'); - t.expect(handler).toHaveBeenCalledWith('still works'); - }); + eventEmitter.emit("display_uri", "still works"); + t.expect(handler).toHaveBeenCalledWith("still works"); + }); - t.it('should handle removing handlers from non-existent events gracefully', () => { - const handler = t.vi.fn(); + t.it("should handle removing handlers from non-existent events gracefully", () => { + const handler = t.vi.fn(); - // 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> = []; + // 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> = []; - // Set a higher limit to avoid warnings - eventEmitter.setMaxListeners(200); + // Set a higher limit to avoid warnings + eventEmitter.setMaxListeners(200); - // Add many handlers - for (let i = 0; i < 100; i++) { - const handler = t.vi.fn(); - handlers.push(handler); - eventEmitter.on('display_uri', handler); - } + // Add many handlers + for (let i = 0; i < 100; i++) { + const handler = t.vi.fn(); + handlers.push(handler); + eventEmitter.on("display_uri", handler); + } - // Remove all handlers - handlers.forEach(handler => { - eventEmitter.off('display_uri', handler); - }); + // Remove all handlers + handlers.forEach((handler) => { + eventEmitter.off("display_uri", handler); + }); - // Emit should not call any handlers - eventEmitter.emit('display_uri', 'should not be handled'); + // Emit should not call any handlers + eventEmitter.emit("display_uri", "should not be handled"); - handlers.forEach(handler => { - t.expect(handler).not.toHaveBeenCalled(); - }); - }); - }) + handlers.forEach((handler) => { + t.expect(handler).not.toHaveBeenCalled(); + }); + }); + }); - t.describe("ExtensionEvents", () => { - let eventEmitter: EventEmitter; - t.beforeEach(() => { - eventEmitter = new EventEmitter(); - }); + t.describe("ExtensionEvents", () => { + let eventEmitter: EventEmitter; + t.beforeEach(() => { + eventEmitter = new EventEmitter(); + }); - t.it('should emit chainChanged event with unknown argument', () => { - const handler = t.vi.fn(); - eventEmitter.on('chainChanged', handler); + t.it("should emit chainChanged event with unknown argument", () => { + const handler = t.vi.fn(); + eventEmitter.on("chainChanged", handler); - const chainData = { chainId: '0x1', networkName: 'mainnet' }; - eventEmitter.emit('chainChanged', chainData); + const chainData = { chainId: "0x1", networkName: "mainnet" }; + eventEmitter.emit("chainChanged", chainData); - t.expect(handler).toHaveBeenCalledWith(chainData); - t.expect(handler).toHaveBeenCalledTimes(1); - }); + t.expect(handler).toHaveBeenCalledWith(chainData); + t.expect(handler).toHaveBeenCalledTimes(1); + }); - t.it('should emit chainChanged event with various data types', () => { - const handler = t.vi.fn(); - eventEmitter.on('chainChanged', handler); + t.it("should emit chainChanged event with various data types", () => { + const handler = t.vi.fn(); + eventEmitter.on("chainChanged", handler); - // Test with string - eventEmitter.emit('chainChanged', 'chain-id'); - t.expect(handler).toHaveBeenCalledWith('chain-id'); + // Test with string + eventEmitter.emit("chainChanged", "chain-id"); + t.expect(handler).toHaveBeenCalledWith("chain-id"); - // Test with number - eventEmitter.emit('chainChanged', 1); - t.expect(handler).toHaveBeenCalledWith(1); + // Test with number + eventEmitter.emit("chainChanged", 1); + t.expect(handler).toHaveBeenCalledWith(1); - // Test with null - eventEmitter.emit('chainChanged', null); - t.expect(handler).toHaveBeenCalledWith(null); - - t.expect(handler).toHaveBeenCalledTimes(3); - }); + // Test with null + eventEmitter.emit("chainChanged", null); + t.expect(handler).toHaveBeenCalledWith(null); + + t.expect(handler).toHaveBeenCalledTimes(3); + }); - t.it('should register handlers for chainChanged event', () => { - const handler = t.vi.fn(); + t.it("should register handlers for chainChanged event", () => { + const handler = t.vi.fn(); - eventEmitter.on('chainChanged', handler); - eventEmitter.emit('chainChanged', { chainId: '0x89' }); + eventEmitter.on("chainChanged", handler); + eventEmitter.emit("chainChanged", { chainId: "0x89" }); - 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' }); - }); - }) - }); + 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" }); + }); + }); + }); }); diff --git a/packages/sdk-multichain/src/domain/index.ts b/packages/sdk-multichain/src/domain/index.ts index 2b86e96e0..bf2517520 100644 --- a/packages/sdk-multichain/src/domain/index.ts +++ b/packages/sdk-multichain/src/domain/index.ts @@ -1,6 +1,6 @@ -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"; diff --git a/packages/sdk-multichain/src/domain/logger/index.ts b/packages/sdk-multichain/src/domain/logger/index.ts index 2f3607116..34c2b1b56 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"; /** * 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?.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..9f0daf804 100644 --- a/packages/sdk-multichain/src/domain/multichain/index.ts +++ b/packages/sdk-multichain/src/domain/multichain/index.ts @@ -1,8 +1,8 @@ -import type { SessionData } from '@metamask/multichain-api-client'; -import type { CaipAccountId, Json } from '@metamask/utils'; +import type { SessionData } from "@metamask/multichain-api-client"; +import type { CaipAccountId, Json } from "@metamask/utils"; -import type { StoreClient } from '../store/client'; -import type { InvokeMethodOptions, NotificationCallback, RPC_URLS_MAP, Scope } from './api/types'; +import type { StoreClient } from "../store/client"; +import type { InvokeMethodOptions, NotificationCallback, RPC_URLS_MAP, Scope } from "./api/types"; /** * Configuration settings for the dapp using the SDK. @@ -12,8 +12,8 @@ import type { InvokeMethodOptions, NotificationCallback, RPC_URLS_MAP, Scope } f * - Using a base64-encoded icon */ export type DappSettings = { - name?: string; - url?: string; + name?: string; + url?: string; } & ({ iconUrl?: string } | { base64Icon?: string }); /** @@ -24,26 +24,26 @@ export type DappSettings = { * 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; - }; + /** 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; + }; }; /** @@ -54,56 +54,53 @@ export type MultichainSDKConstructor = { */ /* c8 ignore start */ export abstract class MultichainSDKBase { - public abstract isInitialized: boolean; - public abstract session: SessionData | undefined; + 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; + /** + * 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; + /** + * 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; + /** + * Registers a listener for incoming notifications. + * + * @param listener - Callback function to handle notifications + * @returns Function to remove the listener + */ + abstract onNotification(listener: NotificationCallback): () => void; - /** - * 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; + /** + * 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; + /** + * Storage client instance for persisting SDK data. + */ + abstract readonly storage: StoreClient; } /* c8 ignore end */ -export type { SessionData } from '@metamask/multichain-api-client'; +export type { SessionData } from "@metamask/multichain-api-client"; /** * Base options for Multichain SDK configuration. @@ -111,10 +108,7 @@ export type { SessionData } from '@metamask/multichain-api-client'; * 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' ->; +export type MultichainSDKBaseOptions = Pick; /** * Complete options for Multichain SDK configuration. @@ -123,12 +117,12 @@ export type MultichainSDKBaseOptions = Pick< * providing all necessary options for SDK initialization. */ export type MultichainSDKOptions = MultichainSDKBaseOptions & { - /** Storage client for persisting SDK data */ - storage: StoreClient; + /** Storage client for persisting SDK data */ + storage: StoreClient; }; -export type CreateMultichainFN = ( options: MultichainSDKBaseOptions ) => Promise; +export type CreateMultichainFN = (options: MultichainSDKBaseOptions) => Promise; -export type * from './api/types'; -export * from './api/constants'; -export * from './api/infura'; +export type * from "./api/types"; +export * from "./api/constants"; +export * from "./api/infura"; diff --git a/packages/sdk-multichain/src/domain/platform/index.ts b/packages/sdk-multichain/src/domain/platform/index.ts index c21c643c3..586f560a0 100644 --- a/packages/sdk-multichain/src/domain/platform/index.ts +++ b/packages/sdk-multichain/src/domain/platform/index.ts @@ -1,75 +1,66 @@ -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; } 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..cac1f9d95 100644 --- a/packages/sdk-multichain/src/domain/store/client.ts +++ b/packages/sdk-multichain/src/domain/store/client.ts @@ -1,11 +1,11 @@ /* c8 ignore start */ 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 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/globals.d.ts b/packages/sdk-multichain/src/globals.d.ts index 3fa8c5882..e230a4184 100644 --- a/packages/sdk-multichain/src/globals.d.ts +++ b/packages/sdk-multichain/src/globals.d.ts @@ -1,18 +1,18 @@ -declare module '@paulmillr/qr'; +declare module "@paulmillr/qr"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export declare const mmsdk: any; declare global { - interface Window { - /** - * TODO: Add types for the window object to manage connection with inApp browser, etc - */ - ReactNativeWebView?: any; - mmsdk?: any; - ethereum?: { - isMetaMask?: boolean; - request?: (request: { method: string; params?: any[] }) => Promise; - on?: (eventName: string, handler: (...args: any[]) => void) => void; - removeListener?: (eventName: string, handler: (...args: any[]) => void) => void; - }; - } + interface Window { + /** + * TODO: Add types for the window object to manage connection with inApp browser, etc + */ + ReactNativeWebView?: any; + mmsdk?: any; + ethereum?: { + isMetaMask?: boolean; + request?: (request: { method: string; params?: any[] }) => Promise; + on?: (eventName: string, handler: (...args: any[]) => void) => void; + removeListener?: (eventName: string, handler: (...args: any[]) => void) => void; + }; + } } diff --git a/packages/sdk-multichain/src/index.browser.ts b/packages/sdk-multichain/src/index.browser.ts index 53c5100d8..5a9dced67 100644 --- a/packages/sdk-multichain/src/index.browser.ts +++ b/packages/sdk-multichain/src/index.browser.ts @@ -1,17 +1,17 @@ -import type { CreateMultichainFN } from './domain'; -import { MultichainSDK } from './multichain'; -import { Store } from './store'; +import type { CreateMultichainFN } from "./domain"; +import { MultichainSDK } from "./multichain"; +import { Store } from "./store"; -export type * from './domain'; +export type * from "./domain"; export const createMetamaskSDK: CreateMultichainFN = async (options) => { - const { StoreAdapterWeb } = await import('./store/adapters/web'); - const adapter = new StoreAdapterWeb(); - return MultichainSDK.create({ - ...options, - storage: new Store(adapter), - ui: { - headless: options.ui?.headless ?? false, - }, - }); -} + const { StoreAdapterWeb } = await import("./store/adapters/web"); + const adapter = new StoreAdapterWeb(); + return MultichainSDK.create({ + ...options, + storage: new Store(adapter), + ui: { + headless: options.ui?.headless ?? false, + }, + }); +}; diff --git a/packages/sdk-multichain/src/index.native.ts b/packages/sdk-multichain/src/index.native.ts index d483b18b4..50c62ac48 100644 --- a/packages/sdk-multichain/src/index.native.ts +++ b/packages/sdk-multichain/src/index.native.ts @@ -1,17 +1,17 @@ -import type { CreateMultichainFN } from './domain'; -import { MultichainSDK } from './multichain'; -import { Store } from './store'; +import type { CreateMultichainFN } from "./domain"; +import { MultichainSDK } from "./multichain"; +import { Store } from "./store"; -export type * from './domain'; +export type * from "./domain"; export const createMetamaskSDK: CreateMultichainFN = async (options) => { - const { StoreAdapterRN } = await import('./store/adapters/rn'); - const adapter = new StoreAdapterRN(); - return MultichainSDK.create({ - ...options, - storage: new Store(adapter), - ui: { - headless: options.ui?.headless ?? false, // React Native can show UI - }, - }); -} + const { StoreAdapterRN } = await import("./store/adapters/rn"); + const adapter = new StoreAdapterRN(); + return MultichainSDK.create({ + ...options, + storage: new Store(adapter), + ui: { + headless: options.ui?.headless ?? false, // React Native can show UI + }, + }); +}; diff --git a/packages/sdk-multichain/src/index.node.ts b/packages/sdk-multichain/src/index.node.ts index e8b0a2b1e..d63087137 100644 --- a/packages/sdk-multichain/src/index.node.ts +++ b/packages/sdk-multichain/src/index.node.ts @@ -1,17 +1,17 @@ -import type { CreateMultichainFN } from './domain'; -import { MultichainSDK } from './multichain'; -import { Store } from './store'; +import type { CreateMultichainFN } from "./domain"; +import { MultichainSDK } from "./multichain"; +import { Store } from "./store"; -export type * from './domain'; +export type * from "./domain"; export const createMetamaskSDK: CreateMultichainFN = async (options) => { - const { StoreAdapterNode } = await import('./store/adapters/node'); - const adapter = new StoreAdapterNode(); - return MultichainSDK.create({ - ...options, - storage: new Store(adapter), - ui: { - headless: true, // Node.js defaults to headless - }, - }); -} + const { StoreAdapterNode } = await import("./store/adapters/node"); + const adapter = new StoreAdapterNode(); + return MultichainSDK.create({ + ...options, + storage: new Store(adapter), + ui: { + headless: true, // Node.js defaults to headless + }, + }); +}; diff --git a/packages/sdk-multichain/src/index.test.ts b/packages/sdk-multichain/src/index.test.ts index 1caed2bca..86aac293d 100644 --- a/packages/sdk-multichain/src/index.test.ts +++ b/packages/sdk-multichain/src/index.test.ts @@ -1,829 +1,808 @@ -import * as t from 'vitest'; -import { vi } from 'vitest'; -import { JSDOM as Page } from 'jsdom'; -import { createMetamaskSDK as createMetamaskSDKWeb } from './index.browser'; -import { MultichainSDKBaseOptions, MultichainSDKBase } from './domain'; -import { MultichainSDK } from './multichain'; -import * as loggerModule from './domain/logger'; -import * as analyticsModule from '@metamask/sdk-analytics'; -import type { SessionData } from '@metamask/multichain-api-client'; -import type { Scope } from './domain/multichain/api/types'; +import * as t from "vitest"; +import { vi } from "vitest"; +import { JSDOM as Page } from "jsdom"; +import { createMetamaskSDK as createMetamaskSDKWeb } from "./index.browser"; +import { MultichainSDKBaseOptions, MultichainSDKBase } from "./domain"; +import { MultichainSDK } from "./multichain"; +import * as loggerModule from "./domain/logger"; +import * as analyticsModule from "@metamask/sdk-analytics"; +import type { SessionData } from "@metamask/multichain-api-client"; +import type { Scope } from "./domain/multichain/api/types"; type NativeStorageStub = { - data: Map; - getItem: t.Mock<(key: string) => string | null>; - setItem: t.Mock<(key: string, value: string) => void>; - removeItem: t.Mock<(key: string) => void>; - clear: t.Mock<() => void>; -} + data: Map; + getItem: t.Mock<(key: string) => string | null>; + setItem: t.Mock<(key: string, value: string) => void>; + removeItem: t.Mock<(key: string) => void>; + clear: t.Mock<() => void>; +}; // Mock the multichain-api-client with proper implementations -vi.mock('@metamask/multichain-api-client', () => { - const mockTransport = { - connect: vi.fn().mockResolvedValue(true), - disconnect: vi.fn().mockResolvedValue(undefined), - isConnected: true, - request: vi.fn(), - onNotification: vi.fn().mockReturnValue(() => { }), - }; - - const mockMultichainClient = { - createSession: vi.fn(), - getSession: vi.fn(), - revokeSession: vi.fn(), - invokeMethod: vi.fn(), - extendsRpcApi: vi.fn(), - onNotification: vi.fn().mockReturnValue(() => { }), - }; - - const mockGetDefaultTransport = vi.fn().mockReturnValue(mockTransport); - const mockGetMultichainClient = vi.fn().mockReturnValue(mockMultichainClient); - - return { - getDefaultTransport: mockGetDefaultTransport, - getMultichainClient: mockGetMultichainClient, - __mockTransport: mockTransport, - __mockMultichainClient: mockMultichainClient, - __mockGetDefaultTransport: mockGetDefaultTransport, - __mockGetMultichainClient: mockGetMultichainClient, - }; +vi.mock("@metamask/multichain-api-client", () => { + const mockTransport = { + connect: vi.fn().mockResolvedValue(true), + disconnect: vi.fn().mockResolvedValue(undefined), + isConnected: true, + request: vi.fn(), + onNotification: vi.fn().mockReturnValue(() => {}), + }; + + const mockMultichainClient = { + createSession: vi.fn(), + getSession: vi.fn(), + revokeSession: vi.fn(), + invokeMethod: vi.fn(), + extendsRpcApi: vi.fn(), + onNotification: vi.fn().mockReturnValue(() => {}), + }; + + const mockGetDefaultTransport = vi.fn().mockReturnValue(mockTransport); + const mockGetMultichainClient = vi.fn().mockReturnValue(mockMultichainClient); + + return { + getDefaultTransport: mockGetDefaultTransport, + getMultichainClient: mockGetMultichainClient, + __mockTransport: mockTransport, + __mockMultichainClient: mockMultichainClient, + __mockGetDefaultTransport: mockGetDefaultTransport, + __mockGetMultichainClient: mockGetMultichainClient, + }; }); -vi.mock('./domain/logger', () => { - const mockLogger = vi.fn(); - return { - createLogger: vi.fn(() => mockLogger), - enableDebug: vi.fn(() => { }), - isEnabled: vi.fn(() => true), - __mockLogger: mockLogger, - } -}) - -t.vi.mock('@metamask/sdk-analytics'); +vi.mock("./domain/logger", () => { + const mockLogger = vi.fn(); + return { + createLogger: vi.fn(() => mockLogger), + enableDebug: vi.fn(() => {}), + isEnabled: vi.fn(() => true), + __mockLogger: mockLogger, + }; +}); +t.vi.mock("@metamask/sdk-analytics"); function createMultiplatformTestCase( - platform: 'web' | 'node' | 'rn', - options: MultichainSDKBaseOptions, - createSDK: (options: MultichainSDKBaseOptions) => Promise, - setupMocks?: (options: NativeStorageStub) => void, - cleanupMocks?: () => void + platform: "web" | "node" | "rn", + options: MultichainSDKBaseOptions, + createSDK: (options: MultichainSDKBaseOptions) => Promise, + setupMocks?: (options: NativeStorageStub) => void, + cleanupMocks?: () => void, ) { + t.describe(`Testing MultichainSDK in ${platform}`, () => { + let sdk: MultichainSDKBase; + let setupAnalyticsSpy: any; + let initSpy: any; + let nativeStorageStub: NativeStorageStub; + + // Mock session data for testing + const mockSessionData: SessionData = { + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction", "eth_accounts"], + notifications: ["accountsChanged", "chainChanged"], + accounts: ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + expiry: new Date(Date.now() + 3600000).toISOString(), + }; + + t.beforeEach(async () => { + (loggerModule as any).__mockLogger.mockReset(); + setupAnalyticsSpy?.mockRestore(); + initSpy?.mockRestore(); + t.vi.clearAllMocks(); + t.vi.resetModules(); + t.vi.unstubAllGlobals(); + + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + const mockGetDefaultTransport = (multichainModule as any).__mockGetDefaultTransport; + const mockGetMultichainClient = (multichainModule as any).__mockGetMultichainClient; + + // Reset transport mocks + mockTransport.connect.mockResolvedValue(true); + mockTransport.disconnect.mockResolvedValue(undefined); + mockTransport.isConnected = true; + mockTransport.request.mockResolvedValue({}); + mockTransport.onNotification.mockReturnValue(() => {}); + + // Reset multichain client mocks + mockMultichainClient.createSession.mockResolvedValue(mockSessionData); + mockMultichainClient.getSession.mockResolvedValue(mockSessionData); + mockMultichainClient.revokeSession.mockResolvedValue(undefined); + mockMultichainClient.invokeMethod.mockResolvedValue({}); + mockMultichainClient.onNotification.mockReturnValue(() => {}); + + // Reset factory function mocks + mockGetDefaultTransport.mockReturnValue(mockTransport); + mockGetMultichainClient.mockReturnValue(mockMultichainClient); + + nativeStorageStub = { + data: new Map(), + getItem: t.vi.fn((key: string) => nativeStorageStub.data.get(key) || null), + setItem: t.vi.fn((key: string, value: string) => { + nativeStorageStub.data.set(key, value); + }), + removeItem: t.vi.fn((key: string) => { + nativeStorageStub.data.delete(key); + }), + clear: t.vi.fn(() => { + nativeStorageStub.data.clear(); + }), + }; + + nativeStorageStub.data.set("DEBUG", "metamask-sdk:*"); + + setupMocks?.(nativeStorageStub); + + // Mock analytics methods + t.vi.mocked(analyticsModule.analytics).setGlobalProperty = t.vi.fn(); + t.vi.mocked(analyticsModule.analytics).enable = t.vi.fn(); + t.vi.mocked(analyticsModule.analytics).track = t.vi.fn(); + setupAnalyticsSpy = t.vi.spyOn(MultichainSDK.prototype as any, "setupAnalytics"); + initSpy = t.vi.spyOn(MultichainSDK.prototype as any, "init"); + }); + + t.afterEach(() => { + nativeStorageStub.data.clear(); + cleanupMocks?.(); + // Reset mock implementations and call history + (loggerModule as any).__mockLogger.mockReset(); + setupAnalyticsSpy?.mockRestore(); + initSpy?.mockRestore(); + t.vi.clearAllMocks(); + t.vi.resetModules(); + t.vi.unstubAllGlobals(); + }); + + t.describe("init", () => { + t.it(`${platform} should call setupAnalytics if analytics is ENABLED and trigger analytics.enable and init evt`, async () => { + options.analytics.enabled = true; + sdk = await createSDK(options); + t.expect(sdk).toBeDefined(); + t.expect(initSpy).toHaveBeenCalled(); + t.expect(setupAnalyticsSpy).toHaveBeenCalled(); + t.expect(analyticsModule.analytics.enable).toHaveBeenCalled(); + t.expect(analyticsModule.analytics.track).toHaveBeenCalledWith("sdk_initialized", {}); + }); + + t.it(`${platform} should NOT call analytics.enable if analytics is DISABLED`, async () => { + options.analytics.enabled = false; + sdk = await createSDK(options); + t.expect(sdk).toBeDefined(); + t.expect(initSpy).toHaveBeenCalled(); + t.expect(setupAnalyticsSpy).toHaveBeenCalled(); + t.expect(analyticsModule.analytics.enable).not.toHaveBeenCalled(); + t.expect(analyticsModule.analytics.track).not.toHaveBeenCalled(); + }); + + t.it(`${platform} should call init and setupAnalytics with logger configuration`, async () => { + const mockLogger = (loggerModule as any).__mockLogger; + + sdk = await createSDK(options); + t.expect(sdk).toBeDefined(); + t.expect(mockLogger).not.toHaveBeenCalled(); + + t.expect(initSpy).toHaveBeenCalled(); + t.expect(setupAnalyticsSpy).toHaveBeenCalled(); + t.expect(sdk.isInitialized).toBe(true); + t.expect(loggerModule.enableDebug).toHaveBeenCalledWith("metamask-sdk:core"); + }); + t.it(`${platform} should properly initialize provider and get session during init`, async () => { + sdk = await createSDK(options); + + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + const mockGetDefaultTransport = (multichainModule as any).__mockGetDefaultTransport; + const mockGetMultichainClient = (multichainModule as any).__mockGetMultichainClient; + + t.expect(sdk).toBeDefined(); + t.expect(sdk.isInitialized).toBe(true); + t.expect(sdk.session).toEqual(mockSessionData); + + // Verify the provider was created with the correct transport + t.expect(mockGetDefaultTransport).toHaveBeenCalled(); + t.expect(mockGetMultichainClient).toHaveBeenCalledWith({ transport: mockTransport }); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + }); + t.it(`${platform} Should gracefully handle init errors by just logging them and return non initialized sdk`, async () => { + const testError = new Error("Test error"); + initSpy.mockImplementation(() => { + throw testError; + }); + + sdk = await createSDK(options); + + t.expect(sdk).toBeDefined(); + t.expect(sdk.isInitialized).toBe(false); + + // Access the mock logger from the module + const mockLogger = (loggerModule as any).__mockLogger; + + // Verify that the logger was called with the error + t.expect(mockLogger).toHaveBeenCalledWith("MetaMaskSDK error during initialization", testError); + }); + }); + + t.describe("session", () => { + t.it(`${platform} should handle session upgrades`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockTransport.isConnected = false; + mockTransport.connect.mockResolvedValue(true); + mockMultichainClient.getSession.mockResolvedValue(mockSessionData); + + sdk = await createSDK(options); + + t.expect(sdk).toBeDefined(); + t.expect(sdk.isInitialized).toBe(true); + t.expect(sdk.session).not.toBeUndefined(); + + const mockedSessionUpgradeData: SessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction", "eth_accounts"], + notifications: ["accountsChanged", "chainChanged"], + accounts: ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }; + + const scopes = ["eip155:137"] as Scope[]; + const caipAccountIds = ["eip155:137:0x1234567890abcdef1234567890abcdef12345678"] as any; + const result = await sdk.connect(scopes, caipAccountIds); + + t.expect(mockTransport.connect).toHaveBeenCalled(); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ + optionalScopes: { + "eip155:137": { + methods: [], + notifications: [], + accounts: ["0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }); + t.expect(result).toEqual(mockedSessionUpgradeData); + }); + + t.it(`${platform} should handle session retrieval when no session exists`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + // Mock no session scenario + mockMultichainClient.getSession.mockResolvedValue(undefined); + + sdk = await createSDK(options); + + t.expect(sdk).toBeDefined(); + t.expect(sdk.isInitialized).toBe(true); + t.expect(sdk.session).toBeUndefined(); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + }); + + t.it(`${platform} should handle provider errors during session retrieval`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + const sessionError = new Error("Session error"); + + // Clear previous calls and set up the error mock + t.vi.clearAllMocks(); + mockMultichainClient.getSession.mockRejectedValue(sessionError); + + sdk = await createSDK(options); + + t.expect(sdk).toBeDefined(); + t.expect(sdk.isInitialized).toBe(false); + + // Access the mock logger from the module + const mockLogger = (loggerModule as any).__mockLogger; + + // Verify that the logger was called with the error + t.expect(mockLogger).toHaveBeenCalledWith("MetaMaskSDK error during initialization", sessionError); + }); + }); + + t.describe(`${platform} connect method tests`, () => { + t.beforeEach(async () => { + sdk = await createSDK(options); + }); + + t.it(`${platform} should connect transport and create session when not connected`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + // Configure mocks for this specific test BEFORE creating SDK + mockTransport.isConnected = false; + mockTransport.connect.mockResolvedValue(true); + mockMultichainClient.getSession.mockResolvedValue(undefined); + + const newSessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }; + mockMultichainClient.createSession.mockResolvedValue(newSessionData); + + // Create a new SDK instance with the mock configured correctly + const testSdk = await createSDK(options); + + const scopes = ["eip155:1"] as Scope[]; + const caipAccountIds = ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"] as any; + + const result = await testSdk.connect(scopes, caipAccountIds); + + t.expect(mockTransport.connect).toHaveBeenCalled(); + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.revokeSession).not.toHaveBeenCalled(); + t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ + optionalScopes: { + "eip155:1": { + methods: [], + notifications: [], + accounts: ["0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }); + t.expect(result).toEqual(newSessionData); + }); + + t.it(`${platform} should skip transport connection when already connected`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + // Mock transport as already connected + mockTransport.isConnected = true; + mockMultichainClient.getSession.mockResolvedValue(undefined); + + const newSessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }; + mockMultichainClient.createSession.mockResolvedValue(newSessionData); + + const scopes = ["eip155:1"] as Scope[]; + const caipAccountIds = ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"] as any; + + const result = await sdk.connect(scopes, caipAccountIds); + + t.expect(mockTransport.connect).not.toHaveBeenCalled(); + t.expect(mockMultichainClient.createSession).toHaveBeenCalled(); + t.expect(result).toEqual(newSessionData); + }); + + t.it(`${platform} should handle invalid CAIP account IDs gracefully`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockTransport.isConnected = true; + mockMultichainClient.getSession.mockResolvedValue(undefined); + + // Mock console.error to capture invalid account ID errors + const consoleErrorSpy = t.vi.spyOn(console, "error").mockImplementation(() => {}); + + const newSessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: [], + }, + }, + }; + mockMultichainClient.createSession.mockResolvedValue(newSessionData); + + const scopes = ["eip155:1"] as Scope[]; + const caipAccountIds = ["invalid-account-id", "eip155:1:0x1234567890abcdef1234567890abcdef12345678"] as any; + + const result = await sdk.connect(scopes, caipAccountIds); + + t.expect(consoleErrorSpy).toHaveBeenCalledWith('Invalid CAIP account ID: "invalid-account-id"', t.expect.any(Error)); + t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ + optionalScopes: { + "eip155:1": { + methods: [], + notifications: [], + accounts: ["0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }); + t.expect(result).toEqual(newSessionData); + + consoleErrorSpy.mockRestore(); + }); + + t.it(`${platform} should return existing session when scopes don't overlap`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockTransport.isConnected = true; + + const existingSessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:137": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: ["eip155:137:0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }; + mockMultichainClient.getSession.mockResolvedValue(existingSessionData); + + const scopes = ["eip155:137"] as Scope[]; // Different scope than existing session + const caipAccountIds = ["eip155:137:0x1234567890abcdef1234567890abcdef12345678"] as any; + + const result = await sdk.connect(scopes, caipAccountIds); + + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.createSession).not.toHaveBeenCalled(); + t.expect(mockMultichainClient.revokeSession).not.toHaveBeenCalled(); + t.expect(result).toEqual(existingSessionData); + }); + + t.it(`should ${platform} simulate sesion upgrade by adding new session scopes to connect`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockTransport.isConnected = true; + + const existingSessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }; + + const newSessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }; + + mockMultichainClient.getSession.mockResolvedValue(existingSessionData); + mockMultichainClient.createSession.mockResolvedValue(newSessionData); + + const scopes = ["eip155:137"] as Scope[]; // Same scope as existing session + const caipAccountIds = ["eip155:137:0x1234567890abcdef1234567890abcdef12345678"] as any; + + const result = await sdk.connect(scopes, caipAccountIds); + + t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ + optionalScopes: { + "eip155:137": { + methods: [], + notifications: [], + accounts: ["0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }); + t.expect(result).toEqual(newSessionData); + }); + + t.it(`${platform} should handle multiple scopes and accounts correctly`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockTransport.isConnected = true; + mockMultichainClient.getSession.mockResolvedValue(undefined); + + const newSessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"], + }, + "eip155:137": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: ["eip155:137:0x9876543210fedcba9876543210fedcba98765432"], + }, + }, + }; + mockMultichainClient.createSession.mockResolvedValue(newSessionData); + + const scopes = ["eip155:1", "eip155:137"] as Scope[]; + const caipAccountIds = ["eip155:1:0x1234567890abcdef1234567890abcdef12345678", "eip155:137:0x9876543210fedcba9876543210fedcba98765432"] as any; + + const result = await sdk.connect(scopes, caipAccountIds); + + t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ + optionalScopes: { + "eip155:1": { + methods: [], + notifications: [], + accounts: ["0x1234567890abcdef1234567890abcdef12345678"], + }, + "eip155:137": { + methods: [], + notifications: [], + accounts: ["0x9876543210fedcba9876543210fedcba98765432"], + }, + }, + }); + t.expect(result).toEqual(newSessionData); + }); + + t.it(`${platform} should handle transport connection errors`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + + // Reset the transport mock to simulate connection failure + mockTransport.isConnected = false; + const connectionError = new Error("Failed to connect transport"); + mockTransport.connect.mockRejectedValue(connectionError); + + const scopes = ["eip155:1"] as Scope[]; + const caipAccountIds = ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"] as any; + + await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow("Failed to connect transport"); + }); + + t.it(`${platform} should handle session creation errors`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockTransport.isConnected = true; + mockMultichainClient.getSession.mockResolvedValue(undefined); + + const sessionError = new Error("Failed to create session"); + mockMultichainClient.createSession.mockRejectedValue(sessionError); + + const scopes = ["eip155:1"] as Scope[]; + const caipAccountIds = ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"] as any; + + await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow("Failed to create session"); + }); + + t.it(`${platform} should handle session revocation errors`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockTransport.isConnected = true; + + const existingSessionData = { + ...mockSessionData, + sessionScopes: { + "eip155:1": { + methods: ["eth_sendTransaction"], + notifications: ["accountsChanged"], + accounts: ["eip155:1:0x1234567890abcdef1234567890abcdef12345678"], + }, + }, + }; + + mockMultichainClient.getSession.mockResolvedValue(existingSessionData); + + const revocationError = new Error("Failed to revoke session"); + mockMultichainClient.revokeSession.mockRejectedValue(revocationError); + + const scopes = ["eip155:137"] as Scope[]; // Same scope as existing session to trigger revocation + const caipAccountIds = ["eip155:137:0x1234567890abcdef1234567890abcdef12345678"] as any; + await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow("Failed to revoke session"); + }); + }); + + t.describe(`${platform} disconnect method tests`, () => { + t.beforeEach(async () => { + sdk = await createSDK(options); + }); + + t.it(`${platform} should disconnect transport successfully`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + + mockTransport.disconnect.mockResolvedValue(undefined); + + await sdk.disconnect(); + + t.expect(mockTransport.disconnect).toHaveBeenCalled(); + }); + + t.it(`${platform} should handle disconnect errors`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockTransport = (multichainModule as any).__mockTransport; + + const disconnectError = new Error("Failed to disconnect transport"); + mockTransport.disconnect.mockRejectedValue(disconnectError); + + await t.expect(sdk.disconnect()).rejects.toThrow("Failed to disconnect transport"); + }); + }); + + t.describe(`${platform} onNotification method tests`, () => { + t.beforeEach(async () => { + sdk = await createSDK(options); + }); + + t.it(`${platform} should register notification listener`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + const mockListener = t.vi.fn(); + const mockUnsubscribe = t.vi.fn(); + mockMultichainClient.onNotification.mockReturnValue(mockUnsubscribe); + + const unsubscribe = sdk.onNotification(mockListener); - t.describe(`Testing MultichainSDK in ${platform}`, () => { - let sdk: MultichainSDKBase; - let setupAnalyticsSpy: any; - let initSpy: any; - let nativeStorageStub: NativeStorageStub; - - // Mock session data for testing - const mockSessionData: SessionData = { - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction', 'eth_accounts'], - notifications: ['accountsChanged', 'chainChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - expiry: new Date(Date.now() + 3600000).toISOString(), - }; - - t.beforeEach(async () => { - (loggerModule as any).__mockLogger.mockReset(); - setupAnalyticsSpy?.mockRestore(); - initSpy?.mockRestore(); - t.vi.clearAllMocks(); - t.vi.resetModules(); - t.vi.unstubAllGlobals(); - - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - const mockGetDefaultTransport = (multichainModule as any).__mockGetDefaultTransport; - const mockGetMultichainClient = (multichainModule as any).__mockGetMultichainClient; - - // Reset transport mocks - mockTransport.connect.mockResolvedValue(true); - mockTransport.disconnect.mockResolvedValue(undefined); - mockTransport.isConnected = true; - mockTransport.request.mockResolvedValue({}); - mockTransport.onNotification.mockReturnValue(() => { }); - - // Reset multichain client mocks - mockMultichainClient.createSession.mockResolvedValue(mockSessionData); - mockMultichainClient.getSession.mockResolvedValue(mockSessionData); - mockMultichainClient.revokeSession.mockResolvedValue(undefined); - mockMultichainClient.invokeMethod.mockResolvedValue({}); - mockMultichainClient.onNotification.mockReturnValue(() => { }); - - // Reset factory function mocks - mockGetDefaultTransport.mockReturnValue(mockTransport); - mockGetMultichainClient.mockReturnValue(mockMultichainClient); - - nativeStorageStub = { - data: new Map(), - getItem: t.vi.fn((key: string) => nativeStorageStub.data.get(key) || null), - setItem: t.vi.fn((key: string, value: string) => { - nativeStorageStub.data.set(key, value); - }), - removeItem: t.vi.fn((key: string) => { - nativeStorageStub.data.delete(key); - }), - clear: t.vi.fn(() => { - nativeStorageStub.data.clear(); - }), - } - - nativeStorageStub.data.set('DEBUG', 'metamask-sdk:*'); - - setupMocks?.(nativeStorageStub); - - // Mock analytics methods - t.vi.mocked(analyticsModule.analytics).setGlobalProperty = t.vi.fn(); - t.vi.mocked(analyticsModule.analytics).enable = t.vi.fn(); - t.vi.mocked(analyticsModule.analytics).track = t.vi.fn(); - setupAnalyticsSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'setupAnalytics'); - initSpy = t.vi.spyOn(MultichainSDK.prototype as any, 'init') - }); - - t.afterEach(() => { - nativeStorageStub.data.clear() - cleanupMocks?.(); - // Reset mock implementations and call history - (loggerModule as any).__mockLogger.mockReset(); - setupAnalyticsSpy?.mockRestore(); - initSpy?.mockRestore(); - t.vi.clearAllMocks(); - t.vi.resetModules(); - t.vi.unstubAllGlobals(); - }); - - t.describe('init', () => { - t.it(`${platform} should call setupAnalytics if analytics is ENABLED and trigger analytics.enable and init evt`, async () => { - options.analytics.enabled = true; - sdk = await createSDK(options); - t.expect(sdk).toBeDefined(); - t.expect(initSpy).toHaveBeenCalled(); - t.expect(setupAnalyticsSpy).toHaveBeenCalled(); - t.expect(analyticsModule.analytics.enable).toHaveBeenCalled(); - t.expect(analyticsModule.analytics.track).toHaveBeenCalledWith('sdk_initialized', {}); - }); - - t.it(`${platform} should NOT call analytics.enable if analytics is DISABLED`, async () => { - options.analytics.enabled = false; - sdk = await createSDK(options); - t.expect(sdk).toBeDefined(); - t.expect(initSpy).toHaveBeenCalled(); - t.expect(setupAnalyticsSpy).toHaveBeenCalled(); - t.expect(analyticsModule.analytics.enable).not.toHaveBeenCalled(); - t.expect(analyticsModule.analytics.track).not.toHaveBeenCalled(); - }); - - t.it(`${platform} should call init and setupAnalytics with logger configuration`, async () => { - const mockLogger = (loggerModule as any).__mockLogger; - - sdk = await createSDK(options); - t.expect(sdk).toBeDefined(); - t.expect(mockLogger).not.toHaveBeenCalled(); - - t.expect(initSpy).toHaveBeenCalled(); - t.expect(setupAnalyticsSpy).toHaveBeenCalled(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(loggerModule.enableDebug).toHaveBeenCalledWith('metamask-sdk:core'); - }); - t.it(`${platform} should properly initialize provider and get session during init`, async () => { - sdk = await createSDK(options); - - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - const mockGetDefaultTransport = (multichainModule as any).__mockGetDefaultTransport; - const mockGetMultichainClient = (multichainModule as any).__mockGetMultichainClient; - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(sdk.session).toEqual(mockSessionData); - - // Verify the provider was created with the correct transport - t.expect(mockGetDefaultTransport).toHaveBeenCalled(); - t.expect(mockGetMultichainClient).toHaveBeenCalledWith({ transport: mockTransport }); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - }); - t.it(`${platform} Should gracefully handle init errors by just logging them and return non initialized sdk`, async () => { - const testError = new Error('Test error'); - initSpy.mockImplementation(() => { - throw testError; - }); - - sdk = await createSDK(options); - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(false); - - // Access the mock logger from the module - const mockLogger = (loggerModule as any).__mockLogger; - - // Verify that the logger was called with the error - t.expect(mockLogger).toHaveBeenCalledWith( - 'MetaMaskSDK error during initialization', - testError - ); - }); - - }); - - t.describe('session', () => { - - t.it(`${platform} should handle session upgrades`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - - mockTransport.isConnected = false; - mockTransport.connect.mockResolvedValue(true); - mockMultichainClient.getSession.mockResolvedValue(mockSessionData); - - sdk = await createSDK(options); - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(sdk.session).not.toBeUndefined(); - - const mockedSessionUpgradeData: SessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction', 'eth_accounts'], - notifications: ['accountsChanged', 'chainChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - - const scopes = ['eip155:137'] as Scope[]; - const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockTransport.connect).toHaveBeenCalled(); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:137': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }); - t.expect(result).toEqual(mockedSessionUpgradeData); - - - }) - - t.it(`${platform} should handle session retrieval when no session exists`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - // Mock no session scenario - mockMultichainClient.getSession.mockResolvedValue(undefined); - - sdk = await createSDK(options); - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(sdk.session).toBeUndefined(); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - }); - - - t.it(`${platform} should handle provider errors during session retrieval`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - const sessionError = new Error('Session error'); - - // Clear previous calls and set up the error mock - t.vi.clearAllMocks(); - mockMultichainClient.getSession.mockRejectedValue(sessionError); - - sdk = await createSDK(options); - - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(false); - - // Access the mock logger from the module - const mockLogger = (loggerModule as any).__mockLogger; - - // Verify that the logger was called with the error - t.expect(mockLogger).toHaveBeenCalledWith( - 'MetaMaskSDK error during initialization', - sessionError - ); - }); - }) - - t.describe(`${platform} connect method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should connect transport and create session when not connected`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - // Configure mocks for this specific test BEFORE creating SDK - mockTransport.isConnected = false; - mockTransport.connect.mockResolvedValue(true); - mockMultichainClient.getSession.mockResolvedValue(undefined); - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - // Create a new SDK instance with the mock configured correctly - const testSdk = await createSDK(options); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await testSdk.connect(scopes, caipAccountIds); - - t.expect(mockTransport.connect).toHaveBeenCalled(); - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.revokeSession).not.toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:1': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }); - t.expect(result).toEqual(newSessionData); - }); - - t.it(`${platform} should skip transport connection when already connected`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - // Mock transport as already connected - mockTransport.isConnected = true; - mockMultichainClient.getSession.mockResolvedValue(undefined); - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockTransport.connect).not.toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).toHaveBeenCalled(); - t.expect(result).toEqual(newSessionData); - }); - - t.it(`${platform} should handle invalid CAIP account IDs gracefully`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - mockMultichainClient.getSession.mockResolvedValue(undefined); - - // Mock console.error to capture invalid account ID errors - const consoleErrorSpy = t.vi.spyOn(console, 'error').mockImplementation(() => { }); - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: [], - }, - }, - }; - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['invalid-account-id', 'eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Invalid CAIP account ID: "invalid-account-id"', - t.expect.any(Error) - ); - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:1': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }); - t.expect(result).toEqual(newSessionData); - - consoleErrorSpy.mockRestore(); - }); - - t.it(`${platform} should return existing session when scopes don't overlap`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - - const existingSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:137': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - mockMultichainClient.getSession.mockResolvedValue(existingSessionData); - - const scopes = ['eip155:137'] as Scope[]; // Different scope than existing session - const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).not.toHaveBeenCalled(); - t.expect(mockMultichainClient.revokeSession).not.toHaveBeenCalled(); - t.expect(result).toEqual(existingSessionData); - }); - - t.it(`should ${platform} simulate sesion upgrade by adding new session scopes to connect`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - - const existingSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - - mockMultichainClient.getSession.mockResolvedValue(existingSessionData); - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - const scopes = ['eip155:137'] as Scope[]; // Same scope as existing session - const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:137': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }); - t.expect(result).toEqual(newSessionData); - }); - - t.it(`${platform} should handle multiple scopes and accounts correctly`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - mockMultichainClient.getSession.mockResolvedValue(undefined); - - const newSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - 'eip155:137': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:137:0x9876543210fedcba9876543210fedcba98765432'], - }, - }, - }; - mockMultichainClient.createSession.mockResolvedValue(newSessionData); - - const scopes = ['eip155:1', 'eip155:137'] as Scope[]; - const caipAccountIds = [ - 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', - 'eip155:137:0x9876543210fedcba9876543210fedcba98765432', - ] as any; - - const result = await sdk.connect(scopes, caipAccountIds); - - t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ - optionalScopes: { - 'eip155:1': { - methods: [], - notifications: [], - accounts: ['0x1234567890abcdef1234567890abcdef12345678'], - }, - 'eip155:137': { - methods: [], - notifications: [], - accounts: ['0x9876543210fedcba9876543210fedcba98765432'], - }, - }, - }); - t.expect(result).toEqual(newSessionData); - }); - - t.it(`${platform} should handle transport connection errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - - // Reset the transport mock to simulate connection failure - mockTransport.isConnected = false; - const connectionError = new Error('Failed to connect transport'); - mockTransport.connect.mockRejectedValue(connectionError); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to connect transport'); - }); - - t.it(`${platform} should handle session creation errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - mockMultichainClient.getSession.mockResolvedValue(undefined); - - const sessionError = new Error('Failed to create session'); - mockMultichainClient.createSession.mockRejectedValue(sessionError); - - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to create session'); - }); - - t.it(`${platform} should handle session revocation errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockTransport.isConnected = true; - - const existingSessionData = { - ...mockSessionData, - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['accountsChanged'], - accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], - }, - }, - }; - - mockMultichainClient.getSession.mockResolvedValue(existingSessionData); - - const revocationError = new Error('Failed to revoke session'); - mockMultichainClient.revokeSession.mockRejectedValue(revocationError); - - const scopes = ['eip155:137'] as Scope[]; // Same scope as existing session to trigger revocation - const caipAccountIds = ['eip155:137:0x1234567890abcdef1234567890abcdef12345678'] as any; - await t.expect(sdk.connect(scopes, caipAccountIds)).rejects.toThrow('Failed to revoke session'); - }); - }); - - t.describe(`${platform} disconnect method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should disconnect transport successfully`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - - mockTransport.disconnect.mockResolvedValue(undefined); - - await sdk.disconnect(); - - t.expect(mockTransport.disconnect).toHaveBeenCalled(); - }); - - t.it(`${platform} should handle disconnect errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockTransport = (multichainModule as any).__mockTransport; - - const disconnectError = new Error('Failed to disconnect transport'); - mockTransport.disconnect.mockRejectedValue(disconnectError); - - await t.expect(sdk.disconnect()).rejects.toThrow('Failed to disconnect transport'); - }); - }); - - t.describe(`${platform} onNotification method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should register notification listener`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - const mockListener = t.vi.fn(); - const mockUnsubscribe = t.vi.fn(); - mockMultichainClient.onNotification.mockReturnValue(mockUnsubscribe); - - const unsubscribe = sdk.onNotification(mockListener); - - t.expect(mockMultichainClient.onNotification).toHaveBeenCalledWith(mockListener); - t.expect(unsubscribe).toBe(mockUnsubscribe); - }); - }); - - t.describe(`${platform} revokeSession method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); - - t.it(`${platform} should revoke session successfully`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - mockMultichainClient.revokeSession.mockResolvedValue(undefined); - - await sdk.revokeSession(); + t.expect(mockMultichainClient.onNotification).toHaveBeenCalledWith(mockListener); + t.expect(unsubscribe).toBe(mockUnsubscribe); + }); + }); + + t.describe(`${platform} revokeSession method tests`, () => { + t.beforeEach(async () => { + sdk = await createSDK(options); + }); + + t.it(`${platform} should revoke session successfully`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + mockMultichainClient.revokeSession.mockResolvedValue(undefined); + + await sdk.revokeSession(); + + t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); + }); + + t.it(`${platform} should handle revoke session errors`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + const revokeError = new Error("Failed to revoke session"); + mockMultichainClient.revokeSession.mockRejectedValue(revokeError); + + await t.expect(sdk.revokeSession()).rejects.toThrow("Failed to revoke session"); + }); + }); + + t.describe(`${platform} invokeMethod method tests`, () => { + t.beforeEach(async () => { + sdk = await createSDK(options); + }); + + t.it(`${platform} should invoke method successfully`, async () => { + // Get mocks from the module mock + const multichainModule = await import("@metamask/multichain-api-client"); + const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - t.expect(mockMultichainClient.revokeSession).toHaveBeenCalled(); - }); + // Mock the RPCClient response + const mockResponse = { result: "success" }; + + // We need to mock the invokeMethod on the rpcClient + // Since rpcClient is private, we'll access it through the sdk instance + const mockRpcClient = { + invokeMethod: t.vi.fn().mockResolvedValue(mockResponse), + }; - t.it(`${platform} should handle revoke session errors`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; - - const revokeError = new Error('Failed to revoke session'); - mockMultichainClient.revokeSession.mockRejectedValue(revokeError); + // Replace the rpcClient in the sdk instance + (sdk as any).rpcClient = mockRpcClient; - await t.expect(sdk.revokeSession()).rejects.toThrow('Failed to revoke session'); - }); - }); - - t.describe(`${platform} invokeMethod method tests`, () => { - t.beforeEach(async () => { - sdk = await createSDK(options); - }); + const options = { + scope: "eip155:1", + method: "eth_accounts", + params: [], + } as any; - t.it(`${platform} should invoke method successfully`, async () => { - // Get mocks from the module mock - const multichainModule = await import('@metamask/multichain-api-client'); - const mockMultichainClient = (multichainModule as any).__mockMultichainClient; + const result = await sdk.invokeMethod(options); - // Mock the RPCClient response - const mockResponse = { result: 'success' }; + t.expect(mockRpcClient.invokeMethod).toHaveBeenCalledWith(options); + t.expect(result).toEqual(mockResponse); + }); - // We need to mock the invokeMethod on the rpcClient - // Since rpcClient is private, we'll access it through the sdk instance - const mockRpcClient = { - invokeMethod: t.vi.fn().mockResolvedValue(mockResponse) - }; + t.it(`${platform} should handle invoke method errors`, async () => { + // Mock the RPCClient response + const mockError = new Error("Failed to invoke method"); - // Replace the rpcClient in the sdk instance - (sdk as any).rpcClient = mockRpcClient; + // We need to mock the invokeMethod on the rpcClient + const mockRpcClient = { + invokeMethod: t.vi.fn().mockRejectedValue(mockError), + }; - const options = { - scope: 'eip155:1', - method: 'eth_accounts', - params: [], - } as any; + // Replace the rpcClient in the sdk instance + (sdk as any).rpcClient = mockRpcClient; - const result = await sdk.invokeMethod(options); - - t.expect(mockRpcClient.invokeMethod).toHaveBeenCalledWith(options); - t.expect(result).toEqual(mockResponse); - }); - - t.it(`${platform} should handle invoke method errors`, async () => { - // Mock the RPCClient response - const mockError = new Error('Failed to invoke method'); - - // We need to mock the invokeMethod on the rpcClient - const mockRpcClient = { - invokeMethod: t.vi.fn().mockRejectedValue(mockError) - }; - - // Replace the rpcClient in the sdk instance - (sdk as any).rpcClient = mockRpcClient; - - const options = { - scope: 'eip155:1', - method: 'eth_accounts', - params: [], - } as any; - - await t.expect(sdk.invokeMethod(options)).rejects.toThrow('Failed to invoke method'); - }); - }); - - }); + const options = { + scope: "eip155:1", + method: "eth_accounts", + params: [], + } as any; + + await t.expect(sdk.invokeMethod(options)).rejects.toThrow("Failed to invoke method"); + }); + }); + }); } -t.describe('MultichainSDK', () => { - createMultiplatformTestCase('web', { - dapp: { - name: 'Test Dapp', - url: 'https://test.dapp', - }, - analytics: { - enabled: false - }, - ui: { - headless: false - } - }, - createMetamaskSDKWeb, - (nativeStorageStub) => { - const dom = new Page('

Hello world

', { url: "https://dapp.io/" }); - const globalStub = { - ...dom.window, - addEventListener: t.vi.fn(), - removeEventListener: t.vi.fn(), - localStorage: nativeStorageStub, - } - t.vi.stubGlobal('navigator', { - ...dom.window.navigator, - product: 'Chrome', - }); - t.vi.stubGlobal('window', globalStub); - t.vi.stubGlobal('location', dom.window.location); - }); +t.describe("MultichainSDK", () => { + createMultiplatformTestCase( + "web", + { + dapp: { + name: "Test Dapp", + url: "https://test.dapp", + }, + analytics: { + enabled: false, + }, + ui: { + headless: false, + }, + }, + createMetamaskSDKWeb, + (nativeStorageStub) => { + const dom = new Page("

Hello world

", { url: "https://dapp.io/" }); + const globalStub = { + ...dom.window, + addEventListener: t.vi.fn(), + removeEventListener: t.vi.fn(), + localStorage: nativeStorageStub, + }; + t.vi.stubGlobal("navigator", { + ...dom.window.navigator, + product: "Chrome", + }); + t.vi.stubGlobal("window", globalStub); + t.vi.stubGlobal("location", dom.window.location); + }, + ); }); - - - diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index ca72bac72..16e134981 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -1,226 +1,179 @@ -import { - getDefaultTransport, - getMultichainClient, - type MultichainApiClient, - type SessionData, -} from "@metamask/multichain-api-client"; -import { - parseCaipAccountId, - parseCaipChainId, - type CaipAccountId, -} from "@metamask/utils"; -import { analytics } from '@metamask/sdk-analytics'; -import { MultichainSDKBase } from "../domain/multichain"; -import type { - MultichainSDKConstructor, - MultichainSDKOptions, - NotificationCallback, - Scope, - RPCAPI, - InvokeMethodOptions, -} from "../domain"; -import { - createLogger, - enableDebug, - isEnabled as isLoggerEnabled, -} from "../domain/logger"; +import { getDefaultTransport, getMultichainClient, type MultichainApiClient, type SessionData } from "@metamask/multichain-api-client"; +import { parseCaipAccountId, parseCaipChainId, type CaipAccountId } from "@metamask/utils"; +import { analytics } from "@metamask/sdk-analytics"; +import type { MultichainSDKBase } from "../domain/multichain"; +import type { MultichainSDKConstructor, MultichainSDKOptions, NotificationCallback, Scope, RPCAPI, InvokeMethodOptions } from "../domain"; +import { createLogger, enableDebug, isEnabled as isLoggerEnabled } from "../domain/logger"; import { getPlatformType, PlatformType } from "../domain/platform"; import { getAnonId, getDappId, getVersion, setupDappMetadata, setupInfuraProvider } from "../utis"; -import { RPCClient } from "src/utis/rpc/client"; -import packageJson from '../../package.json'; -import { StoreClient } from "src/domain/store/client"; +import { RPCClient } from "../utis/rpc/client"; +import packageJson from "../../package.json"; +import type { StoreClient } from "../domain/store/client"; import { EventEmitter } from "../domain/events"; import type { SDKEvents } from "../domain/events/types/sdk"; //ENFORCE NAMESPACE THAT CAN BE DISABLED const logger = createLogger("metamask-sdk:core"); - -type OptionalScopes = Record< - Scope, - { methods: string[]; notifications: string[]; accounts: CaipAccountId[] } ->; +type OptionalScopes = Record; export class MultichainSDK extends EventEmitter implements MultichainSDKBase { - private provider!: MultichainApiClient; - private readonly options: MultichainSDKConstructor; - public readonly storage: StoreClient; - private readonly rpcClient: RPCClient; - public isInitialized: boolean = false; - public session: SessionData | undefined; - - private constructor(options: MultichainSDKConstructor) { - super(); - - const withInfuraRPCMethods = setupInfuraProvider(options); - const withDappMetadata = setupDappMetadata(withInfuraRPCMethods); - this.options = withDappMetadata; - this.storage = options.storage; - const platformType = getPlatformType(); - const sdkInfo = `Sdk/Javascript SdkVersion/${packageJson.version - } Platform/${platformType} dApp/${this.options.dapp.url ?? this.options.dapp.name} dAppTitle/${this.options.dapp.name - }`; - this.provider = getMultichainClient({ transport: this.transport }); - this.rpcClient = new RPCClient( - this.provider, - this.options.api, - sdkInfo - ); - } - - private get transport() { - const platformType = getPlatformType(); - if ( - platformType === PlatformType.DesktopWeb || - platformType === PlatformType.MetaMaskMobileWebview || - platformType === PlatformType.MobileWeb) { - //Direct support for web and externally connectable - const transport = getDefaultTransport(this.options.transport); - return transport; - } - //Mobile wallet protocol support - throw new Error('Not implemented'); - } - - static async create(options: MultichainSDKOptions) { - - const instance = new MultichainSDK(options); - const isEnabled = await isLoggerEnabled( - "metamask-sdk:core", - instance.storage, - ); - if (isEnabled) { - enableDebug("metamask-sdk:core"); - } - try { - await instance.init(); - if (typeof window !== "undefined") { - window.mmsdk = instance; - } - } catch (err) { - logger("MetaMaskSDK error during initialization", err); - } - return instance; - } - - private async setupAnalytics() { - if (!this.options.analytics.enabled) { - return - } - - const platform = getPlatformType(); - const isBrowser = - platform === PlatformType.MetaMaskMobileWebview || - platform === PlatformType.DesktopWeb || - platform === PlatformType.MobileWeb; - - const isReactNative = platform === PlatformType.ReactNative; - - if (!isBrowser && !isReactNative) { - return - } - - const version = getVersion(); - const dappId = getDappId(this.options.dapp); - const anonId = await getAnonId(this.storage); - - const integrationType = this.options.analytics.integrationType; - analytics.setGlobalProperty('sdk_version', version); - analytics.setGlobalProperty('dapp_id', dappId); - analytics.setGlobalProperty('anon_id', anonId); - analytics.setGlobalProperty('platform', platform); - analytics.setGlobalProperty('integration_type', integrationType); - analytics.enable(); - analytics.track('sdk_initialized', {}); - } - - async init() { - if (typeof window !== "undefined" && window.mmsdk?.isInitialized) { - logger("MetaMaskSDK: init already initialized"); - } - await this.setupAnalytics(); - this.session = await this.provider.getSession(); - this.isInitialized = true; - } - - async connect( - scopes: Scope[], - caipAccountIds: CaipAccountId[], - ): Promise { - if (!this.transport.isConnected) { - await this.transport.connect(); - } - - const optionalScopes = scopes.reduce((prev, scope) => ({ - ...prev, - [scope]: { - methods: [], - notifications: [], - accounts: [], - }, - }), {}); - - const validAccounts = caipAccountIds.reduce[]>((caipAccounts, caipAccountId) => { - try { - return [ - ...caipAccounts, - parseCaipAccountId(caipAccountId) - ] - } catch (err) { - const stringifiedAccountId = JSON.stringify(caipAccountId); - console.error( - `Invalid CAIP account ID: ${stringifiedAccountId}`, - err, - ); - return caipAccounts; - } - }, []); - - for (const account of validAccounts) { - for (const scopeKey of Object.keys(optionalScopes)) { - const scope = scopeKey as Scope; - const scopeDetails = parseCaipChainId(scope); - if (scopeDetails.namespace === account.chain.namespace && scopeDetails.reference === account.chain.reference) { - const scopeData = optionalScopes[scope]; - if (scopeData) { - scopeData.accounts.push(account.address as CaipAccountId); - } - } - } - } - - const session = await this.provider.getSession() - if (session) { - const existingScopes = Object.keys(session.sessionScopes).sort(); - const scopesMatch = - existingScopes.every(scope => scopes.includes(scope as Scope)) && - scopes.every(scope => existingScopes.includes(scope)); - - if (scopesMatch) { - // Existing session has exactly the same scopes as requested, reuse it - return session; - } - // Scopes don't match (different set), revoke the session and create new one - await this.provider.revokeSession(); - } - this.session = await this.provider.createSession({ optionalScopes }); - return this.session; - - } - - async disconnect(): Promise { - return this.transport.disconnect(); - } - - onNotification(listener: NotificationCallback) { - return this.provider.onNotification(listener); - } - - async revokeSession() { - return this.provider.revokeSession(); - } - - async invokeMethod(options: InvokeMethodOptions) { - return this.rpcClient.invokeMethod(options); - } + private provider!: MultichainApiClient; + private readonly options: MultichainSDKConstructor; + public readonly storage: StoreClient; + private readonly rpcClient: RPCClient; + public isInitialized: boolean = false; + public session: SessionData | undefined; + + private constructor(options: MultichainSDKConstructor) { + super(); + + const withInfuraRPCMethods = setupInfuraProvider(options); + const withDappMetadata = setupDappMetadata(withInfuraRPCMethods); + this.options = withDappMetadata; + this.storage = options.storage; + const platformType = getPlatformType(); + const sdkInfo = `Sdk/Javascript SdkVersion/${packageJson.version} Platform/${platformType} dApp/${this.options.dapp.url ?? this.options.dapp.name} dAppTitle/${ + this.options.dapp.name + }`; + this.provider = getMultichainClient({ transport: this.transport }); + this.rpcClient = new RPCClient(this.provider, this.options.api, sdkInfo); + } + + private get transport() { + const platformType = getPlatformType(); + if (platformType === PlatformType.DesktopWeb || platformType === PlatformType.MetaMaskMobileWebview || platformType === PlatformType.MobileWeb) { + //Direct support for web and externally connectable + const transport = getDefaultTransport(this.options.transport); + return transport; + } + //Mobile wallet protocol support + throw new Error("Not implemented"); + } + + static async create(options: MultichainSDKOptions) { + const instance = new MultichainSDK(options); + const isEnabled = await isLoggerEnabled("metamask-sdk:core", instance.storage); + if (isEnabled) { + enableDebug("metamask-sdk:core"); + } + try { + await instance.init(); + if (typeof window !== "undefined") { + window.mmsdk = instance; + } + } catch (err) { + logger("MetaMaskSDK error during initialization", err); + } + return instance; + } + + private async setupAnalytics() { + if (!this.options.analytics.enabled) { + return; + } + + const platform = getPlatformType(); + const isBrowser = platform === PlatformType.MetaMaskMobileWebview || platform === PlatformType.DesktopWeb || platform === PlatformType.MobileWeb; + + const isReactNative = platform === PlatformType.ReactNative; + + if (!isBrowser && !isReactNative) { + return; + } + + const version = getVersion(); + const dappId = getDappId(this.options.dapp); + const anonId = await getAnonId(this.storage); + + const integrationType = this.options.analytics.integrationType; + analytics.setGlobalProperty("sdk_version", version); + analytics.setGlobalProperty("dapp_id", dappId); + analytics.setGlobalProperty("anon_id", anonId); + analytics.setGlobalProperty("platform", platform); + analytics.setGlobalProperty("integration_type", integrationType); + analytics.enable(); + analytics.track("sdk_initialized", {}); + } + + async init() { + if (typeof window !== "undefined" && window.mmsdk?.isInitialized) { + logger("MetaMaskSDK: init already initialized"); + } + await this.setupAnalytics(); + this.session = await this.provider.getSession(); + this.isInitialized = true; + } + + async connect(scopes: Scope[], caipAccountIds: CaipAccountId[]): Promise { + if (!this.transport.isConnected) { + await this.transport.connect(); + } + + const optionalScopes = scopes.reduce( + (prev, scope) => ({ + ...prev, + [scope]: { + methods: [], + notifications: [], + accounts: [], + }, + }), + {}, + ); + + const validAccounts = caipAccountIds.reduce[]>((caipAccounts, caipAccountId) => { + try { + return [...caipAccounts, parseCaipAccountId(caipAccountId)]; + } catch (err) { + const stringifiedAccountId = JSON.stringify(caipAccountId); + console.error(`Invalid CAIP account ID: ${stringifiedAccountId}`, err); + return caipAccounts; + } + }, []); + + for (const account of validAccounts) { + for (const scopeKey of Object.keys(optionalScopes)) { + const scope = scopeKey as Scope; + const scopeDetails = parseCaipChainId(scope); + if (scopeDetails.namespace === account.chain.namespace && scopeDetails.reference === account.chain.reference) { + const scopeData = optionalScopes[scope]; + if (scopeData) { + scopeData.accounts.push(account.address as CaipAccountId); + } + } + } + } + + const session = await this.provider.getSession(); + if (session) { + const existingScopes = Object.keys(session.sessionScopes).sort(); + const scopesMatch = existingScopes.every((scope) => scopes.includes(scope as Scope)) && scopes.every((scope) => existingScopes.includes(scope)); + + if (scopesMatch) { + // Existing session has exactly the same scopes as requested, reuse it + return session; + } + // Scopes don't match (different set), revoke the session and create new one + await this.provider.revokeSession(); + } + this.session = await this.provider.createSession({ optionalScopes }); + return this.session; + } + + async disconnect(): Promise { + return this.transport.disconnect(); + } + + onNotification(listener: NotificationCallback) { + return this.provider.onNotification(listener); + } + + async revokeSession() { + return this.provider.revokeSession(); + } + + async invokeMethod(options: InvokeMethodOptions) { + return this.rpcClient.invokeMethod(options); + } } diff --git a/packages/sdk-multichain/src/store/adapters/node.ts b/packages/sdk-multichain/src/store/adapters/node.ts index 89efd36d5..3c99ff531 100644 --- a/packages/sdk-multichain/src/store/adapters/node.ts +++ b/packages/sdk-multichain/src/store/adapters/node.ts @@ -1,49 +1,49 @@ import { StoreAdapter } from "../../domain"; -import fs from 'fs' -import path from 'path'; +import fs from "node:fs"; +import path from "node:path"; -const CONFIG_FILE = path.resolve(process.cwd(), '.metamask.json'); +const CONFIG_FILE = path.resolve(process.cwd(), ".metamask.json"); export class StoreAdapterNode extends StoreAdapter { - readonly platform = 'node' + readonly platform = "node"; - private safeParse(contents: string): Record { - try { - return JSON.parse(contents); - } catch (e) { - return {}; - } - } + private safeParse(contents: string): Record { + try { + return JSON.parse(contents); + } catch (_e) { + return {}; + } + } - async getItem(key: string): Promise { - if (!fs.existsSync(CONFIG_FILE)) { - return null; - } - const contents = fs.readFileSync(CONFIG_FILE, 'utf8'); - const config = this.safeParse(contents); - if (config[key] !== undefined) { - return config[key]; - } - return null; - } + async getItem(key: string): Promise { + if (!fs.existsSync(CONFIG_FILE)) { + return null; + } + const contents = fs.readFileSync(CONFIG_FILE, "utf8"); + const config = this.safeParse(contents); + if (config[key] !== undefined) { + return config[key]; + } + return null; + } - async setItem(key: string, value: string): Promise { - if (!fs.existsSync(CONFIG_FILE)) { - fs.writeFileSync(CONFIG_FILE, '{}'); - } - const contents = fs.readFileSync(CONFIG_FILE, 'utf8'); - const config = this.safeParse(contents); - config[key] = value; - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); - } + async setItem(key: string, value: string): Promise { + if (!fs.existsSync(CONFIG_FILE)) { + fs.writeFileSync(CONFIG_FILE, "{}"); + } + const contents = fs.readFileSync(CONFIG_FILE, "utf8"); + const config = this.safeParse(contents); + config[key] = value; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + } - async deleteItem(key: string): Promise { - if (!fs.existsSync(CONFIG_FILE)) { - return; - } - const contents = fs.readFileSync(CONFIG_FILE, 'utf8'); - const config = this.safeParse(contents); - delete config[key]; - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); - } + async deleteItem(key: string): Promise { + if (!fs.existsSync(CONFIG_FILE)) { + return; + } + const contents = fs.readFileSync(CONFIG_FILE, "utf8"); + const config = this.safeParse(contents); + delete config[key]; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + } } diff --git a/packages/sdk-multichain/src/store/adapters/rn.ts b/packages/sdk-multichain/src/store/adapters/rn.ts index 13283e8a6..58cb706f4 100644 --- a/packages/sdk-multichain/src/store/adapters/rn.ts +++ b/packages/sdk-multichain/src/store/adapters/rn.ts @@ -1,17 +1,17 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { StoreAdapter } from "../../domain"; export class StoreAdapterRN extends StoreAdapter { - readonly platform = 'rn' - async getItem(key: string): Promise { - return AsyncStorage.getItem(key); - } + readonly platform = "rn"; + async getItem(key: string): Promise { + return AsyncStorage.getItem(key); + } - async setItem(key: string, value: string): Promise { - return AsyncStorage.setItem(key, value); - } + async setItem(key: string, value: string): Promise { + return AsyncStorage.setItem(key, value); + } - async deleteItem(key: string): Promise { - return AsyncStorage.removeItem(key); - } + async deleteItem(key: string): Promise { + return AsyncStorage.removeItem(key); + } } diff --git a/packages/sdk-multichain/src/store/adapters/web.ts b/packages/sdk-multichain/src/store/adapters/web.ts index e9cfbcd7c..dea806f00 100644 --- a/packages/sdk-multichain/src/store/adapters/web.ts +++ b/packages/sdk-multichain/src/store/adapters/web.ts @@ -1,23 +1,23 @@ import { StoreAdapter } from "../../domain"; export class StoreAdapterWeb extends StoreAdapter { - readonly platform = 'web' + readonly platform = "web"; - private get internal() { - if (typeof window === 'undefined' || !window.localStorage) { - throw new Error('localStorage is not available in this environment'); - } - return window.localStorage; - } - async getItem(key: string): Promise { - return this.internal.getItem(key); - } + private get internal() { + if (typeof window === "undefined" || !window.localStorage) { + throw new Error("localStorage is not available in this environment"); + } + return window.localStorage; + } + async getItem(key: string): Promise { + return this.internal.getItem(key); + } - async setItem(key: string, value: string): Promise { - return this.internal.setItem(key, value); - } + async setItem(key: string, value: string): Promise { + return this.internal.setItem(key, value); + } - async deleteItem(key: string): Promise { - return this.internal.removeItem(key); - } + async deleteItem(key: string): Promise { + return this.internal.removeItem(key); + } } diff --git a/packages/sdk-multichain/src/store/index.test.ts b/packages/sdk-multichain/src/store/index.test.ts index 6c8d7654a..b8548e734 100644 --- a/packages/sdk-multichain/src/store/index.test.ts +++ b/packages/sdk-multichain/src/store/index.test.ts @@ -1,218 +1,212 @@ -import * as t from 'vitest' -import fs from 'fs' -import path from 'path'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -import { Store } from './index'; -import type { StoreAdapter } from '../domain'; -import { StoreAdapterWeb } from './adapters/web'; -import { StoreAdapterRN } from './adapters/rn'; -import { StoreAdapterNode } from './adapters/node'; -import { StorageGetErr, StorageSetErr, StorageDeleteErr } from '../domain/errors/storage'; +import * as t from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +import { Store } from "./index"; +import type { StoreAdapter } from "../domain"; +import { StoreAdapterWeb } from "./adapters/web"; +import { StoreAdapterRN } from "./adapters/rn"; +import { StoreAdapterNode } from "./adapters/node"; +import { StorageGetErr, StorageSetErr, StorageDeleteErr } from "../domain/errors/storage"; /** * Dummy mocked storage to keep track of data between tests */ const nativeStorageStub = { - data: new Map(), - getItem: t.vi.fn((key: string) => nativeStorageStub.data.get(key) || null), - setItem: t.vi.fn((key: string, value: string) => { - nativeStorageStub.data.set(key, value); - }), - removeItem: t.vi.fn((key: string) => { - nativeStorageStub.data.delete(key); - }), - clear: t.vi.fn(() => { - nativeStorageStub.data.clear(); - }), -} + data: new Map(), + getItem: t.vi.fn((key: string) => nativeStorageStub.data.get(key) || null), + setItem: t.vi.fn((key: string, value: string) => { + nativeStorageStub.data.set(key, value); + }), + removeItem: t.vi.fn((key: string) => { + nativeStorageStub.data.delete(key); + }), + clear: t.vi.fn(() => { + nativeStorageStub.data.clear(); + }), +}; // Reusable test function that can be used with any adapter -function createStoreTests( - adapterName: string, - createAdapter: () => StoreAdapter, - setupMocks?: () => void, - cleanupMocks?: () => void -) { - let store: Store; - let adapter: StoreAdapter; - - t.beforeEach(async () => { - setupMocks?.(); - adapter = createAdapter(); - store = new Store(adapter); - }); - - t.afterEach(async () => { - nativeStorageStub.data.clear() - cleanupMocks?.(); - }); - - t.describe(`${adapterName} constructor`, () => { - t.it('should create a Store instance with the provided adapter', () => { - t.expect(store).toBeInstanceOf(Store); - }); - }); - - t.describe(`${adapterName} getAnonId`, () => { - t.it('should return the anonymous ID when it exists', async () => { - await adapter.setItem('anonId', 'test-anon-id'); - const result = await store.getAnonId(); - t.expect(result).toBe('test-anon-id'); - }); - - t.it('should return null when anonymous ID does not exist', async () => { - const result = await store.getAnonId(); - t.expect(result).toBeNull(); - }); - }); - - t.describe(`${adapterName} setAnonId`, () => { - t.it('should set the anonymous ID successfully', async () => { - await store.setAnonId('new-anon-id'); - const result = await adapter.getItem('anonId'); - t.expect(result).toBe('new-anon-id'); - }); - }); - - t.describe(`${adapterName} removeAnonId`, () => { - t.it('should remove the anonymous ID successfully', async () => { - await adapter.setItem('anonId', 'test-anon-id'); - const beforeRemove = await adapter.getItem('anonId'); - t.expect(beforeRemove).toBe('test-anon-id'); - - await store.removeAnonId(); - const afterRemove = await adapter.getItem('anonId'); - t.expect(afterRemove).toBeNull(); - }); - }); - - t.describe(`${adapterName} getExtensionId`, () => { - t.it('should return the extension ID when it exists', async () => { - await adapter.setItem('extensionId', 'test-extension-id'); - const result = await store.getExtensionId(); - t.expect(result).toBe('test-extension-id'); - }); - - t.it('should return null when extension ID does not exist', async () => { - const result = await store.getExtensionId(); - t.expect(result).toBeNull(); - }); - }); - - t.describe(`${adapterName} setExtensionId`, () => { - t.it('should set the extension ID successfully', async () => { - await store.setExtensionId('new-extension-id'); - const result = await adapter.getItem('extensionId'); - t.expect(result).toBe('new-extension-id'); - }); - }); - - t.describe(`${adapterName} removeExtensionId`, () => { - t.it('should remove the extension ID successfully', async () => { - await adapter.setItem('extensionId', 'test-extension-id'); - const beforeRemove = await adapter.getItem('extensionId'); - t.expect(beforeRemove).toBe('test-extension-id'); - - await store.removeExtensionId(); - const afterRemove = await adapter.getItem('extensionId'); - t.expect(afterRemove).toBeNull(); - }); - }); - - t.describe(`${adapterName} getDebug`, () => { - t.it('should return the debug value when it exists', async () => { - await adapter.setItem('DEBUG', 'metamask-sdk:*'); - const result = await store.getDebug(); - t.expect(result).toBe('metamask-sdk:*'); - }); - - t.it('should return null when debug value does not exist', async () => { - const result = await store.getDebug(); - t.expect(result).toBeNull(); - }); - }); - - // Error handling tests - t.describe(`${adapterName} error handling`, () => { - t.it('should throw StorageGetErr when fetching a key fails', async () => { - const errorMessage = 'getItem failed'; - t.vi.spyOn(adapter, 'getItem').mockRejectedValue(new Error(errorMessage)); - await t.expect(store.getAnonId()).rejects.toBeInstanceOf(StorageGetErr); - await t.expect(store.getExtensionId()).rejects.toBeInstanceOf(StorageGetErr); - await t.expect(store.getDebug()).rejects.toBeInstanceOf(StorageGetErr); - }); - - t.it('should throw StorageSetErr when setting a key fails', async () => { - const errorMessage = 'setItem failed'; - t.vi.spyOn(adapter, 'setItem').mockRejectedValue(new Error(errorMessage)); - await t.expect(store.setAnonId('test-id')).rejects.toThrow(StorageSetErr); - await t.expect(store.setExtensionId('test-id')).rejects.toThrow(StorageSetErr); - await t.expect(store.setExtensionId('test-id')).rejects.toThrow(StorageSetErr); - }); - - t.it('should throw StorageDeleteErr when removing a key fails', async () => { - const errorMessage = 'deleteItem failed'; - t.vi.spyOn(adapter, 'deleteItem').mockRejectedValue(new Error(errorMessage)); - await t.expect(store.removeAnonId()).rejects.toThrow(StorageDeleteErr); - await t.expect(store.removeExtensionId()).rejects.toThrow(StorageDeleteErr); - }); - }); +function createStoreTests(adapterName: string, createAdapter: () => StoreAdapter, setupMocks?: () => void, cleanupMocks?: () => void) { + let store: Store; + let adapter: StoreAdapter; + + t.beforeEach(async () => { + setupMocks?.(); + adapter = createAdapter(); + store = new Store(adapter); + }); + + t.afterEach(async () => { + nativeStorageStub.data.clear(); + cleanupMocks?.(); + }); + + t.describe(`${adapterName} constructor`, () => { + t.it("should create a Store instance with the provided adapter", () => { + t.expect(store).toBeInstanceOf(Store); + }); + }); + + t.describe(`${adapterName} getAnonId`, () => { + t.it("should return the anonymous ID when it exists", async () => { + await adapter.setItem("anonId", "test-anon-id"); + const result = await store.getAnonId(); + t.expect(result).toBe("test-anon-id"); + }); + + t.it("should return null when anonymous ID does not exist", async () => { + const result = await store.getAnonId(); + t.expect(result).toBeNull(); + }); + }); + + t.describe(`${adapterName} setAnonId`, () => { + t.it("should set the anonymous ID successfully", async () => { + await store.setAnonId("new-anon-id"); + const result = await adapter.getItem("anonId"); + t.expect(result).toBe("new-anon-id"); + }); + }); + + t.describe(`${adapterName} removeAnonId`, () => { + t.it("should remove the anonymous ID successfully", async () => { + await adapter.setItem("anonId", "test-anon-id"); + const beforeRemove = await adapter.getItem("anonId"); + t.expect(beforeRemove).toBe("test-anon-id"); + + await store.removeAnonId(); + const afterRemove = await adapter.getItem("anonId"); + t.expect(afterRemove).toBeNull(); + }); + }); + + t.describe(`${adapterName} getExtensionId`, () => { + t.it("should return the extension ID when it exists", async () => { + await adapter.setItem("extensionId", "test-extension-id"); + const result = await store.getExtensionId(); + t.expect(result).toBe("test-extension-id"); + }); + + t.it("should return null when extension ID does not exist", async () => { + const result = await store.getExtensionId(); + t.expect(result).toBeNull(); + }); + }); + + t.describe(`${adapterName} setExtensionId`, () => { + t.it("should set the extension ID successfully", async () => { + await store.setExtensionId("new-extension-id"); + const result = await adapter.getItem("extensionId"); + t.expect(result).toBe("new-extension-id"); + }); + }); + + t.describe(`${adapterName} removeExtensionId`, () => { + t.it("should remove the extension ID successfully", async () => { + await adapter.setItem("extensionId", "test-extension-id"); + const beforeRemove = await adapter.getItem("extensionId"); + t.expect(beforeRemove).toBe("test-extension-id"); + + await store.removeExtensionId(); + const afterRemove = await adapter.getItem("extensionId"); + t.expect(afterRemove).toBeNull(); + }); + }); + + t.describe(`${adapterName} getDebug`, () => { + t.it("should return the debug value when it exists", async () => { + await adapter.setItem("DEBUG", "metamask-sdk:*"); + const result = await store.getDebug(); + t.expect(result).toBe("metamask-sdk:*"); + }); + + t.it("should return null when debug value does not exist", async () => { + const result = await store.getDebug(); + t.expect(result).toBeNull(); + }); + }); + + // Error handling tests + t.describe(`${adapterName} error handling`, () => { + t.it("should throw StorageGetErr when fetching a key fails", async () => { + const errorMessage = "getItem failed"; + t.vi.spyOn(adapter, "getItem").mockRejectedValue(new Error(errorMessage)); + await t.expect(store.getAnonId()).rejects.toBeInstanceOf(StorageGetErr); + await t.expect(store.getExtensionId()).rejects.toBeInstanceOf(StorageGetErr); + await t.expect(store.getDebug()).rejects.toBeInstanceOf(StorageGetErr); + }); + + t.it("should throw StorageSetErr when setting a key fails", async () => { + const errorMessage = "setItem failed"; + t.vi.spyOn(adapter, "setItem").mockRejectedValue(new Error(errorMessage)); + await t.expect(store.setAnonId("test-id")).rejects.toThrow(StorageSetErr); + await t.expect(store.setExtensionId("test-id")).rejects.toThrow(StorageSetErr); + await t.expect(store.setExtensionId("test-id")).rejects.toThrow(StorageSetErr); + }); + + t.it("should throw StorageDeleteErr when removing a key fails", async () => { + const errorMessage = "deleteItem failed"; + t.vi.spyOn(adapter, "deleteItem").mockRejectedValue(new Error(errorMessage)); + await t.expect(store.removeAnonId()).rejects.toThrow(StorageDeleteErr); + await t.expect(store.removeExtensionId()).rejects.toThrow(StorageDeleteErr); + }); + }); } - t.describe(`Store with NodeAdapter`, () => { - // Test with Node Adapter and mocked file system - createStoreTests( - 'NodeAdapter', - () => new StoreAdapterNode(), - async () => { - const memfs = new Map() - t.vi.spyOn(fs, 'existsSync').mockImplementation((path) => memfs.has(path.toString())) - t.vi.spyOn(fs, 'writeFileSync').mockImplementation((path, data) => memfs.set(path.toString(), data)) - t.vi.spyOn(fs, 'readFileSync').mockImplementation((path) => memfs.get(path.toString())) - } - ); - - t.it("Should gracefully manage deleteItem even if the config file does not exist", async () => { - const CONFIG_FILE = path.resolve(process.cwd(), '.metamask.json'); - t.vi.spyOn(fs, 'existsSync').mockImplementation(() => false) - const store = new Store(new StoreAdapterNode()) - await store.removeExtensionId() - t.expect(fs.existsSync).toHaveBeenCalledWith(CONFIG_FILE) - }) + // Test with Node Adapter and mocked file system + createStoreTests( + "NodeAdapter", + () => new StoreAdapterNode(), + async () => { + const memfs = new Map(); + t.vi.spyOn(fs, "existsSync").mockImplementation((path) => memfs.has(path.toString())); + t.vi.spyOn(fs, "writeFileSync").mockImplementation((path, data) => memfs.set(path.toString(), data)); + t.vi.spyOn(fs, "readFileSync").mockImplementation((path) => memfs.get(path.toString())); + }, + ); + + t.it("Should gracefully manage deleteItem even if the config file does not exist", async () => { + const CONFIG_FILE = path.resolve(process.cwd(), ".metamask.json"); + t.vi.spyOn(fs, "existsSync").mockImplementation(() => false); + const store = new Store(new StoreAdapterNode()); + await store.removeExtensionId(); + t.expect(fs.existsSync).toHaveBeenCalledWith(CONFIG_FILE); + }); }); t.describe(`Store with WebAdapter`, () => { - //Test browser storage with mocked local storage - createStoreTests( - 'WebAdapter', - () => new StoreAdapterWeb(), - () => { - t.vi.stubGlobal('window', { - localStorage: nativeStorageStub, - }); - } - ); - - t.it("Should throw an exception if we try using the store with a browser that doesn't support localStorage", async () => { - t.vi.stubGlobal('window', { - localStorage: null, - }); - const store = new Store(new StoreAdapterWeb()); - await t.expect(() => store.getAnonId()).rejects.toThrow(); - }); + //Test browser storage with mocked local storage + createStoreTests( + "WebAdapter", + () => new StoreAdapterWeb(), + () => { + t.vi.stubGlobal("window", { + localStorage: nativeStorageStub, + }); + }, + ); + + t.it("Should throw an exception if we try using the store with a browser that doesn't support localStorage", async () => { + t.vi.stubGlobal("window", { + localStorage: null, + }); + const store = new Store(new StoreAdapterWeb()); + await t.expect(() => store.getAnonId()).rejects.toThrow(); + }); }); t.describe(`Store with RNAdapter`, () => { - // Test RN storage with mocked AsyncStorage - createStoreTests( - 'RNAdapter', - () => new StoreAdapterRN(), - () => { - t.vi.spyOn(AsyncStorage, 'getItem').mockImplementation(async (key) => nativeStorageStub.getItem(key)) - t.vi.spyOn(AsyncStorage, 'setItem').mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value)) - t.vi.spyOn(AsyncStorage, 'removeItem').mockImplementation(async (key) => nativeStorageStub.removeItem(key)) - } - ); + // Test RN storage with mocked AsyncStorage + createStoreTests( + "RNAdapter", + () => new StoreAdapterRN(), + () => { + t.vi.spyOn(AsyncStorage, "getItem").mockImplementation(async (key) => nativeStorageStub.getItem(key)); + t.vi.spyOn(AsyncStorage, "setItem").mockImplementation(async (key, value) => nativeStorageStub.setItem(key, value)); + t.vi.spyOn(AsyncStorage, "removeItem").mockImplementation(async (key) => nativeStorageStub.removeItem(key)); + }, + ); }); diff --git a/packages/sdk-multichain/src/store/index.ts b/packages/sdk-multichain/src/store/index.ts index 977d3c3a4..a6005b4a6 100644 --- a/packages/sdk-multichain/src/store/index.ts +++ b/packages/sdk-multichain/src/store/index.ts @@ -1,95 +1,66 @@ import { StorageDeleteErr, StorageGetErr, StorageSetErr } from "../domain/errors/storage"; -import type {StoreClient, StoreAdapter } from "../domain"; - +import type { StoreClient, StoreAdapter } from "../domain"; export class Store implements StoreClient { - readonly #adapter: StoreAdapter; + readonly #adapter: StoreAdapter; - constructor(adapter: StoreAdapter) { - this.#adapter = adapter; - } + constructor(adapter: StoreAdapter) { + this.#adapter = adapter; + } - async getAnonId(): Promise { - try { - return await this.#adapter.getItem('anonId'); - } catch (err) { - throw new StorageGetErr( - this.#adapter.platform, - 'anonId', - err.message - ); - } - } + async getAnonId(): Promise { + try { + return await this.#adapter.getItem("anonId"); + } catch (err) { + throw new StorageGetErr(this.#adapter.platform, "anonId", err.message); + } + } - async getExtensionId(): Promise { - try { - return await this.#adapter.getItem('extensionId'); - } catch (err) { - throw new StorageGetErr( - this.#adapter.platform, - 'extensionId', - err.message - ); - } - } + async getExtensionId(): Promise { + try { + return await this.#adapter.getItem("extensionId"); + } catch (err) { + throw new StorageGetErr(this.#adapter.platform, "extensionId", err.message); + } + } - async setAnonId(anonId: string): Promise { - try { - return await this.#adapter.setItem('anonId', anonId); - } catch (err) { - throw new StorageSetErr( - this.#adapter.platform, - 'anonId', - err.message - ); - } - } + async setAnonId(anonId: string): Promise { + try { + return await this.#adapter.setItem("anonId", anonId); + } catch (err) { + throw new StorageSetErr(this.#adapter.platform, "anonId", err.message); + } + } - async setExtensionId(extensionId: string): Promise { - try { - return await this.#adapter.setItem('extensionId', extensionId); - } catch (err) { - throw new StorageSetErr( - this.#adapter.platform, - 'extensionId', - err.message - ); - } - } + async setExtensionId(extensionId: string): Promise { + try { + return await this.#adapter.setItem("extensionId", extensionId); + } catch (err) { + throw new StorageSetErr(this.#adapter.platform, "extensionId", err.message); + } + } - async removeExtensionId(): Promise { - try { - return await this.#adapter.deleteItem('extensionId'); - } catch (err) { - throw new StorageDeleteErr( - this.#adapter.platform, - 'extensionId', - err.message - ); - } - } + async removeExtensionId(): Promise { + try { + return await this.#adapter.deleteItem("extensionId"); + } catch (err) { + throw new StorageDeleteErr(this.#adapter.platform, "extensionId", err.message); + } + } - async removeAnonId(): Promise { - try { - return await this.#adapter.deleteItem('anonId'); - } catch (err) { - throw new StorageDeleteErr( - this.#adapter.platform, - 'anonId', - err.message - ); - } - } + async removeAnonId(): Promise { + try { + return await this.#adapter.deleteItem("anonId"); + } catch (err) { + throw new StorageDeleteErr(this.#adapter.platform, "anonId", err.message); + } + } - async getDebug(): Promise { - try { - return await this.#adapter.getItem('DEBUG'); - } catch (err) { - throw new StorageGetErr( - this.#adapter.platform, - 'DEBUG', - err.message - ); - } - } + async getDebug(): Promise { + try { + return await this.#adapter.getItem("DEBUG"); + } catch (err) { + throw new StorageGetErr(this.#adapter.platform, "DEBUG", err.message); + } + } } diff --git a/packages/sdk-multichain/src/utis/base64/index.test.ts b/packages/sdk-multichain/src/utis/base64/index.test.ts index 8b4fad650..b011ab78c 100644 --- a/packages/sdk-multichain/src/utis/base64/index.test.ts +++ b/packages/sdk-multichain/src/utis/base64/index.test.ts @@ -1,58 +1,57 @@ -import * as t from 'vitest'; +import * as t from "vitest"; -import { base64Encode } from '.'; +import { base64Encode } from "."; -t.describe('base64Encode', () => { - t.it('should encode an empty string', () => { - t.expect(base64Encode('')).toBe(''); - }); +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 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 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 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 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; + 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((str) => 'base64encoded'); + const btoa = t.vi.spyOn(global, "btoa"); + btoa.mockImplementation(() => "base64encoded"); - t.expect(base64Encode('Hello, World!')).toBe('base64encoded'); + t.expect(base64Encode("Hello, World!")).toBe("base64encoded"); - // Restore Buffer - global.Buffer = originalBuffer; - btoa.mockRestore(); - }); + // Restore Buffer + global.Buffer = originalBuffer; + btoa.mockRestore(); + }); + t.it("should encode a long string", () => { + const longString = "a".repeat(1000); + const encodedString = base64Encode(longString); - 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 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 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); - // 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); - }); + // 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/utis/base64/index.ts b/packages/sdk-multichain/src/utis/base64/index.ts index b72fc7f6b..7d175648e 100644 --- a/packages/sdk-multichain/src/utis/base64/index.ts +++ b/packages/sdk-multichain/src/utis/base64/index.ts @@ -2,34 +2,29 @@ * Base64 encode string for URL params */ export function base64Encode(str: string): string { - let base64string: 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; + 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); - }; - }); + 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/utis/index.test.ts b/packages/sdk-multichain/src/utis/index.test.ts index 8d31cb0d2..8987068d2 100644 --- a/packages/sdk-multichain/src/utis/index.test.ts +++ b/packages/sdk-multichain/src/utis/index.test.ts @@ -1,349 +1,330 @@ -import * as t from 'vitest'; -import { vi } from 'vitest'; -import packageJson from '../../package.json'; - -import * as utils from '.'; -import { Store } from '../store'; -import { MultichainSDKConstructor } from '../domain/multichain'; -import { PlatformType, getPlatformType } from '../domain/platform'; - - - -vi.mock('../domain/platform', async () => { - const actual = await vi.importActual('../domain/platform') as any; - return { - ...actual, - getPlatformType:t.vi.fn(), - } +import * as t from "vitest"; +import { vi } from "vitest"; +import packageJson from "../../package.json"; + +import * as utils from "."; +import { Store } from "../store"; +import { MultichainSDKConstructor } from "../domain/multichain"; +import { PlatformType, getPlatformType } from "../domain/platform"; + +vi.mock("../domain/platform", async () => { + const actual = (await vi.importActual("../domain/platform")) as any; + return { + ...actual, + getPlatformType: t.vi.fn(), + }; }); -t.describe('Utils', () => { - let options: MultichainSDKConstructor; - - t.beforeEach(() => { - t.vi.clearAllMocks(); - options = { - dapp: { - name: 'test', - url: 'test', - }, - api: { - infuraAPIKey: 'testKey', - } - } as MultichainSDKConstructor; - }); - - t.describe('getDappId', () => { - const mockHostname = 'mockdapp.com'; - const mockDappName = 'Mock DApp Name'; - const mockDappUrl = 'http://mockdapp.com'; - const originalWindow = global.window; - - t.afterEach(() => { - global.window = originalWindow; - }); - - t.it('should return window.location.hostname if window and window.location are defined', () => { - global.window = { - location: { - hostname: mockHostname, - }, - } as any; - t.expect(utils.getDappId()).toBe(mockHostname); - }); - - t.it('should return dappMetadata.name if window is undefined and name is available', () => { - global.window = undefined as any; - const dappSettings = { name: mockDappName, url: mockDappUrl }; - t.expect(utils.getDappId(dappSettings)).toBe(mockDappName); - }); - - t.it('should return dappMetadata.url if window is undefined and name is not available but url is', () => { - global.window = undefined as any; - const dappSettings = { url: mockDappUrl }; - t.expect(utils.getDappId(dappSettings)).toBe(mockDappUrl); - }); - - t.it('should return "N/A" if window is undefined and neither name nor url is available', () => { - global.window = undefined as any; - const dappSettings = {}; - t.expect(utils.getDappId(dappSettings)).toBe('N/A'); - }); - - t.describe('Anonymous ID Management', () => { - const mockUuidValue = 'test-uuid-value'; - const data = new Map(); - const mockAnonId = 'test-anon-id'; - let storageMock: Store; - let getAnonIdStorageMock: t.MockInstance<() => Promise>; - let setAnonIdStorageMock: t.MockInstance<(anonId: string) => Promise>; - - t.beforeEach(async () => { - t.vi.clearAllMocks(); - // For some reason mock only works with vi.mock not t.vi.mock ¿? - vi.mock('uuid', () => ({ - v4: vi.fn(() => 'test-uuid-value') - })); - - const storeClassMock = t.vi.mocked(Store); - storageMock = new storeClassMock({ - getItem: t.vi.fn(async (key: string) => data.get(key) || null), - setItem: t.vi.fn(async (key: string, value: string) => { - data.set(key, value); - }), - deleteItem: t.vi.fn(async (key: string) => { - data.delete(key); - }), - platform: 'web' - }); - getAnonIdStorageMock = t.vi.spyOn(storageMock, 'getAnonId'); - setAnonIdStorageMock = t.vi.spyOn(storageMock, 'setAnonId'); - }); - - t.afterEach(() => { - t.vi.clearAllMocks(); - data.clear(); - }); - - t.describe('getAnonId', () => { - t.it('should return existing _anonId if already set', async () => { - await storageMock.setAnonId(mockAnonId); - const anonId = await utils.getAnonId(storageMock); - t.expect(anonId).toBe(mockAnonId); - t.expect(getAnonIdStorageMock).toHaveBeenCalled(); - }); - t.it('should generate new anonId if not set', async () => { - const anonId = await utils.getAnonId(storageMock); - t.expect(anonId).toBe(mockUuidValue); - t.expect(getAnonIdStorageMock).toHaveBeenCalled(); - t.expect(setAnonIdStorageMock).toHaveBeenCalledWith(mockUuidValue); - }); - }); - }); - - }); - - t.describe("getSDKVersion", () => { - t.it('should get SDK version', () => { - t.expect(utils.getVersion()).toBe(packageJson.version); - }); - }) - - t.describe('extractFavicon', () => { - t.it('should return undefined if document is undefined', () => { - global.document = undefined as any; - - t.expect(utils.extractFavicon()).toBeUndefined(); - }); - - t.it('should return favicon href if rel is icon', () => { - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([ - { - getAttribute: (attr: string) => - attr === 'rel' ? 'icon' : '/favicon.ico', - }, - ]), - } as any; - - t.expect(utils.extractFavicon()).toBe('/favicon.ico'); - }); - - t.it('should return favicon href if rel is shortcut icon', () => { - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([ - { - getAttribute: (attr: string) => - attr === 'rel' ? 'shortcut icon' : '/favicon.ico', - }, - ]), - } as any; - - t.expect(utils.extractFavicon()).toBe('/favicon.ico'); - }); - - t.it('should return undefined if no favicon is found', () => { - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([]), - } as any; - - t.expect(utils.extractFavicon()).toBeUndefined(); - }); - - t.it('should return undefined if rel attribute is different', () => { - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([ - { - getAttribute: (attr: string) => - attr === 'rel' ? 'something else' : '/favicon.ico', - }, - ]), - } as any; - - t.expect(utils.extractFavicon()).toBeUndefined(); - }); - }); - - t.describe('setupInfuraProvider', () => { - - t.it('should not set up infura provider if infuraAPIKey is not provided', async () => { - options.api!.infuraAPIKey = undefined; - await utils.setupInfuraProvider(options); - t.expect(options.api?.readonlyRPCMap).toBeUndefined(); - }); - - t.it('should set up infura provider with infuraAPIKey', async () => { - await utils.setupInfuraProvider(options); - t.expect(options.api?.readonlyRPCMap?.['eip155:1']).toBe(`https://mainnet.infura.io/v3/testKey`); - }); - - t.it('Should allow customizing the readonlyRPCMap + merge with defaults', async () => { - const customChainId = 'eip155:12345' - const customEndpoint = 'https://mainnet.infura.io/12345' - options.api!.readonlyRPCMap = { - [customChainId]: customEndpoint, - }; - await utils.setupInfuraProvider(options); - t.expect(options.api?.readonlyRPCMap?.['eip155:1']).toBe(`https://mainnet.infura.io/v3/testKey`); - t.expect(options.api?.readonlyRPCMap?.[customChainId]).toBe(customEndpoint); - }); - - }); - - t.describe('setupDappMetadata', () => { - - t.beforeEach(() => { - // Mock the document object to avoid undefined errors - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([]) - } as any; - - t.vi.spyOn(utils, 'extractFavicon').mockReturnValue('xd'); - }) - - t.afterEach(() => { - t.vi.restoreAllMocks(); - }) - - t.it('should attach dappMetadata to the instance if valid', async () => { - (options.dapp as any).iconUrl = 'https://example.com/favicon.ico'; - options.dapp.url = 'https://example.com'; - const originalDappOptions = { - ...options.dapp - } - await utils.setupDappMetadata(options); - t.expect(options.dapp).toStrictEqual(originalDappOptions); - }); - - t.it('should set iconUrl to undenied if it does not start with http:// or https:// and favicon is undefined', async () => { - (options.dapp as any).iconUrl = 'ftp://example.com/favicon.ico'; - options.dapp.url = 'https://example.com'; - const consoleWarnSpy = t.vi.spyOn(console, 'warn') - - await utils.setupDappMetadata(options); - - t.expect((options.dapp as any).iconUrl).toBeUndefined(); - t.expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Invalid dappMetadata.iconUrl: URL must start with http:// or https://', - ); - }); - - t.it('should set url to undenied if it does not start with http:// or https:// and favicon is undefined', async () => { - options.dapp.url = 'wrong'; - const consoleWarnSpy = t.vi.spyOn(console, 'warn') - - await utils.setupDappMetadata(options); - - t.expect((options.dapp as any).iconUrl).toBeUndefined(); - t.expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Invalid dappMetadata.url: URL must start with http:// or https://', - ); - }); - - t.it('should prove that dapp is mandatory is platform is not browser', async () => { - (options.dapp as any) = undefined; - await t.expect(() => utils.setupDappMetadata(options)).toThrow('You must provide dapp url'); - }) - - t.it('should prove that dapp is optional is platform is browser', async () => { - (getPlatformType as any).mockReturnValue(PlatformType.DesktopWeb); - t.vi.stubGlobal('window', { - location: { - protocol: 'https:', - host: 'example.com', - }, - }); - (options.dapp as any) = undefined; - utils.setupDappMetadata(options) - t.expect(options.dapp.url).toBe('https://example.com'); - }) - - t.it('should set base64Icon to undefined if its length exceeds 163400 characters', async () => { - const longString = new Array(163401).fill('a').join(''); - const consoleWarnSpy = t.vi.spyOn(console, 'warn') - - options.dapp = { - iconUrl: 'https://example.com/favicon.ico', - url: 'https://example.com', - base64Icon: longString, - }; - - await utils.setupDappMetadata(options); - - t.expect((options.dapp as any).base64Icon).toBeUndefined(); - t.expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Invalid dappMetadata.base64Icon: Base64-encoded icon string length must be less than 163400 characters', - ); - }); - - t.it('should set iconUrl to the extracted favicon if iconUrl and base64Icon are not provided', async () => { - options.dapp = { - url: 'https://example.com', - }; - - global.window = { - location: { - protocol: 'https:', - host: 'example.com', - }, - } as any; - - // Mock document.getElementsByTagName to return a link element with favicon - global.document = { - getElementsByTagName: t.vi.fn().mockReturnValue([ - { - getAttribute: (attr: string) => { - if (attr === 'rel') return 'icon'; - if (attr === 'href') return '/favicon.ico'; - return null; - }, - }, - ]), - } as any; - - await utils.setupDappMetadata(options); - - t.expect((options.dapp as any).iconUrl).toBe('https://example.com/favicon.ico'); - }); - }); - - t.describe('isMetaMaskInstalled', () => { - - t.it('should return true if MetaMask is installed', () => { - t.vi.stubGlobal('window', { - ethereum : { - isMetaMask: true, - } - }); - t.expect(utils.isMetaMaskInstalled()).toBe(true); - }); - - t.it('should return false if MetaMask is not installed', () => { - t.vi.stubGlobal('window', undefined); - t.expect(utils.isMetaMaskInstalled()).toBe(false); - }); - - }); - - -}) +t.describe("Utils", () => { + let options: MultichainSDKConstructor; + + t.beforeEach(() => { + t.vi.clearAllMocks(); + options = { + dapp: { + name: "test", + url: "test", + }, + api: { + infuraAPIKey: "testKey", + }, + } as MultichainSDKConstructor; + }); + + t.describe("getDappId", () => { + const mockHostname = "mockdapp.com"; + const mockDappName = "Mock DApp Name"; + const mockDappUrl = "http://mockdapp.com"; + const originalWindow = global.window; + + t.afterEach(() => { + global.window = originalWindow; + }); + + t.it("should return window.location.hostname if window and window.location are defined", () => { + global.window = { + location: { + hostname: mockHostname, + }, + } as any; + t.expect(utils.getDappId()).toBe(mockHostname); + }); + + t.it("should return dappMetadata.name if window is undefined and name is available", () => { + global.window = undefined as any; + const dappSettings = { name: mockDappName, url: mockDappUrl }; + t.expect(utils.getDappId(dappSettings)).toBe(mockDappName); + }); + + t.it("should return dappMetadata.url if window is undefined and name is not available but url is", () => { + global.window = undefined as any; + const dappSettings = { url: mockDappUrl }; + t.expect(utils.getDappId(dappSettings)).toBe(mockDappUrl); + }); + + t.it('should return "N/A" if window is undefined and neither name nor url is available', () => { + global.window = undefined as any; + const dappSettings = {}; + t.expect(utils.getDappId(dappSettings)).toBe("N/A"); + }); + + t.describe("Anonymous ID Management", () => { + const mockUuidValue = "test-uuid-value"; + const data = new Map(); + const mockAnonId = "test-anon-id"; + let storageMock: Store; + let getAnonIdStorageMock: t.MockInstance<() => Promise>; + let setAnonIdStorageMock: t.MockInstance<(anonId: string) => Promise>; + + t.beforeEach(async () => { + t.vi.clearAllMocks(); + // For some reason mock only works with vi.mock not t.vi.mock ¿? + vi.mock("uuid", () => ({ + v4: vi.fn(() => "test-uuid-value"), + })); + + const storeClassMock = t.vi.mocked(Store); + storageMock = new storeClassMock({ + getItem: t.vi.fn(async (key: string) => data.get(key) || null), + setItem: t.vi.fn(async (key: string, value: string) => { + data.set(key, value); + }), + deleteItem: t.vi.fn(async (key: string) => { + data.delete(key); + }), + platform: "web", + }); + getAnonIdStorageMock = t.vi.spyOn(storageMock, "getAnonId"); + setAnonIdStorageMock = t.vi.spyOn(storageMock, "setAnonId"); + }); + + t.afterEach(() => { + t.vi.clearAllMocks(); + data.clear(); + }); + + t.describe("getAnonId", () => { + t.it("should return existing _anonId if already set", async () => { + await storageMock.setAnonId(mockAnonId); + const anonId = await utils.getAnonId(storageMock); + t.expect(anonId).toBe(mockAnonId); + t.expect(getAnonIdStorageMock).toHaveBeenCalled(); + }); + t.it("should generate new anonId if not set", async () => { + const anonId = await utils.getAnonId(storageMock); + t.expect(anonId).toBe(mockUuidValue); + t.expect(getAnonIdStorageMock).toHaveBeenCalled(); + t.expect(setAnonIdStorageMock).toHaveBeenCalledWith(mockUuidValue); + }); + }); + }); + }); + + t.describe("getSDKVersion", () => { + t.it("should get SDK version", () => { + t.expect(utils.getVersion()).toBe(packageJson.version); + }); + }); + + t.describe("extractFavicon", () => { + t.it("should return undefined if document is undefined", () => { + global.document = undefined as any; + + t.expect(utils.extractFavicon()).toBeUndefined(); + }); + + t.it("should return favicon href if rel is icon", () => { + global.document = { + getElementsByTagName: t.vi.fn().mockReturnValue([ + { + getAttribute: (attr: string) => (attr === "rel" ? "icon" : "/favicon.ico"), + }, + ]), + } as any; + + t.expect(utils.extractFavicon()).toBe("/favicon.ico"); + }); + + t.it("should return favicon href if rel is shortcut icon", () => { + global.document = { + getElementsByTagName: t.vi.fn().mockReturnValue([ + { + getAttribute: (attr: string) => (attr === "rel" ? "shortcut icon" : "/favicon.ico"), + }, + ]), + } as any; + + t.expect(utils.extractFavicon()).toBe("/favicon.ico"); + }); + + t.it("should return undefined if no favicon is found", () => { + global.document = { + getElementsByTagName: t.vi.fn().mockReturnValue([]), + } as any; + + t.expect(utils.extractFavicon()).toBeUndefined(); + }); + + t.it("should return undefined if rel attribute is different", () => { + global.document = { + getElementsByTagName: t.vi.fn().mockReturnValue([ + { + getAttribute: (attr: string) => (attr === "rel" ? "something else" : "/favicon.ico"), + }, + ]), + } as any; + + t.expect(utils.extractFavicon()).toBeUndefined(); + }); + }); + + t.describe("setupInfuraProvider", () => { + t.it("should not set up infura provider if infuraAPIKey is not provided", async () => { + options.api!.infuraAPIKey = undefined; + await utils.setupInfuraProvider(options); + t.expect(options.api?.readonlyRPCMap).toBeUndefined(); + }); + + t.it("should set up infura provider with infuraAPIKey", async () => { + await utils.setupInfuraProvider(options); + t.expect(options.api?.readonlyRPCMap?.["eip155:1"]).toBe(`https://mainnet.infura.io/v3/testKey`); + }); + + t.it("Should allow customizing the readonlyRPCMap + merge with defaults", async () => { + const customChainId = "eip155:12345"; + const customEndpoint = "https://mainnet.infura.io/12345"; + options.api!.readonlyRPCMap = { + [customChainId]: customEndpoint, + }; + await utils.setupInfuraProvider(options); + t.expect(options.api?.readonlyRPCMap?.["eip155:1"]).toBe(`https://mainnet.infura.io/v3/testKey`); + t.expect(options.api?.readonlyRPCMap?.[customChainId]).toBe(customEndpoint); + }); + }); + + t.describe("setupDappMetadata", () => { + t.beforeEach(() => { + // Mock the document object to avoid undefined errors + global.document = { + getElementsByTagName: t.vi.fn().mockReturnValue([]), + } as any; + + t.vi.spyOn(utils, "extractFavicon").mockReturnValue("xd"); + }); + + t.afterEach(() => { + t.vi.restoreAllMocks(); + }); + + t.it("should attach dappMetadata to the instance if valid", async () => { + (options.dapp as any).iconUrl = "https://example.com/favicon.ico"; + options.dapp.url = "https://example.com"; + const originalDappOptions = { + ...options.dapp, + }; + await utils.setupDappMetadata(options); + t.expect(options.dapp).toStrictEqual(originalDappOptions); + }); + + t.it("should set iconUrl to undenied if it does not start with http:// or https:// and favicon is undefined", async () => { + (options.dapp as any).iconUrl = "ftp://example.com/favicon.ico"; + options.dapp.url = "https://example.com"; + const consoleWarnSpy = t.vi.spyOn(console, "warn"); + + await utils.setupDappMetadata(options); + + t.expect((options.dapp as any).iconUrl).toBeUndefined(); + t.expect(consoleWarnSpy).toHaveBeenCalledWith("Invalid dappMetadata.iconUrl: URL must start with http:// or https://"); + }); + + t.it("should set url to undenied if it does not start with http:// or https:// and favicon is undefined", async () => { + options.dapp.url = "wrong"; + const consoleWarnSpy = t.vi.spyOn(console, "warn"); + + await utils.setupDappMetadata(options); + + t.expect((options.dapp as any).iconUrl).toBeUndefined(); + t.expect(consoleWarnSpy).toHaveBeenCalledWith("Invalid dappMetadata.url: URL must start with http:// or https://"); + }); + + t.it("should prove that dapp is mandatory is platform is not browser", async () => { + (options.dapp as any) = undefined; + await t.expect(() => utils.setupDappMetadata(options)).toThrow("You must provide dapp url"); + }); + + t.it("should prove that dapp is optional is platform is browser", async () => { + (getPlatformType as any).mockReturnValue(PlatformType.DesktopWeb); + t.vi.stubGlobal("window", { + location: { + protocol: "https:", + host: "example.com", + }, + }); + (options.dapp as any) = undefined; + utils.setupDappMetadata(options); + t.expect(options.dapp.url).toBe("https://example.com"); + }); + + t.it("should set base64Icon to undefined if its length exceeds 163400 characters", async () => { + const longString = new Array(163401).fill("a").join(""); + const consoleWarnSpy = t.vi.spyOn(console, "warn"); + + options.dapp = { + iconUrl: "https://example.com/favicon.ico", + url: "https://example.com", + base64Icon: longString, + }; + + await utils.setupDappMetadata(options); + + t.expect((options.dapp as any).base64Icon).toBeUndefined(); + t.expect(consoleWarnSpy).toHaveBeenCalledWith("Invalid dappMetadata.base64Icon: Base64-encoded icon string length must be less than 163400 characters"); + }); + + t.it("should set iconUrl to the extracted favicon if iconUrl and base64Icon are not provided", async () => { + options.dapp = { + url: "https://example.com", + }; + + global.window = { + location: { + protocol: "https:", + host: "example.com", + }, + } as any; + + // Mock document.getElementsByTagName to return a link element with favicon + global.document = { + getElementsByTagName: t.vi.fn().mockReturnValue([ + { + getAttribute: (attr: string) => { + if (attr === "rel") return "icon"; + if (attr === "href") return "/favicon.ico"; + return null; + }, + }, + ]), + } as any; + + await utils.setupDappMetadata(options); + + t.expect((options.dapp as any).iconUrl).toBe("https://example.com/favicon.ico"); + }); + }); + + t.describe("isMetaMaskInstalled", () => { + t.it("should return true if MetaMask is installed", () => { + t.vi.stubGlobal("window", { + ethereum: { + isMetaMask: true, + }, + }); + t.expect(utils.isMetaMaskInstalled()).toBe(true); + }); + + t.it("should return false if MetaMask is not installed", () => { + t.vi.stubGlobal("window", undefined); + t.expect(utils.isMetaMaskInstalled()).toBe(false); + }); + }); +}); diff --git a/packages/sdk-multichain/src/utis/index.ts b/packages/sdk-multichain/src/utis/index.ts index 5c3ad2ce7..31d458674 100644 --- a/packages/sdk-multichain/src/utis/index.ts +++ b/packages/sdk-multichain/src/utis/index.ts @@ -1,151 +1,117 @@ -import * as uuid from 'uuid'; -import type { StoreClient } from '../domain/store/client'; -import packageJson from '../../package.json'; -import { getInfuraRpcUrls, type DappSettings, type MultichainSDKConstructor } from '../domain/multichain'; -import { getPlatformType, PlatformType } from '../domain/platform'; +import * as uuid from "uuid"; +import type { StoreClient } from "../domain/store/client"; +import packageJson from "../../package.json"; +import { getInfuraRpcUrls, type DappSettings, type MultichainSDKConstructor } from "../domain/multichain"; +import { getPlatformType, PlatformType } from "../domain/platform"; export function getVersion() { - return packageJson.version; + return packageJson.version; } export function getDappId(dapp?: DappSettings) { - if ( - typeof window === 'undefined' || - typeof window.location === 'undefined' - ) { - return ( - dapp?.name ?? - dapp?.url ?? - 'N/A' - ); - } + if (typeof window === "undefined" || typeof window.location === "undefined") { + return dapp?.name ?? dapp?.url ?? "N/A"; + } - return window.location.hostname; + return window.location.hostname; } export async function getAnonId(storage: StoreClient) { - const anonId = await storage.getAnonId(); - if (anonId) { - return anonId; - } - const newAnonId = uuid.v4(); - await storage.setAnonId(newAnonId); - return newAnonId; + const anonId = await storage.getAnonId(); + if (anonId) { + return anonId; + } + const newAnonId = uuid.v4(); + await storage.setAnonId(newAnonId); + return newAnonId; } - export const extractFavicon = () => { - if (typeof document === 'undefined') { - return undefined; - } + if (typeof document === "undefined") { + return undefined; + } - let favicon:string | undefined = undefined - const nodeList = document.getElementsByTagName('link'); - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < nodeList.length; i++) { - if ( - nodeList[i].getAttribute('rel') === 'icon' || - nodeList[i].getAttribute('rel') === 'shortcut icon' - ) { - favicon = nodeList[i].getAttribute('href') ?? undefined; - } - } - return favicon; + let favicon: string | undefined; + const nodeList = document.getElementsByTagName("link"); + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < nodeList.length; i++) { + if (nodeList[i].getAttribute("rel") === "icon" || nodeList[i].getAttribute("rel") === "shortcut icon") { + favicon = nodeList[i].getAttribute("href") ?? undefined; + } + } + return favicon; }; export function setupInfuraProvider(options: MultichainSDKConstructor): MultichainSDKConstructor { - const infuraAPIKey = options.api?.infuraAPIKey; - if (!infuraAPIKey) { - return options - } - const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); - if (options.api?.readonlyRPCMap) { - options.api.readonlyRPCMap = { - ...options.api.readonlyRPCMap, - ...urlsWithToken, - }; - } else if (options.api){ - options.api.readonlyRPCMap = urlsWithToken; - } - return options + const infuraAPIKey = options.api?.infuraAPIKey; + if (!infuraAPIKey) { + return options; + } + const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); + if (options.api?.readonlyRPCMap) { + options.api.readonlyRPCMap = { + ...options.api.readonlyRPCMap, + ...urlsWithToken, + }; + } else if (options.api) { + options.api.readonlyRPCMap = urlsWithToken; + } + return options; } export function setupDappMetadata(options: MultichainSDKConstructor): MultichainSDKConstructor { - const platform = getPlatformType(); - const isBrowser = - platform === PlatformType.DesktopWeb || - platform === PlatformType.MobileWeb || - platform === PlatformType.MetaMaskMobileWebview - - if (!options.dapp?.url) { - // Automatically set dappMetadata on web env if not defined - if (isBrowser) { - options.dapp = { - ...options.dapp, - url: `${window.location.protocol}//${window.location.host}`, - }; - } else { - throw new Error("You must provide dapp url"); - } - } - const BASE_64_ICON_MAX_LENGTH = 163400; - // Check if iconUrl and url are valid - const urlPattern = /^(http|https):\/\/[^\s]*$/; // Regular expression for URLs starting with http:// or https:// - if (options.dapp) { - if ('iconUrl' in options.dapp) { - if ( - options.dapp.iconUrl && - !urlPattern.test(options.dapp.iconUrl) - ) { - console.warn( - 'Invalid dappMetadata.iconUrl: URL must start with http:// or https://', - ); - options.dapp.iconUrl = undefined; - } - } - // This check ensures that the base64Icon string in the dappMetadata does not exceed 163,400 characters. - // The character limit is important because a longer base64-encoded string causes the connection to the mobile app to fail. - // Keeping the base64Icon string length below this threshold ensures reliable communication and functionality. - if ('base64Icon' in options.dapp) { - if ( - options.dapp.base64Icon && - options.dapp.base64Icon.length > BASE_64_ICON_MAX_LENGTH - ) { - console.warn( - 'Invalid dappMetadata.base64Icon: Base64-encoded icon string length must be less than 163400 characters', - ); + const platform = getPlatformType(); + const isBrowser = platform === PlatformType.DesktopWeb || platform === PlatformType.MobileWeb || platform === PlatformType.MetaMaskMobileWebview; - options.dapp.base64Icon = undefined; - } - } - if ( - options.dapp.url && - !urlPattern.test(options.dapp.url) - ) { - console.warn( - 'Invalid dappMetadata.url: URL must start with http:// or https://', - ); - } - const favicon = extractFavicon(); - if ( - favicon && - !('iconUrl' in options.dapp) && - !('base64Icon' in options.dapp) - ) { + if (!options.dapp?.url) { + // Automatically set dappMetadata on web env if not defined + if (isBrowser) { + options.dapp = { + ...options.dapp, + url: `${window.location.protocol}//${window.location.host}`, + }; + } else { + throw new Error("You must provide dapp url"); + } + } + const BASE_64_ICON_MAX_LENGTH = 163400; + // Check if iconUrl and url are valid + const urlPattern = /^(http|https):\/\/[^\s]*$/; // Regular expression for URLs starting with http:// or https:// + if (options.dapp) { + if ("iconUrl" in options.dapp) { + if (options.dapp.iconUrl && !urlPattern.test(options.dapp.iconUrl)) { + console.warn("Invalid dappMetadata.iconUrl: URL must start with http:// or https://"); + options.dapp.iconUrl = undefined; + } + } + // This check ensures that the base64Icon string in the dappMetadata does not exceed 163,400 characters. + // The character limit is important because a longer base64-encoded string causes the connection to the mobile app to fail. + // Keeping the base64Icon string length below this threshold ensures reliable communication and functionality. + if ("base64Icon" in options.dapp) { + if (options.dapp.base64Icon && options.dapp.base64Icon.length > BASE_64_ICON_MAX_LENGTH) { + console.warn("Invalid dappMetadata.base64Icon: Base64-encoded icon string length must be less than 163400 characters"); - const faviconUrl = `${window.location.protocol}//${window.location.host}${favicon}`; - // @ts-ignore - options.dapp.iconUrl = faviconUrl; - } - } - return options + options.dapp.base64Icon = undefined; + } + } + if (options.dapp.url && !urlPattern.test(options.dapp.url)) { + console.warn("Invalid dappMetadata.url: URL must start with http:// or https://"); + } + const favicon = extractFavicon(); + if (favicon && !("iconUrl" in options.dapp) && !("base64Icon" in options.dapp)) { + const faviconUrl = `${window.location.protocol}//${window.location.host}${favicon}`; + // @ts-ignore + options.dapp.iconUrl = faviconUrl; + } + } + return options; } /** * Check if MetaMask extension is installed */ export function isMetaMaskInstalled(): boolean { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return false; } return Boolean(window.ethereum?.isMetaMask); diff --git a/packages/sdk-multichain/src/utis/rpc/client.test.ts b/packages/sdk-multichain/src/utis/rpc/client.test.ts index 89b177c04..a78d7bd02 100644 --- a/packages/sdk-multichain/src/utis/rpc/client.test.ts +++ b/packages/sdk-multichain/src/utis/rpc/client.test.ts @@ -1,307 +1,294 @@ -import * as t from 'vitest'; -import { vi } from 'vitest'; +import * as t from "vitest"; +import { vi } from "vitest"; import { - type MultichainSDKConstructor, - type InvokeMethodOptions, - type Scope, - RPC_METHODS, - RPCInvokeMethodErr, - RPCReadonlyResponseErr, - RPCHttpErr, - RPCReadonlyRequestErr, -} from '../../domain'; -import type { RPCClient } from './client'; - + type MultichainSDKConstructor, + type InvokeMethodOptions, + type Scope, + RPC_METHODS, + RPCInvokeMethodErr, + RPCReadonlyResponseErr, + RPCHttpErr, + RPCReadonlyRequestErr, +} from "../../domain"; +import type { RPCClient } from "./client"; // Mock cross-fetch with proper implementation -vi.mock('cross-fetch', () => { - const mockFetch = vi.fn(); - return { - default: mockFetch, - __mockFetch: mockFetch, - }; +vi.mock("cross-fetch", () => { + const mockFetch = vi.fn(); + return { + default: mockFetch, + __mockFetch: mockFetch, + }; }); // Mock the entire client module -vi.mock('./client', async () => { - const actual = await vi.importActual('./client'); - return { - ...actual, - }; +vi.mock("./client", async () => { + const actual = await vi.importActual("./client"); + return { + ...actual, + }; }); - -t.describe('RPCClient', () => { - let mockProvider: any; - let mockConfig: MultichainSDKConstructor['api']; - let sdkInfo: string; - let rpcClient: RPCClient; - let rpcClientModule: typeof RPCClient - let defaultHeaders: Record; - let headers: Record; - let mockFetch: any; - let baseOptions: InvokeMethodOptions; - - t.beforeEach(async () => { - const clientModule = await import('./client'); - baseOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_getBalance', - params: { address: '0x123', blockNumber: 'latest' }, - }, - } - mockProvider = { - invokeMethod: t.vi.fn(), - }; - mockConfig = { - infuraAPIKey: 'test-infura-key', - readonlyRPCMap: { - 'eip155:1': 'https://custom-mainnet.com', - }, - }; - sdkInfo = 'Sdk/Javascript SdkVersion/1.0.0 Platform/web'; - rpcClient = new clientModule.RPCClient(mockProvider, mockConfig, sdkInfo); - rpcClientModule = clientModule.RPCClient; - // Get mock fetch from the module mock - const fetchModule = await import('cross-fetch'); - mockFetch = (fetchModule as any).__mockFetch; - // Reset mocks - mockProvider.invokeMethod.mockClear(); - mockFetch.mockClear(); - defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', - } - headers = { - ...defaultHeaders, - 'Metamask-Sdk-Info': sdkInfo, - } - }); - - t.afterEach(async () => { - t.vi.clearAllMocks(); - t.vi.resetAllMocks(); - }); - - t.describe('getHeaders', () => { - t.it('should return default headers when RPC endpoint does not include infura', () => { - const customRpcEndpoint = 'https://custom-ethereum-node.com/rpc'; - const headers = (rpcClient as any).getHeaders(customRpcEndpoint); - t.expect(headers).toEqual(defaultHeaders); - t.expect(headers).not.toHaveProperty('Metamask-Sdk-Info'); - }); - - t.it('should return headers with Metamask-Sdk-Info when RPC endpoint includes infura', () => { - const infuraEndpoint = 'https://mainnet.infura.io/v3/test-key'; - const currentHeaders = (rpcClient as any).getHeaders(infuraEndpoint); - t.expect(currentHeaders).toEqual(headers); - }); - }); - - t.describe('invokeMethod', () => { - - - t.describe('redirect to provider cases', () => { - t.it('should not redirect to provider for readonly methods', async () => { - // Mock successful fetch response - const mockJsonResponse = { - jsonrpc: '2.0', - result: '0x1234567890abcdef', - id: 1, - }; - - const mockResponse = { - ok: true, - json: t.vi.fn().mockResolvedValue(mockJsonResponse), - }; - - mockFetch.mockResolvedValue(mockResponse); - - const result = await rpcClient.invokeMethod(baseOptions); - - t.expect(result).toBe('0x1234567890abcdef'); - t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); - t.expect(mockFetch).toHaveBeenCalledWith( - 'https://mainnet.infura.io/v3/test-infura-key', - { - method: 'POST', - headers: headers, - body: t.expect.stringContaining('"method":"eth_getBalance"'), - } - ); - t.expect(mockResponse.json).toHaveBeenCalled(); - }); - - t.it('should throw RPCReadonlyResponseErr when response cannot be parsed as JSON', async () => { - const mockResponse = { - ok: true, - json: t.vi.fn().mockRejectedValue(new Error('Invalid JSON')), - }; - - mockFetch.mockResolvedValue(mockResponse); - - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyResponseErr); - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow('Invalid JSON'); - }); - - t.it('should throw RPCHttpErr when fetch response is not ok', async () => { - const mockResponse = { - ok: false, - status: 500, - }; - - mockFetch.mockResolvedValue(mockResponse); - - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCHttpErr); - }); - - t.it('should throw RPCReadonlyRequestErr when fetch throws', async () => { - const fetchError = new Error('Network error'); - mockFetch.mockRejectedValue(fetchError); - - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyRequestErr); - await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow('Network error'); - }); - - t.it('should use only Infura URLs when readonlyRPCMapConfig is not provided', async () => { - const configWithoutRPCMap = { - infuraAPIKey: 'test-infura-key', - // No readonlyRPCMap provided - }; - const clientWithoutRPCMap = new rpcClientModule(mockProvider, configWithoutRPCMap, sdkInfo); - - const mockJsonResponse = { - jsonrpc: '2.0', - result: '0xabcdef123456', - id: 1, - }; - - const mockResponse = { - ok: true, - json: t.vi.fn().mockResolvedValue(mockJsonResponse), - }; - - mockFetch.mockResolvedValue(mockResponse); - - const result = await clientWithoutRPCMap.invokeMethod(baseOptions); - - t.expect(result).toBe('0xabcdef123456'); - t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); - - // Should still use Infura URL since infuraAPIKey is provided - t.expect(mockFetch).toHaveBeenCalledWith( - 'https://mainnet.infura.io/v3/test-infura-key', - { - method: 'POST', - headers: headers, - body: t.expect.stringMatching('"method":"eth_getBalance"'), - } - ); - }); - - t.it('should use only default headers when RPC endpoint does not include infura and custom readonly RPC is provided', async () => { - const configWithCustomRPC = { - readonlyRPCMap: { - 'eip155:1': 'https://custom-ethereum-node.com/rpc', - }, - }; - const clientWithCustomRPC = new rpcClientModule(mockProvider, configWithCustomRPC, sdkInfo); - const mockJsonResponse = { - jsonrpc: '2.0', - result: '0x123456account12345', - id: 1, - }; - const mockResponse = { - ok: true, - json: t.vi.fn().mockResolvedValue(mockJsonResponse), - }; - - mockFetch.mockResolvedValue(mockResponse); - baseOptions.request = { - method: 'eth_accounts', - params: undefined, - }; - - const result = await clientWithCustomRPC.invokeMethod(baseOptions); - t.expect(result).toBe('0x123456account12345'); - t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); - t.expect(mockFetch).toHaveBeenCalledWith( - 'https://custom-ethereum-node.com/rpc', - { - method: 'POST', - headers: defaultHeaders, - body: t.expect.stringMatching(/^\{"jsonrpc":"2\.0","method":"eth_accounts","id":\d+\}$/), - } - ); - }); - - t.it('should redirect to provider for methods in METHODS_TO_REDIRECT', async () => { - const redirectOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: RPC_METHODS.ETH_SENDTRANSACTION, - params: { to: '0x123', value: '0x100' }, - }, - }; - mockProvider.invokeMethod.mockResolvedValue('0xhash'); - const result = await rpcClient.invokeMethod(redirectOptions); - t.expect(result).toBe('0xhash'); - t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(redirectOptions); - t.expect(mockFetch).not.toHaveBeenCalled(); - }); - - t.it('should redirect to provider for personal_sign method', async () => { - const signOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: RPC_METHODS.PERSONAL_SIGN, - params: { message: 'hello world' }, - }, - }; - mockProvider.invokeMethod.mockResolvedValue('0xsignature'); - const result = await rpcClient.invokeMethod(signOptions); - t.expect(result).toBe('0xsignature'); - t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(signOptions); - t.expect(mockFetch).not.toHaveBeenCalled(); - }); - - t.it('should redirect to provider when no RPC endpoint is available', async () => { - const noRpcOptions: InvokeMethodOptions = { - scope: 'eip155:999' as Scope, // Unknown chain - request: { - method: 'eth_getBalance', - params: { address: '0x123', blockNumber: 'latest' }, - }, - }; - mockProvider.invokeMethod.mockResolvedValue('0xbalance'); - const result = await rpcClient.invokeMethod(noRpcOptions); - t.expect(result).toBe('0xbalance'); - t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(noRpcOptions); - t.expect(mockFetch).not.toHaveBeenCalled(); - }); - - t.it('should redirect to provider when no infura API key and no custom RPC map', async () => { - const noConfigClient = new rpcClientModule(mockProvider, {}, sdkInfo); - mockProvider.invokeMethod.mockResolvedValue('0xfallback'); - const result = await noConfigClient.invokeMethod(baseOptions); - t.expect(result).toBe('0xfallback'); - t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(baseOptions); - t.expect(mockFetch).not.toHaveBeenCalled(); - }); - - t.it('should throw RPCInvokeMethodErr when provider throws', async () => { - const redirectOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: RPC_METHODS.ETH_SENDTRANSACTION, - params: { to: '0x123', value: '0x100' }, - }, - }; - mockProvider.invokeMethod.mockRejectedValue(new Error('Provider error')); - await t.expect(rpcClient.invokeMethod(redirectOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); - t.expect(mockFetch).not.toHaveBeenCalled(); - }); - }); - }); +t.describe("RPCClient", () => { + let mockProvider: any; + let mockConfig: MultichainSDKConstructor["api"]; + let sdkInfo: string; + let rpcClient: RPCClient; + let rpcClientModule: typeof RPCClient; + let defaultHeaders: Record; + let headers: Record; + let mockFetch: any; + let baseOptions: InvokeMethodOptions; + + t.beforeEach(async () => { + const clientModule = await import("./client"); + baseOptions = { + scope: "eip155:1" as Scope, + request: { + method: "eth_getBalance", + params: { address: "0x123", blockNumber: "latest" }, + }, + }; + mockProvider = { + invokeMethod: t.vi.fn(), + }; + mockConfig = { + infuraAPIKey: "test-infura-key", + readonlyRPCMap: { + "eip155:1": "https://custom-mainnet.com", + }, + }; + sdkInfo = "Sdk/Javascript SdkVersion/1.0.0 Platform/web"; + rpcClient = new clientModule.RPCClient(mockProvider, mockConfig, sdkInfo); + rpcClientModule = clientModule.RPCClient; + // Get mock fetch from the module mock + const fetchModule = await import("cross-fetch"); + mockFetch = (fetchModule as any).__mockFetch; + // Reset mocks + mockProvider.invokeMethod.mockClear(); + mockFetch.mockClear(); + defaultHeaders = { + Accept: "application/json", + "Content-Type": "application/json", + }; + headers = { + ...defaultHeaders, + "Metamask-Sdk-Info": sdkInfo, + }; + }); + + t.afterEach(async () => { + t.vi.clearAllMocks(); + t.vi.resetAllMocks(); + }); + + t.describe("getHeaders", () => { + t.it("should return default headers when RPC endpoint does not include infura", () => { + const customRpcEndpoint = "https://custom-ethereum-node.com/rpc"; + const headers = (rpcClient as any).getHeaders(customRpcEndpoint); + t.expect(headers).toEqual(defaultHeaders); + t.expect(headers).not.toHaveProperty("Metamask-Sdk-Info"); + }); + + t.it("should return headers with Metamask-Sdk-Info when RPC endpoint includes infura", () => { + const infuraEndpoint = "https://mainnet.infura.io/v3/test-key"; + const currentHeaders = (rpcClient as any).getHeaders(infuraEndpoint); + t.expect(currentHeaders).toEqual(headers); + }); + }); + + t.describe("invokeMethod", () => { + t.describe("redirect to provider cases", () => { + t.it("should not redirect to provider for readonly methods", async () => { + // Mock successful fetch response + const mockJsonResponse = { + jsonrpc: "2.0", + result: "0x1234567890abcdef", + id: 1, + }; + + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await rpcClient.invokeMethod(baseOptions); + + t.expect(result).toBe("0x1234567890abcdef"); + t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); + t.expect(mockFetch).toHaveBeenCalledWith("https://mainnet.infura.io/v3/test-infura-key", { + method: "POST", + headers: headers, + body: t.expect.stringContaining('"method":"eth_getBalance"'), + }); + t.expect(mockResponse.json).toHaveBeenCalled(); + }); + + t.it("should throw RPCReadonlyResponseErr when response cannot be parsed as JSON", async () => { + const mockResponse = { + ok: true, + json: t.vi.fn().mockRejectedValue(new Error("Invalid JSON")), + }; + + mockFetch.mockResolvedValue(mockResponse); + + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyResponseErr); + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow("Invalid JSON"); + }); + + t.it("should throw RPCHttpErr when fetch response is not ok", async () => { + const mockResponse = { + ok: false, + status: 500, + }; + + mockFetch.mockResolvedValue(mockResponse); + + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCHttpErr); + }); + + t.it("should throw RPCReadonlyRequestErr when fetch throws", async () => { + const fetchError = new Error("Network error"); + mockFetch.mockRejectedValue(fetchError); + + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyRequestErr); + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow("Network error"); + }); + + t.it("should use only Infura URLs when readonlyRPCMapConfig is not provided", async () => { + const configWithoutRPCMap = { + infuraAPIKey: "test-infura-key", + // No readonlyRPCMap provided + }; + const clientWithoutRPCMap = new rpcClientModule(mockProvider, configWithoutRPCMap, sdkInfo); + + const mockJsonResponse = { + jsonrpc: "2.0", + result: "0xabcdef123456", + id: 1, + }; + + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await clientWithoutRPCMap.invokeMethod(baseOptions); + + t.expect(result).toBe("0xabcdef123456"); + t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); + + // Should still use Infura URL since infuraAPIKey is provided + t.expect(mockFetch).toHaveBeenCalledWith("https://mainnet.infura.io/v3/test-infura-key", { + method: "POST", + headers: headers, + body: t.expect.stringMatching('"method":"eth_getBalance"'), + }); + }); + + t.it("should use only default headers when RPC endpoint does not include infura and custom readonly RPC is provided", async () => { + const configWithCustomRPC = { + readonlyRPCMap: { + "eip155:1": "https://custom-ethereum-node.com/rpc", + }, + }; + const clientWithCustomRPC = new rpcClientModule(mockProvider, configWithCustomRPC, sdkInfo); + const mockJsonResponse = { + jsonrpc: "2.0", + result: "0x123456account12345", + id: 1, + }; + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + mockFetch.mockResolvedValue(mockResponse); + baseOptions.request = { + method: "eth_accounts", + params: undefined, + }; + + const result = await clientWithCustomRPC.invokeMethod(baseOptions); + t.expect(result).toBe("0x123456account12345"); + t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); + t.expect(mockFetch).toHaveBeenCalledWith("https://custom-ethereum-node.com/rpc", { + method: "POST", + headers: defaultHeaders, + body: t.expect.stringMatching(/^\{"jsonrpc":"2\.0","method":"eth_accounts","id":\d+\}$/), + }); + }); + + t.it("should redirect to provider for methods in METHODS_TO_REDIRECT", async () => { + const redirectOptions: InvokeMethodOptions = { + scope: "eip155:1" as Scope, + request: { + method: RPC_METHODS.ETH_SENDTRANSACTION, + params: { to: "0x123", value: "0x100" }, + }, + }; + mockProvider.invokeMethod.mockResolvedValue("0xhash"); + const result = await rpcClient.invokeMethod(redirectOptions); + t.expect(result).toBe("0xhash"); + t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(redirectOptions); + t.expect(mockFetch).not.toHaveBeenCalled(); + }); + + t.it("should redirect to provider for personal_sign method", async () => { + const signOptions: InvokeMethodOptions = { + scope: "eip155:1" as Scope, + request: { + method: RPC_METHODS.PERSONAL_SIGN, + params: { message: "hello world" }, + }, + }; + mockProvider.invokeMethod.mockResolvedValue("0xsignature"); + const result = await rpcClient.invokeMethod(signOptions); + t.expect(result).toBe("0xsignature"); + t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(signOptions); + t.expect(mockFetch).not.toHaveBeenCalled(); + }); + + t.it("should redirect to provider when no RPC endpoint is available", async () => { + const noRpcOptions: InvokeMethodOptions = { + scope: "eip155:999" as Scope, // Unknown chain + request: { + method: "eth_getBalance", + params: { address: "0x123", blockNumber: "latest" }, + }, + }; + mockProvider.invokeMethod.mockResolvedValue("0xbalance"); + const result = await rpcClient.invokeMethod(noRpcOptions); + t.expect(result).toBe("0xbalance"); + t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(noRpcOptions); + t.expect(mockFetch).not.toHaveBeenCalled(); + }); + + t.it("should redirect to provider when no infura API key and no custom RPC map", async () => { + const noConfigClient = new rpcClientModule(mockProvider, {}, sdkInfo); + mockProvider.invokeMethod.mockResolvedValue("0xfallback"); + const result = await noConfigClient.invokeMethod(baseOptions); + t.expect(result).toBe("0xfallback"); + t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(baseOptions); + t.expect(mockFetch).not.toHaveBeenCalled(); + }); + + t.it("should throw RPCInvokeMethodErr when provider throws", async () => { + const redirectOptions: InvokeMethodOptions = { + scope: "eip155:1" as Scope, + request: { + method: RPC_METHODS.ETH_SENDTRANSACTION, + params: { to: "0x123", value: "0x100" }, + }, + }; + mockProvider.invokeMethod.mockRejectedValue(new Error("Provider error")); + await t.expect(rpcClient.invokeMethod(redirectOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); + t.expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/sdk-multichain/src/utis/rpc/client.ts b/packages/sdk-multichain/src/utis/rpc/client.ts index 4c0440a8f..4fad7bddb 100644 --- a/packages/sdk-multichain/src/utis/rpc/client.ts +++ b/packages/sdk-multichain/src/utis/rpc/client.ts @@ -3,142 +3,118 @@ import fetch from "cross-fetch"; import type { InvokeMethodParams, MultichainApiClient } from "@metamask/multichain-api-client"; import { - type InvokeMethodOptions, - type MultichainSDKConstructor, - type RPC_URLS_MAP, - type RPCAPI, - type RPCResponse, - type Scope, - METHODS_TO_REDIRECT, - infuraRpcUrls, - RPCReadonlyResponseErr, - RPCHttpErr, - RPCReadonlyRequestErr, - RPCInvokeMethodErr, - getInfuraRpcUrls -} from '../../domain' - + type InvokeMethodOptions, + type MultichainSDKConstructor, + type RPC_URLS_MAP, + type RPCAPI, + type RPCResponse, + type Scope, + METHODS_TO_REDIRECT, + RPCReadonlyResponseErr, + RPCHttpErr, + RPCReadonlyRequestErr, + RPCInvokeMethodErr, + getInfuraRpcUrls, +} from "../../domain"; let rpcId = 1; export function getNextRpcId() { - rpcId += 1; - return rpcId; + rpcId += 1; + return rpcId; } export class RPCClient { - constructor( - private readonly provider: MultichainApiClient, - private readonly config: MultichainSDKConstructor['api'], - private readonly sdkInfo: string) { - } + constructor( + private readonly provider: MultichainApiClient, + private readonly config: MultichainSDKConstructor["api"], + private readonly sdkInfo: string, + ) {} - private async fetch( - endpoint: string, - body: string, - method: string, - headers: Record - ) { - try { - const response = await fetch(endpoint, { - method, - headers, - body, - }); - if (!response.ok) { - throw new RPCHttpErr( - endpoint, - method, - response.status - ) - } - return response; - } catch (error) { - if (error instanceof RPCHttpErr) { - throw error; - } - throw new RPCReadonlyRequestErr(error.message) - } - } + private async fetch(endpoint: string, body: string, method: string, headers: Record) { + try { + const response = await fetch(endpoint, { + method, + headers, + body, + }); + if (!response.ok) { + throw new RPCHttpErr(endpoint, method, response.status); + } + return response; + } catch (error) { + if (error instanceof RPCHttpErr) { + throw error; + } + throw new RPCReadonlyRequestErr(error.message); + } + } - private async parseResponse(response: Response) { - try { - const rpcResponse = await response.json() as RPCResponse; - return rpcResponse.result as Json; - } catch (error) { - throw new RPCReadonlyResponseErr(error.message) - } - } + private async parseResponse(response: Response) { + try { + const rpcResponse = (await response.json()) as RPCResponse; + return rpcResponse.result as Json; + } catch (error) { + throw new RPCReadonlyResponseErr(error.message); + } + } - private getHeaders(rpcEndpoint: string) { - const defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', - } - if (rpcEndpoint.includes('infura')) { - return { - ...defaultHeaders, - 'Metamask-Sdk-Info': this.sdkInfo, - } - } - return defaultHeaders; - } + private getHeaders(rpcEndpoint: string) { + const defaultHeaders = { + Accept: "application/json", + "Content-Type": "application/json", + }; + if (rpcEndpoint.includes("infura")) { + return { + ...defaultHeaders, + "Metamask-Sdk-Info": this.sdkInfo, + }; + } + return defaultHeaders; + } - private async runReadOnlyMethod( - options: InvokeMethodOptions, - rpcEndpoint: string - ) { - const {request} = options; - const body = JSON.stringify({ - jsonrpc: '2.0', - method: request.method, - params: request.params, - id: getNextRpcId(), - }); - const rpcRequest = await this.fetch( - rpcEndpoint, - body, - "POST", - this.getHeaders(rpcEndpoint) - ); - const response = await this.parseResponse(rpcRequest); - return response - } + private async runReadOnlyMethod(options: InvokeMethodOptions, rpcEndpoint: string) { + const { request } = options; + const body = JSON.stringify({ + jsonrpc: "2.0", + method: request.method, + params: request.params, + id: getNextRpcId(), + }); + const rpcRequest = await this.fetch(rpcEndpoint, body, "POST", this.getHeaders(rpcEndpoint)); + const response = await this.parseResponse(rpcRequest); + return response; + } - async invokeMethod(options: InvokeMethodOptions) { - const { request } = options; - const { config } = this; - const { infuraAPIKey, readonlyRPCMap: readonlyRPCMapConfig } = config ?? {}; - let readonlyRPCMap: RPC_URLS_MAP = {}; - if (infuraAPIKey) { - const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); - if (readonlyRPCMapConfig) { - readonlyRPCMap = { - ...readonlyRPCMapConfig, - ...urlsWithToken, - }; - } else { - readonlyRPCMap = urlsWithToken; - } - } else { - readonlyRPCMap = readonlyRPCMapConfig ?? {}; - } - const rpcEndpoint = readonlyRPCMap[options.scope]; - const isReadOnlyMethod = !METHODS_TO_REDIRECT[request.method]; - if (rpcEndpoint && isReadOnlyMethod) { - const response = await this.runReadOnlyMethod( - options, - rpcEndpoint - ); - return response; - } + async invokeMethod(options: InvokeMethodOptions) { + const { request } = options; + const { config } = this; + const { infuraAPIKey, readonlyRPCMap: readonlyRPCMapConfig } = config ?? {}; + let readonlyRPCMap: RPC_URLS_MAP = {}; + if (infuraAPIKey) { + const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); + if (readonlyRPCMapConfig) { + readonlyRPCMap = { + ...readonlyRPCMapConfig, + ...urlsWithToken, + }; + } else { + readonlyRPCMap = urlsWithToken; + } + } else { + readonlyRPCMap = readonlyRPCMapConfig ?? {}; + } + const rpcEndpoint = readonlyRPCMap[options.scope]; + const isReadOnlyMethod = !METHODS_TO_REDIRECT[request.method]; + if (rpcEndpoint && isReadOnlyMethod) { + const response = await this.runReadOnlyMethod(options, rpcEndpoint); + return response; + } try { - const response = await this.provider.invokeMethod( - options as InvokeMethodParams, - ); - return response - } catch (error) { - throw new RPCInvokeMethodErr(error.message) - } - } + const response = await this.provider.invokeMethod(options as InvokeMethodParams); + return response; + } catch (error) { + throw new RPCInvokeMethodErr(error.message); + } + } }