From e06a0cf20d6f337fcc87dfd8dcbbf1994cbf9044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Thu, 10 Jul 2025 11:01:12 +0200 Subject: [PATCH 01/13] fix: add initial implementation of the multichain building-block --- packages/sdk-multichain/README.md | 2 + packages/sdk-multichain/src/globals.d.ts | 7 + packages/sdk-multichain/src/index.browser.ts | 15 ++ packages/sdk-multichain/src/index.native.ts | 15 ++ packages/sdk-multichain/src/index.node.ts | 15 ++ .../sdk-multichain/src/multichain/index.ts | 236 ++++++++++++++++++ packages/sdk-multichain/src/utis/index.ts | 167 +++++++++++++ .../sdk-multichain/src/utis/rpc/client.ts | 81 ++++++ 8 files changed, 538 insertions(+) create mode 100644 packages/sdk-multichain/src/multichain/index.ts create mode 100644 packages/sdk-multichain/src/utis/index.ts create mode 100644 packages/sdk-multichain/src/utis/rpc/client.ts 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/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/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts new file mode 100644 index 000000000..53b007665 --- /dev/null +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -0,0 +1,236 @@ +import { + getDefaultTransport, + getMultichainClient, + type InvokeMethodParams, + type MultichainApiClient, + type SessionData, + type Transport, +} from "@metamask/multichain-api-client"; +import { + parseCaipAccountId, + parseCaipChainId, + type CaipAccountId, +} from "@metamask/utils"; +import { analytics } from '@metamask/sdk-analytics'; +import { MultichainSDKBase, type RPC_URLS_MAP } 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, getInfuraRpcUrls, 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"; +import { METHODS_TO_REDIRECT } from "src/domain/multichain/api/constants"; + +//ENFORCE NAMESPACE THAT CAN BE DISABLED +const logger = createLogger("metamask-sdk:core"); + +export class MultichainSDK extends EventEmitter implements MultichainSDKBase { + private transport!: Transport; + private provider!: MultichainApiClient; + private isInitialized : boolean = false; + private readonly options: MultichainSDKConstructor; + public readonly storage: StoreClient; + + private constructor(options: MultichainSDKConstructor) { + super(); + const transport = getDefaultTransport(options.transport); + const withInfuraRPCMethods = setupInfuraProvider(options); + const withDappMetadata = setupDappMetadata(withInfuraRPCMethods); + this.options = withDappMetadata; + this.storage = options.storage; + this.provider = getMultichainClient({ transport }); + this.transport = transport; + } + + 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; + + //Replaces old !isBrowser() && !isReactNative() + 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', {}); + } + + private async init() { + if (typeof window !== "undefined" && window.mmsdk?.isInitialized) { + logger("MetaMaskSDK: init already initialized"); + } + await this.setupAnalytics(); + this.isInitialized = true; + } + + async connect(): Promise { + // Handle mobile connection with UI + if (!this.transport.isConnected) { + await this.transport.connect(); + } + return this.transport.isConnected() + } + + async disconnect(): Promise { + this.transport.disconnect(); + } + /** + * Get the current connection QR code link (if available) + */ + getConnectionLink(): string | undefined { + // This would need to be stored when connection is initiated + // For now, return undefined - could be enhanced to store the last generated link + return undefined; + } + + onNotification(listener: NotificationCallback): () => void { + return this.provider.onNotification(listener as any); + } + + async getSession() { + return this.provider.getSession(); + } + + async revokeSession() { + return this.provider.revokeSession(); + } + + async createSession( + scopes: Scope[], + caipAccountIds: CaipAccountId[], + ): Promise { + const optionalScopes: Record< + Scope, + { methods: string[]; notifications: string[]; accounts: CaipAccountId[] } + > = {}; + + // biome-ignore lint/complexity/noForEach: + scopes.forEach((scope) => { + optionalScopes[scope] = { + methods: [], + notifications: [], + accounts: [], + }; + }); + + // biome-ignore lint/complexity/noForEach: + caipAccountIds.forEach((accountId: CaipAccountId) => { + try { + const { + chain: { namespace: accountNamespace }, + } = parseCaipAccountId(accountId); + + // biome-ignore lint/complexity/noForEach: + Object.keys(optionalScopes).forEach((scopeKey) => { + const scope = scopeKey as Scope; + const scopeDetails = parseCaipChainId(scope); + + if (scopeDetails.namespace === accountNamespace) { + const scopeData = optionalScopes[scope]; + if (scopeData) { + scopeData.accounts.push(accountId); + } + } + }); + } catch (error) { + const stringifiedAccountId = JSON.stringify(accountId); + console.error( + `Invalid CAIP account ID: ${stringifiedAccountId}`, + error, + ); + } + }); + return this.provider.createSession({ optionalScopes }); + } + + + async invokeMethod(options: InvokeMethodOptions) { + const {request} = options; + let readonlyRPCMap: RPC_URLS_MAP = {}; + const infuraAPIKey = this.options.api?.infuraAPIKey; + if (infuraAPIKey) { + const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); + if (this.options.api?.readonlyRPCMap) { + readonlyRPCMap = { + ...this.options.api.readonlyRPCMap, + ...urlsWithToken, + }; + } else { + readonlyRPCMap = urlsWithToken; + } + } + const platformType = getPlatformType(); + const rpcEndpoint = readonlyRPCMap[options.scope]; + const isReadOnlyMethod = !METHODS_TO_REDIRECT[request.method]; + const sdkInfo = `Sdk/Javascript SdkVersion/${ + packageJson.version + } Platform/${platformType} dApp/${this.options.dapp.url ?? this.options.dapp.name} dAppTitle/${ + this.options.dapp.name + }`; + + if (rpcEndpoint && isReadOnlyMethod) { + const client = new RPCClient(rpcEndpoint, sdkInfo); + const response = await client.request(request.method, request.params); + return response; + } + // TODO: Expose types on multichain api package + return this.provider.invokeMethod( + options as InvokeMethodParams, + ); + } +} diff --git a/packages/sdk-multichain/src/utis/index.ts b/packages/sdk-multichain/src/utis/index.ts new file mode 100644 index 000000000..9fc216572 --- /dev/null +++ b/packages/sdk-multichain/src/utis/index.ts @@ -0,0 +1,167 @@ +import { v4 as uuidv4 } from 'uuid'; +import type { StoreClient } from '../domain/store/client'; +import packageJson from '../../package.json'; +import type { DappSettings, MultichainSDKConstructor, RPC_URLS_MAP } from '../domain/multichain'; +import { infuraRpcUrls } from 'src/domain/multichain/api/constants'; + +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 = uuidv4(); + await storage.setAnonId(newAnonId); + return newAnonId; +} + +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); +} + +export const extractFavicon = () => { + if (typeof document === 'undefined') { + return undefined; + } + + let favicon; + 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'); + } + } + 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 = {}; + } + options.api.readonlyRPCMap = urlsWithToken; + } + return options +} + +export function setupDappMetadata(options: MultichainSDKConstructor): MultichainSDKConstructor { + if (!options.dapp?.url) { + // Automatically set dappMetadata on web env if not defined + if (typeof window !== "undefined" && typeof document !== "undefined") { + 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); +} + +/** + * Base64 encode string for URL params + */ +export function base64Encode(str: string): string { + if (typeof btoa !== 'undefined') { + return btoa(str); + } + // Node.js fallback + return Buffer.from(str).toString('base64'); +} 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..71b4ce684 --- /dev/null +++ b/packages/sdk-multichain/src/utis/rpc/client.ts @@ -0,0 +1,81 @@ +import { Json } from "@metamask/utils"; +import crossFetch from "cross-fetch"; + + +let rpcId = 1; + +function getNextRpcId() { + rpcId += 1; + return rpcId; +} + +export type RPCResponse = { + id: number, jsonrpc: string, result: unknown +} + + +export class RPCClient { + + constructor( + private readonly endpoint: string, + private readonly sdkInfo: string) { + } + + private get headers() { + const defaultHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', + } + if (this.endpoint.includes('infura')) { + return { + ...defaultHeaders, + 'Metamask-Sdk-Info': this.sdkInfo, + } + } + return defaultHeaders; + } + + private async _fetch(body: string, method: string, headers: Record) { + try { + const {endpoint} = this; + const response = await crossFetch(endpoint, { + method, + headers, + body, + }); + if (!response.ok) { + throw new Error(`Server responded with a status of ${response.status}`); + } + return response; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to fetch from RPC: ${error.message}`); + } else { + throw new Error(`Failed to fetch from RPC: ${error}`); + } + } + } + + private async parseResponse(response: Response) { + try { + const rpcResponse = await response.json() as RPCResponse; + return rpcResponse.result as Json; + } catch (error) { + throw new Error(`Failed to parse response from RPC: ${error}`); + } + } + + async request(method: string, params: unknown) { + const body = JSON.stringify({ + jsonrpc: '2.0', + method, + params, + id: getNextRpcId(), + }); + const request = await this._fetch(body, "POST", this.headers); + const response = await this.parseResponse(request); + return response + } + + +} \ No newline at end of file From 02962105609cfb77c4376dac2a5ab9bc30e59d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Tue, 15 Jul 2025 12:00:59 +0200 Subject: [PATCH 02/13] fix: useUnknownInCatchVariables in tsconfig to allow err in catch to be any not unknown --- packages/sdk-multichain/tsconfig.json | 1 + 1 file changed, 1 insertion(+) 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, From 702a4d5a89b2ebeb257d95385a37bf01f0c79652 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Tue, 15 Jul 2025 12:01:35 +0200 Subject: [PATCH 03/13] fix: add error handling and rpcClient coverage --- .../sdk-multichain/src/domain/errors/base.ts | 14 + .../sdk-multichain/src/domain/errors/index.ts | 2 + .../sdk-multichain/src/domain/errors/rpc.ts | 53 ++++ .../src/domain/errors/storage.ts | 45 +++ .../sdk-multichain/src/domain/errors/types.ts | 14 + .../sdk-multichain/src/domain/index.test.ts | 4 +- packages/sdk-multichain/src/domain/index.ts | 1 + .../src/domain/multichain/api/constants.ts | 2 +- .../src/domain/multichain/api/infura.ts | 10 + .../src/domain/multichain/api/types.ts | 35 +- .../src/domain/multichain/index.ts | 2 + .../src/domain/store/adapter.ts | 3 + .../sdk-multichain/src/multichain/index.ts | 298 ++++++++---------- .../sdk-multichain/src/store/adapters/node.ts | 2 +- .../sdk-multichain/src/store/adapters/rn.ts | 1 + .../sdk-multichain/src/store/adapters/web.ts | 6 +- .../sdk-multichain/src/store/index.test.ts | 31 +- packages/sdk-multichain/src/store/index.ts | 71 ++++- packages/sdk-multichain/src/utis/index.ts | 10 +- .../src/utis/rpc/client.test.ts | 262 +++++++++++++++ .../sdk-multichain/src/utis/rpc/client.ts | 141 ++++++--- 21 files changed, 758 insertions(+), 249 deletions(-) create mode 100644 packages/sdk-multichain/src/domain/errors/base.ts create mode 100644 packages/sdk-multichain/src/domain/errors/index.ts create mode 100644 packages/sdk-multichain/src/domain/errors/rpc.ts create mode 100644 packages/sdk-multichain/src/domain/errors/storage.ts create mode 100644 packages/sdk-multichain/src/domain/errors/types.ts create mode 100644 packages/sdk-multichain/src/domain/multichain/api/infura.ts create mode 100644 packages/sdk-multichain/src/utis/rpc/client.test.ts 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..9fefb99cb --- /dev/null +++ b/packages/sdk-multichain/src/domain/errors/base.ts @@ -0,0 +1,14 @@ +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..fc13c2840 100644 --- a/packages/sdk-multichain/src/domain/multichain/index.ts +++ b/packages/sdk-multichain/src/domain/multichain/index.ts @@ -143,3 +143,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/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index 53b007665..c427e3c79 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -1,80 +1,95 @@ import { - getDefaultTransport, - getMultichainClient, - type InvokeMethodParams, - type MultichainApiClient, - type SessionData, - type Transport, + getDefaultTransport, + getMultichainClient, + type MultichainApiClient, + type SessionData, + type Transport, } from "@metamask/multichain-api-client"; import { - parseCaipAccountId, - parseCaipChainId, - type CaipAccountId, + parseCaipAccountId, + parseCaipChainId, + type CaipAccountId, } from "@metamask/utils"; import { analytics } from '@metamask/sdk-analytics'; -import { MultichainSDKBase, type RPC_URLS_MAP } from "../domain/multichain"; +import { MultichainSDKBase } from "../domain/multichain"; import type { - MultichainSDKConstructor, - MultichainSDKOptions, - NotificationCallback, - Scope, - RPCAPI, - InvokeMethodOptions, + MultichainSDKConstructor, + MultichainSDKOptions, + NotificationCallback, + Scope, + RPCAPI, + InvokeMethodOptions, } from "../domain"; import { - createLogger, - enableDebug, - isEnabled as isLoggerEnabled, + createLogger, + enableDebug, + isEnabled as isLoggerEnabled, } from "../domain/logger"; import { getPlatformType, PlatformType } from "../domain/platform"; -import { getAnonId, getDappId, getInfuraRpcUrls, getVersion, setupDappMetadata, setupInfuraProvider } from "../utis"; +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"; -import { METHODS_TO_REDIRECT } from "src/domain/multichain/api/constants"; //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 transport!: Transport; - private provider!: MultichainApiClient; - private isInitialized : boolean = false; + private transport!: Transport; + private provider!: MultichainApiClient; private readonly options: MultichainSDKConstructor; public readonly storage: StoreClient; + private readonly rpcClient: RPCClient; - private constructor(options: MultichainSDKConstructor) { + private constructor(options: MultichainSDKConstructor) { super(); const transport = getDefaultTransport(options.transport); const withInfuraRPCMethods = setupInfuraProvider(options); const withDappMetadata = setupDappMetadata(withInfuraRPCMethods); this.options = withDappMetadata; this.storage = options.storage; - this.provider = getMultichainClient({ transport }); - this.transport = transport; - } - - 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; - } + this.provider = getMultichainClient({ transport }); + this.transport = transport; + + 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.rpcClient = new RPCClient( + this.provider, + this.options.api, + sdkInfo + ); + } + + 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) { @@ -89,9 +104,8 @@ export class MultichainSDK extends EventEmitter implements Multichain const isReactNative = platform === PlatformType.ReactNative; - //Replaces old !isBrowser() && !isReactNative() if (!isBrowser && !isReactNative) { - return + return } const version = getVersion(); @@ -99,138 +113,94 @@ export class MultichainSDK extends EventEmitter implements Multichain 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', {}); } - private async init() { - if (typeof window !== "undefined" && window.mmsdk?.isInitialized) { - logger("MetaMaskSDK: init already initialized"); - } + private async init() { + if (typeof window !== "undefined" && window.mmsdk?.isInitialized) { + logger("MetaMaskSDK: init already initialized"); + } await this.setupAnalytics(); - this.isInitialized = true; - } + } - async connect(): Promise { + async connect(): Promise { // Handle mobile connection with UI if (!this.transport.isConnected) { await this.transport.connect(); } return this.transport.isConnected() - } - - async disconnect(): Promise { - this.transport.disconnect(); - } - /** - * Get the current connection QR code link (if available) - */ - getConnectionLink(): string | undefined { - // This would need to be stored when connection is initiated - // For now, return undefined - could be enhanced to store the last generated link - return undefined; } - onNotification(listener: NotificationCallback): () => void { - return this.provider.onNotification(listener as any); - } - - async getSession() { - return this.provider.getSession(); - } - - async revokeSession() { - return this.provider.revokeSession(); - } - - async createSession( - scopes: Scope[], - caipAccountIds: CaipAccountId[], - ): Promise { - const optionalScopes: Record< - Scope, - { methods: string[]; notifications: string[]; accounts: CaipAccountId[] } - > = {}; - - // biome-ignore lint/complexity/noForEach: - scopes.forEach((scope) => { - optionalScopes[scope] = { - methods: [], - notifications: [], - accounts: [], - }; - }); - - // biome-ignore lint/complexity/noForEach: - caipAccountIds.forEach((accountId: CaipAccountId) => { - try { - const { - chain: { namespace: accountNamespace }, - } = parseCaipAccountId(accountId); - - // biome-ignore lint/complexity/noForEach: - Object.keys(optionalScopes).forEach((scopeKey) => { - const scope = scopeKey as Scope; - const scopeDetails = parseCaipChainId(scope); - - if (scopeDetails.namespace === accountNamespace) { - const scopeData = optionalScopes[scope]; - if (scopeData) { - scopeData.accounts.push(accountId); - } - } - }); - } catch (error) { - const stringifiedAccountId = JSON.stringify(accountId); - console.error( - `Invalid CAIP account ID: ${stringifiedAccountId}`, - error, - ); - } - }); - return this.provider.createSession({ optionalScopes }); - } - - - async invokeMethod(options: InvokeMethodOptions) { - const {request} = options; - let readonlyRPCMap: RPC_URLS_MAP = {}; - const infuraAPIKey = this.options.api?.infuraAPIKey; - if (infuraAPIKey) { - const urlsWithToken = getInfuraRpcUrls(infuraAPIKey); - if (this.options.api?.readonlyRPCMap) { - readonlyRPCMap = { - ...this.options.api.readonlyRPCMap, - ...urlsWithToken, - }; - } else { - readonlyRPCMap = urlsWithToken; + async disconnect(): Promise { + this.transport.disconnect(); + } + + onNotification(listener: NotificationCallback) { + return this.provider.onNotification(listener); + } + + async getSession() { + return this.provider.getSession(); + } + + async revokeSession() { + return this.provider.revokeSession(); + } + + async createSession( + scopes: Scope[], + caipAccountIds: CaipAccountId[], + ): Promise { + + 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) { + const scopeData = optionalScopes[scope]; + if (scopeData) { + scopeData.accounts.push(account.address as CaipAccountId); + } + } } } - const platformType = getPlatformType(); - const rpcEndpoint = readonlyRPCMap[options.scope]; - const isReadOnlyMethod = !METHODS_TO_REDIRECT[request.method]; - const sdkInfo = `Sdk/Javascript SdkVersion/${ - packageJson.version - } Platform/${platformType} dApp/${this.options.dapp.url ?? this.options.dapp.name} dAppTitle/${ - this.options.dapp.name - }`; - - if (rpcEndpoint && isReadOnlyMethod) { - const client = new RPCClient(rpcEndpoint, sdkInfo); - const response = await client.request(request.method, request.params); - return response; - } - // TODO: Expose types on multichain api package - return this.provider.invokeMethod( - options as InvokeMethodParams, - ); - } + + return this.provider.createSession({ optionalScopes }); + } + + 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/index.ts b/packages/sdk-multichain/src/utis/index.ts index 9fc216572..82446a3b5 100644 --- a/packages/sdk-multichain/src/utis/index.ts +++ b/packages/sdk-multichain/src/utis/index.ts @@ -1,8 +1,7 @@ import { v4 as uuidv4 } from 'uuid'; import type { StoreClient } from '../domain/store/client'; import packageJson from '../../package.json'; -import type { DappSettings, MultichainSDKConstructor, RPC_URLS_MAP } from '../domain/multichain'; -import { infuraRpcUrls } from 'src/domain/multichain/api/constants'; +import { getInfuraRpcUrls, type DappSettings, type MultichainSDKConstructor } from '../domain/multichain'; export function getVersion() { return packageJson.version; @@ -33,13 +32,6 @@ export async function getAnonId(storage: StoreClient) { return newAnonId; } -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); -} export const extractFavicon = () => { if (typeof document === 'undefined') { 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..d4a38d5e2 --- /dev/null +++ b/packages/sdk-multichain/src/utis/rpc/client.test.ts @@ -0,0 +1,262 @@ +import * as t from 'vitest'; + +import { + MultichainSDKConstructor, + type InvokeMethodOptions, + type Scope, + RPC_METHODS, + RPCInvokeMethodErr, + RPCReadonlyResponseErr, +} from '../../domain'; +import { RPCClient, getNextRpcId } from './client'; + +t.describe('RPCClient', () => { + let mockProvider: any; + let mockConfig: MultichainSDKConstructor['api']; + let sdkInfo: string; + let rpcClient: RPCClient; + let defaultHeaders: Record; + let headers: Record; + + t.beforeEach(() => { + 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 RPCClient(mockProvider, mockConfig, sdkInfo); + // Reset mocks + mockProvider.invokeMethod.mockClear(); + defaultHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', + } + headers = { + ...defaultHeaders, + 'Metamask-Sdk-Info': sdkInfo, + } + }); + + t.describe('getNextRpcId', () => { + t.it('should increment and return RPC ID', () => { + const id1 = getNextRpcId(); + const id2 = getNextRpcId(); + t.expect(id2).toBeGreaterThan(id1); + t.expect(id2).toBe(id1 + 1); + }); + }); + + 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 headers = (rpcClient as any).getHeaders(infuraEndpoint); + + t.expect(headers).toEqual(headers); + }); + }); + + t.describe('invokeMethod', () => { + const baseOptions: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_getBalance', + params: { address: '0x123', blockNumber: 'latest' }, + }, + }; + + t.describe('redirect to provider cases', () => { + t.it('should not redirect to provider for readonly methods', async () => { + + // Mock the private fetch method + const mockJsonResponse = { + jsonrpc: '2.0', + result: '0x1234567890abcdef', + id: 1, + }; + + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + + }; + + const fetchSpy = t.vi.spyOn(rpcClient as any, 'fetch').mockResolvedValue(mockResponse); + + const result = await rpcClient.invokeMethod(baseOptions); + + t.expect(result).toBe('0x1234567890abcdef'); + t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); + t.expect(fetchSpy).toHaveBeenCalledWith( + 'https://mainnet.infura.io/v3/test-infura-key', + t.expect.stringContaining('"method":"eth_getBalance"'), + 'POST', + t.expect.objectContaining(headers) + ); + t.expect(mockResponse.json).toHaveBeenCalled(); + + fetchSpy.mockRestore(); + }); + + 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')), + }; + + const fetchSpy = t.vi.spyOn(rpcClient as any, 'fetch').mockResolvedValue(mockResponse); + + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyResponseErr); + await t.expect(rpcClient.invokeMethod(baseOptions)).rejects.toThrow('Invalid JSON'); + + fetchSpy.mockRestore(); + }); + + 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 RPCClient(mockProvider, configWithoutRPCMap, sdkInfo); + + const mockJsonResponse = { + jsonrpc: '2.0', + result: '0xabcdef123456', + id: 1, + }; + + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + const fetchSpy = t.vi.spyOn(clientWithoutRPCMap as any, 'fetch').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(fetchSpy).toHaveBeenCalledWith( + 'https://mainnet.infura.io/v3/test-infura-key', + t.expect.stringMatching('"method":"eth_getBalance"'), + 'POST', + t.expect.objectContaining(headers) + ); + + fetchSpy.mockRestore(); + }); + + t.it('should use only default headers when RPC endpoint does not include infura', async () => { + const configWithCustomRPC = { + readonlyRPCMap: { + 'eip155:1': 'https://custom-ethereum-node.com/rpc', + }, + }; + const clientWithCustomRPC = new RPCClient(mockProvider, configWithCustomRPC, sdkInfo); + + const mockJsonResponse = { + jsonrpc: '2.0', + result: '0xbalance123', + id: 1, + } + + mockProvider.invokeMethod.mockResolvedValue(mockJsonResponse); + const fetchSpy = t.vi.spyOn(clientWithCustomRPC as any, 'fetch') + const result = await clientWithCustomRPC.invokeMethod(baseOptions); + + t.expect(result).toBe(mockJsonResponse); + t.expect(mockProvider.invokeMethod).toHaveBeenCalled(); + t.expect( mockProvider.invokeMethod).toHaveBeenCalledWith(baseOptions); + t.expect(fetchSpy).not.toHaveBeenCalled(); + }); + + 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.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.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.it('should redirect to provider when no infura API key and no custom RPC map', async () => { + const noConfigClient = new RPCClient(mockProvider, {}, sdkInfo); + + mockProvider.invokeMethod.mockResolvedValue('0xfallback'); + + const result = await noConfigClient.invokeMethod(baseOptions); + + t.expect(result).toBe('0xfallback'); + t.expect(mockProvider.invokeMethod).toHaveBeenCalledWith(baseOptions); + }); + + 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); + }); + }); + }); +}); diff --git a/packages/sdk-multichain/src/utis/rpc/client.ts b/packages/sdk-multichain/src/utis/rpc/client.ts index 71b4ce684..cfe337536 100644 --- a/packages/sdk-multichain/src/utis/rpc/client.ts +++ b/packages/sdk-multichain/src/utis/rpc/client.ts @@ -1,58 +1,63 @@ -import { Json } from "@metamask/utils"; -import crossFetch from "cross-fetch"; +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; -function getNextRpcId() { +export function getNextRpcId() { rpcId += 1; return rpcId; } -export type RPCResponse = { - id: number, jsonrpc: string, result: unknown -} - - export class RPCClient { - constructor( - private readonly endpoint: string, + private readonly provider: MultichainApiClient, + private readonly config: MultichainSDKConstructor['api'], private readonly sdkInfo: string) { } - private get headers() { - const defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', - } - if (this.endpoint.includes('infura')) { - return { - ...defaultHeaders, - 'Metamask-Sdk-Info': this.sdkInfo, - } - } - return defaultHeaders; - } - - private async _fetch(body: string, method: string, headers: Record) { + private async fetch( + endpoint: string, + body: string, + method: string, + headers: Record + ) { try { - const {endpoint} = this; - const response = await crossFetch(endpoint, { + const response = await fetch(endpoint, { method, headers, body, }); if (!response.ok) { - throw new Error(`Server responded with a status of ${response.status}`); + throw new RPCHttpErr( + endpoint, + method, + response.status + ) } return response; } catch (error) { - if (error instanceof Error) { - throw new Error(`Failed to fetch from RPC: ${error.message}`); - } else { - throw new Error(`Failed to fetch from RPC: ${error}`); + if (error instanceof RPCHttpErr) { + throw error; } + throw new RPCReadonlyRequestErr(error.message) } } @@ -61,21 +66,77 @@ export class RPCClient { const rpcResponse = await response.json() as RPCResponse; return rpcResponse.result as Json; } catch (error) { - throw new Error(`Failed to parse response from RPC: ${error}`); + throw new RPCReadonlyResponseErr(error.message) } } - async request(method: string, params: unknown) { + 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, - params, + method: request.method, + params: request.params, id: getNextRpcId(), }); - const request = await this._fetch(body, "POST", this.headers); - const response = await this.parseResponse(request); + const rpcRequest = await this.fetch( + rpcEndpoint, + body, + "POST", + this.getHeaders(rpcEndpoint) + ); + const response = await this.parseResponse(rpcRequest); return response } - -} \ No newline at end of file + 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; + } + } + 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) + } + } +} From 68bad1c6dea585acd5b858d9adba43b041fc6bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Tue, 15 Jul 2025 15:51:21 +0200 Subject: [PATCH 04/13] fix: add test coverage --- .../src/domain/multichain/index.ts | 7 +- .../src/utis/base64/index.test.ts | 58 +++ .../sdk-multichain/src/utis/base64/index.ts | 35 ++ .../sdk-multichain/src/utis/index.test.ts | 350 ++++++++++++++++++ packages/sdk-multichain/src/utis/index.ts | 33 +- 5 files changed, 460 insertions(+), 23 deletions(-) create mode 100644 packages/sdk-multichain/src/utis/base64/index.test.ts create mode 100644 packages/sdk-multichain/src/utis/base64/index.ts create mode 100644 packages/sdk-multichain/src/utis/index.test.ts diff --git a/packages/sdk-multichain/src/domain/multichain/index.ts b/packages/sdk-multichain/src/domain/multichain/index.ts index fc13c2840..73e02debe 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. 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..fb1e9c717 --- /dev/null +++ b/packages/sdk-multichain/src/utis/index.test.ts @@ -0,0 +1,350 @@ +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'; +import { base64Encode } from '../../../sdk/src/utils/base64'; + + + +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 index 82446a3b5..50988972f 100644 --- a/packages/sdk-multichain/src/utis/index.ts +++ b/packages/sdk-multichain/src/utis/index.ts @@ -1,7 +1,8 @@ -import { v4 as uuidv4 } from 'uuid'; +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; @@ -27,7 +28,7 @@ export async function getAnonId(storage: StoreClient) { if (anonId) { return anonId; } - const newAnonId = uuidv4(); + const newAnonId = uuid.v4(); await storage.setAnonId(newAnonId); return newAnonId; } @@ -63,19 +64,22 @@ export function setupInfuraProvider(options: MultichainSDKConstructor): Multicha ...options.api.readonlyRPCMap, ...urlsWithToken, }; - } else { - if (!options.api) { - options.api = {}; - } + } 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 (typeof window !== "undefined" && typeof document !== "undefined") { + // Automatically set dappMetadata on web env if not defined + if (isBrowser) { options.dapp = { ...options.dapp, url: `${window.location.protocol}//${window.location.host}`, @@ -123,12 +127,12 @@ export function setupDappMetadata(options: MultichainSDKConstructor): Multichain ); } 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; @@ -146,14 +150,3 @@ export function isMetaMaskInstalled(): boolean { } return Boolean(window.ethereum?.isMetaMask); } - -/** - * Base64 encode string for URL params - */ -export function base64Encode(str: string): string { - if (typeof btoa !== 'undefined') { - return btoa(str); - } - // Node.js fallback - return Buffer.from(str).toString('base64'); -} From 3b9095619543013c4996b0317b7d36e5062b7810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Tue, 15 Jul 2025 20:39:49 +0200 Subject: [PATCH 05/13] fix: improve tests for multichain init --- .../src/domain/multichain/index.ts | 23 +-- packages/sdk-multichain/src/index.test.ts | 172 ++++++++++++++++++ .../sdk-multichain/src/multichain/index.ts | 72 ++++---- .../sdk-multichain/src/utis/index.test.ts | 3 +- 4 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 packages/sdk-multichain/src/index.test.ts diff --git a/packages/sdk-multichain/src/domain/multichain/index.ts b/packages/sdk-multichain/src/domain/multichain/index.ts index 73e02debe..91abe5722 100644 --- a/packages/sdk-multichain/src/domain/multichain/index.ts +++ b/packages/sdk-multichain/src/domain/multichain/index.ts @@ -54,12 +54,17 @@ export type MultichainSDKConstructor = { */ /* c8 ignore start */ export abstract class MultichainSDKBase { + public abstract isInitialized: boolean; + /** - * 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. @@ -75,18 +80,6 @@ export abstract class MultichainSDKBase { */ 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. * diff --git a/packages/sdk-multichain/src/index.test.ts b/packages/sdk-multichain/src/index.test.ts new file mode 100644 index 000000000..6c03d38d6 --- /dev/null +++ b/packages/sdk-multichain/src/index.test.ts @@ -0,0 +1,172 @@ +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'; + +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>; +} + +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(`Running createMetamaskSDK in ${platform}`, () => { + let sdk: MultichainSDKBase; + let setupAnalyticsSpy: any; + let initSpy: any; + let nativeStorageStub: NativeStorageStub; + + t.beforeEach(async () => { + t.vi.clearAllMocks(); + t.vi.resetModules(); + + 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?.(); + t.vi.clearAllMocks(); + t.vi.resetModules(); + t.vi.unstubAllGlobals(); + }); + + 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 () => { + sdk = await createSDK(options); + t.expect(sdk).toBeDefined(); + 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 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('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/" }); + + t.vi.stubGlobal('navigator', { + ...dom.window.navigator, + product: 'Chrome', + }); + + t.vi.stubGlobal('window', { + ...dom.window, + location: { + hostname: 'test.dapp', + }, + addEventListener: t.vi.fn(), + removeEventListener: t.vi.fn(), + localStorage: nativeStorageStub, + }); + }); +}); + + + diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index c427e3c79..2f9801221 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -3,7 +3,6 @@ import { getMultichainClient, type MultichainApiClient, type SessionData, - type Transport, } from "@metamask/multichain-api-client"; import { parseCaipAccountId, @@ -43,22 +42,19 @@ type OptionalScopes = Record< >; export class MultichainSDK extends EventEmitter implements MultichainSDKBase { - private transport!: Transport; private provider!: MultichainApiClient; private readonly options: MultichainSDKConstructor; public readonly storage: StoreClient; private readonly rpcClient: RPCClient; + public isInitialized: boolean = false; private constructor(options: MultichainSDKConstructor) { super(); - const transport = getDefaultTransport(options.transport); + const withInfuraRPCMethods = setupInfuraProvider(options); const withDappMetadata = setupDappMetadata(withInfuraRPCMethods); this.options = withDappMetadata; this.storage = options.storage; - this.provider = getMultichainClient({ transport }); - this.transport = transport; - 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 @@ -71,7 +67,14 @@ export class MultichainSDK extends EventEmitter implements Multichain ); } + private get transport() { + const transport = getDefaultTransport(this.options.transport); + this.provider = getMultichainClient({ transport }); + return transport + } + static async create(options: MultichainSDKOptions) { + const instance = new MultichainSDK(options); const isEnabled = await isLoggerEnabled( "metamask-sdk:core", @@ -122,41 +125,28 @@ export class MultichainSDK extends EventEmitter implements Multichain analytics.track('sdk_initialized', {}); } - private async init() { + async init() { if (typeof window !== "undefined" && window.mmsdk?.isInitialized) { logger("MetaMaskSDK: init already initialized"); } await this.setupAnalytics(); + this.isInitialized = true; } - async connect(): Promise { - // Handle mobile connection with UI - if (!this.transport.isConnected) { - await this.transport.connect(); - } - return this.transport.isConnected() - } - - async disconnect(): Promise { - this.transport.disconnect(); - } - - onNotification(listener: NotificationCallback) { - return this.provider.onNotification(listener); - } - - async getSession() { - return this.provider.getSession(); - } - - async revokeSession() { - return this.provider.revokeSession(); - } - - async createSession( + async connect( scopes: Scope[], caipAccountIds: CaipAccountId[], ): Promise { + if (!this.transport.isConnected) { + await this.transport.connect(); + } + const session = await this.provider.getSession() + if (session) { + // TODO! + // It could be that the session we have has different permissions + // We should check if and trigger a new session if needed + return session; + } const optionalScopes = scopes.reduce((prev, scope) => ({ ...prev, @@ -183,7 +173,6 @@ export class MultichainSDK extends EventEmitter implements Multichain } }, []); - for (const account of validAccounts) { for (const scopeKey of Object.keys(optionalScopes)) { const scope = scopeKey as Scope; @@ -196,10 +185,25 @@ export class MultichainSDK extends EventEmitter implements Multichain } } } - return this.provider.createSession({ optionalScopes }); } + async disconnect(): Promise { + this.transport.disconnect(); + } + + onNotification(listener: NotificationCallback) { + return this.provider.onNotification(listener); + } + + async getSession() { + return this.provider.getSession(); + } + + async revokeSession() { + return this.provider.revokeSession(); + } + async invokeMethod(options: InvokeMethodOptions) { return this.rpcClient.invokeMethod(options); } diff --git a/packages/sdk-multichain/src/utis/index.test.ts b/packages/sdk-multichain/src/utis/index.test.ts index fb1e9c717..8d31cb0d2 100644 --- a/packages/sdk-multichain/src/utis/index.test.ts +++ b/packages/sdk-multichain/src/utis/index.test.ts @@ -6,14 +6,13 @@ import * as utils from '.'; import { Store } from '../store'; import { MultichainSDKConstructor } from '../domain/multichain'; import { PlatformType, getPlatformType } from '../domain/platform'; -import { base64Encode } from '../../../sdk/src/utils/base64'; vi.mock('../domain/platform', async () => { const actual = await vi.importActual('../domain/platform') as any; return { -...actual, + ...actual, getPlatformType:t.vi.fn(), } }); From 1cb965629832a3641fff842ffe4239b49cf6bd6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 16 Jul 2025 14:54:08 +0200 Subject: [PATCH 06/13] fix: improve testing coverage --- .../src/domain/multichain/index.ts | 9 +- packages/sdk-multichain/src/index.test.ts | 735 ++++++++++++++++-- .../sdk-multichain/src/multichain/index.ts | 50 +- .../src/utis/rpc/client.test.ts | 181 +++-- .../sdk-multichain/src/utis/rpc/client.ts | 2 + 5 files changed, 833 insertions(+), 144 deletions(-) diff --git a/packages/sdk-multichain/src/domain/multichain/index.ts b/packages/sdk-multichain/src/domain/multichain/index.ts index 91abe5722..a16c55c97 100644 --- a/packages/sdk-multichain/src/domain/multichain/index.ts +++ b/packages/sdk-multichain/src/domain/multichain/index.ts @@ -55,6 +55,7 @@ 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, or re-use existing session @@ -72,14 +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; - /** * Revokes the current session. * diff --git a/packages/sdk-multichain/src/index.test.ts b/packages/sdk-multichain/src/index.test.ts index 6c03d38d6..872326f47 100644 --- a/packages/sdk-multichain/src/index.test.ts +++ b/packages/sdk-multichain/src/index.test.ts @@ -6,6 +6,8 @@ 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; @@ -15,15 +17,48 @@ type NativeStorageStub = { 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(() => {}), + enableDebug: vi.fn(() => { }), isEnabled: vi.fn(() => true), __mockLogger: mockLogger, } }) + t.vi.mock('@metamask/sdk-analytics'); @@ -35,15 +70,56 @@ function createMultiplatformTestCase( cleanupMocks?: () => void ) { - t.describe(`Running createMetamaskSDK in ${platform}`, () => { + 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(), @@ -67,7 +143,6 @@ function createMultiplatformTestCase( 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') }); @@ -75,61 +150,624 @@ function createMultiplatformTestCase( 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.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.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.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.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 mockMultichainClient = (multichainModule as any).__mockMultichainClient; + + // Mock no session scenario + 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 = { + sessionScopes: { + 'eip155:1': { + methods: ['eth_sendTransaction', 'eth_accounts'], + notifications: ['accountsChanged', 'chainChanged'], + accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + }, + }, + expiry: new Date(Date.now() + 3600000).toISOString(), + }; + + }) + + 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.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.it(`${platform} should call init and setupAnalytics with logger configuration`, async () => { - sdk = await createSDK(options); - t.expect(sdk).toBeDefined(); - t.expect(initSpy).toHaveBeenCalled(); - t.expect(setupAnalyticsSpy).toHaveBeenCalled(); - t.expect(sdk.isInitialized).toBe(true); - t.expect(loggerModule.enableDebug).toHaveBeenCalledWith('metamask-sdk:core'); + 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.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; + t.describe(`${platform} revokeSession method tests`, () => { + t.beforeEach(async () => { + sdk = await createSDK(options); }); - 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; - t.expect(sdk).toBeDefined(); - t.expect(sdk.isInitialized).toBe(false); + mockMultichainClient.revokeSession.mockResolvedValue(undefined); - // Access the mock logger from the module - const mockLogger = (loggerModule as any).__mockLogger; + await sdk.revokeSession(); - // Verify that the logger was called with the error - t.expect(mockLogger).toHaveBeenCalledWith( - 'MetaMaskSDK error during initialization', - testError - ); + 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'); + }); + }); }); } @@ -150,21 +788,18 @@ t.describe('MultichainSDK', () => { createMetamaskSDKWeb, (nativeStorageStub) => { const dom = new Page('

