diff --git a/packages/sdk-multichain/README.md b/packages/sdk-multichain/README.md index f2bee92db..aaf539148 100644 --- a/packages/sdk-multichain/README.md +++ b/packages/sdk-multichain/README.md @@ -19,4 +19,6 @@ src/ │ ├── events/ # Event-driven communication │ ├── platform/ # Platform detection utilities │ └── store/ # Storage abstractions +├── multichain/ # Concrete implementations +└── store/ # Storage implementations ``` diff --git a/packages/sdk-multichain/package.json b/packages/sdk-multichain/package.json index 395923983..b8a38e86c 100644 --- a/packages/sdk-multichain/package.json +++ b/packages/sdk-multichain/package.json @@ -34,6 +34,7 @@ "@react-native-async-storage/async-storage": "^1.19.6", "@vitest/coverage-v8": "^3.2.4", "esbuild-plugin-umd-wrapper": "^3.0.0", + "jsdom": "^26.1.0", "nock": "^14.0.4", "prettier": "^3.3.3", "tsup": "^8.5.0", diff --git a/packages/sdk-multichain/src/domain/errors/base.ts b/packages/sdk-multichain/src/domain/errors/base.ts new file mode 100644 index 000000000..fac7a6622 --- /dev/null +++ b/packages/sdk-multichain/src/domain/errors/base.ts @@ -0,0 +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); + } +} diff --git a/packages/sdk-multichain/src/domain/errors/index.ts b/packages/sdk-multichain/src/domain/errors/index.ts new file mode 100644 index 000000000..514b0ecde --- /dev/null +++ b/packages/sdk-multichain/src/domain/errors/index.ts @@ -0,0 +1,2 @@ +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 new file mode 100644 index 000000000..8f46c6bc8 --- /dev/null +++ b/packages/sdk-multichain/src/domain/errors/rpc.ts @@ -0,0 +1,53 @@ + +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 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 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 new file mode 100644 index 000000000..05fd8c6f7 --- /dev/null +++ b/packages/sdk-multichain/src/domain/errors/storage.ts @@ -0,0 +1,45 @@ + +import { BaseErr } from "./base"; +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 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 + ); + } +} diff --git a/packages/sdk-multichain/src/domain/errors/types.ts b/packages/sdk-multichain/src/domain/errors/types.ts new file mode 100644 index 000000000..27f1c2736 --- /dev/null +++ b/packages/sdk-multichain/src/domain/errors/types.ts @@ -0,0 +1,14 @@ +export type Enumerate = Acc['length'] extends N +? Acc[number] +: Enumerate; + +export type ErrorCodeRange = Exclude, Enumerate>; + +export type DomainErrorCodes = ErrorCodeRange<1, 50>; +export type RPCErrorCodes = ErrorCodeRange<50, 60>; +export type StorageErrorCodes = ErrorCodeRange<60, 70>; + +export type ErrorCodes = + | DomainErrorCodes + | RPCErrorCodes + | StorageErrorCodes; diff --git a/packages/sdk-multichain/src/domain/index.test.ts b/packages/sdk-multichain/src/domain/index.test.ts index a23f139b6..f31baad84 100644 --- a/packages/sdk-multichain/src/domain/index.test.ts +++ b/packages/sdk-multichain/src/domain/index.test.ts @@ -3,9 +3,7 @@ import * as t from 'vitest' import Bowser from 'bowser'; -import { EventEmitter,ExtensionEvents, SDKEvents, getPlatformType, PlatformType, createLogger, enableDebug, isEnabled } from './'; - - +import { EventEmitter,type ExtensionEvents, type SDKEvents, getPlatformType, PlatformType, createLogger, enableDebug, isEnabled } from './'; const parseMock = t.vi.fn(); diff --git a/packages/sdk-multichain/src/domain/index.ts b/packages/sdk-multichain/src/domain/index.ts index 7fbea409d..2b86e96e0 100644 --- a/packages/sdk-multichain/src/domain/index.ts +++ b/packages/sdk-multichain/src/domain/index.ts @@ -1,3 +1,4 @@ +export * from './errors'; export * from './platform'; export * from './multichain'; export * from './events'; diff --git a/packages/sdk-multichain/src/domain/multichain/api/constants.ts b/packages/sdk-multichain/src/domain/multichain/api/constants.ts index 03689bfb5..9236c87db 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/constants.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/constants.ts @@ -1,5 +1,5 @@ /* c8 ignore start */ -import { RPC_URLS_MAP } from "./types"; +import type { RPC_URLS_MAP } from "./types"; export const infuraRpcUrls: RPC_URLS_MAP = { // ###### Ethereum ###### diff --git a/packages/sdk-multichain/src/domain/multichain/api/infura.ts b/packages/sdk-multichain/src/domain/multichain/api/infura.ts new file mode 100644 index 000000000..185ed668f --- /dev/null +++ b/packages/sdk-multichain/src/domain/multichain/api/infura.ts @@ -0,0 +1,10 @@ +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) +} diff --git a/packages/sdk-multichain/src/domain/multichain/api/types.ts b/packages/sdk-multichain/src/domain/multichain/api/types.ts index b0383db79..c03334f0e 100644 --- a/packages/sdk-multichain/src/domain/multichain/api/types.ts +++ b/packages/sdk-multichain/src/domain/multichain/api/types.ts @@ -1,4 +1,4 @@ -import { Json } from "@metamask/utils"; +import type { Json } from "@metamask/utils"; import type EIP155 from "./eip155"; /** @@ -41,22 +41,6 @@ export type RPCAPI = { eip155: EIP155; }; -/** - * Represents a notification object for RPC communication. - * - * Notifications are one-way messages sent from the server to the client - * that don't require a response. They follow the JSON-RPC 2.0 specification - * for notification messages. - */ -export type Notification = { - /** The name of the method being called */ - method: string; - /** Optional parameters for the method call */ - params?: Json; - /** JSON-RPC version identifier (typically "2.0") */ - jsonrpc?: string; -}; - /** * Callback function type for handling incoming notifications. * @@ -65,7 +49,7 @@ export type Notification = { * * @param notification - The notification object to handle */ -export type NotificationCallback = (notification: Notification) => void; +export type NotificationCallback = (notification: unknown) => void; /** * Options for invoking RPC methods with specific scope and request parameters. @@ -97,4 +81,17 @@ export type RPC_URLS_MAP = { [chainId: `${string}:${string}`]: string; }; - +/** + * Represents the structure of a JSON-RPC response. + * + * This type defines the expected format of JSON-RPC responses from RPC endpoints. + * It includes the unique identifier for the request, the JSON-RPC version used, + * and the result of the RPC call. + * + * @property id - The unique identifier for the request + * @property jsonrpc - The JSON-RPC version used + * @property result - The result of the RPC call JSON + */ +export type RPCResponse = { + 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 7b4481f8a..a16c55c97 100644 --- a/packages/sdk-multichain/src/domain/multichain/index.ts +++ b/packages/sdk-multichain/src/domain/multichain/index.ts @@ -11,9 +11,10 @@ import type { InvokeMethodOptions, NotificationCallback, RPC_URLS_MAP, Scope } f * - Using a regular icon URL * - Using a base64-encoded icon */ -export type DappSettings = - | { name?: string; url?: string; iconUrl?: string } - | { name?: string; url?: string; base64Icon?: string }; +export type DappSettings = { + name?: string; + url?: string; +} & ({ iconUrl?: string } | { base64Icon?: string }); /** * Constructor options for creating a Multichain SDK instance. @@ -53,12 +54,18 @@ export type MultichainSDKConstructor = { */ /* c8 ignore start */ export abstract class MultichainSDKBase { + public abstract isInitialized: boolean; + public abstract session: SessionData | undefined; + /** - * Establishes a connection to the multichain provider. + * Establishes a connection to the multichain provider, or re-use existing session * - * @returns Promise that resolves to true if connection is successful, false otherwise + * @returns Promise that resolves to the session data */ - abstract connect(): Promise; + abstract connect( + scopes: Scope[], + caipAccountIds: CaipAccountId[], + ): Promise; /** * Disconnects from the multichain provider. @@ -66,26 +73,6 @@ export abstract class MultichainSDKBase { * @returns Promise that resolves when disconnection is complete */ abstract disconnect(): Promise; - - /** - * Retrieves the current session data. - * - * @returns Promise that resolves to the current session data, or undefined if no session exists - */ - abstract getSession(): Promise; - - /** - * Creates a new session with the specified scopes and account IDs. - * - * @param scopes - Array of blockchain scopes to request access to - * @param caipAccountIds - Array of CAIP account IDs to associate with the session - * @returns Promise that resolves to the created session data - */ - abstract createSession( - scopes: Scope[], - caipAccountIds: CaipAccountId[], - ): Promise; - /** * Revokes the current session. * @@ -143,3 +130,5 @@ export type MultichainSDKOptions = MultichainSDKBaseOptions & { export type CreateMultichainFN = ( options: MultichainSDKBaseOptions ) => Promise; export type * from './api/types'; +export * from './api/constants'; +export * from './api/infura'; diff --git a/packages/sdk-multichain/src/domain/store/adapter.ts b/packages/sdk-multichain/src/domain/store/adapter.ts index 304a8602b..4c5a4d71f 100644 --- a/packages/sdk-multichain/src/domain/store/adapter.ts +++ b/packages/sdk-multichain/src/domain/store/adapter.ts @@ -1,7 +1,10 @@ /* c8 ignore start */ export type StoreOptions = Record; + + export abstract class StoreAdapter { + abstract platform: 'web' | 'rn' | 'node' constructor(public options?: StoreOptions) {} abstract getItem(key: string): Promise; diff --git a/packages/sdk-multichain/src/globals.d.ts b/packages/sdk-multichain/src/globals.d.ts index e83dab810..3fa8c5882 100644 --- a/packages/sdk-multichain/src/globals.d.ts +++ b/packages/sdk-multichain/src/globals.d.ts @@ -7,5 +7,12 @@ declare global { * 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 00523e58c..53c5100d8 100644 --- a/packages/sdk-multichain/src/index.browser.ts +++ b/packages/sdk-multichain/src/index.browser.ts @@ -1,2 +1,17 @@ +import type { CreateMultichainFN } from './domain'; +import { MultichainSDK } from './multichain'; +import { Store } from './store'; + 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, + }, + }); +} diff --git a/packages/sdk-multichain/src/index.native.ts b/packages/sdk-multichain/src/index.native.ts index 00523e58c..d483b18b4 100644 --- a/packages/sdk-multichain/src/index.native.ts +++ b/packages/sdk-multichain/src/index.native.ts @@ -1,2 +1,17 @@ +import type { CreateMultichainFN } from './domain'; +import { MultichainSDK } from './multichain'; +import { Store } from './store'; + 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 + }, + }); +} diff --git a/packages/sdk-multichain/src/index.node.ts b/packages/sdk-multichain/src/index.node.ts index 00523e58c..e8b0a2b1e 100644 --- a/packages/sdk-multichain/src/index.node.ts +++ b/packages/sdk-multichain/src/index.node.ts @@ -1,2 +1,17 @@ +import type { CreateMultichainFN } from './domain'; +import { MultichainSDK } from './multichain'; +import { Store } from './store'; + 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 + }, + }); +} diff --git a/packages/sdk-multichain/src/index.test.ts b/packages/sdk-multichain/src/index.test.ts new file mode 100644 index 000000000..1caed2bca --- /dev/null +++ b/packages/sdk-multichain/src/index.test.ts @@ -0,0 +1,829 @@ +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>; +} + +// 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('./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 +) { + + 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.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; + + // 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) + }; + + // Replace the rpcClient in the sdk instance + (sdk as any).rpcClient = mockRpcClient; + + const options = { + scope: 'eip155:1', + method: 'eth_accounts', + params: [], + } as any; + + 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'); + }); + }); + + }); +} + +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 new file mode 100644 index 000000000..ca72bac72 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -0,0 +1,226 @@ +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 { 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 { 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[] } +>; + +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); + } +} diff --git a/packages/sdk-multichain/src/store/adapters/node.ts b/packages/sdk-multichain/src/store/adapters/node.ts index b76e5ce0a..89efd36d5 100644 --- a/packages/sdk-multichain/src/store/adapters/node.ts +++ b/packages/sdk-multichain/src/store/adapters/node.ts @@ -5,7 +5,7 @@ import path from 'path'; const CONFIG_FILE = path.resolve(process.cwd(), '.metamask.json'); export class StoreAdapterNode extends StoreAdapter { - + readonly platform = 'node' private safeParse(contents: string): Record { try { diff --git a/packages/sdk-multichain/src/store/adapters/rn.ts b/packages/sdk-multichain/src/store/adapters/rn.ts index f8771769e..13283e8a6 100644 --- a/packages/sdk-multichain/src/store/adapters/rn.ts +++ b/packages/sdk-multichain/src/store/adapters/rn.ts @@ -2,6 +2,7 @@ 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); } diff --git a/packages/sdk-multichain/src/store/adapters/web.ts b/packages/sdk-multichain/src/store/adapters/web.ts index 1380a6de1..e9cfbcd7c 100644 --- a/packages/sdk-multichain/src/store/adapters/web.ts +++ b/packages/sdk-multichain/src/store/adapters/web.ts @@ -1,6 +1,8 @@ import { StoreAdapter } from "../../domain"; export class StoreAdapterWeb extends StoreAdapter { + readonly platform = 'web' + private get internal() { if (typeof window === 'undefined' || !window.localStorage) { throw new Error('localStorage is not available in this environment'); @@ -12,10 +14,10 @@ export class StoreAdapterWeb extends StoreAdapter { } async setItem(key: string, value: string): Promise { - this.internal.setItem(key, value); + return this.internal.setItem(key, value); } async deleteItem(key: string): Promise { - this.internal.removeItem(key); + 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 0cc4dc739..6c8d7654a 100644 --- a/packages/sdk-multichain/src/store/index.test.ts +++ b/packages/sdk-multichain/src/store/index.test.ts @@ -3,13 +3,12 @@ import fs from 'fs' import path from 'path'; import AsyncStorage from '@react-native-async-storage/async-storage'; - - import { Store } from './index'; -import { StoreAdapter } from '../domain'; +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 @@ -133,6 +132,32 @@ function createStoreTests( 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); + }); + }); } diff --git a/packages/sdk-multichain/src/store/index.ts b/packages/sdk-multichain/src/store/index.ts index 333ad6a9d..977d3c3a4 100644 --- a/packages/sdk-multichain/src/store/index.ts +++ b/packages/sdk-multichain/src/store/index.ts @@ -1,3 +1,4 @@ +import { StorageDeleteErr, StorageGetErr, StorageSetErr } from "../domain/errors/storage"; import type {StoreClient, StoreAdapter } from "../domain"; @@ -9,30 +10,86 @@ export class Store implements StoreClient { } async getAnonId(): Promise { - return this.#adapter.getItem('anonId'); + try { + return await this.#adapter.getItem('anonId'); + } catch (err) { + throw new StorageGetErr( + this.#adapter.platform, + 'anonId', + err.message + ); + } } async getExtensionId(): Promise { - return this.#adapter.getItem('extensionId'); + try { + return await this.#adapter.getItem('extensionId'); + } catch (err) { + throw new StorageGetErr( + this.#adapter.platform, + 'extensionId', + err.message + ); + } } async setAnonId(anonId: string): Promise { - return this.#adapter.setItem('anonId', anonId); + try { + return await this.#adapter.setItem('anonId', anonId); + } catch (err) { + throw new StorageSetErr( + this.#adapter.platform, + 'anonId', + err.message + ); + } } async setExtensionId(extensionId: string): Promise { - return this.#adapter.setItem('extensionId', extensionId); + try { + return await this.#adapter.setItem('extensionId', extensionId); + } catch (err) { + throw new StorageSetErr( + this.#adapter.platform, + 'extensionId', + err.message + ); + } } async removeExtensionId(): Promise { - return this.#adapter.deleteItem('extensionId'); + try { + return await this.#adapter.deleteItem('extensionId'); + } catch (err) { + throw new StorageDeleteErr( + this.#adapter.platform, + 'extensionId', + err.message + ); + } } async removeAnonId(): Promise { - return this.#adapter.deleteItem('anonId'); + try { + return await this.#adapter.deleteItem('anonId'); + } catch (err) { + throw new StorageDeleteErr( + this.#adapter.platform, + 'anonId', + err.message + ); + } } async getDebug(): Promise { - return this.#adapter.getItem('DEBUG'); + 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 new file mode 100644 index 000000000..8b4fad650 --- /dev/null +++ b/packages/sdk-multichain/src/utis/base64/index.test.ts @@ -0,0 +1,58 @@ +import * as t from 'vitest'; + +import { base64Encode } from '.'; + +t.describe('base64Encode', () => { + t.it('should encode an empty string', () => { + t.expect(base64Encode('')).toBe(''); + }); + + t.it('should encode a simple ASCII string', () => { + t.expect(base64Encode('Hello, World!')).toBe('SGVsbG8sIFdvcmxkIQ=='); + }); + + t.it('should encode a string with special characters', () => { + t.expect(base64Encode('!@#$%^&*()_+')).toBe('IUAjJCVeJiooKV8r'); + }); + + t.it('should encode a Unicode string', () => { + t.expect(base64Encode('こんにちは')).toBe('44GT44KT44Gr44Gh44Gv'); + }); + + t.it('should encode a mixed ASCII and Unicode string', () => { + t.expect(base64Encode('Hello, 世界!')).toBe('SGVsbG8sIOS4lueVjCE='); + }); + + t.it('should encode using global btoa object if buffer is undefined or not available', () => { + const originalBuffer = global.Buffer; + global.Buffer = undefined as any; + + const btoa = t.vi.spyOn(global, 'btoa'); + btoa.mockImplementation((str) => 'base64encoded'); + + t.expect(base64Encode('Hello, World!')).toBe('base64encoded'); + + // Restore Buffer + global.Buffer = originalBuffer; + btoa.mockRestore(); + }); + + + t.it('should encode a long string', () => { + const longString = 'a'.repeat(1000); + const encodedString = base64Encode(longString); + + // Check the length (it should be consistent) + t.expect(encodedString).toHaveLength(1336); + + // Check the start and end of the string + t.expect(encodedString.startsWith('YWFhYWFhYWFh')).toBe(true); + t.expect(encodedString.endsWith('YWFhYWFhYWFhYQ==')).toBe(true); + + // Check that it only contains valid base64 characters + t.expect(encodedString).toMatch(/^[A-Za-z0-9+/]+=*$/u); + + // Optionally, you can check the number of 'Y' characters, which should be consistent + t.expect(encodedString.match(/Y/gu)?.length).toBe(334); + }); +}); diff --git a/packages/sdk-multichain/src/utis/base64/index.ts b/packages/sdk-multichain/src/utis/base64/index.ts new file mode 100644 index 000000000..b72fc7f6b --- /dev/null +++ b/packages/sdk-multichain/src/utis/base64/index.ts @@ -0,0 +1,35 @@ +/** + * Base64 encode string for URL params + */ +export function base64Encode(str: string): string { + let base64string: string; + + if (typeof Buffer !== 'undefined') { + base64string = Buffer.from(str, 'utf8').toString('base64'); + } else if (typeof btoa === 'function') { + base64string = btoa( + encodeURIComponent(str).replace(/%([0-9A-F]{2})/gu, (_match, p1) => + String.fromCharCode(parseInt(p1, 16)), + ), + ); + } else if (typeof global === 'object' && 'Buffer' in global) { + base64string = global.Buffer.from(str, 'utf8').toString('base64'); + } else { + throw new Error('Unable to base64 encode: No available method.'); + } + return base64string; +} + + +export const getBase64FromUrl = async (url: string): Promise => { + const data = await fetch(url); + const blob = await data.blob(); + return new Promise((resolve) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => { + const base64data = reader.result as string; + resolve(base64data); + }; + }); +}; diff --git a/packages/sdk-multichain/src/utis/index.test.ts b/packages/sdk-multichain/src/utis/index.test.ts new file mode 100644 index 000000000..8d31cb0d2 --- /dev/null +++ b/packages/sdk-multichain/src/utis/index.test.ts @@ -0,0 +1,349 @@ +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); + }); + + }); + + +}) diff --git a/packages/sdk-multichain/src/utis/index.ts b/packages/sdk-multichain/src/utis/index.ts new file mode 100644 index 000000000..5c3ad2ce7 --- /dev/null +++ b/packages/sdk-multichain/src/utis/index.ts @@ -0,0 +1,152 @@ +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; +} + +export function getDappId(dapp?: DappSettings) { + if ( + typeof window === 'undefined' || + typeof window.location === 'undefined' + ) { + return ( + dapp?.name ?? + dapp?.url ?? + 'N/A' + ); + } + + 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; +} + + +export const extractFavicon = () => { + 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; +}; + +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 +} + +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', + ); + + 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') { + 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 new file mode 100644 index 000000000..89b177c04 --- /dev/null +++ b/packages/sdk-multichain/src/utis/rpc/client.test.ts @@ -0,0 +1,307 @@ +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'; + + +// Mock cross-fetch with proper implementation +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, + }; +}); + + +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 new file mode 100644 index 000000000..4c0440a8f --- /dev/null +++ b/packages/sdk-multichain/src/utis/rpc/client.ts @@ -0,0 +1,144 @@ +import type { Json } from "@metamask/utils"; +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' + + +let rpcId = 1; + +export function getNextRpcId() { + rpcId += 1; + return rpcId; +} + +export class RPCClient { + 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 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 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; + } + try { + const response = await this.provider.invokeMethod( + options as InvokeMethodParams, + ); + return response + } catch (error) { + throw new RPCInvokeMethodErr(error.message) + } + } +} diff --git a/packages/sdk-multichain/tsconfig.json b/packages/sdk-multichain/tsconfig.json index 09ffe9b0c..29ca1ad9b 100644 --- a/packages/sdk-multichain/tsconfig.json +++ b/packages/sdk-multichain/tsconfig.json @@ -11,6 +11,7 @@ "module": "ES2020", "moduleResolution": "bundler", "resolveJsonModule": true, + "useUnknownInCatchVariables": false, "incremental": false, "lib": ["DOM", "ES2016"], "skipLibCheck": true, diff --git a/yarn.lock b/yarn.lock index b079b25e8..b93b74871 100644 --- a/yarn.lock +++ b/yarn.lock @@ -92,6 +92,19 @@ __metadata: languageName: node linkType: hard +"@asamuzakjp/css-color@npm:^3.2.0": + version: 3.2.0 + resolution: "@asamuzakjp/css-color@npm:3.2.0" + dependencies: + "@csstools/css-calc": ^2.1.3 + "@csstools/css-color-parser": ^3.0.9 + "@csstools/css-parser-algorithms": ^3.0.4 + "@csstools/css-tokenizer": ^3.0.3 + lru-cache: ^10.4.3 + checksum: e253261700fff817af23d8903e58c6a8ccf1aacc13059eb68fe0744e9084f3912869944715cdbe40dd09a1f3406d9b313a5cf1e08c7584d2339aa7a17209802d + languageName: node + linkType: hard + "@aw-web-design/x-default-browser@npm:1.4.126": version: 1.4.126 resolution: "@aw-web-design/x-default-browser@npm:1.4.126" @@ -6502,6 +6515,52 @@ __metadata: languageName: node linkType: hard +"@csstools/color-helpers@npm:^5.0.2": + version: 5.0.2 + resolution: "@csstools/color-helpers@npm:5.0.2" + checksum: 76753f9823579af959630be5f7682e1abe5ae13b75621532927cfc1ff601cc1e31b78547fe387699980820bb7353e20e8cab258fab590aac9d19aa44984283d5 + languageName: node + linkType: hard + +"@csstools/css-calc@npm:^2.1.3, @csstools/css-calc@npm:^2.1.4": + version: 2.1.4 + resolution: "@csstools/css-calc@npm:2.1.4" + peerDependencies: + "@csstools/css-parser-algorithms": ^3.0.5 + "@csstools/css-tokenizer": ^3.0.4 + checksum: b833d1a031dfb3e3268655aa384121b864fce9bad05f111a3cf2a343eed69ba5d723f3f7cd0793fd7b7a28de2f8141f94568828f48de41d86cefa452eee06390 + languageName: node + linkType: hard + +"@csstools/css-color-parser@npm:^3.0.9": + version: 3.0.10 + resolution: "@csstools/css-color-parser@npm:3.0.10" + dependencies: + "@csstools/color-helpers": ^5.0.2 + "@csstools/css-calc": ^2.1.4 + peerDependencies: + "@csstools/css-parser-algorithms": ^3.0.5 + "@csstools/css-tokenizer": ^3.0.4 + checksum: 53741dd054b5347c1c5fc51efdff336f9ac4398ef9402603eabd95cf046e8a7c1eae67dfe2497af77b6bfae3dcd5f5ae23aaa37e7d6329210e1768a9c8e8fc90 + languageName: node + linkType: hard + +"@csstools/css-parser-algorithms@npm:^3.0.4": + version: 3.0.5 + resolution: "@csstools/css-parser-algorithms@npm:3.0.5" + peerDependencies: + "@csstools/css-tokenizer": ^3.0.4 + checksum: 80647139574431071e4664ad3c3e141deef4368f0ca536a63b3872487db68cf0d908fb76000f967deb1866963a90e6357fc6b9b00fdfa032f3321cebfcc66cd7 + languageName: node + linkType: hard + +"@csstools/css-tokenizer@npm:^3.0.3": + version: 3.0.4 + resolution: "@csstools/css-tokenizer@npm:3.0.4" + checksum: adc6681d3a0d7a75dc8e5ee0488c99ad4509e4810ae45dd6549a2e64a996e8d75512e70bb244778dc0c6ee85723e20eaeea8c083bf65b51eb19034e182554243 + languageName: node + linkType: hard + "@csstools/normalize.css@npm:*": version: 12.0.0 resolution: "@csstools/normalize.css@npm:12.0.0" @@ -11170,6 +11229,7 @@ __metadata: cross-fetch: ^4.1.0 esbuild-plugin-umd-wrapper: ^3.0.0 eventemitter2: ^6.4.9 + jsdom: ^26.1.0 nock: ^14.0.4 prettier: ^3.3.3 tsup: ^8.5.0 @@ -22571,6 +22631,13 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 86a7f542af277cfbd77dd61e7df8422f90bac512953709003a1c530171a9d019d072e2400eab2b59f84b49ab9dd237be44315ca663ac73e82b3922d10ea5eafa + languageName: node + linkType: hard + "agentkeepalive@npm:^4.2.1": version: 4.3.0 resolution: "agentkeepalive@npm:4.3.0" @@ -27136,6 +27203,16 @@ __metadata: languageName: node linkType: hard +"cssstyle@npm:^4.2.1": + version: 4.6.0 + resolution: "cssstyle@npm:4.6.0" + dependencies: + "@asamuzakjp/css-color": ^3.2.0 + rrweb-cssom: ^0.8.0 + checksum: 0bdb1229e9f5a78ec73d0153299bc2b58f9c995124412beedcb2409bce4a1231e371946f61a8c04bdfa6b36f2ffb48d5f2c85738986662ed6722426f43937dc7 + languageName: node + linkType: hard + "csstype@npm:^3.0.2": version: 3.1.2 resolution: "csstype@npm:3.1.2" @@ -27203,6 +27280,16 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^5.0.0": + version: 5.0.0 + resolution: "data-urls@npm:5.0.0" + dependencies: + whatwg-mimetype: ^4.0.0 + whatwg-url: ^14.0.0 + checksum: 5c40568c31b02641a70204ff233bc4e42d33717485d074244a98661e5f2a1e80e38fe05a5755dfaf2ee549f2ab509d6a3af2a85f4b2ad2c984e5d176695eaf46 + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.1": version: 1.0.1 resolution: "data-view-buffer@npm:1.0.1" @@ -27375,6 +27462,13 @@ __metadata: languageName: node linkType: hard +"decimal.js@npm:^10.5.0": + version: 10.6.0 + resolution: "decimal.js@npm:10.6.0" + checksum: 9302b990cd6f4da1c7602200002e40e15d15660374432963421d3cd6d81cc6e27e0a488356b030fee64650947e32e78bdbea245d596dadfeeeb02e146d485999 + languageName: node + linkType: hard + "decode-uri-component@npm:^0.2.0, decode-uri-component@npm:^0.2.2": version: 0.2.2 resolution: "decode-uri-component@npm:0.2.2" @@ -28818,6 +28912,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 937b952e81aca641660a6a07f70001c6821973dea3ae7f6a5013eadce94620f3ed2e9c745832d503c8811ce6e97704d8a0396159580c0e567d815234de7fdecf + languageName: node + linkType: hard + "env-editor@npm:^0.4.1": version: 0.4.2 resolution: "env-editor@npm:0.4.2" @@ -34001,6 +34102,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^4.0.0": + version: 4.0.0 + resolution: "html-encoding-sniffer@npm:4.0.0" + dependencies: + whatwg-encoding: ^3.1.1 + checksum: 3339b71dab2723f3159a56acf541ae90a408ce2d11169f00fe7e0c4663d31d6398c8a4408b504b4eec157444e47b084df09b3cb039c816660f0dd04846b8957d + languageName: node + linkType: hard + "html-entities@npm:^2.1.0, html-entities@npm:^2.3.2": version: 2.4.0 resolution: "html-entities@npm:2.4.0" @@ -34155,7 +34265,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1": +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.1, http-proxy-agent@npm:^7.0.2": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -34248,6 +34358,16 @@ __metadata: languageName: node linkType: hard +"https-proxy-agent@npm:^7.0.6": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: ^7.1.2 + debug: 4 + checksum: b882377a120aa0544846172e5db021fa8afbf83fea2a897d397bd2ddd8095ab268c24bc462f40a15f2a8c600bf4aa05ce52927f70038d4014e68aefecfa94e8d + languageName: node + linkType: hard + "human-signals@npm:^2.1.0": version: 2.1.0 resolution: "human-signals@npm:2.1.0" @@ -38178,6 +38298,39 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^26.1.0": + version: 26.1.0 + resolution: "jsdom@npm:26.1.0" + dependencies: + cssstyle: ^4.2.1 + data-urls: ^5.0.0 + decimal.js: ^10.5.0 + html-encoding-sniffer: ^4.0.0 + http-proxy-agent: ^7.0.2 + https-proxy-agent: ^7.0.6 + is-potential-custom-element-name: ^1.0.1 + nwsapi: ^2.2.16 + parse5: ^7.2.1 + rrweb-cssom: ^0.8.0 + saxes: ^6.0.0 + symbol-tree: ^3.2.4 + tough-cookie: ^5.1.1 + w3c-xmlserializer: ^5.0.0 + webidl-conversions: ^7.0.0 + whatwg-encoding: ^3.1.1 + whatwg-mimetype: ^4.0.0 + whatwg-url: ^14.1.1 + ws: ^8.18.0 + xml-name-validator: ^5.0.0 + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 248e500a872b70bfba3fdbd01a13890ab520bfe42912bb85cb99e7f2eda375d80aa4adfcbd5c4716b6e35e93c2c72b127b8e74527a598c5b6d8e62e05f29eb9b + languageName: node + linkType: hard + "jsesc@npm:^2.5.1": version: 2.5.2 resolution: "jsesc@npm:2.5.2" @@ -39234,7 +39387,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^10.2.0": +"lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" checksum: 6476138d2125387a6d20f100608c2583d415a4f64a0fecf30c9e2dda976614f09cad4baa0842447bd37dd459a7bd27f57d9d8f8ce558805abd487c583f3d774a @@ -42191,6 +42344,13 @@ __metadata: languageName: node linkType: hard +"nwsapi@npm:^2.2.16": + version: 2.2.20 + resolution: "nwsapi@npm:2.2.20" + checksum: 37100d6023b278d85fc6893fb9f8c13172ced31f6cfd1de8d67d15229526ab51991dfd6b863163a9df684d339a359abe9d34b953676c68c062e2f12dcd39ac47 + languageName: node + linkType: hard + "ob1@npm:0.76.7": version: 0.76.7 resolution: "ob1@npm:0.76.7" @@ -43067,6 +43227,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.2.1": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: ^6.0.0 + checksum: ffd040c4695d93f0bc370e3d6d75c1b352178514af41be7afa212475ea5cead1d6e377cd9d4cec6a5e2bcf497ca50daf9e0088eadaa37dbc271f60def08fdfcd + languageName: node + linkType: hard + "parseurl@npm:~1.3.2, parseurl@npm:~1.3.3": version: 1.3.3 resolution: "parseurl@npm:1.3.3" @@ -45035,6 +45204,13 @@ __metadata: languageName: node linkType: hard +"punycode@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: bb0a0ceedca4c3c57a9b981b90601579058903c62be23c5e8e843d2c2d4148a3ecf029d5133486fb0e1822b098ba8bba09e89d6b21742d02fa26bda6441a6fb2 + languageName: node + linkType: hard + "puppeteer-core@npm:22.2.0": version: 22.2.0 resolution: "puppeteer-core@npm:22.2.0" @@ -47868,6 +48044,13 @@ __metadata: languageName: node linkType: hard +"rrweb-cssom@npm:^0.8.0": + version: 0.8.0 + resolution: "rrweb-cssom@npm:0.8.0" + checksum: b84912cd1fbab9c972bf3fd5ca27b492efb442cacb23b6fd5a5ef6508572a91e411d325692609bf758865bc38a01b876e343c552d0e2ae87d0ff9907d96ef622 + languageName: node + linkType: hard + "rst-selector-parser@npm:^2.2.3": version: 2.2.3 resolution: "rst-selector-parser@npm:2.2.3" @@ -51124,6 +51307,24 @@ __metadata: languageName: node linkType: hard +"tldts-core@npm:^6.1.86": + version: 6.1.86 + resolution: "tldts-core@npm:6.1.86" + checksum: 0a715457e03101deff9b34cf45dcd91b81985ef32d35b8e9c4764dcf76369bf75394304997584080bb7b8897e94e20f35f3e8240a1ec87d6faba3cc34dc5a954 + languageName: node + linkType: hard + +"tldts@npm:^6.1.32": + version: 6.1.86 + resolution: "tldts@npm:6.1.86" + dependencies: + tldts-core: ^6.1.86 + bin: + tldts: bin/cli.js + checksum: e5c57664f73663c6c8f7770db02c0c03d6f877fe837854c72037be8092826f95b8e568962358441ef18431b80b7e40ed88391c70873ee7ec0d4344999a12e3de + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33" @@ -51207,6 +51408,15 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^5.1.1": + version: 5.1.2 + resolution: "tough-cookie@npm:5.1.2" + dependencies: + tldts: ^6.1.32 + checksum: 31c626a77ac247b881665851035773afe7eeac283b91ed8da3c297ed55480ea1dd1ba3f5bb1f94b653ac2d5b184f17ce4bf1cf6ca7c58ee7c321b4323c4f8024 + languageName: node + linkType: hard + "tr46@npm:^1.0.1": version: 1.0.1 resolution: "tr46@npm:1.0.1" @@ -51234,6 +51444,15 @@ __metadata: languageName: node linkType: hard +"tr46@npm:^5.1.0": + version: 5.1.1 + resolution: "tr46@npm:5.1.1" + dependencies: + punycode: ^2.3.1 + checksum: da7a04bd3f77e641abdabe948bb84f24e6ee73e81c8c96c36fe79796c889ba97daf3dbacae778f8581ff60307a4136ee14c9540a5f85ebe44f99c6cc39a97690 + languageName: node + linkType: hard + "tr46@npm:~0.0.3": version: 0.0.3 resolution: "tr46@npm:0.0.3" @@ -53302,6 +53521,15 @@ __metadata: languageName: node linkType: hard +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: ^5.0.0 + checksum: 593acc1fdab3f3207ec39d851e6df0f3fa41a36b5809b0ace364c7a6d92e351938c53424a7618ce8e0fbaffee8be2e8e070a5734d05ee54666a8bdf1a376cc40 + languageName: node + linkType: hard + "wagmi@npm:1.4.12": version: 1.4.12 resolution: "wagmi@npm:1.4.12" @@ -54101,6 +54329,15 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^3.1.1": + version: 3.1.1 + resolution: "whatwg-encoding@npm:3.1.1" + dependencies: + iconv-lite: 0.6.3 + checksum: f75a61422421d991e4aec775645705beaf99a16a88294d68404866f65e92441698a4f5b9fa11dd609017b132d7b286c3c1534e2de5b3e800333856325b549e3c + languageName: node + linkType: hard + "whatwg-fetch@npm:^3.0.0": version: 3.6.19 resolution: "whatwg-fetch@npm:3.6.19" @@ -54129,6 +54366,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^4.0.0": + version: 4.0.0 + resolution: "whatwg-mimetype@npm:4.0.0" + checksum: f97edd4b4ee7e46a379f3fb0e745de29fe8b839307cc774300fd49059fcdd560d38cb8fe21eae5575b8f39b022f23477cc66e40b0355c2851ce84760339cef30 + languageName: node + linkType: hard + "whatwg-url-without-unicode@npm:8.0.0-3": version: 8.0.0-3 resolution: "whatwg-url-without-unicode@npm:8.0.0-3" @@ -54150,6 +54394,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.1": + version: 14.2.0 + resolution: "whatwg-url@npm:14.2.0" + dependencies: + tr46: ^5.1.0 + webidl-conversions: ^7.0.0 + checksum: c4f1ae1d353b9e56ab3c154cd73bf2b621cea1a2499fd2a9b2a17d448c2ed5e73a8922a0f395939de565fc3661461140111ae2aea26d4006a1ad0cfbf021c034 + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" @@ -54927,6 +55181,13 @@ __metadata: languageName: node linkType: hard +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 86effcc7026f437701252fcc308b877b4bc045989049cfc79b0cc112cb365cf7b009f4041fab9fb7cd1795498722c3e9fe9651afc66dfa794c16628a639a4c45 + languageName: node + linkType: hard + "xml2js@npm:0.6.0": version: 0.6.0 resolution: "xml2js@npm:0.6.0"