Hello world

', { url: "https://dapp.io/" }); - - t.vi.stubGlobal('navigator', { - ...dom.window.navigator, - product: 'Chrome', - }); - - t.vi.stubGlobal('window', { + const globalStub = { ...dom.window, - location: { - hostname: 'test.dapp', - }, addEventListener: t.vi.fn(), removeEventListener: t.vi.fn(), localStorage: nativeStorageStub, + } + t.vi.stubGlobal('navigator', { + ...dom.window.navigator, + product: 'Chrome', }); + t.vi.stubGlobal('window', globalStub); + t.vi.stubGlobal('location', dom.window.location); }); }); diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index 2f9801221..cba7f85d8 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -46,7 +46,8 @@ export class MultichainSDK extends EventEmitter implements Multichain private readonly options: MultichainSDKConstructor; public readonly storage: StoreClient; private readonly rpcClient: RPCClient; - public isInitialized: boolean = false; + public isInitialized: boolean = false; + public session: SessionData | undefined; private constructor(options: MultichainSDKConstructor) { super(); @@ -65,12 +66,22 @@ export class MultichainSDK extends EventEmitter implements Multichain this.options.api, sdkInfo ); + + this.provider = getMultichainClient({ transport: this.transport }); } private get transport() { - const transport = getDefaultTransport(this.options.transport); - this.provider = getMultichainClient({ transport }); - return 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) { @@ -130,6 +141,7 @@ export class MultichainSDK extends EventEmitter implements Multichain logger("MetaMaskSDK: init already initialized"); } await this.setupAnalytics(); + this.session = await this.provider.getSession(); this.isInitialized = true; } @@ -140,13 +152,6 @@ export class MultichainSDK extends EventEmitter implements Multichain if (!this.transport.isConnected) { await this.transport.connect(); } - const session = await this.provider.getSession() - if (session) { - // TODO! - // It could be that the session we have has different permissions - // We should check if and trigger a new session if needed - return session; - } const optionalScopes = scopes.reduce((prev, scope) => ({ ...prev, @@ -177,7 +182,7 @@ export class MultichainSDK extends EventEmitter implements Multichain for (const scopeKey of Object.keys(optionalScopes)) { const scope = scopeKey as Scope; const scopeDetails = parseCaipChainId(scope); - if (scopeDetails.namespace === account.chain.namespace) { + if (scopeDetails.namespace === account.chain.namespace && scopeDetails.reference === account.chain.reference) { const scopeData = optionalScopes[scope]; if (scopeData) { scopeData.accounts.push(account.address as CaipAccountId); @@ -185,21 +190,30 @@ export class MultichainSDK extends EventEmitter implements Multichain } } } - return this.provider.createSession({ optionalScopes }); + + const session = await this.provider.getSession() + if (session) { + const noOverlap = Object.keys(session.sessionScopes).every(scope => scopes.includes(scope as Scope)); + if (noOverlap) { + // No overlap between existing and new scopes, return the existing session + return session; + } + // Scopes have overlap, revoke the session and create new one + await this.provider.revokeSession(); + } + this.session = await this.provider.createSession({ optionalScopes }); + return this.session; + } async disconnect(): Promise { - this.transport.disconnect(); + return this.transport.disconnect(); } onNotification(listener: NotificationCallback) { return this.provider.onNotification(listener); } - async getSession() { - return this.provider.getSession(); - } - async revokeSession() { return this.provider.revokeSession(); } diff --git a/packages/sdk-multichain/src/utis/rpc/client.test.ts b/packages/sdk-multichain/src/utis/rpc/client.test.ts index d4a38d5e2..e7e825135 100644 --- a/packages/sdk-multichain/src/utis/rpc/client.test.ts +++ b/packages/sdk-multichain/src/utis/rpc/client.test.ts @@ -1,24 +1,56 @@ import * as t from 'vitest'; - +import { vi } from 'vitest'; import { - MultichainSDKConstructor, + type MultichainSDKConstructor, type InvokeMethodOptions, type Scope, RPC_METHODS, RPCInvokeMethodErr, RPCReadonlyResponseErr, + RPCHttpErr, + RPCReadonlyRequestErr, } from '../../domain'; -import { RPCClient, getNextRpcId } from './client'; +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(() => { + 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(), }; @@ -29,33 +61,33 @@ t.describe('RPCClient', () => { }, }; sdkInfo = 'Sdk/Javascript SdkVersion/1.0.0 Platform/web'; - rpcClient = new RPCClient(mockProvider, mockConfig, sdkInfo); + 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, + 'Metamask-Sdk-Info': sdkInfo, } }); - t.describe('getNextRpcId', () => { - t.it('should increment and return RPC ID', () => { - const id1 = getNextRpcId(); - const id2 = getNextRpcId(); - t.expect(id2).toBeGreaterThan(id1); - t.expect(id2).toBe(id1 + 1); - }); + 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'); }); @@ -63,24 +95,16 @@ t.describe('RPCClient', () => { t.it('should return headers with Metamask-Sdk-Info when RPC endpoint includes infura', () => { const infuraEndpoint = 'https://mainnet.infura.io/v3/test-key'; const headers = (rpcClient as any).getHeaders(infuraEndpoint); - t.expect(headers).toEqual(headers); }); }); t.describe('invokeMethod', () => { - const baseOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_getBalance', - params: { address: '0x123', blockNumber: 'latest' }, - }, - }; + t.describe('redirect to provider cases', () => { t.it('should not redirect to provider for readonly methods', async () => { - - // Mock the private fetch method + // Mock successful fetch response const mockJsonResponse = { jsonrpc: '2.0', result: '0x1234567890abcdef', @@ -90,24 +114,23 @@ t.describe('RPCClient', () => { const mockResponse = { ok: true, json: t.vi.fn().mockResolvedValue(mockJsonResponse), - }; - const fetchSpy = t.vi.spyOn(rpcClient as any, 'fetch').mockResolvedValue(mockResponse); + mockFetch.mockResolvedValue(mockResponse); const result = await rpcClient.invokeMethod(baseOptions); t.expect(result).toBe('0x1234567890abcdef'); t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); - t.expect(fetchSpy).toHaveBeenCalledWith( + t.expect(mockFetch).toHaveBeenCalledWith( 'https://mainnet.infura.io/v3/test-infura-key', - t.expect.stringContaining('"method":"eth_getBalance"'), - 'POST', - t.expect.objectContaining(headers) + { + method: 'POST', + headers: headers, + body: t.expect.stringContaining('"method":"eth_getBalance"'), + } ); t.expect(mockResponse.json).toHaveBeenCalled(); - - fetchSpy.mockRestore(); }); t.it('should throw RPCReadonlyResponseErr when response cannot be parsed as JSON', async () => { @@ -116,12 +139,29 @@ t.describe('RPCClient', () => { json: t.vi.fn().mockRejectedValue(new Error('Invalid JSON')), }; - const fetchSpy = t.vi.spyOn(rpcClient as any, 'fetch').mockResolvedValue(mockResponse); + 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, + }; - fetchSpy.mockRestore(); + 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 () => { @@ -129,7 +169,7 @@ t.describe('RPCClient', () => { infuraAPIKey: 'test-infura-key', // No readonlyRPCMap provided }; - const clientWithoutRPCMap = new RPCClient(mockProvider, configWithoutRPCMap, sdkInfo); + const clientWithoutRPCMap = new rpcClientModule(mockProvider, configWithoutRPCMap, sdkInfo); const mockJsonResponse = { jsonrpc: '2.0', @@ -142,7 +182,7 @@ t.describe('RPCClient', () => { json: t.vi.fn().mockResolvedValue(mockJsonResponse), }; - const fetchSpy = t.vi.spyOn(clientWithoutRPCMap as any, 'fetch').mockResolvedValue(mockResponse); + mockFetch.mockResolvedValue(mockResponse); const result = await clientWithoutRPCMap.invokeMethod(baseOptions); @@ -150,38 +190,50 @@ t.describe('RPCClient', () => { t.expect(mockProvider.invokeMethod).not.toHaveBeenCalled(); // Should still use Infura URL since infuraAPIKey is provided - t.expect(fetchSpy).toHaveBeenCalledWith( + t.expect(mockFetch).toHaveBeenCalledWith( 'https://mainnet.infura.io/v3/test-infura-key', - t.expect.stringMatching('"method":"eth_getBalance"'), - 'POST', - t.expect.objectContaining(headers) + { + method: 'POST', + headers: headers, + body: t.expect.stringMatching('"method":"eth_getBalance"'), + } ); - - fetchSpy.mockRestore(); }); - t.it('should use only default headers when RPC endpoint does not include infura', async () => { + 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 RPCClient(mockProvider, configWithCustomRPC, sdkInfo); - - const mockJsonResponse = { + const clientWithCustomRPC = new rpcClientModule(mockProvider, configWithCustomRPC, sdkInfo); + const mockJsonResponse = { jsonrpc: '2.0', - result: '0xbalance123', + result: '0x123456account12345', id: 1, - } + }; + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; - mockProvider.invokeMethod.mockResolvedValue(mockJsonResponse); - const fetchSpy = t.vi.spyOn(clientWithCustomRPC as any, 'fetch') - const result = await clientWithCustomRPC.invokeMethod(baseOptions); + mockFetch.mockResolvedValue(mockResponse); + baseOptions.request = { + method: 'eth_accounts', + params: undefined, + }; - t.expect(result).toBe(mockJsonResponse); - t.expect(mockProvider.invokeMethod).toHaveBeenCalled(); - t.expect( mockProvider.invokeMethod).toHaveBeenCalledWith(baseOptions); - t.expect(fetchSpy).not.toHaveBeenCalled(); + 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 () => { @@ -192,13 +244,11 @@ t.describe('RPCClient', () => { 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 () => { @@ -209,13 +259,11 @@ t.describe('RPCClient', () => { 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 () => { @@ -226,24 +274,20 @@ t.describe('RPCClient', () => { 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 RPCClient(mockProvider, {}, sdkInfo); - + 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 () => { @@ -256,6 +300,7 @@ t.describe('RPCClient', () => { }; mockProvider.invokeMethod.mockRejectedValue(new Error('Provider error')); await t.expect(rpcClient.invokeMethod(redirectOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); + t.expect(mockFetch).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/sdk-multichain/src/utis/rpc/client.ts b/packages/sdk-multichain/src/utis/rpc/client.ts index cfe337536..4c0440a8f 100644 --- a/packages/sdk-multichain/src/utis/rpc/client.ts +++ b/packages/sdk-multichain/src/utis/rpc/client.ts @@ -120,6 +120,8 @@ export class RPCClient { } else { readonlyRPCMap = urlsWithToken; } + } else { + readonlyRPCMap = readonlyRPCMapConfig ?? {}; } const rpcEndpoint = readonlyRPCMap[options.scope]; const isReadOnlyMethod = !METHODS_TO_REDIRECT[request.method]; From bd96bd09fc35d201a042c896887f97edb9c0b821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 16 Jul 2025 16:27:47 +0200 Subject: [PATCH 07/13] fix: remove empty lines --- packages/sdk-multichain/src/domain/errors/base.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/sdk-multichain/src/domain/errors/base.ts b/packages/sdk-multichain/src/domain/errors/base.ts index 9fefb99cb..fac7a6622 100644 --- a/packages/sdk-multichain/src/domain/errors/base.ts +++ b/packages/sdk-multichain/src/domain/errors/base.ts @@ -1,9 +1,5 @@ import type { ErrorCodes } from "./types"; - - - - export abstract class BaseErr extends Error { constructor( public readonly message: `${C}Err${T}: ${string}`, From 6644540dadef9f4847edfe684b063c02463d6e8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 16 Jul 2025 16:50:10 +0200 Subject: [PATCH 08/13] fix: remove biome lint err --- packages/sdk-multichain/src/utis/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk-multichain/src/utis/index.ts b/packages/sdk-multichain/src/utis/index.ts index 50988972f..af28a1431 100644 --- a/packages/sdk-multichain/src/utis/index.ts +++ b/packages/sdk-multichain/src/utis/index.ts @@ -39,7 +39,7 @@ export const extractFavicon = () => { return undefined; } - let favicon; + let favicon:string | null = null const nodeList = document.getElementsByTagName('link'); // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < nodeList.length; i++) { From d3b3a01b4f8b999ce6391ea26834068350a6e8b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 16 Jul 2025 16:50:45 +0200 Subject: [PATCH 09/13] fix: add missing jsdom pkg --- packages/sdk-multichain/package.json | 1 + yarn.lock | 265 ++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 2 deletions(-) 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/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" From 81c84fe6934a32caebe6f8e2d407fa1b08fcdeb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 16 Jul 2025 17:04:50 +0200 Subject: [PATCH 10/13] fix: code + test improvements --- packages/sdk-multichain/src/multichain/index.ts | 12 ++++++++---- packages/sdk-multichain/src/utis/index.ts | 4 ++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index cba7f85d8..0974d64ab 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -193,12 +193,16 @@ export class MultichainSDK extends EventEmitter implements Multichain const session = await this.provider.getSession() if (session) { - const noOverlap = Object.keys(session.sessionScopes).every(scope => scopes.includes(scope as Scope)); - if (noOverlap) { - // No overlap between existing and new scopes, return the existing 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 have overlap, revoke the session and create new one + // Scopes don't match (different set), revoke the session and create new one await this.provider.revokeSession(); } this.session = await this.provider.createSession({ optionalScopes }); diff --git a/packages/sdk-multichain/src/utis/index.ts b/packages/sdk-multichain/src/utis/index.ts index af28a1431..5c3ad2ce7 100644 --- a/packages/sdk-multichain/src/utis/index.ts +++ b/packages/sdk-multichain/src/utis/index.ts @@ -39,7 +39,7 @@ export const extractFavicon = () => { return undefined; } - let favicon:string | null = null + 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++) { @@ -47,7 +47,7 @@ export const extractFavicon = () => { nodeList[i].getAttribute('rel') === 'icon' || nodeList[i].getAttribute('rel') === 'shortcut icon' ) { - favicon = nodeList[i].getAttribute('href'); + favicon = nodeList[i].getAttribute('href') ?? undefined; } } return favicon; From d9c75c3e0fee30ff68e223b4d9af3f0f29a2eb9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 16 Jul 2025 17:13:04 +0200 Subject: [PATCH 11/13] fix: session upgrade test --- packages/sdk-multichain/src/index.test.ts | 28 ++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/sdk-multichain/src/index.test.ts b/packages/sdk-multichain/src/index.test.ts index 872326f47..1caed2bca 100644 --- a/packages/sdk-multichain/src/index.test.ts +++ b/packages/sdk-multichain/src/index.test.ts @@ -239,9 +239,12 @@ function createMultiplatformTestCase( 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; - // Mock no session scenario + + mockTransport.isConnected = false; + mockTransport.connect.mockResolvedValue(true); mockMultichainClient.getSession.mockResolvedValue(mockSessionData); sdk = await createSDK(options); @@ -250,8 +253,8 @@ function createMultiplatformTestCase( t.expect(sdk.isInitialized).toBe(true); t.expect(sdk.session).not.toBeUndefined(); - const mockedSessionUpgradeData: SessionData = { + ...mockSessionData, sessionScopes: { 'eip155:1': { methods: ['eth_sendTransaction', 'eth_accounts'], @@ -259,9 +262,27 @@ function createMultiplatformTestCase( accounts: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], }, }, - expiry: new Date(Date.now() + 3600000).toISOString(), }; + 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 () => { @@ -345,6 +366,7 @@ function createMultiplatformTestCase( t.expect(mockTransport.connect).toHaveBeenCalled(); t.expect(mockMultichainClient.getSession).toHaveBeenCalled(); + t.expect(mockMultichainClient.revokeSession).not.toHaveBeenCalled(); t.expect(mockMultichainClient.createSession).toHaveBeenCalledWith({ optionalScopes: { 'eip155:1': { From 70b307feb92e6addbc3042d38ec946572bba3d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 16 Jul 2025 17:15:12 +0200 Subject: [PATCH 12/13] fix: using provider before its defined in sdk constructor --- packages/sdk-multichain/src/multichain/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sdk-multichain/src/multichain/index.ts b/packages/sdk-multichain/src/multichain/index.ts index 0974d64ab..ca72bac72 100644 --- a/packages/sdk-multichain/src/multichain/index.ts +++ b/packages/sdk-multichain/src/multichain/index.ts @@ -60,14 +60,12 @@ export class MultichainSDK extends EventEmitter implements Multichain 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 ); - - this.provider = getMultichainClient({ transport: this.transport }); } private get transport() { From 7a99f2f8ff84e2031c74db5e7a7e8e67673f860a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Ribo=CC=81?= Date: Wed, 16 Jul 2025 17:16:48 +0200 Subject: [PATCH 13/13] fix: testing against wrong expect --- packages/sdk-multichain/src/utis/rpc/client.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk-multichain/src/utis/rpc/client.test.ts b/packages/sdk-multichain/src/utis/rpc/client.test.ts index e7e825135..89b177c04 100644 --- a/packages/sdk-multichain/src/utis/rpc/client.test.ts +++ b/packages/sdk-multichain/src/utis/rpc/client.test.ts @@ -94,8 +94,8 @@ t.describe('RPCClient', () => { t.it('should return headers with Metamask-Sdk-Info when RPC endpoint includes infura', () => { const infuraEndpoint = 'https://mainnet.infura.io/v3/test-key'; - const headers = (rpcClient as any).getHeaders(infuraEndpoint); - t.expect(headers).toEqual(headers); + const currentHeaders = (rpcClient as any).getHeaders(infuraEndpoint); + t.expect(currentHeaders).toEqual(headers); }); });