diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 00a8b8eb..a8ddc392 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -10,12 +10,16 @@ ignores: - '@lavamoat/allow-scripts' - '@metamask/auto-changelog' - '@metamask/create-release-branch' + - '@ts-bridge/cli' - 'depcheck' - 'eslint-interactive' + - 'jest' + - 'prettier-2' - 'rimraf' - 'simple-git-hooks' - 'ts-node' - 'typedoc' + - 'typescript' # Ignore plugins for tools - '@typescript-eslint/*' - 'babel-jest' diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index 0e1b2a87..5681afe8 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -24,27 +24,26 @@ jobs: echo "child-workspace-package-names=$(yarn workspaces list --no-private --json | jq --slurp --raw-output 'map(.name) | @json')" >> "$GITHUB_OUTPUT" shell: bash - ## TODO: Address as tech debt and re-introduce this step, making sure lint conventions followed in whole repo - # lint: - # name: Lint - # runs-on: ubuntu-latest - # needs: prepare - # strategy: - # matrix: - # node-version: [22.x] - # steps: - # - name: Checkout and setup environment - # uses: MetaMask/action-checkout-and-setup@v1 - # with: - # is-high-risk-environment: false - # - run: yarn lint - # - name: Require clean working directory - # shell: bash - # run: | - # if ! git diff --exit-code; then - # echo "Working tree dirty at end of job" - # exit 1 - # fi + lint: + name: Lint + runs-on: ubuntu-latest + needs: prepare + strategy: + matrix: + node-version: [22.x] + steps: + - name: Checkout and setup environment + uses: MetaMask/action-checkout-and-setup@v1 + with: + is-high-risk-environment: false + - run: yarn lint + - name: Require clean working directory + shell: bash + run: | + if ! git diff --exit-code; then + echo "Working tree dirty at end of job" + exit 1 + fi validate-changelog: name: Validate changelog diff --git a/docs/contributing.md b/docs/contributing.md index d414f3e8..fa654a5d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -102,7 +102,6 @@ If you're developing your project locally and want to test changes to a package, 1. First, you must build the monorepo, by running `yarn build`. 2. Next, you need to connect the package to your project by overriding the resolution logic in your package manager to replace the published version of the package with the local version. - 1. Open `package.json` in the project and locate the dependency entry for the package. 2. Locate the section responsible for resolution overrides (or create it if it doesn't exist). If you're using Yarn, this is `resolutions`; if you're using NPM or any other package manager, this is `overrides`. 3. Add a line to this section that mirrors the dependency entry on the left-hand side and points to the local path on the right-hand side: @@ -135,7 +134,6 @@ If you're a member of the MetaMask organization, you can create preview builds b 2. After a few minutes, the action should complete and you will see a new comment that lists the newly published packages along with their versions. Note two things about each package: - - The name is scoped to `@metamask-previews` instead of `@metamask`. - The ID of the last commit in the branch is appended to the version, e.g. `1.2.3-preview-e2df9b4` instead of `1.2.3`. @@ -158,7 +156,6 @@ If you've forked this repository, you can create preview builds based on a branc ``` You should be able to see the published version of each package in the output. Note two things: - - The name is scoped to the NPM organization you entered instead of `@metamask`. - The ID of the last commit in the branch is appended to the version, e.g. `1.2.3-preview-e2df9b4` instead of `1.2.3`. @@ -208,7 +205,6 @@ Use the following process to release new packages in this repo: 2. **Select packages to release.** The UI will show all packages with changes since their last release. For each package: - - Choose whether to include it in the release - Select an appropriate version bump (patch, minor, or major) following SemVer rules - The UI will automatically validate your selections and identify dependencies that need to be included @@ -216,12 +212,10 @@ Use the following process to release new packages in this repo: 3. **Review and resolve dependency requirements.** The UI automatically analyzes your selections and identifies potential dependency issues that need to be addressed before proceeding. You'll need to review and resolve these issues by either: - - Including the suggested additional packages - Confirming that you want to skip certain packages (if you're certain they don't need to be updated) Common types of dependency issues you might encounter: - - **Missing dependencies**: If you're releasing Package A that depends on Package B, the UI will prompt you to include Package B - **Breaking change impacts**: If you're releasing Package B with breaking changes, the UI will identify packages that have peer dependencies on Package B that need to be updated - **Version incompatibilities**: The UI will flag if your selected version bumps don't follow semantic versioning rules relative to dependent packages @@ -231,7 +225,6 @@ Use the following process to release new packages in this repo: 4. **Confirm your selections.** Once you're satisfied with your package selections and version bumps, confirm them in the UI. This will: - - Create a new branch named `release/` - Update the version in each package's `package.json` - Add a new section to each package's `CHANGELOG.md` for the new version @@ -239,7 +232,6 @@ Use the following process to release new packages in this repo: 5. **Review and update changelogs.** Each selected package will have a new changelog section. Review these entries to ensure they are helpful for consumers: - - Categorize entries appropriately following the ["Keep a Changelog"](https://keepachangelog.com/en/1.0.0/) guidelines. Ensure that no changes are listed under "Uncategorized". - Remove changelog entries that don't affect consumers of the package (e.g. lockfile changes or development environment changes). Exceptions may be made for changes that might be of interest despite not having an effect upon the published package (e.g. major test improvements, security improvements, improved documentation, etc.). - Reword changelog entries to explain changes in terms that users of the package will understand (e.g., avoid referencing internal variables/concepts). diff --git a/eslint.config.mjs b/eslint.config.mjs index 1b9d367c..155431ee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -75,6 +75,9 @@ const config = createConfig([ allowDefaultProject: [ './scripts/*.ts', 'packages/*/stencil.config.ts', + 'packages/*/*.config.ts', + 'playground/*/*.config.ts', + 'integrations/wagmi/wagmi.config.ts', ], }, }, diff --git a/integrations/wagmi/metamask-connector.ts b/integrations/wagmi/metamask-connector.ts index 52fca8e6..a2942115 100644 --- a/integrations/wagmi/metamask-connector.ts +++ b/integrations/wagmi/metamask-connector.ts @@ -1,3 +1,14 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Wagmi connector API */ +/* eslint-disable no-restricted-globals -- Browser connector uses window */ +/* eslint-disable @typescript-eslint/no-misused-promises -- Event handlers are async */ +/* eslint-disable require-atomic-updates -- Race conditions are acceptable for caching */ +/* eslint-disable id-denylist -- 'err' is clear in catch context */ +/* eslint-disable id-length -- 'x' is clear in lambda context */ +/* eslint-disable @typescript-eslint/no-shadow -- accounts shadow is intentional */ +/* eslint-disable no-nested-ternary -- Ternary chain is clearer here */ +/* eslint-disable jsdoc/require-param-description -- Wagmi connector API */ +/* eslint-disable jsdoc/require-returns -- Wagmi connector API */ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Provider is guaranteed after check */ import type { createEVMClient, EIP1193Provider, @@ -46,6 +57,10 @@ export type MetaMaskParameters = UnionCompute< type CreateEVMClientParameters = Parameters[0]; metaMask.type = 'metaMask' as const; +/** + * + * @param parameters + */ export function metaMask(parameters: MetaMaskParameters = {}) { type Provider = EIP1193Provider; type Properties = { @@ -67,7 +82,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { const provider = instance.getProvider(); let accounts: readonly Address[] = []; - if (isReconnecting) accounts = await this.getAccounts().catch(() => []); + if (isReconnecting) { + accounts = await this.getAccounts().catch(() => []); + } try { let signResponse: string | undefined; @@ -75,17 +92,18 @@ export function metaMask(parameters: MetaMaskParameters = {}) { if (!accounts?.length) { const chainIds = config.chains.map((chain) => chain.id); if (parameters.connectAndSign || parameters.connectWith) { - if (parameters.connectAndSign) + if (parameters.connectAndSign) { signResponse = await instance.connectAndSign({ chainIds, message: parameters.connectAndSign, }); - else if (parameters.connectWith) + } else if (parameters.connectWith) { connectWithResponse = await instance.connectWith({ chainIds, method: parameters.connectWith.method, params: parameters.connectWith.params, }); + } accounts = await this.getAccounts(); } else { @@ -94,27 +112,30 @@ export function metaMask(parameters: MetaMaskParameters = {}) { } } // Switch to chain if provided - let currentChainId = (await this.getChainId()) as number; + let currentChainId = await this.getChainId(); if (chainId && currentChainId !== chainId) { const chain = await this.switchChain!({ chainId }).catch((error) => { - if (error.code === UserRejectedRequestError.code) throw error; + if (error.code === UserRejectedRequestError.code) { + throw error; + } return { id: currentChainId }; }); currentChainId = chain?.id ?? currentChainId; } - if (signResponse) + if (signResponse) { provider.emit('connectAndSign', { accounts, chainId: currentChainId, signResponse, }); - else if (connectWithResponse) + } else if (connectWithResponse) { provider.emit('connectWith', { accounts, chainId: currentChainId, connectWithResponse, }); + } return { // TODO(v3): Make `withCapabilities: true` default behavior @@ -125,10 +146,12 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }; } catch (err) { const error = err as RpcError; - if (error.code === UserRejectedRequestError.code) + if (error.code === UserRejectedRequestError.code) { throw new UserRejectedRequestError(error); - if (error.code === ResourceUnavailableRpcError.code) + } + if (error.code === ResourceUnavailableRpcError.code) { throw new ResourceUnavailableRpcError(error); + } throw error; } }, @@ -138,8 +161,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async getAccounts() { const instance = await this.getInstance(); - if (instance.accounts.length) + if (instance.accounts.length) { return instance.accounts.map((x) => getAddress(x)); + } // Fallback to provider if SDK doesn't return accounts const provider = instance.getProvider(); const accounts = (await provider.request({ @@ -149,7 +173,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async getChainId() { const instance = await this.getInstance(); - if (instance.getChainId()) return Number(instance.getChainId()); + if (instance.getChainId()) { + return Number(instance.getChainId()); + } // Fallback to provider if SDK doesn't return chainId const provider = instance.getProvider(); const chainId = await provider.request({ method: 'eth_chainId' }); @@ -165,25 +191,29 @@ export function metaMask(parameters: MetaMaskParameters = {}) { // JSON-RPC requests on page load const timeout = 10; const accounts = await withRetry( - () => + async () => withTimeout( async () => { const accounts = await this.getAccounts(); - if (!accounts.length) throw new Error('try again'); + if (!accounts.length) { + throw new Error('try again'); + } return accounts; }, { timeout }, ), { delay: timeout + 1, retryCount: 3 }, ); - return !!accounts.length; + return Boolean(accounts.length); } catch { return false; } }, async switchChain({ addEthereumChainParameter, chainId }) { const chain = config.chains.find(({ id }) => id === chainId); - if (!chain) throw new SwitchChainError(new ChainNotConfiguredError()); + if (!chain) { + throw new SwitchChainError(new ChainNotConfiguredError()); + } try { const instance = await this.getInstance(); @@ -211,8 +241,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { } catch (err) { const error = err as RpcError; - if (error.code === UserRejectedRequestError.code) + if (error.code === UserRejectedRequestError.code) { throw new UserRejectedRequestError(error); + } throw new SwitchChainError(error); } @@ -228,7 +259,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async onConnect(connectInfo) { const accounts = await this.getAccounts(); - if (accounts.length === 0) return; + if (accounts.length === 0) { + return; + } const chainId = Number(connectInfo.chainId); config.emitter.emit('connect', { accounts, chainId }); @@ -238,7 +271,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { // https://github.com/MetaMask/providers/pull/120 if (error && (error as RpcError<1013>).code === 1013) { const provider = await this.getProvider(); - if (provider && !!(await this.getAccounts()).length) return; + if (provider && Boolean((await this.getAccounts()).length)) { + return; + } } config.emitter.emit('disconnect'); @@ -249,7 +284,7 @@ export function metaMask(parameters: MetaMaskParameters = {}) { async getInstance() { if (!metamask) { if (!metamaskPromise) { - const { createEVMClient } = await (() => { + const { createEVMClient } = await (async () => { try { return import('@metamask/connect-evm'); } catch { @@ -266,16 +301,24 @@ export function metaMask(parameters: MetaMaskParameters = {}) { ), }, dapp: (() => { - if (parameters.dappMetadata) return parameters.dappMetadata; - if (parameters.dapp) return parameters.dapp; - if (typeof window === 'undefined') return { name: 'wagmi' }; + if (parameters.dappMetadata) { + return parameters.dappMetadata; + } + if (parameters.dapp) { + return parameters.dapp; + } + if (typeof window === 'undefined') { + return { name: 'wagmi' }; + } return { name: window.location.hostname, url: window.location.href, }; })(), debug: (() => { - if (parameters.logging) return true; + if (parameters.logging) { + return true; + } return parameters.debug; })(), eventHandlers: { diff --git a/integrations/wagmi/src/contracts.ts b/integrations/wagmi/src/contracts.ts index d7d66754..d1ab9652 100644 --- a/integrations/wagmi/src/contracts.ts +++ b/integrations/wagmi/src/contracts.ts @@ -199,4 +199,4 @@ export const wagmiContractConfig = { type: 'function', }, ], -} as const +} as const; diff --git a/integrations/wagmi/src/vite-env.d.ts b/integrations/wagmi/src/vite-env.d.ts index 11f02fe2..14fa7492 100644 --- a/integrations/wagmi/src/vite-env.d.ts +++ b/integrations/wagmi/src/vite-env.d.ts @@ -1 +1 @@ -/// +// / diff --git a/integrations/wagmi/src/wagmi.ts b/integrations/wagmi/src/wagmi.ts index d2d77c02..ca42ade1 100644 --- a/integrations/wagmi/src/wagmi.ts +++ b/integrations/wagmi/src/wagmi.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-globals -- Browser wagmi config uses window */ import { createConfig, http } from 'wagmi'; import { celo, mainnet, optimism, sepolia } from 'wagmi/chains'; @@ -6,9 +7,11 @@ import { metaMask } from '../metamask-connector'; export const config = createConfig({ chains: [mainnet, sepolia, optimism, celo], connectors: [ - metaMask({ dapp: { - name: window.location.hostname, - url: window.location.href,} + metaMask({ + dapp: { + name: window.location.hostname, + url: window.location.href, + }, }), ], transports: { @@ -20,6 +23,7 @@ export const config = createConfig({ }); declare module 'wagmi' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Register { config: typeof config; } diff --git a/integrations/wagmi/tsconfig.node.json b/integrations/wagmi/tsconfig.node.json index 42872c59..ef0fd3f5 100644 --- a/integrations/wagmi/tsconfig.node.json +++ b/integrations/wagmi/tsconfig.node.json @@ -4,7 +4,8 @@ "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "strictNullChecks": true }, "include": ["vite.config.ts"] } diff --git a/integrations/wagmi/vite.config.ts b/integrations/wagmi/vite.config.ts index 08e4b7c7..86a5fa03 100644 --- a/integrations/wagmi/vite.config.ts +++ b/integrations/wagmi/vite.config.ts @@ -1,8 +1,8 @@ -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], base: './', -}) +}); diff --git a/integrations/wagmi/wagmi.config.ts b/integrations/wagmi/wagmi.config.ts index d524220b..87c709a5 100644 --- a/integrations/wagmi/wagmi.config.ts +++ b/integrations/wagmi/wagmi.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from '@wagmi/cli' -import { foundry, hardhat } from '@wagmi/cli/plugins' +import { defineConfig } from '@wagmi/cli'; +import { foundry, hardhat } from '@wagmi/cli/plugins'; export default defineConfig({ out: 'src/generated.ts', @@ -14,4 +14,4 @@ export default defineConfig({ project: '../../packages/cli/src/plugins/__fixtures__/hardhat', }), ], -}) +}); diff --git a/packages/analytics/src/schema.ts b/packages/analytics/src/schema.ts index 863caa34..43396018 100644 --- a/packages/analytics/src/schema.ts +++ b/packages/analytics/src/schema.ts @@ -1,527 +1,592 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Auto-generated API schema types */ /** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ -export interface paths { - "/v1/events": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; +export type paths = { + '/v1/events': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Track events + * + * @description Endpoint to submit analytics events for the MetaMask SDK (version 1). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['Event'][]; }; - get?: never; - put?: never; - /** - * Track events - * @description Endpoint to submit analytics events for the MetaMask SDK (version 1). - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["Event"][]; - }; - }; - responses: { - /** @description Events tracked successfully */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** - * @description Indicates the success of the event tracking. - * @example success - */ - status?: string; - }; - }; - }; + }; + responses: { + /** @description Events tracked successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** + * @description Indicates the success of the event tracking. + * @example success + */ + status?: string; }; + }; }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; + }; }; - "/v2/events": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/events': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Track V2 namespaced events + * + * @description Endpoint to submit namespaced analytics events for the MetaMask SDK (version 2). + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['EventV2'][]; }; - get?: never; - put?: never; - /** - * Track V2 namespaced events - * @description Endpoint to submit namespaced analytics events for the MetaMask SDK (version 2). - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["EventV2"][]; - }; - }; - responses: { - /** @description Events tracked successfully */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** - * @description Indicates the success of the event tracking. - * @example success - */ - status?: string; - }; - }; - }; + }; + responses: { + /** @description Events tracked successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** + * @description Indicates the success of the event tracking. + * @example success + */ + status?: string; }; + }; }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; + }; }; -} + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +}; export type webhooks = Record; -export interface components { - schemas: { - /** @description A union of all possible event types, differentiated by the 'name' property. */ - Event: components["schemas"]["SdkInitializedEvent"] | components["schemas"]["SdkUsedChainEvent"] | components["schemas"]["SdkConnectionInitiatedEvent"] | components["schemas"]["SdkConnectionEstablishedEvent"] | components["schemas"]["SdkConnectionRejectedEvent"] | components["schemas"]["SdkConnectionFailedEvent"] | components["schemas"]["WalletConnectionRequestReceivedEvent"] | components["schemas"]["WalletConnectionUserApprovedEvent"] | components["schemas"]["WalletConnectionUserRejectedEvent"] | components["schemas"]["SdkActionRequestedEvent"] | components["schemas"]["SdkActionSucceededEvent"] | components["schemas"]["SdkActionFailedEvent"] | components["schemas"]["SdkActionRejectedEvent"] | components["schemas"]["WalletActionReceivedEvent"] | components["schemas"]["WalletActionUserApprovedEvent"] | components["schemas"]["WalletActionUserRejectedEvent"]; - SdkInitializedEvent: { - /** - * @description Identifies the event as SDK initialization. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_initialized"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - SdkUsedChainEvent: { - /** - * @description Identifies the event as SDK chain usage. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_used_chain"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** @description CAIP-2 chain ID used by the SDK. */ - caip_chain_id: string; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - SdkConnectionInitiatedEvent: { - /** - * @description Identifies the event as connection initiation. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_connection_initiated"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Type of transport used for the connection. - * @enum {string} - */ - transport_type: "direct" | "websocket" | "deeplink"; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - SdkConnectionEstablishedEvent: { - /** - * @description Identifies the event as connection established. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_connection_established"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Type of transport used for the connection. - * @enum {string} - */ - transport_type: "direct" | "websocket" | "deeplink"; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - SdkConnectionRejectedEvent: { - /** - * @description Identifies the event as connection rejected. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_connection_rejected"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Type of transport used for the connection. - * @enum {string} - */ - transport_type: "direct" | "websocket" | "deeplink"; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - SdkConnectionFailedEvent: { - /** - * @description Identifies the event as connection failed. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_connection_failed"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Type of transport used for the connection. - * @enum {string} - */ - transport_type: "direct" | "websocket" | "deeplink"; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - WalletConnectionRequestReceivedEvent: { - /** - * @description Identifies the event as connection request received. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "wallet_connection_request_received"; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Platform receiving the connection request. - * @enum {string} - */ - platform: "extension" | "mobile"; - }; - WalletConnectionUserApprovedEvent: { - /** - * @description Identifies the event as user-approved connection. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "wallet_connection_user_approved"; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Platform where the approval occurred. - * @enum {string} - */ - platform: "extension" | "mobile"; - }; - WalletConnectionUserRejectedEvent: { - /** - * @description Identifies the event as user-rejected connection. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "wallet_connection_user_rejected"; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Platform where the rejection occurred. - * @enum {string} - */ - platform: "extension" | "mobile"; - }; - SdkActionRequestedEvent: { - /** - * @description Identifies the event as a wallet action request. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_action_requested"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** @description The specific wallet action requested. */ - action: string; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - SdkActionSucceededEvent: { - /** - * @description Identifies the event as a successful wallet action. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_action_succeeded"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** @description The specific wallet action that succeeded. */ - action: string; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - SdkActionFailedEvent: { - /** - * @description Identifies the event as a failed wallet action. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_action_failed"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** @description The specific wallet action that failed. */ - action: string; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - SdkActionRejectedEvent: { - /** - * @description Identifies the event as a rejected wallet action. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "sdk_action_rejected"; - /** @description Version of the SDK. */ - sdk_version: string; - /** @description Unique identifier for the dApp. */ - dapp_id: string; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** @description The specific wallet action that was rejected. */ - action: string; - /** - * @description Platform on which the SDK is running. - * @enum {string} - */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - /** @description Type of integration used by the SDK. */ - integration_type: string; - }; - WalletActionReceivedEvent: { - /** - * @description Identifies the event as a received wallet action. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "wallet_action_received"; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Platform receiving the wallet action. - * @enum {string} - */ - platform: "extension" | "mobile"; - }; - WalletActionUserApprovedEvent: { - /** - * @description Identifies the event as an approved wallet action. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "wallet_action_user_approved"; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Platform where the approval occurred. - * @enum {string} - */ - platform: "extension" | "mobile"; - }; - WalletActionUserRejectedEvent: { - /** - * @description Identifies the event as a rejected wallet action. (enum property replaced by openapi-typescript) - * @enum {string} - */ - name: "wallet_action_user_rejected"; - /** - * Format: uuid - * @description Anonymous identifier for the user or session. - */ - anon_id: string; - /** - * @description Platform where the rejection occurred. - * @enum {string} - */ - platform: "extension" | "mobile"; - }; - EventV2: components["schemas"]["MMConnectPayload"] | components["schemas"]["MobileSDKConnectV2Payload"]; - MMConnectPayload: { - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - namespace: "metamask/connect"; - /** @enum {string} */ - event_name: "mmconnect_initialized" | "mmconnect_connection_initiated" | "mmconnect_connection_established" | "mmconnect_connection_rejected" | "mmconnect_connection_failed" | "mmconnect_wallet_action_requested" | "mmconnect_wallet_action_succeeded" | "mmconnect_wallet_action_failed" | "mmconnect_wallet_action_rejected"; - properties: components["schemas"]["MMConnectProperties"]; - }; - MobileSDKConnectV2Payload: { - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - namespace: "mobile/sdk-connect-v2"; - /** @enum {string} */ - event_name: "wallet_connection_request_received" | "wallet_connection_request_failed" | "wallet_connection_user_approved" | "wallet_connection_user_rejected" | "wallet_action_received" | "wallet_action_user_approved" | "wallet_action_user_rejected"; - properties: components["schemas"]["MobileSDKConnectV2Properties"]; - }; - MMConnectProperties: { - mmconnect_version: string; - dapp_id: string; - /** Format: uuid */ - anon_id: string; - /** @enum {string} */ - platform: "web-desktop" | "web-mobile" | "nodejs" | "in-app-browser" | "react-native"; - integration_type: string; - /** @enum {string} */ - transport_type?: "browser" | "mwp" | "unknown"; - method?: string; - caip_chain_id?: string; - /** @description Array of CAIP-2 chain IDs that the dApp has configured */ - dapp_configured_chains?: string[]; - /** @description Array of CAIP-2 chain IDs that the dApp has requested */ - dapp_requested_chains?: string[]; - /** @description Array of CAIP-2 chain IDs that the user has permissioned */ - user_permissioned_chains?: string[]; - }; - MobileSDKConnectV2Properties: { - /** Format: uuid */ - anon_id: string; - /** @enum {string} */ - platform: "mobile"; - }; +export type components = { + schemas: { + /** @description A union of all possible event types, differentiated by the 'name' property. */ + Event: + | components['schemas']['SdkInitializedEvent'] + | components['schemas']['SdkUsedChainEvent'] + | components['schemas']['SdkConnectionInitiatedEvent'] + | components['schemas']['SdkConnectionEstablishedEvent'] + | components['schemas']['SdkConnectionRejectedEvent'] + | components['schemas']['SdkConnectionFailedEvent'] + | components['schemas']['WalletConnectionRequestReceivedEvent'] + | components['schemas']['WalletConnectionUserApprovedEvent'] + | components['schemas']['WalletConnectionUserRejectedEvent'] + | components['schemas']['SdkActionRequestedEvent'] + | components['schemas']['SdkActionSucceededEvent'] + | components['schemas']['SdkActionFailedEvent'] + | components['schemas']['SdkActionRejectedEvent'] + | components['schemas']['WalletActionReceivedEvent'] + | components['schemas']['WalletActionUserApprovedEvent'] + | components['schemas']['WalletActionUserRejectedEvent']; + SdkInitializedEvent: { + /** + * @description Identifies the event as SDK initialization. (enum property replaced by openapi-typescript) + */ + name: 'sdk_initialized'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + SdkUsedChainEvent: { + /** + * @description Identifies the event as SDK chain usage. (enum property replaced by openapi-typescript) + */ + name: 'sdk_used_chain'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** @description CAIP-2 chain ID used by the SDK. */ + caip_chain_id: string; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + SdkConnectionInitiatedEvent: { + /** + * @description Identifies the event as connection initiation. (enum property replaced by openapi-typescript) + */ + name: 'sdk_connection_initiated'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Type of transport used for the connection. + */ + transport_type: 'direct' | 'websocket' | 'deeplink'; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + SdkConnectionEstablishedEvent: { + /** + * @description Identifies the event as connection established. (enum property replaced by openapi-typescript) + */ + name: 'sdk_connection_established'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Type of transport used for the connection. + */ + transport_type: 'direct' | 'websocket' | 'deeplink'; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + SdkConnectionRejectedEvent: { + /** + * @description Identifies the event as connection rejected. (enum property replaced by openapi-typescript) + */ + name: 'sdk_connection_rejected'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Type of transport used for the connection. + */ + transport_type: 'direct' | 'websocket' | 'deeplink'; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + SdkConnectionFailedEvent: { + /** + * @description Identifies the event as connection failed. (enum property replaced by openapi-typescript) + */ + name: 'sdk_connection_failed'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Type of transport used for the connection. + */ + transport_type: 'direct' | 'websocket' | 'deeplink'; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + WalletConnectionRequestReceivedEvent: { + /** + * @description Identifies the event as connection request received. (enum property replaced by openapi-typescript) + */ + name: 'wallet_connection_request_received'; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Platform receiving the connection request. + */ + platform: 'extension' | 'mobile'; + }; + WalletConnectionUserApprovedEvent: { + /** + * @description Identifies the event as user-approved connection. (enum property replaced by openapi-typescript) + */ + name: 'wallet_connection_user_approved'; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Platform where the approval occurred. + */ + platform: 'extension' | 'mobile'; + }; + WalletConnectionUserRejectedEvent: { + /** + * @description Identifies the event as user-rejected connection. (enum property replaced by openapi-typescript) + */ + name: 'wallet_connection_user_rejected'; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Platform where the rejection occurred. + */ + platform: 'extension' | 'mobile'; + }; + SdkActionRequestedEvent: { + /** + * @description Identifies the event as a wallet action request. (enum property replaced by openapi-typescript) + */ + name: 'sdk_action_requested'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** @description The specific wallet action requested. */ + action: string; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + SdkActionSucceededEvent: { + /** + * @description Identifies the event as a successful wallet action. (enum property replaced by openapi-typescript) + */ + name: 'sdk_action_succeeded'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** @description The specific wallet action that succeeded. */ + action: string; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + SdkActionFailedEvent: { + /** + * @description Identifies the event as a failed wallet action. (enum property replaced by openapi-typescript) + */ + name: 'sdk_action_failed'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** @description The specific wallet action that failed. */ + action: string; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + SdkActionRejectedEvent: { + /** + * @description Identifies the event as a rejected wallet action. (enum property replaced by openapi-typescript) + */ + name: 'sdk_action_rejected'; + /** @description Version of the SDK. */ + sdk_version: string; + /** @description Unique identifier for the dApp. */ + dapp_id: string; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** @description The specific wallet action that was rejected. */ + action: string; + /** + * @description Platform on which the SDK is running. + */ + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + /** @description Type of integration used by the SDK. */ + integration_type: string; + }; + WalletActionReceivedEvent: { + /** + * @description Identifies the event as a received wallet action. (enum property replaced by openapi-typescript) + */ + name: 'wallet_action_received'; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Platform receiving the wallet action. + */ + platform: 'extension' | 'mobile'; + }; + WalletActionUserApprovedEvent: { + /** + * @description Identifies the event as an approved wallet action. (enum property replaced by openapi-typescript) + */ + name: 'wallet_action_user_approved'; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Platform where the approval occurred. + */ + platform: 'extension' | 'mobile'; + }; + WalletActionUserRejectedEvent: { + /** + * @description Identifies the event as a rejected wallet action. (enum property replaced by openapi-typescript) + */ + name: 'wallet_action_user_rejected'; + /** + * Format: uuid + * + * @description Anonymous identifier for the user or session. + */ + anon_id: string; + /** + * @description Platform where the rejection occurred. + */ + platform: 'extension' | 'mobile'; + }; + EventV2: + | components['schemas']['MMConnectPayload'] + | components['schemas']['MobileSDKConnectV2Payload']; + MMConnectPayload: { + /** + * @description discriminator enum property added by openapi-typescript + */ + namespace: 'metamask/connect'; + event_name: + | 'mmconnect_initialized' + | 'mmconnect_connection_initiated' + | 'mmconnect_connection_established' + | 'mmconnect_connection_rejected' + | 'mmconnect_connection_failed' + | 'mmconnect_wallet_action_requested' + | 'mmconnect_wallet_action_succeeded' + | 'mmconnect_wallet_action_failed' + | 'mmconnect_wallet_action_rejected'; + properties: components['schemas']['MMConnectProperties']; + }; + MobileSDKConnectV2Payload: { + /** + * @description discriminator enum property added by openapi-typescript + */ + namespace: 'mobile/sdk-connect-v2'; + event_name: + | 'wallet_connection_request_received' + | 'wallet_connection_request_failed' + | 'wallet_connection_user_approved' + | 'wallet_connection_user_rejected' + | 'wallet_action_received' + | 'wallet_action_user_approved' + | 'wallet_action_user_rejected'; + properties: components['schemas']['MobileSDKConnectV2Properties']; + }; + MMConnectProperties: { + mmconnect_version: string; + dapp_id: string; + /** Format: uuid */ + anon_id: string; + platform: + | 'web-desktop' + | 'web-mobile' + | 'nodejs' + | 'in-app-browser' + | 'react-native'; + integration_type: string; + transport_type?: 'browser' | 'mwp' | 'unknown'; + method?: string; + caip_chain_id?: string; + /** @description Array of CAIP-2 chain IDs that the dApp has configured */ + dapp_configured_chains?: string[]; + /** @description Array of CAIP-2 chain IDs that the dApp has requested */ + dapp_requested_chains?: string[]; + /** @description Array of CAIP-2 chain IDs that the user has permissioned */ + user_permissioned_chains?: string[]; + }; + MobileSDKConnectV2Properties: { + /** Format: uuid */ + anon_id: string; + platform: 'mobile'; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +}; export type $defs = Record; export type operations = Record; diff --git a/packages/analytics/tsconfig.build.json b/packages/analytics/tsconfig.build.json index f5038561..ba08889f 100644 --- a/packages/analytics/tsconfig.build.json +++ b/packages/analytics/tsconfig.build.json @@ -1,10 +1,10 @@ - { - "extends": "../../tsconfig.packages.build.json", - "compilerOptions": { - "composite": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["./src"], - "exclude": ["node_modules", "dist"] - } +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json index bd163aee..acd83246 100644 --- a/packages/analytics/tsconfig.json +++ b/packages/analytics/tsconfig.json @@ -1,9 +1,9 @@ - { - "extends": "../../tsconfig.packages.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["./src"], - "exclude": ["node_modules", "dist"] - } +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/connect-evm/src/connect.test.ts b/packages/connect-evm/src/connect.test.ts index 7749ee19..34a74163 100644 --- a/packages/connect-evm/src/connect.test.ts +++ b/packages/connect-evm/src/connect.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ import { describe, it, expect } from 'vitest'; describe('smoke', () => { diff --git a/packages/connect-evm/src/connect.ts b/packages/connect-evm/src/connect.ts index b28ecd6d..0424f643 100644 --- a/packages/connect-evm/src/connect.ts +++ b/packages/connect-evm/src/connect.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-syntax -- Private class properties use established patterns */ import { analytics } from '@metamask/analytics'; import type { Caip25CaveatValue } from '@metamask/chain-agnostic-permission'; import type { @@ -840,7 +841,10 @@ export class MetamaskConnectEVM { // Skip session recovery if transport is not initialized yet. // Transport is only initialized when there's a stored session or after connect() is called. // Only attempt recovery if we're in a state where transport should be available. - if (this.#core.status !== 'connected' && this.#core.status !== 'connecting') { + if ( + this.#core.status !== 'connected' && + this.#core.status !== 'connecting' + ) { return; } try { @@ -962,8 +966,9 @@ export class MetamaskConnectEVM { * @returns The Metamask Connect/EVM layer instance */ export async function createEVMClient( - options: Pick & - { ui?: Omit; } & { + options: Pick & { + ui?: Omit; + } & { eventHandlers?: Partial; debug?: boolean; }, diff --git a/packages/connect-evm/src/provider.ts b/packages/connect-evm/src/provider.ts index 7026cee7..302d1a98 100644 --- a/packages/connect-evm/src/provider.ts +++ b/packages/connect-evm/src/provider.ts @@ -1,3 +1,8 @@ +/* eslint-disable promise/always-return -- Legacy callback patterns */ +/* eslint-disable promise/no-callback-in-promise -- Legacy sendAsync/send API */ +/* eslint-disable consistent-return -- Legacy method returns void or Promise */ +/* eslint-disable @typescript-eslint/no-floating-promises -- Legacy fire-and-forget pattern */ +/* eslint-disable jsdoc/require-returns -- Inherited from abstract class */ import type { MultichainCore, Scope } from '@metamask/connect-multichain'; import { EventEmitter } from '@metamask/connect-multichain'; import { hexToNumber, numberToHex } from '@metamask/utils'; @@ -141,6 +146,7 @@ export class EIP1193Provider extends EventEmitter { /** * Legacy method for sending JSON-RPC requests. + * * @deprecated Use `request` instead. This method is provided for backwards compatibility. * @param request - The JSON-RPC request object * @param callback - Optional callback function. If provided, the method returns void. @@ -195,6 +201,7 @@ export class EIP1193Provider extends EventEmitter { /** * Legacy method for sending JSON-RPC requests synchronously (callback-based). + * * @deprecated Use `request` instead. This method is provided for backwards compatibility. * @param request - The JSON-RPC request object * @param callback - The callback function to receive the response diff --git a/packages/connect-evm/src/types.ts b/packages/connect-evm/src/types.ts index a66a7d11..3203938c 100644 --- a/packages/connect-evm/src/types.ts +++ b/packages/connect-evm/src/types.ts @@ -31,7 +31,7 @@ export type EIP1193ProviderEvents = { }; export type EventHandlers = { - connect: (result: { chainId: string, accounts: Address[] }) => void; + connect: (result: { chainId: string; accounts: Address[] }) => void; disconnect: () => void; accountsChanged: (accounts: Address[]) => void; chainChanged: (chainId: Hex) => void; @@ -122,6 +122,7 @@ export type ProviderRequestInterceptor = ( ) => Promise; // JSON-RPC types for legacy compatibility (sendAsync/send) +// eslint-disable-next-line @typescript-eslint/naming-convention -- T is standard type parameter export type JsonRpcRequest = { id?: number | string; jsonrpc?: '2.0'; @@ -129,6 +130,7 @@ export type JsonRpcRequest = { params?: T; }; +// eslint-disable-next-line @typescript-eslint/naming-convention -- T is standard type parameter export type JsonRpcResponse = { id: number | string; jsonrpc: '2.0'; @@ -140,6 +142,7 @@ export type JsonRpcResponse = { }; }; +// eslint-disable-next-line @typescript-eslint/naming-convention -- T is standard type parameter export type JsonRpcCallback = ( error: Error | null, response: JsonRpcResponse | null, diff --git a/packages/connect-evm/tsconfig.build.json b/packages/connect-evm/tsconfig.build.json index 0b9617d3..902233de 100644 --- a/packages/connect-evm/tsconfig.build.json +++ b/packages/connect-evm/tsconfig.build.json @@ -25,11 +25,5 @@ { "path": "../connect-multichain/tsconfig.build.json" } ], "include": ["./src"], - "exclude": [ - "node_modules", - "dist", - "./tests", - "**/*.spec.ts", - "**/*.test.ts" - ], + "exclude": ["node_modules", "dist", "./tests", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/packages/connect-evm/tsconfig.json b/packages/connect-evm/tsconfig.json index ee0f475d..f679d33d 100644 --- a/packages/connect-evm/tsconfig.json +++ b/packages/connect-evm/tsconfig.json @@ -18,5 +18,5 @@ { "path": "../connect-multichain/tsconfig.build.json" } ], "include": ["./src", "./tests"], - "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] + "exclude": ["node_modules", "dist"] } diff --git a/packages/connect-evm/tsup.config.ts b/packages/connect-evm/tsup.config.ts index f84398fb..26c65f8b 100644 --- a/packages/connect-evm/tsup.config.ts +++ b/packages/connect-evm/tsup.config.ts @@ -1,10 +1,17 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Tsup config convention */ + import { defineConfig } from 'tsup'; + import pkg from './package.json'; -const deps = Object.keys((pkg as any).dependencies || {}); -const peerDeps = Object.keys((pkg as any).peerDependencies || {}); +const deps = Object.keys( + (pkg as { dependencies?: Record }).dependencies ?? {}, +); +const peerDeps = Object.keys( + (pkg as { peerDependencies?: Record }).peerDependencies ?? {}, +); const external = [...deps, ...peerDeps]; -const entryName = (pkg as any).name.replace('@metamask/', ''); +const entryName = (pkg as { name: string }).name.replace('@metamask/', ''); export default defineConfig([ { @@ -18,11 +25,11 @@ export default defineConfig([ sourcemap: true, external, tsconfig: './tsconfig.json', - esbuildOptions: (o) => { - o.platform = 'browser'; - o.mainFields = ['browser', 'module', 'main']; - o.conditions = ['browser']; - o.outExtension = { '.js': '.mjs' }; + esbuildOptions: (options) => { + options.platform = 'browser'; + options.mainFields = ['browser', 'module', 'main']; + options.conditions = ['browser']; + options.outExtension = { '.js': '.mjs' }; }, }, { diff --git a/packages/connect-multichain/jest.config.js b/packages/connect-multichain/jest.config.js index ca084133..3b71c080 100644 --- a/packages/connect-multichain/jest.config.js +++ b/packages/connect-multichain/jest.config.js @@ -3,6 +3,8 @@ * https://jestjs.io/docs/configuration */ +/* eslint-disable import-x/no-unresolved -- Jest config pattern */ +/* eslint-disable import-x/extensions -- Jest config pattern */ const merge = require('deepmerge'); const path = require('path'); diff --git a/packages/connect-multichain/package.json b/packages/connect-multichain/package.json index e5a9ab91..b0c77523 100644 --- a/packages/connect-multichain/package.json +++ b/packages/connect-multichain/package.json @@ -50,6 +50,7 @@ "dist/" ], "scripts": { + "allow-scripts": "", "build": "yarn clean && tsc -b tsconfig.build.json && npx tsup", "build:docs": "typedoc", "changelog:format": "../../scripts/format-changelog.sh @metamask/connect-multichain", @@ -66,8 +67,7 @@ "test:ci": "yarn pretest:ci && vitest run --coverage --coverage.reporter=text --silent", "test:unit": "vitest run", "test:verbose": "vitest run --reporter=verbose", - "test:watch": "vitest watch", - "allow-scripts": "" + "test:watch": "vitest watch" }, "dependencies": { "@metamask/analytics": "workspace:^", diff --git a/packages/connect-multichain/src/connect.test.ts b/packages/connect-multichain/src/connect.test.ts index f6f17964..8cbeec90 100644 --- a/packages/connect-multichain/src/connect.test.ts +++ b/packages/connect-multichain/src/connect.test.ts @@ -1,4 +1,19 @@ +/* eslint-disable @typescript-eslint/no-unused-vars -- Test types */ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable jsdoc/require-param-description -- Test helpers */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test functions */ +/* eslint-disable @typescript-eslint/naming-convention -- Test naming */ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ +/* eslint-disable no-plusplus -- Test loops */ +/* eslint-disable promise/param-names -- Test promise patterns */ +/* eslint-disable no-negated-condition -- Test assertions */ +/* eslint-disable @typescript-eslint/unbound-method -- Mock assertions */ +/* eslint-disable prefer-const -- Incremental test variables */ +/* eslint-disable @typescript-eslint/no-floating-promises -- Test assertions */ +/* eslint-disable no-restricted-globals -- Test environment mocks */ +import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; import * as t from 'vitest'; + import type { MultichainOptions, MultichainCore, @@ -6,17 +21,20 @@ import type { SessionData, } from './domain'; // Careful, order of import matters to keep mocks working +import { Store } from './store'; +import { mockSessionData, mockSessionRequestData } from '../tests/data'; import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, runTestsInWebMobileEnv, } from '../tests/fixtures.test'; -import { Store } from './store'; -import { mockSessionData, mockSessionRequestData } from '../tests/data'; import type { TestSuiteOptions, MockedData } from '../tests/types'; -import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; +/** + * + * @param sdk + */ async function waitForInstallModal(sdk: MultichainCore) { // Spy on the UI factory's renderInstallModal instead of the private #showInstallModal const onRenderInstallModal = t.vi.spyOn( @@ -37,6 +55,10 @@ async function waitForInstallModal(sdk: MultichainCore) { t.expect(onRenderInstallModal).toHaveBeenCalled(); } +/** + * + * @param sdk + */ async function expectUIFactoryRenderInstallModal(sdk: MultichainCore) { const onRenderInstallModal = t.vi.spyOn( (sdk as any).options.ui.factory, @@ -56,6 +78,13 @@ async function expectUIFactoryRenderInstallModal(sdk: MultichainCore) { t.expect(onRenderInstallModal).toHaveBeenCalled(); } +/** + * + * @param options0 + * @param options0.platform + * @param options0.createSDK + * @param options0.options + */ function testSuite({ platform, createSDK, @@ -97,15 +126,15 @@ function testSuite({ ui: uiOptions, storage: new Store({ platform: platform as 'web' | 'rn' | 'node', - get(key) { + async get(key) { return Promise.resolve(mockedData.nativeStorageStub.getItem(key)); }, - set(key, value) { + async set(key, value) { return Promise.resolve( mockedData.nativeStorageStub.setItem(key, value), ); }, - delete(key) { + async delete(key) { return Promise.resolve( mockedData.nativeStorageStub.removeItem(key), ); @@ -121,11 +150,11 @@ function testSuite({ t.it(`${platform} should handle transport connection errors`, async () => { const connectionError = new Error('Failed to connect transport'); - //Mock defaultTransport for Extension + Browser + // Mock defaultTransport for Extension + Browser mockedData.mockDefaultTransport.connect.mockRejectedValue( connectionError, ); - //Mock dappClient for MWP + // Mock dappClient for MWP mockedData.mockDappClient.connect.mockRejectedValue(connectionError); const scopes = ['eip155:1'] as Scope[]; @@ -145,9 +174,9 @@ function testSuite({ const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Test timeout')), 3000); }); - + const connectPromise = sdk.connect(scopes, caipAccountIds); - + // Ensure both promises have catch handlers BEFORE racing to prevent unhandled rejections // This ensures that even if one promise rejects after the race resolves, it won't be unhandled connectPromise.catch(() => { @@ -156,16 +185,16 @@ function testSuite({ timeoutPromise.catch(() => { // Silently handle - timeout will be processed by race or ignored if connect wins }); - + let connectError: any; let timedOut = false; - + try { await Promise.race([connectPromise, timeoutPromise]); t.expect.fail('Expected connect to throw an error'); } catch (error) { clearTimeout(timeoutId); - + if (error instanceof Error && error.message === 'Test timeout') { timedOut = true; } else { @@ -178,18 +207,20 @@ function testSuite({ // For web-mobile, timeout might be expected due to deeplink hanging if (!timedOut) { t.expect(connectError).toBe(connectionError); - //Expect to find all the transport mocks DISCONNECTED + // Expect to find all the transport mocks DISCONNECTED t.expect(mockedData.mockDefaultTransport.__isConnected).toBe(false); t.expect(mockedData.mockDappClient.state).toBe('DISCONNECTED'); t.expect(sdk.status === 'disconnected').toBe(true); } else { // If timed out, at least verify it's not connected - t.expect(['loaded', 'disconnected', 'connecting']).toContain(sdk.status); + t.expect(['loaded', 'disconnected', 'connecting']).toContain( + sdk.status, + ); } - + // Ensure both promises are fully handled to prevent unhandled rejections await new Promise((resolve) => setTimeout(resolve, 0)); - + // Disconnect SDK to clean up any ongoing async operations try { if (sdk.status !== 'disconnected' && sdk.status !== 'pending') { @@ -213,7 +244,7 @@ function testSuite({ 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', ] as any; - //Empty initial session + // Empty initial session mockedData.mockWalletGetSession.mockImplementation( async () => undefined as any, ); @@ -236,7 +267,7 @@ function testSuite({ t.expect(sdk.status).toBe('connected'); t.expect(sdk.storage).toBeDefined(); t.expect(sdk.transport).toBeDefined(); - t.expect(sdk.provider).toBeDefined();; + t.expect(sdk.provider).toBeDefined(); if (isMWPPlatform) { t.expect(mockedData.mockDappClient.state).toBe('CONNECTED'); @@ -268,7 +299,7 @@ function testSuite({ sdk = await createSDK(testOptions); t.expect(sdk.status).toBe('connected'); t.expect(sdk.transport).toBeDefined(); - t.expect(sdk.provider).toBeDefined();; + t.expect(sdk.provider).toBeDefined(); t.expect(sdk.storage).toBeDefined(); await t @@ -301,7 +332,7 @@ function testSuite({ sdk = await createSDK(testOptions); t.expect(sdk.transport).toBeDefined(); - t.expect(sdk.provider).toBeDefined();; + t.expect(sdk.provider).toBeDefined(); t.expect(sdk.storage).toBeDefined(); t.expect(sdk.status).toBe('connected'); @@ -359,7 +390,7 @@ function testSuite({ if (isMWPPlatform) { if (platform !== 'web-mobile') { (mockedData.mockDappClient as any).__state = 'CONNECTED'; - //For MWP we simulate a connection with DappClient after showing the QRCode + // For MWP we simulate a connection with DappClient after showing the QRCode await expectUIFactoryRenderInstallModal(sdk); } @@ -382,7 +413,7 @@ function testSuite({ t.expect(sdk.status).toBe('connected'); t.expect(sdk.storage).toBeDefined(); - t.expect(sdk.provider).toBeDefined();; + t.expect(sdk.provider).toBeDefined(); t.expect(sdk.transport).toBeDefined(); if (isMWPPlatform) { @@ -422,9 +453,9 @@ function testSuite({ const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Test timeout')), 3000); }); - + const connectPromise = sdk.connect(scopes, caipAccountIds); - + // Ensure both promises have catch handlers BEFORE racing to prevent unhandled rejections // This ensures that even if one promise rejects after the race resolves, it won't be unhandled connectPromise.catch(() => { @@ -433,16 +464,16 @@ function testSuite({ timeoutPromise.catch(() => { // Silently handle - timeout will be processed by race or ignored if connect wins }); - + let connectError: any; let timedOut = false; - + try { await Promise.race([connectPromise, timeoutPromise]); t.expect.fail('Expected connect to throw an error'); } catch (error) { clearTimeout(timeoutId); - + if (error instanceof Error && error.message === 'Test timeout') { timedOut = true; } else { @@ -451,19 +482,21 @@ function testSuite({ } finally { clearTimeout(timeoutId); } - + // For web-mobile, timeout might be expected due to deeplink hanging if (!timedOut) { t.expect(connectError).toBe(sessionError); t.expect(sdk.status === 'disconnected').toBe(true); } else { // If timed out, at least verify it's not connected - t.expect(['loaded', 'disconnected', 'connecting']).toContain(sdk.status); + t.expect(['loaded', 'disconnected', 'connecting']).toContain( + sdk.status, + ); } - + // Ensure both promises are fully handled to prevent unhandled rejections await new Promise((resolve) => setTimeout(resolve, 0)); - + // Disconnect SDK to clean up any ongoing async operations try { if (sdk.status !== 'disconnected' && sdk.status !== 'pending') { @@ -520,7 +553,7 @@ function testSuite({ sdk = await createSDK(testOptions); await sdk.connect(scopes, caipAccountIds); t.expect(sdk.status).toBe('connected'); - t.expect(sdk.provider).toBeDefined();; + t.expect(sdk.provider).toBeDefined(); t.expect(sdk.transport).toBeDefined(); await t @@ -543,7 +576,7 @@ function testSuite({ sdk = await createSDK(testOptions); t.expect(sdk.status).toBe('connected'); - t.expect(sdk.provider).toBeDefined();; + t.expect(sdk.provider).toBeDefined(); t.expect(sdk.transport).toBeDefined(); t.expect(mockedData.mockDappClient.state).toBe('CONNECTED'); diff --git a/packages/connect-multichain/src/domain/index.test.ts b/packages/connect-multichain/src/domain/index.test.ts index 4eb8d238..35293ba9 100644 --- a/packages/connect-multichain/src/domain/index.test.ts +++ b/packages/connect-multichain/src/domain/index.test.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/naming-convention -- External library Bowser */ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable n/no-process-env -- Test environment setup */ import Bowser from 'bowser'; import * as t from 'vitest'; @@ -9,7 +12,7 @@ import { isEnabled, PlatformType, type SDKEvents, -} from './'; +} from '.'; const parseMock = t.vi.fn(); // Mock Bowser at the top level diff --git a/packages/connect-multichain/src/domain/multichain/api/constants.ts b/packages/connect-multichain/src/domain/multichain/api/constants.ts index 98178d45..a0e10895 100644 --- a/packages/connect-multichain/src/domain/multichain/api/constants.ts +++ b/packages/connect-multichain/src/domain/multichain/api/constants.ts @@ -66,39 +66,39 @@ export const infuraRpcUrls: RpcUrlsMap = { // Methods that are passed through to the RPC node export const RPC_HANDLED_METHODS = new Set([ - 'eth_blockNumber', - 'eth_gasPrice', - 'eth_maxPriorityFeePerGas', - 'eth_blobBaseFee', - 'eth_feeHistory', - 'eth_getBalance', - 'eth_getCode', - 'eth_getStorageAt', - 'eth_call', - 'eth_estimateGas', - 'eth_getLogs', - 'eth_getProof', - 'eth_getTransactionCount', - 'eth_getBlockByNumber', - 'eth_getBlockByHash', - 'eth_getBlockTransactionCountByNumber', - 'eth_getBlockTransactionCountByHash', - 'eth_getUncleCountByBlockNumber', - 'eth_getUncleCountByBlockHash', - 'eth_getTransactionByHash', - 'eth_getTransactionByBlockNumberAndIndex', - 'eth_getTransactionByBlockHashAndIndex', - 'eth_getTransactionReceipt', - 'eth_getUncleByBlockNumberAndIndex', - 'eth_getUncleByBlockHashAndIndex', - 'eth_getFilterChanges', - 'eth_getFilterLogs', - 'eth_newBlockFilter', - 'eth_newFilter', - 'eth_newPendingTransactionFilter', - 'eth_sendRawTransaction', - 'eth_syncing', - 'eth_uninstallFilter', + 'eth_blockNumber', + 'eth_gasPrice', + 'eth_maxPriorityFeePerGas', + 'eth_blobBaseFee', + 'eth_feeHistory', + 'eth_getBalance', + 'eth_getCode', + 'eth_getStorageAt', + 'eth_call', + 'eth_estimateGas', + 'eth_getLogs', + 'eth_getProof', + 'eth_getTransactionCount', + 'eth_getBlockByNumber', + 'eth_getBlockByHash', + 'eth_getBlockTransactionCountByNumber', + 'eth_getBlockTransactionCountByHash', + 'eth_getUncleCountByBlockNumber', + 'eth_getUncleCountByBlockHash', + 'eth_getTransactionByHash', + 'eth_getTransactionByBlockNumberAndIndex', + 'eth_getTransactionByBlockHashAndIndex', + 'eth_getTransactionReceipt', + 'eth_getUncleByBlockNumberAndIndex', + 'eth_getUncleByBlockHashAndIndex', + 'eth_getFilterChanges', + 'eth_getFilterLogs', + 'eth_newBlockFilter', + 'eth_newFilter', + 'eth_newPendingTransactionFilter', + 'eth_sendRawTransaction', + 'eth_syncing', + 'eth_uninstallFilter', ]); // Methods that are handled by the SDK directly diff --git a/packages/connect-multichain/src/domain/multichain/api/types.ts b/packages/connect-multichain/src/domain/multichain/api/types.ts index 16d91036..7f0f2d65 100644 --- a/packages/connect-multichain/src/domain/multichain/api/types.ts +++ b/packages/connect-multichain/src/domain/multichain/api/types.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { CaipChainId } from '@metamask/utils'; +import type { CaipChainId } from '@metamask/utils'; + import type EIP155 from './eip155'; /** diff --git a/packages/connect-multichain/src/domain/multichain/index.ts b/packages/connect-multichain/src/domain/multichain/index.ts index 794c02b3..0aa16dd8 100644 --- a/packages/connect-multichain/src/domain/multichain/index.ts +++ b/packages/connect-multichain/src/domain/multichain/index.ts @@ -3,7 +3,6 @@ import type { MultichainApiClient, SessionProperties, - Transport, } from '@metamask/multichain-api-client'; import type { CaipAccountId, Json } from '@metamask/utils'; diff --git a/packages/connect-multichain/src/domain/multichain/types.ts b/packages/connect-multichain/src/domain/multichain/types.ts index bd933f27..266d656f 100644 --- a/packages/connect-multichain/src/domain/multichain/types.ts +++ b/packages/connect-multichain/src/domain/multichain/types.ts @@ -1,4 +1,7 @@ -import type { Session, SessionRequest } from '@metamask/mobile-wallet-protocol-core'; +import type { + Session, + SessionRequest, +} from '@metamask/mobile-wallet-protocol-core'; import type { SessionProperties, Transport, diff --git a/packages/connect-multichain/src/domain/platform/index.ts b/packages/connect-multichain/src/domain/platform/index.ts index 0b8d59f1..2484d06a 100644 --- a/packages/connect-multichain/src/domain/platform/index.ts +++ b/packages/connect-multichain/src/domain/platform/index.ts @@ -2,6 +2,7 @@ /* eslint-disable no-restricted-globals */ /* eslint-disable jsdoc/require-jsdoc */ /* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable import-x/no-named-as-default-member -- Bowser.parse is the intended API */ import Bowser from 'bowser'; export enum PlatformType { @@ -117,8 +118,8 @@ const detectionPromise: Promise = (async () => { setTimeout(() => { window.removeEventListener('eip6963:announceProvider', handler); - const hasMetaMask = providers.some( - (provider) => provider?.info?.rdns?.startsWith('io.metamask') + const hasMetaMask = providers.some((provider) => + provider?.info?.rdns?.startsWith('io.metamask'), ); resolve(hasMetaMask); diff --git a/packages/connect-multichain/src/globals.d.ts b/packages/connect-multichain/src/globals.d.ts index 6d8dbad9..5a2c4d84 100644 --- a/packages/connect-multichain/src/globals.d.ts +++ b/packages/connect-multichain/src/globals.d.ts @@ -6,7 +6,7 @@ declare global { /** * TODO: Add types for the window object to manage connection with inApp browser, etc */ - ReactNativeWebView?: any; + reactNativeWebView?: any; mmsdk?: any; ethereum?: { isMetaMask?: boolean; diff --git a/packages/connect-multichain/src/index.browser.ts b/packages/connect-multichain/src/index.browser.ts index 08b32fca..46db32d3 100644 --- a/packages/connect-multichain/src/index.browser.ts +++ b/packages/connect-multichain/src/index.browser.ts @@ -1,3 +1,4 @@ +/* eslint-disable import-x/no-unassigned-import -- Polyfill must be imported first */ // Buffer polyfill must be imported first to set up globalThis.Buffer import './polyfills/buffer-shim'; @@ -11,12 +12,12 @@ export * from './domain'; export const createMultichainClient: CreateMultichainFN = async (options) => { const uiModules = await import('./ui/modals/web'); let storage: StoreClient; - if (!options.storage) { + if (options.storage) { + storage = options.storage; + } else { const { StoreAdapterWeb } = await import('./store/adapters/web'); const adapter = new StoreAdapterWeb(); storage = new Store(adapter); - } else { - storage = options.storage; } const factory = new ModalFactory(uiModules); return MetaMaskConnectMultichain.create({ diff --git a/packages/connect-multichain/src/index.native.ts b/packages/connect-multichain/src/index.native.ts index 8e36f997..34f89cd0 100644 --- a/packages/connect-multichain/src/index.native.ts +++ b/packages/connect-multichain/src/index.native.ts @@ -1,3 +1,4 @@ +/* eslint-disable import-x/no-unassigned-import -- Polyfill must be imported first */ // Buffer polyfill must be imported first to set up global.Buffer import './polyfills/buffer-shim'; @@ -11,12 +12,12 @@ export * from './domain'; export const createMultichainClient: CreateMultichainFN = async (options) => { const uiModules = await import('./ui/modals/rn'); let storage: StoreClient; - if (!options.storage) { + if (options.storage) { + storage = options.storage; + } else { const { StoreAdapterRN } = await import('./store/adapters/rn'); const adapter = new StoreAdapterRN(); storage = new Store(adapter); - } else { - storage = options.storage; } const factory = new ModalFactory(uiModules); return MetaMaskConnectMultichain.create({ diff --git a/packages/connect-multichain/src/index.node.ts b/packages/connect-multichain/src/index.node.ts index c36b34e7..2ee90b4b 100644 --- a/packages/connect-multichain/src/index.node.ts +++ b/packages/connect-multichain/src/index.node.ts @@ -8,12 +8,12 @@ export * from './domain'; export const createMultichainClient: CreateMultichainFN = async (options) => { const uiModules = await import('./ui/modals/node'); let storage: StoreClient; - if (!options.storage) { + if (options.storage) { + storage = options.storage; + } else { const { StoreAdapterNode } = await import('./store/adapters/node'); const adapter = new StoreAdapterNode(); storage = new Store(adapter); - } else { - storage = options.storage; } const factory = new ModalFactory(uiModules); return MetaMaskConnectMultichain.create({ diff --git a/packages/connect-multichain/src/init.test.ts b/packages/connect-multichain/src/init.test.ts index 90fc4659..55b30c0a 100644 --- a/packages/connect-multichain/src/init.test.ts +++ b/packages/connect-multichain/src/init.test.ts @@ -1,4 +1,12 @@ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable import-x/order -- Mock imports need specific order */ +/* eslint-disable jsdoc/require-param-description -- Test helpers */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test functions */ +/* eslint-disable @typescript-eslint/naming-convention -- Test naming and snake_case APIs */ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- Test assertions */ import * as t from 'vitest'; + import type { MultichainOptions, MultichainCore } from './domain'; import { runTestsInNodeEnv, @@ -10,9 +18,16 @@ import { // Careful, order of import matters to keep mocks working import { analytics } from '@metamask/analytics'; import * as loggerModule from './domain/logger'; -import type { TestSuiteOptions, MockedData } from '../tests/types'; import { mockSessionData, mockSessionRequestData } from '../tests/data'; +import type { TestSuiteOptions, MockedData } from '../tests/types'; +/** + * + * @param options0 + * @param options0.platform + * @param options0.createSDK + * @param options0.options + */ function testSuite({ platform, createSDK, @@ -177,7 +192,7 @@ function testSuite({ ...testOptions, transport: { ...(testOptions.transport ?? {}), - onNotification: onNotification, + onNotification, }, }; sdk = await createSDK(optionsWithEvent); @@ -210,7 +225,10 @@ function testSuite({ if (platform === 'node') { // Node: set multichain-transport in storage first, then throw when reading it // getTransport() calls adapter.get('multichain-transport') which calls getItem - mockedData.nativeStorageStub.data.set('multichain-transport', 'browser'); + mockedData.nativeStorageStub.data.set( + 'multichain-transport', + 'browser', + ); getItemSpy.mockImplementation((key: string) => { if (key === 'multichain-transport') { throw testError; @@ -256,7 +274,8 @@ function testSuite({ // Verify that the logger was called with the error // The error might be wrapped in a StorageGetErr, so check for the error message t.expect(mockLogger).toHaveBeenCalled(); - const lastCall = mockLogger.mock.calls[mockLogger.mock.calls.length - 1]; + const lastCall = + mockLogger.mock.calls[mockLogger.mock.calls.length - 1]; t.expect(lastCall[0]).toBe('MetaMaskSDK error during initialization'); // The error might be wrapped, so check if it contains our test error message const loggedError = lastCall[1]; diff --git a/packages/connect-multichain/src/invoke.test.ts b/packages/connect-multichain/src/invoke.test.ts index c10857d3..506e6770 100644 --- a/packages/connect-multichain/src/invoke.test.ts +++ b/packages/connect-multichain/src/invoke.test.ts @@ -1,249 +1,359 @@ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/naming-convention -- Test naming and mock names */ +/* eslint-disable jsdoc/require-param-description -- Test helpers */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test functions */ +/* eslint-disable @typescript-eslint/no-unused-vars -- Test helper functions */ +/* eslint-disable no-plusplus -- Test loops */ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals and test scopes */ import * as t from 'vitest'; import { vi } from 'vitest'; -import type { InvokeMethodOptions, MultichainOptions, MultichainCore, Scope } from './domain'; + +import type { + InvokeMethodOptions, + MultichainOptions, + MultichainCore, + Scope, +} from './domain'; // Careful, order of import matters to keep mocks working -import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, runTestsInWebMobileEnv } from '../tests/fixtures.test'; import { Store } from './store'; import { mockSessionData, mockSessionRequestData } from '../tests/data'; +import { + runTestsInNodeEnv, + runTestsInRNEnv, + runTestsInWebEnv, + runTestsInWebMobileEnv, +} from '../tests/fixtures.test'; import type { TestSuiteOptions, MockedData } from '../tests/types'; import { RequestRouter } from './multichain/rpc/requestRouter'; vi.mock('cross-fetch', () => { - const mockFetch = vi.fn(); - return { - default: mockFetch, - __mockFetch: mockFetch, - }; + const mockFetch = vi.fn(); + return { + default: mockFetch, + __mockFetch: mockFetch, + }; }); +/** + * + * @param sdk + */ async function waitForInstallModal(sdk: MultichainCore) { - const onShowInstallModal = t.vi.spyOn(sdk as any, 'showInstallModal'); - - let attempts = 5; - while (attempts > 0) { - try { - t.expect(onShowInstallModal).toHaveBeenCalled(); - break; - } catch { - attempts--; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - t.expect(onShowInstallModal).toHaveBeenCalled(); + const onShowInstallModal = t.vi.spyOn(sdk as any, 'showInstallModal'); + + let attempts = 5; + while (attempts > 0) { + try { + t.expect(onShowInstallModal).toHaveBeenCalled(); + break; + } catch { + attempts--; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + t.expect(onShowInstallModal).toHaveBeenCalled(); } +/** + * + * @param sdk + */ async function expectUIFactoryRenderInstallModal(sdk: MultichainCore) { - const onRenderInstallModal = t.vi.spyOn((sdk as any).options.ui.factory, 'renderInstallModal'); - - let attempts = 5; - while (attempts > 0) { - try { - t.expect(onRenderInstallModal).toHaveBeenCalled(); - break; - } catch { - attempts--; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } - t.expect(onRenderInstallModal).toHaveBeenCalled(); + const onRenderInstallModal = t.vi.spyOn( + (sdk as any).options.ui.factory, + 'renderInstallModal', + ); + + let attempts = 5; + while (attempts > 0) { + try { + t.expect(onRenderInstallModal).toHaveBeenCalled(); + break; + } catch { + attempts--; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + t.expect(onRenderInstallModal).toHaveBeenCalled(); } -function testSuite({ platform, createSDK, options: sdkOptions, ...options }: TestSuiteOptions) { - const { beforeEach, afterEach } = options; - const originalSdkOptions = sdkOptions; - let sdk: MultichainCore; - - t.describe(`${platform} tests`, () => { - const isMWPPlatform = platform === 'web-mobile' || platform === 'rn' || platform === 'node'; - - let mockedData: MockedData; - let testOptions: T; - const transportString = platform === 'web' ? 'browser' : 'mwp'; - - t.beforeEach(async () => { - const uiOptions: MultichainOptions['ui'] = - platform === 'web-mobile' - ? { - ...originalSdkOptions.ui, - showInstallModal: false, - preferExtension: false, - } - : originalSdkOptions.ui; - mockedData = await beforeEach(); - // Set the transport type as a string in storage (this is how it's stored) - testOptions = { - ...originalSdkOptions, - analytics: { - ...originalSdkOptions.analytics, - enabled: platform !== 'node', - integrationType: 'test', - }, - ui: uiOptions, - storage: new Store({ - platform: platform as 'web' | 'rn' | 'node', - get(key) { - return Promise.resolve(mockedData.nativeStorageStub.getItem(key)); - }, - set(key, value) { - return Promise.resolve(mockedData.nativeStorageStub.setItem(key, value)); - }, - delete(key) { - return Promise.resolve(mockedData.nativeStorageStub.removeItem(key)); - }, - }), - }; - }); - - t.afterEach(async () => { - await afterEach(mockedData); - }); - - t.it(`${platform} should invoke method successfully from provider with an active session and connected transport`, async () => { - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); - mockedData.mockWalletGetSession.mockImplementation(async () => undefined as any); - mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); - mockedData.mockWalletInvokeMethod.mockImplementation(async () => { - return { - id: 1, - jsonrpc: '2.0', - result: 'success', - }; - }); - - sdk = await createSDK(testOptions); - - t.expect(sdk.status).toBe('loaded'); - // Provider is always available via wrapper transport (handles connection state internally) - t.expect(sdk.provider).toBeDefined(); - t.expect(() => sdk.transport).toThrow(); - - await sdk.connect(scopes, caipAccountIds); - - t.expect(sdk.status).toBe('connected'); - t.expect(sdk.storage).toBeDefined(); - t.expect(sdk.transport).toBeDefined(); - if (platform === 'web-mobile') { - sdk.transport.getActiveSession = t.vi.fn().mockResolvedValue({id: 'mock-session-id'}); - } - - const providerInvokeMethodSpy = t.vi.spyOn(RequestRouter.prototype, 'invokeMethod'); - const options = { - id: 1, - scope: 'eip155:1', - request: { method: 'eth_accounts', params: [] }, - } as InvokeMethodOptions; - - const result = await sdk.invokeMethod(options); - t.expect(providerInvokeMethodSpy).toHaveBeenCalledWith(options); - t.expect(result).toEqual({ - id: 1, - jsonrpc: '2.0', - result: 'success', - }); - }); - - t.it( - `${platform} should reject invoke in case of failure in RequestRouter`, - async () => { - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); - mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); - mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); - mockedData.mockWalletInvokeMethod.mockRejectedValue(new Error('Failed to invoke method')); - - sdk = await createSDK(testOptions); - await sdk.connect(scopes, caipAccountIds); - t.expect(sdk.status).toBe('connected'); - - if (platform === 'web-mobile') { - sdk.transport.getActiveSession = t.vi.fn().mockResolvedValue({id: 'mock-session-id'}); - } - - const options = { - scope: 'eip155:1', - request: { method: 'eth_accounts', params: [] }, - } as InvokeMethodOptions; - - await t.expect(sdk.invokeMethod(options)).rejects.toThrow('RPCErr53: RPC Client invoke method reason (Failed to invoke method)'); - }, - { timeout: 100000 }, - ); - - // TODO: Re-enable this test once we handle RPC node routing (INTERCEPT_AND_ROUTE_TO_RPC_NODE strategy) - t.it.skip(`${platform} should invoke readonly method successfully from client if infuraAPIKey exists`, async () => { - const scopes = ['eip155:1'] as Scope[]; - const caipAccountIds = ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'] as any; - - mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); - mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); - mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); - - // Mock the RequestRouter response - const mockJsonResponse = { result: 'success' }; - const fetchModule = await import('cross-fetch'); - const mockFetch = (fetchModule as any).__mockFetch; - const mockResponse = { - ok: true, - json: t.vi.fn().mockResolvedValue({ result: mockJsonResponse }), - }; - - mockFetch.mockResolvedValue(mockResponse); - - sdk = await createSDK({ - ...testOptions, - api: { - ...testOptions.api, - infuraAPIKey: '1234567890', - }, - }); - - t.expect(sdk.status).toBe('loaded'); - t.expect(() => sdk.provider).toThrow(); - t.expect(() => sdk.transport).toThrow(); - - await sdk.connect(scopes, caipAccountIds); - - t.expect(sdk.status).toBe('connected'); - - const options = { scope: 'eip155:1', request: { method: 'eth_accounts', params: [] } } as InvokeMethodOptions; - const result = await sdk.invokeMethod(options); - - t.expect(mockFetch).toHaveBeenCalled(); - t.expect(result).toEqual(mockJsonResponse); - }); - - t.it(`${platform} should handle invoke method errors`, async () => { - const mockError = new Error('Failed to invoke method'); - mockedData.nativeStorageStub.setItem('multichain-transport', transportString); - - mockedData.nativeStorageStub.setItem('multichain-transport', transportString); - mockedData.mockSessionRequest.mockImplementation(async () => mockSessionRequestData); - mockedData.mockWalletGetSession.mockImplementation(async () => mockSessionData); - mockedData.mockWalletInvokeMethod.mockRejectedValue(mockError); - mockedData.mockWalletCreateSession.mockImplementation(async () => mockSessionData); - - sdk = await createSDK(testOptions); - const options = { - scope: 'eip155:1', - request: { - method: 'eth_accounts', - params: [], - }, - } as InvokeMethodOptions; - t.expect(sdk.status).toBe('connected'); - t.expect(sdk.provider).toBeDefined();; - - if (platform === 'web-mobile') { - sdk.transport.getActiveSession = t.vi.fn().mockResolvedValue({id: 'mock-session-id'}); - } - - await t.expect(sdk.invokeMethod(options)).rejects.toThrow('RPCErr53: RPC Client invoke method reason (Failed to invoke method)'); - }); - }); +/** + * + * @param options0 + * @param options0.platform + * @param options0.createSDK + * @param options0.options + */ +function testSuite({ + platform, + createSDK, + options: sdkOptions, + ...options +}: TestSuiteOptions) { + const { beforeEach, afterEach } = options; + const originalSdkOptions = sdkOptions; + let sdk: MultichainCore; + + t.describe(`${platform} tests`, () => { + const isMWPPlatform = + platform === 'web-mobile' || platform === 'rn' || platform === 'node'; + + let mockedData: MockedData; + let testOptions: T; + const transportString = platform === 'web' ? 'browser' : 'mwp'; + + t.beforeEach(async () => { + const uiOptions: MultichainOptions['ui'] = + platform === 'web-mobile' + ? { + ...originalSdkOptions.ui, + showInstallModal: false, + preferExtension: false, + } + : originalSdkOptions.ui; + mockedData = await beforeEach(); + // Set the transport type as a string in storage (this is how it's stored) + testOptions = { + ...originalSdkOptions, + analytics: { + ...originalSdkOptions.analytics, + enabled: platform !== 'node', + integrationType: 'test', + }, + ui: uiOptions, + storage: new Store({ + platform: platform as 'web' | 'rn' | 'node', + async get(key) { + return Promise.resolve(mockedData.nativeStorageStub.getItem(key)); + }, + async set(key, value) { + return Promise.resolve( + mockedData.nativeStorageStub.setItem(key, value), + ); + }, + async delete(key) { + return Promise.resolve( + mockedData.nativeStorageStub.removeItem(key), + ); + }, + }), + }; + }); + + t.afterEach(async () => { + await afterEach(mockedData); + }); + + t.it( + `${platform} should invoke method successfully from provider with an active session and connected transport`, + async () => { + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ] as any; + + mockedData.mockSessionRequest.mockImplementation( + async () => mockSessionRequestData, + ); + mockedData.mockWalletGetSession.mockImplementation( + async () => undefined as any, + ); + mockedData.mockWalletCreateSession.mockImplementation( + async () => mockSessionData, + ); + mockedData.mockWalletInvokeMethod.mockImplementation(async () => { + return { + id: 1, + jsonrpc: '2.0', + result: 'success', + }; + }); + + sdk = await createSDK(testOptions); + + t.expect(sdk.status).toBe('loaded'); + // Provider is always available via wrapper transport (handles connection state internally) + t.expect(sdk.provider).toBeDefined(); + t.expect(() => sdk.transport).toThrow(); + + await sdk.connect(scopes, caipAccountIds); + + t.expect(sdk.status).toBe('connected'); + t.expect(sdk.storage).toBeDefined(); + t.expect(sdk.transport).toBeDefined(); + if (platform === 'web-mobile') { + sdk.transport.getActiveSession = t.vi + .fn() + .mockResolvedValue({ id: 'mock-session-id' }); + } + + const providerInvokeMethodSpy = t.vi.spyOn( + RequestRouter.prototype, + 'invokeMethod', + ); + const options = { + id: 1, + scope: 'eip155:1', + request: { method: 'eth_accounts', params: [] }, + } as InvokeMethodOptions; + + const result = await sdk.invokeMethod(options); + t.expect(providerInvokeMethodSpy).toHaveBeenCalledWith(options); + t.expect(result).toEqual({ + id: 1, + jsonrpc: '2.0', + result: 'success', + }); + }, + ); + + t.it( + `${platform} should reject invoke in case of failure in RequestRouter`, + async () => { + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ] as any; + mockedData.mockSessionRequest.mockImplementation( + async () => mockSessionRequestData, + ); + mockedData.mockWalletGetSession.mockImplementation( + async () => mockSessionData, + ); + mockedData.mockWalletCreateSession.mockImplementation( + async () => mockSessionData, + ); + mockedData.mockWalletInvokeMethod.mockRejectedValue( + new Error('Failed to invoke method'), + ); + + sdk = await createSDK(testOptions); + await sdk.connect(scopes, caipAccountIds); + t.expect(sdk.status).toBe('connected'); + + if (platform === 'web-mobile') { + sdk.transport.getActiveSession = t.vi + .fn() + .mockResolvedValue({ id: 'mock-session-id' }); + } + + const options = { + scope: 'eip155:1', + request: { method: 'eth_accounts', params: [] }, + } as InvokeMethodOptions; + + await t + .expect(sdk.invokeMethod(options)) + .rejects.toThrow( + 'RPCErr53: RPC Client invoke method reason (Failed to invoke method)', + ); + }, + { timeout: 100000 }, + ); + + // TODO: Re-enable this test once we handle RPC node routing (INTERCEPT_AND_ROUTE_TO_RPC_NODE strategy) + t.it.skip( + `${platform} should invoke readonly method successfully from client if infuraAPIKey exists`, + async () => { + const scopes = ['eip155:1'] as Scope[]; + const caipAccountIds = [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ] as any; + + mockedData.mockSessionRequest.mockImplementation( + async () => mockSessionRequestData, + ); + mockedData.mockWalletGetSession.mockImplementation( + async () => mockSessionData, + ); + mockedData.mockWalletCreateSession.mockImplementation( + async () => mockSessionData, + ); + + // Mock the RequestRouter response + const mockJsonResponse = { result: 'success' }; + const fetchModule = await import('cross-fetch'); + const mockFetch = (fetchModule as any).__mockFetch; + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue({ result: mockJsonResponse }), + }; + + mockFetch.mockResolvedValue(mockResponse); + + sdk = await createSDK({ + ...testOptions, + api: { + ...testOptions.api, + infuraAPIKey: '1234567890', + }, + }); + + t.expect(sdk.status).toBe('loaded'); + t.expect(() => sdk.provider).toThrow(); + t.expect(() => sdk.transport).toThrow(); + + await sdk.connect(scopes, caipAccountIds); + + t.expect(sdk.status).toBe('connected'); + + const options = { + scope: 'eip155:1', + request: { method: 'eth_accounts', params: [] }, + } as InvokeMethodOptions; + const result = await sdk.invokeMethod(options); + + t.expect(mockFetch).toHaveBeenCalled(); + t.expect(result).toEqual(mockJsonResponse); + }, + ); + + t.it(`${platform} should handle invoke method errors`, async () => { + const mockError = new Error('Failed to invoke method'); + mockedData.nativeStorageStub.setItem( + 'multichain-transport', + transportString, + ); + + mockedData.nativeStorageStub.setItem( + 'multichain-transport', + transportString, + ); + mockedData.mockSessionRequest.mockImplementation( + async () => mockSessionRequestData, + ); + mockedData.mockWalletGetSession.mockImplementation( + async () => mockSessionData, + ); + mockedData.mockWalletInvokeMethod.mockRejectedValue(mockError); + mockedData.mockWalletCreateSession.mockImplementation( + async () => mockSessionData, + ); + + sdk = await createSDK(testOptions); + const options = { + scope: 'eip155:1', + request: { + method: 'eth_accounts', + params: [], + }, + } as InvokeMethodOptions; + t.expect(sdk.status).toBe('connected'); + t.expect(sdk.provider).toBeDefined(); + + if (platform === 'web-mobile') { + sdk.transport.getActiveSession = t.vi + .fn() + .mockResolvedValue({ id: 'mock-session-id' }); + } + + await t + .expect(sdk.invokeMethod(options)) + .rejects.toThrow( + 'RPCErr53: RPC Client invoke method reason (Failed to invoke method)', + ); + }); + }); } const exampleDapp = { name: 'Test Dapp', url: 'https://test.dapp' }; diff --git a/packages/connect-multichain/src/multichain/index.ts b/packages/connect-multichain/src/multichain/index.ts index 961ccd61..a90e2f1d 100644 --- a/packages/connect-multichain/src/multichain/index.ts +++ b/packages/connect-multichain/src/multichain/index.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable no-restricted-globals */ +/* eslint-disable promise/always-return -- Event handlers */ +/* eslint-disable no-async-promise-executor -- Async promise executor needed for complex flow */ import { analytics } from '@metamask/analytics'; import { ErrorCode, @@ -11,8 +13,8 @@ import { } from '@metamask/mobile-wallet-protocol-core'; import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; import { + type SessionProperties, getMultichainClient, - SessionProperties, type MultichainApiClient, type SessionData, } from '@metamask/multichain-api-client'; @@ -56,10 +58,10 @@ import { import { RpcClient } from './rpc/handlers/rpcClient'; import { RequestRouter } from './rpc/requestRouter'; import { DefaultTransport } from './transports/default'; +import { MultichainApiClientWrapperTransport } from './transports/multichainApiClientWrapper'; import { MWPTransport } from './transports/mwp'; import { keymanager } from './transports/mwp/KeyManager'; import { getDappId, openDeeplink, setupDappMetadata } from './utils'; -import { MultichainApiClientWrapperTransport } from './transports/multichainApiClientWrapper'; export { getInfuraRpcUrls } from '../domain/multichain/api/infura'; @@ -67,9 +69,9 @@ export { getInfuraRpcUrls } from '../domain/multichain/api/infura'; const logger = createLogger('metamask-sdk:core'); export class MetaMaskConnectMultichain extends MultichainCore { - #provider: MultichainApiClient; + readonly #provider: MultichainApiClient; - #providerTransportWrapper: MultichainApiClientWrapperTransport; + readonly #providerTransportWrapper: MultichainApiClientWrapperTransport; #transport: ExtendedTransport | undefined = undefined; @@ -142,11 +144,17 @@ export class MetaMaskConnectMultichain extends MultichainCore { super(allOptions); - this.#providerTransportWrapper = new MultichainApiClientWrapperTransport(this); - this.#provider = getMultichainClient({ transport: this.#providerTransportWrapper }); + this.#providerTransportWrapper = new MultichainApiClientWrapperTransport( + this, + ); + this.#provider = getMultichainClient({ + transport: this.#providerTransportWrapper, + }); } - static async create(options: MultichainOptions): Promise { + static async create( + options: MultichainOptions, + ): Promise { const instance = new MetaMaskConnectMultichain(options); const isEnabled = await isLoggerEnabled( 'metamask-sdk:core', @@ -377,7 +385,11 @@ export class MetaMaskConnectMultichain extends MultichainCore { (async (): Promise => { try { - await this.transport.connect({ scopes, caipAccountIds, sessionProperties }); + await this.transport.connect({ + scopes, + caipAccountIds, + sessionProperties, + }); await this.options.ui.factory.unload(); this.options.ui.factory.modal?.unmount(); this.status = 'connected'; @@ -482,7 +494,8 @@ export class MetaMaskConnectMultichain extends MultichainCore { }; // Generate and emit the QR code link - const deeplink = this.options.ui.factory.createConnectionDeeplink(connectionRequest); + const deeplink = + this.options.ui.factory.createConnectionDeeplink(connectionRequest); this.emit('display_uri', deeplink); }, ); @@ -528,7 +541,7 @@ export class MetaMaskConnectMultichain extends MultichainCore { caipAccountIds: CaipAccountId[], sessionProperties?: SessionProperties, ): Promise { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { // Handle the response to the initial wallet_createSession request const dappClientMessageHandler = (payload: unknown): void => { if ( @@ -709,12 +722,20 @@ export class MetaMaskConnectMultichain extends MultichainCore { } // Needed because empty object will cause wallet_createSession to return an error - const nonEmptySessionProperites = Object.keys(sessionProperties ?? {}).length > 0 ? sessionProperties : undefined; + const nonEmptySessionProperites = + Object.keys(sessionProperties ?? {}).length > 0 + ? sessionProperties + : undefined; if (this.#transport?.isConnected() && !secure) { return this.#handleConnection( this.#transport - .connect({ scopes, caipAccountIds, sessionProperties: nonEmptySessionProperites, forceRequest }) + .connect({ + scopes, + caipAccountIds, + sessionProperties: nonEmptySessionProperites, + forceRequest, + }) .then(async () => { if (this.#transport instanceof MWPTransport) { return this.storage.setTransport(TransportType.MWP); @@ -730,7 +751,12 @@ export class MetaMaskConnectMultichain extends MultichainCore { if (platformType === PlatformType.MetaMaskMobileWebview) { const defaultTransport = await this.#setupDefaultTransport(); return this.#handleConnection( - defaultTransport.connect({ scopes, caipAccountIds, sessionProperties: nonEmptySessionProperites, forceRequest }), + defaultTransport.connect({ + scopes, + caipAccountIds, + sessionProperties: nonEmptySessionProperites, + forceRequest, + }), scopes, transportType, ); @@ -741,7 +767,12 @@ export class MetaMaskConnectMultichain extends MultichainCore { const defaultTransport = await this.#setupDefaultTransport(); // Web transport has no initial payload return this.#handleConnection( - defaultTransport.connect({ scopes, caipAccountIds, sessionProperties: nonEmptySessionProperites, forceRequest }), + defaultTransport.connect({ + scopes, + caipAccountIds, + sessionProperties: nonEmptySessionProperites, + forceRequest, + }), scopes, transportType, ); @@ -758,7 +789,11 @@ export class MetaMaskConnectMultichain extends MultichainCore { if (secure && !shouldShowInstallModal) { // Desktop is not preferred option, so we use deeplinks (mobile web) return this.#handleConnection( - this.#deeplinkConnect(scopes, caipAccountIds, nonEmptySessionProperites), + this.#deeplinkConnect( + scopes, + caipAccountIds, + nonEmptySessionProperites, + ), scopes, transportType, ); @@ -766,7 +801,12 @@ export class MetaMaskConnectMultichain extends MultichainCore { // Show install modal for RN, Web + Node return this.#handleConnection( - this.#showInstallModal(shouldShowInstallModal, scopes, caipAccountIds, nonEmptySessionProperites), + this.#showInstallModal( + shouldShowInstallModal, + scopes, + caipAccountIds, + nonEmptySessionProperites, + ), scopes, transportType, ); diff --git a/packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.test.ts b/packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.test.ts index fdef7a9e..ad3f77ce 100644 --- a/packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.test.ts +++ b/packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.test.ts @@ -1,178 +1,230 @@ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/naming-convention -- Test mock names */ +/* eslint-disable @typescript-eslint/no-shadow -- Test scopes */ +/* eslint-disable new-cap -- Constructor naming */ +/* eslint-disable require-unicode-regexp -- Test regex */ import * as t from 'vitest'; import { vi } from 'vitest'; + import { MissingRpcEndpointErr, type RpcClient } from './rpcClient'; -import { RPCHttpErr, RPCReadonlyRequestErr, RPCReadonlyResponseErr, Scope } from '../../../domain'; +import { + RPCHttpErr, + RPCReadonlyRequestErr, + RPCReadonlyResponseErr, + type Scope, +} from '../../../domain'; // Mock cross-fetch with proper implementation vi.mock('cross-fetch', () => { - const mockFetch = vi.fn(); - return { - default: mockFetch, - __mockFetch: mockFetch, - }; + const mockFetch = vi.fn(); + return { + default: mockFetch, + __mockFetch: mockFetch, + }; }); t.describe('RpcClient', () => { - let mockConfig: any; - let sdkInfo: string; - let rpcClient: RpcClient; - let rpcClientModule: typeof RpcClient; - let defaultHeaders: Record; - let headers: Record; - let mockFetch: any; - let baseOptions: any; - - t.beforeEach(async () => { - const clientModule = await import('./rpcClient'); - baseOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_getBalance', - params: { address: '0x123', blockNumber: 'latest' }, - }, - }; - - mockConfig = { - api: { - supportedNetworks: { - 'eip155:1': 'https://mainnet.infura.io/v3/01234567890', - 'eip155:11155111': 'https://custom-sepolia.com', - }, - }, - }; - sdkInfo = 'Sdk/Javascript SdkVersion/1.0.0 Platform/web'; - rpcClient = new clientModule.RpcClient(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 - mockFetch.mockClear(); - defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - headers = { - ...defaultHeaders, - 'Metamask-Sdk-Info': sdkInfo, - }; - }); - - t.afterEach(async () => { - t.vi.clearAllMocks(); - t.vi.resetAllMocks(); - }); - - t.describe('getHeaders', () => { - t.it('should return default headers when RPC endpoint does not include infura', () => { - const customRpcEndpoint = 'https://custom-ethereum-node.com/rpc'; - const headers = (rpcClient as any).getHeaders(customRpcEndpoint); - t.expect(headers).toEqual(defaultHeaders); - t.expect(headers).not.toHaveProperty('Metamask-Sdk-Info'); - }); - - t.it('should return headers with Metamask-Sdk-Info when RPC endpoint includes infura', () => { - const infuraEndpoint = 'https://mainnet.infura.io/v3/test-key'; - const currentHeaders = (rpcClient as any).getHeaders(infuraEndpoint); - t.expect(currentHeaders).toEqual(headers); - }); - }); - - t.describe('request', () => { - t.it('should use supportedNetworks rpc endpoint', async () => { - const mockJsonResponse = { - jsonrpc: '2.0', - result: '0x1234567890abcdef', - id: 1, - }; - - const mockResponse = { - ok: true, - json: t.vi.fn().mockResolvedValue(mockJsonResponse), - }; - - mockFetch.mockResolvedValue(mockResponse); - - const result = await rpcClient.request({ ...baseOptions, scope: 'eip155:11155111' }); - - t.expect(result).toBe('0x1234567890abcdef'); - t.expect(mockFetch).toHaveBeenCalledWith('https://custom-sepolia.com', t.expect.objectContaining({ - method: 'POST', - headers: defaultHeaders, - body: t.expect.stringContaining('"method":"eth_getBalance"'), - signal: t.expect.any(AbortSignal), - })); - }); - - t.it('should throw RPCReadonlyResponseErr when response cannot be parsed as JSON', async () => { - const mockResponse = { - ok: true, - json: t.vi.fn().mockRejectedValue(new Error('Invalid JSON')), - }; - - mockFetch.mockResolvedValue(mockResponse); - - await t.expect(rpcClient.request(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyResponseErr); - await t.expect(rpcClient.request(baseOptions)).rejects.toThrow('Invalid JSON'); - }); - - t.it('should throw RPCHttpErr when fetch response is not ok', async () => { - const mockResponse = { - ok: false, - status: 500, - }; - - mockFetch.mockResolvedValue(mockResponse); - - await t.expect(rpcClient.request(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.request(baseOptions)).rejects.toBeInstanceOf(RPCReadonlyRequestErr); - await t.expect(rpcClient.request(baseOptions)).rejects.toThrow('Network error'); - }); - - t.it('should use only default headers when RPC endpoint does not include infura and custom readonly RPC is provided', async () => { - const configWithCustomRPC = { - api: { - supportedNetworks: { - 'eip155:1': 'https://custom-ethereum-node.com/rpc', - }, - }, - } as any; - const clientWithCustomRPC = new rpcClientModule(configWithCustomRPC, sdkInfo); - const mockJsonResponse = { - jsonrpc: '2.0', - result: '0x123456account12345', - id: 1, - }; - const mockResponse = { - ok: true, - json: t.vi.fn().mockResolvedValue(mockJsonResponse), - }; - - mockFetch.mockResolvedValue(mockResponse); - baseOptions.request = { - method: 'eth_accounts', - params: undefined, - }; - - const result = await clientWithCustomRPC.request(baseOptions); - t.expect(result).toBe('0x123456account12345'); - t.expect(mockFetch).toHaveBeenCalledWith('https://custom-ethereum-node.com/rpc', t.expect.objectContaining({ - method: 'POST', - headers: defaultHeaders, - body: t.expect.stringMatching(/^\{"jsonrpc":"2\.0","method":"eth_accounts","id":\d+\}$/), - signal: t.expect.any(AbortSignal), - })); - }); - - t.it('should throw MissingRpcEndpointErr when no RPC endpoint is available', async () => { - const options = { ...baseOptions, scope: 'eip155:999' as Scope }; - await t.expect(rpcClient.request(options)).rejects.toBeInstanceOf(MissingRpcEndpointErr); + let mockConfig: any; + let sdkInfo: string; + let rpcClient: RpcClient; + let rpcClientModule: typeof RpcClient; + let defaultHeaders: Record; + let headers: Record; + let mockFetch: any; + let baseOptions: any; + + t.beforeEach(async () => { + const clientModule = await import('./rpcClient'); + baseOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_getBalance', + params: { address: '0x123', blockNumber: 'latest' }, + }, + }; + + mockConfig = { + api: { + supportedNetworks: { + 'eip155:1': 'https://mainnet.infura.io/v3/01234567890', + 'eip155:11155111': 'https://custom-sepolia.com', + }, + }, + }; + sdkInfo = 'Sdk/Javascript SdkVersion/1.0.0 Platform/web'; + rpcClient = new clientModule.RpcClient(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 + mockFetch.mockClear(); + defaultHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + headers = { + ...defaultHeaders, + 'Metamask-Sdk-Info': sdkInfo, + }; + }); + + t.afterEach(async () => { + t.vi.clearAllMocks(); + t.vi.resetAllMocks(); + }); + + t.describe('getHeaders', () => { + t.it( + 'should return default headers when RPC endpoint does not include infura', + () => { + const customRpcEndpoint = 'https://custom-ethereum-node.com/rpc'; + const headers = (rpcClient as any).getHeaders(customRpcEndpoint); + t.expect(headers).toEqual(defaultHeaders); + t.expect(headers).not.toHaveProperty('Metamask-Sdk-Info'); + }, + ); + + t.it( + 'should return headers with Metamask-Sdk-Info when RPC endpoint includes infura', + () => { + const infuraEndpoint = 'https://mainnet.infura.io/v3/test-key'; + const currentHeaders = (rpcClient as any).getHeaders(infuraEndpoint); + t.expect(currentHeaders).toEqual(headers); + }, + ); + }); + + t.describe('request', () => { + t.it('should use supportedNetworks rpc endpoint', async () => { + const mockJsonResponse = { + jsonrpc: '2.0', + result: '0x1234567890abcdef', + id: 1, + }; + + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + mockFetch.mockResolvedValue(mockResponse); + + const result = await rpcClient.request({ + ...baseOptions, + scope: 'eip155:11155111', }); - }); + + t.expect(result).toBe('0x1234567890abcdef'); + t.expect(mockFetch).toHaveBeenCalledWith( + 'https://custom-sepolia.com', + t.expect.objectContaining({ + method: 'POST', + headers: defaultHeaders, + body: t.expect.stringContaining('"method":"eth_getBalance"'), + signal: t.expect.any(AbortSignal), + }), + ); + }); + + t.it( + 'should throw RPCReadonlyResponseErr when response cannot be parsed as JSON', + async () => { + const mockResponse = { + ok: true, + json: t.vi.fn().mockRejectedValue(new Error('Invalid JSON')), + }; + + mockFetch.mockResolvedValue(mockResponse); + + await t + .expect(rpcClient.request(baseOptions)) + .rejects.toBeInstanceOf(RPCReadonlyResponseErr); + await t + .expect(rpcClient.request(baseOptions)) + .rejects.toThrow('Invalid JSON'); + }, + ); + + t.it('should throw RPCHttpErr when fetch response is not ok', async () => { + const mockResponse = { + ok: false, + status: 500, + }; + + mockFetch.mockResolvedValue(mockResponse); + + await t + .expect(rpcClient.request(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.request(baseOptions)) + .rejects.toBeInstanceOf(RPCReadonlyRequestErr); + await t + .expect(rpcClient.request(baseOptions)) + .rejects.toThrow('Network error'); + }); + + t.it( + 'should use only default headers when RPC endpoint does not include infura and custom readonly RPC is provided', + async () => { + const configWithCustomRPC = { + api: { + supportedNetworks: { + 'eip155:1': 'https://custom-ethereum-node.com/rpc', + }, + }, + } as any; + const clientWithCustomRPC = new rpcClientModule( + configWithCustomRPC, + sdkInfo, + ); + const mockJsonResponse = { + jsonrpc: '2.0', + result: '0x123456account12345', + id: 1, + }; + const mockResponse = { + ok: true, + json: t.vi.fn().mockResolvedValue(mockJsonResponse), + }; + + mockFetch.mockResolvedValue(mockResponse); + baseOptions.request = { + method: 'eth_accounts', + params: undefined, + }; + + const result = await clientWithCustomRPC.request(baseOptions); + t.expect(result).toBe('0x123456account12345'); + t.expect(mockFetch).toHaveBeenCalledWith( + 'https://custom-ethereum-node.com/rpc', + t.expect.objectContaining({ + method: 'POST', + headers: defaultHeaders, + body: t.expect.stringMatching( + /^\{"jsonrpc":"2\.0","method":"eth_accounts","id":\d+\}$/, + ), + signal: t.expect.any(AbortSignal), + }), + ); + }, + ); + + t.it( + 'should throw MissingRpcEndpointErr when no RPC endpoint is available', + async () => { + const options = { ...baseOptions, scope: 'eip155:999' as Scope }; + await t + .expect(rpcClient.request(options)) + .rejects.toBeInstanceOf(MissingRpcEndpointErr); + }, + ); + }); }); diff --git a/packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.ts b/packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.ts index 1305e607..3810cab6 100644 --- a/packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.ts +++ b/packages/connect-multichain/src/multichain/rpc/handlers/rpcClient.ts @@ -1,115 +1,137 @@ +/* eslint-disable no-restricted-syntax -- Private class properties use established patterns */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Inferred types are sufficient */ +/* eslint-disable @typescript-eslint/parameter-properties -- Constructor shorthand is intentional */ +/* eslint-disable @typescript-eslint/no-shadow -- fetch import shadows global */ + import type { Json } from '@metamask/utils'; import fetch from 'cross-fetch'; + import { - getInfuraRpcUrls, - type InvokeMethodOptions, - type MultichainOptions, - RPCHttpErr, - RPCReadonlyRequestErr, - RPCReadonlyResponseErr, - RPCResponse, - RpcUrlsMap, - Scope, + RPCHttpErr, + RPCReadonlyRequestErr, + RPCReadonlyResponseErr, +} from '../../../domain'; +import type { + RPCResponse, + RpcUrlsMap, + Scope, + InvokeMethodOptions, + MultichainOptions, } from '../../../domain'; let rpcId = 1; -export function getNextRpcId() { - rpcId += 1; - return rpcId; +/** + * Gets the next RPC ID for request tracking. + * + * @returns The next unique RPC ID. + */ +export function getNextRpcId(): number { + rpcId += 1; + return rpcId; } -export class MissingRpcEndpointErr extends Error { }; +export class MissingRpcEndpointErr extends Error {} export class RpcClient { - constructor( - private readonly config: MultichainOptions, - private readonly sdkInfo: string, - ) {} - - /** - * Routes the request to a configured RPC node. - * @param options - The invoke method options - */ - async request(options: InvokeMethodOptions): Promise { - const { request } = options; - const body = JSON.stringify({ - jsonrpc: '2.0', - method: request.method, - params: request.params, - id: getNextRpcId(), - }); - const rpcEndpoint = this.getRpcEndpoint(options.scope); - const rpcRequest = await this.fetchWithTimeout(rpcEndpoint, body, 'POST', this.getHeaders(rpcEndpoint), 30_000); // 30 seconds default timeout - const response = await this.parseResponse(rpcRequest); - return response; - } + constructor( + private readonly config: MultichainOptions, + private readonly sdkInfo: string, + ) {} - private getRpcEndpoint(scope: Scope) { + /** + * Routes the request to a configured RPC node. + * + * @param options - The invoke method options. + * @returns The JSON response from the RPC node. + */ + async request(options: InvokeMethodOptions): Promise { + const { request } = options; + const body = JSON.stringify({ + jsonrpc: '2.0', + method: request.method, + params: request.params, + id: getNextRpcId(), + }); + const rpcEndpoint = this.getRpcEndpoint(options.scope); + const rpcRequest = await this.fetchWithTimeout( + rpcEndpoint, + body, + 'POST', + this.getHeaders(rpcEndpoint), + 30_000, + ); // 30 seconds default timeout + const response = await this.parseResponse(rpcRequest); + return response; + } - const supportedNetworks: RpcUrlsMap = this.config?.api?.supportedNetworks ?? {}; + private getRpcEndpoint(scope: Scope) { + const supportedNetworks: RpcUrlsMap = + this.config?.api?.supportedNetworks ?? {}; - const rpcEndpoint = supportedNetworks[scope]; - if (!rpcEndpoint) { - throw new MissingRpcEndpointErr(`No RPC endpoint found for scope ${scope}`); - } - return rpcEndpoint; - } + const rpcEndpoint = supportedNetworks[scope]; + if (!rpcEndpoint) { + throw new MissingRpcEndpointErr( + `No RPC endpoint found for scope ${scope}`, + ); + } + return rpcEndpoint; + } - private async fetchWithTimeout( - endpoint: string, - body: string, - method: string, - headers: Record, - timeout: number, - ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); + private async fetchWithTimeout( + endpoint: string, + body: string, + method: string, + headers: Record, + timeout: number, + ): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); - try { - const response = await fetch(endpoint, { - method, - headers, - body, - signal: controller.signal, - }); - clearTimeout(timeoutId); - if (!response.ok) { - throw new RPCHttpErr(endpoint, method, response.status); - } - return response; - } catch (error) { - clearTimeout(timeoutId); - if (error instanceof RPCHttpErr) { - throw error; - } - if (error instanceof Error && error.name === 'AbortError') { - throw new RPCReadonlyRequestErr(`Request timeout after ${timeout}ms`); - } - throw new RPCReadonlyRequestErr(error instanceof Error ? error.message : 'Unknown error'); - } - } + try { + const response = await fetch(endpoint, { + method, + headers, + body, + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!response.ok) { + throw new RPCHttpErr(endpoint, method, response.status); + } + return response; + } catch (error) { + clearTimeout(timeoutId); + if (error instanceof RPCHttpErr) { + throw error; + } + if (error instanceof Error && error.name === 'AbortError') { + throw new RPCReadonlyRequestErr(`Request timeout after ${timeout}ms`); + } + throw new RPCReadonlyRequestErr(error.message); + } + } - private async parseResponse(response: Response) { - try { - const rpcResponse = (await response.json()) as RPCResponse; - return rpcResponse.result as Json; - } catch (error) { - throw new RPCReadonlyResponseErr(error.message); - } - } + private async parseResponse(response: Response) { + try { + const rpcResponse = (await response.json()) as RPCResponse; + return rpcResponse.result as Json; + } catch (error) { + throw new RPCReadonlyResponseErr(error.message); + } + } - private getHeaders(rpcEndpoint: string) { - const defaultHeaders = { - Accept: 'application/json', - 'Content-Type': 'application/json', - }; - if (rpcEndpoint.includes('infura')) { - return { - ...defaultHeaders, - 'Metamask-Sdk-Info': this.sdkInfo, - }; - } - return defaultHeaders; - } + private getHeaders(rpcEndpoint: string) { + const defaultHeaders = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + if (rpcEndpoint.includes('infura')) { + return { + ...defaultHeaders, + 'Metamask-Sdk-Info': this.sdkInfo, + }; + } + return defaultHeaders; + } } diff --git a/packages/connect-multichain/src/multichain/rpc/requestRouter.test.ts b/packages/connect-multichain/src/multichain/rpc/requestRouter.test.ts index 1be8dac4..d22f96b2 100644 --- a/packages/connect-multichain/src/multichain/rpc/requestRouter.test.ts +++ b/packages/connect-multichain/src/multichain/rpc/requestRouter.test.ts @@ -1,150 +1,187 @@ -import * as t from 'vitest'; +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable no-empty-function -- Empty mock functions */ import { analytics } from '@metamask/analytics'; -import { type InvokeMethodOptions, RPCInvokeMethodErr, type Scope } from '../../domain'; +import * as t from 'vitest'; + import type { RequestRouter } from './requestRouter'; +import { + type InvokeMethodOptions, + RPCInvokeMethodErr, + type Scope, +} from '../../domain'; import { MissingRpcEndpointErr } from './handlers/rpcClient'; t.describe('RequestRouter', () => { - let mockTransport: any; - let mockConfig: any; - let mockRpcClient: any; - let requestRouter: RequestRouter; - let baseOptions: any; - let mockStorage: any; - - t.beforeEach(async () => { - const requestRouterModule = await import('./requestRouter'); - baseOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_sendTransaction', - params: { to: '0x123', value: '0x100' }, - }, - }; - mockTransport = { - request: t.vi.fn(), - }; - mockRpcClient = { - request: t.vi.fn(), - }; - mockStorage = { - getAnonId: t.vi.fn().mockResolvedValue('test-anon-id'), - }; - mockConfig = { - dapp: { - name: 'Test Dapp', - url: 'https://test-dapp.com', - }, - storage: mockStorage, - analytics: { - integrationType: 'test', - }, - ui: { - factory: t.vi.fn(), - }, - }; - // Mock analytics.track to prevent actual analytics calls - t.vi.spyOn(analytics, 'track').mockImplementation(() => {}); - requestRouter = new requestRouterModule.RequestRouter(mockTransport, mockRpcClient, mockConfig); - // Reset mocks - mockTransport.request.mockClear(); - }); - - t.afterEach(async () => { - t.vi.clearAllMocks(); - t.vi.resetAllMocks(); - }); - - t.describe('invokeMethod', () => { - t.describe('when the requested method is neither in the `RPC_HANDLED_METHODS` nor the `SDK_HANDLED_METHODS`', () => { - t.it('should route to the wallet', async () => { - const signOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'personal_sign', - params: { message: 'hello world' }, - }, - }; - mockTransport.request.mockResolvedValue({ result: '0xsignature' }); - const result = await requestRouter.invokeMethod(signOptions); - - t.expect(result).toBe('0xsignature'); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: signOptions, - }); - }); - - t.it('should fallback to the wallet for unknown methods', async () => { - const unknownOptions: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'unknown_method', - params: [], - }, - }; - mockTransport.request.mockResolvedValue({ result: 'unknown_result' }); - const result = await requestRouter.invokeMethod(unknownOptions); - - t.expect(result).toBe('unknown_result'); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: unknownOptions, - }); - }); - - t.it('should throw RPCInvokeMethodErr when transport request fails', async () => { - mockTransport.request.mockRejectedValue(new Error('Transport error')); - - await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); - await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toThrow('Transport error'); - }); - - t.it('should throw RPCInvokeMethodErr when response contains an error', async () => { - mockTransport.request.mockResolvedValue({ - error: { code: -32603, message: 'Internal error' }, - }); - - await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toBeInstanceOf(RPCInvokeMethodErr); - await t.expect(requestRouter.invokeMethod(baseOptions)).rejects.toThrow('RPC Request failed with code -32603: Internal error'); - }); - }); - }); - - t.describe('when the request method is in `RPC_HANDLED_METHODS`', () => { - t.it('should route to the rpcClient', async () => { - const options: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_blockNumber', - params: [], - }, - }; - mockRpcClient.request.mockResolvedValue('0x123'); - const result = await requestRouter.invokeMethod(options); - - t.expect(result).toBe('0x123'); - t.expect(mockRpcClient.request).toHaveBeenCalledWith(options); - }); - - t.it('should re-route to the wallet if the rpc node request fails with a MissingRpcEndpointErr', async () => { - const options: InvokeMethodOptions = { - scope: 'eip155:1' as Scope, - request: { - method: 'eth_blockNumber', - params: [], - }, - }; - mockTransport.request.mockResolvedValue({ result: '0x999' }); - mockRpcClient.request.mockRejectedValue(new MissingRpcEndpointErr('No RPC endpoint found for scope eip155:1')); - const result = await requestRouter.invokeMethod(options); - - t.expect(result).toBe('0x999'); - t.expect(mockRpcClient.request).toHaveBeenCalledWith(options); - t.expect(mockTransport.request).toHaveBeenCalledWith({ - method: 'wallet_invokeMethod', - params: options, - }); - }); - }); + let mockTransport: any; + let mockConfig: any; + let mockRpcClient: any; + let requestRouter: RequestRouter; + let baseOptions: any; + let mockStorage: any; + + t.beforeEach(async () => { + const requestRouterModule = await import('./requestRouter'); + baseOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_sendTransaction', + params: { to: '0x123', value: '0x100' }, + }, + }; + mockTransport = { + request: t.vi.fn(), + }; + mockRpcClient = { + request: t.vi.fn(), + }; + mockStorage = { + getAnonId: t.vi.fn().mockResolvedValue('test-anon-id'), + }; + mockConfig = { + dapp: { + name: 'Test Dapp', + url: 'https://test-dapp.com', + }, + storage: mockStorage, + analytics: { + integrationType: 'test', + }, + ui: { + factory: t.vi.fn(), + }, + }; + // Mock analytics.track to prevent actual analytics calls + t.vi.spyOn(analytics, 'track').mockImplementation(() => {}); + requestRouter = new requestRouterModule.RequestRouter( + mockTransport, + mockRpcClient, + mockConfig, + ); + // Reset mocks + mockTransport.request.mockClear(); + }); + + t.afterEach(async () => { + t.vi.clearAllMocks(); + t.vi.resetAllMocks(); + }); + + t.describe('invokeMethod', () => { + t.describe( + 'when the requested method is neither in the `RPC_HANDLED_METHODS` nor the `SDK_HANDLED_METHODS`', + () => { + t.it('should route to the wallet', async () => { + const signOptions: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'personal_sign', + params: { message: 'hello world' }, + }, + }; + mockTransport.request.mockResolvedValue({ result: '0xsignature' }); + const result = await requestRouter.invokeMethod(signOptions); + + t.expect(result).toBe('0xsignature'); + t.expect(mockTransport.request).toHaveBeenCalledWith({ + method: 'wallet_invokeMethod', + params: signOptions, + }); + }); + + t.it('should fallback to the wallet for unknown methods', async () => { + const unknownOptions: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'unknown_method', + params: [], + }, + }; + mockTransport.request.mockResolvedValue({ result: 'unknown_result' }); + const result = await requestRouter.invokeMethod(unknownOptions); + + t.expect(result).toBe('unknown_result'); + t.expect(mockTransport.request).toHaveBeenCalledWith({ + method: 'wallet_invokeMethod', + params: unknownOptions, + }); + }); + + t.it( + 'should throw RPCInvokeMethodErr when transport request fails', + async () => { + mockTransport.request.mockRejectedValue( + new Error('Transport error'), + ); + + await t + .expect(requestRouter.invokeMethod(baseOptions)) + .rejects.toBeInstanceOf(RPCInvokeMethodErr); + await t + .expect(requestRouter.invokeMethod(baseOptions)) + .rejects.toThrow('Transport error'); + }, + ); + + t.it( + 'should throw RPCInvokeMethodErr when response contains an error', + async () => { + mockTransport.request.mockResolvedValue({ + error: { code: -32603, message: 'Internal error' }, + }); + + await t + .expect(requestRouter.invokeMethod(baseOptions)) + .rejects.toBeInstanceOf(RPCInvokeMethodErr); + await t + .expect(requestRouter.invokeMethod(baseOptions)) + .rejects.toThrow( + 'RPC Request failed with code -32603: Internal error', + ); + }, + ); + }, + ); + }); + + t.describe('when the request method is in `RPC_HANDLED_METHODS`', () => { + t.it('should route to the rpcClient', async () => { + const options: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_blockNumber', + params: [], + }, + }; + mockRpcClient.request.mockResolvedValue('0x123'); + const result = await requestRouter.invokeMethod(options); + + t.expect(result).toBe('0x123'); + t.expect(mockRpcClient.request).toHaveBeenCalledWith(options); + }); + + t.it( + 'should re-route to the wallet if the rpc node request fails with a MissingRpcEndpointErr', + async () => { + const options: InvokeMethodOptions = { + scope: 'eip155:1' as Scope, + request: { + method: 'eth_blockNumber', + params: [], + }, + }; + mockTransport.request.mockResolvedValue({ result: '0x999' }); + mockRpcClient.request.mockRejectedValue( + new MissingRpcEndpointErr('No RPC endpoint found for scope eip155:1'), + ); + const result = await requestRouter.invokeMethod(options); + + t.expect(result).toBe('0x999'); + t.expect(mockRpcClient.request).toHaveBeenCalledWith(options); + t.expect(mockTransport.request).toHaveBeenCalledWith({ + method: 'wallet_invokeMethod', + params: options, + }); + }, + ); + }); }); diff --git a/packages/connect-multichain/src/multichain/rpc/requestRouter.ts b/packages/connect-multichain/src/multichain/rpc/requestRouter.ts index 8e6377cc..c909c39c 100644 --- a/packages/connect-multichain/src/multichain/rpc/requestRouter.ts +++ b/packages/connect-multichain/src/multichain/rpc/requestRouter.ts @@ -1,168 +1,241 @@ +/* eslint-disable no-restricted-syntax -- Private class properties use established patterns */ +/* eslint-disable @typescript-eslint/parameter-properties -- Constructor shorthand is intentional */ +/* eslint-disable jsdoc/require-param-description -- Auto-generated JSDoc */ +/* eslint-disable jsdoc/require-returns -- Auto-generated JSDoc */ +/* eslint-disable @typescript-eslint/no-misused-promises -- setTimeout callback is async intentionally */ import { analytics } from '@metamask/analytics'; import type { Json } from '@metamask/utils'; -import { METAMASK_CONNECT_BASE_URL, METAMASK_DEEPLINK_BASE } from '../../config'; -import { type ExtendedTransport, type InvokeMethodOptions, isSecure, type MultichainOptions, RPC_HANDLED_METHODS, RPCInvokeMethodErr, SDK_HANDLED_METHODS } from '../../domain'; +import { + METAMASK_CONNECT_BASE_URL, + METAMASK_DEEPLINK_BASE, +} from '../../config'; +import { + type ExtendedTransport, + type InvokeMethodOptions, + isSecure, + type MultichainOptions, + RPC_HANDLED_METHODS, + RPCInvokeMethodErr, + SDK_HANDLED_METHODS, +} from '../../domain'; import { openDeeplink } from '../utils'; -import { getWalletActionAnalyticsProperties, isRejectionError } from '../utils/analytics'; -import { MissingRpcEndpointErr, RpcClient } from './handlers/rpcClient'; +import { + getWalletActionAnalyticsProperties, + isRejectionError, +} from '../utils/analytics'; +import type { RpcClient } from './handlers/rpcClient'; +import { MissingRpcEndpointErr } from './handlers/rpcClient'; let rpcId = 1; -export function getNextRpcId() { - rpcId += 1; - return rpcId; +/** + * Gets the next RPC ID for request tracking. + * + * @returns The next unique RPC ID. + */ +export function getNextRpcId(): number { + rpcId += 1; + return rpcId; } export class RequestRouter { - constructor( - private readonly transport: ExtendedTransport, - private readonly rpcClient: RpcClient, - private readonly config: MultichainOptions, - ) {} - - /** - * The main entry point for invoking an RPC method. - * This method acts as a router, determining the correct handling strategy - * for the request and delegating to the appropriate private handler. - */ - async invokeMethod(options: InvokeMethodOptions): Promise { - const method = options.request.method; - if (RPC_HANDLED_METHODS.has(method)) { - return this.handleWithRpcNode(options); - } - if (SDK_HANDLED_METHODS.has(method)) { - return this.handleWithSdkState(options); - } - return this.handleWithWallet(options); - } - - /** - * Forwards the request directly to the wallet via the transport. - */ - private async handleWithWallet(options: InvokeMethodOptions): Promise { - return this.#withAnalyticsTracking(options, async () => { - const request = this.transport.request({ - method: 'wallet_invokeMethod', - params: options, - }); - - const { ui, mobile } = this.config; - const { showInstallModal = false } = ui ?? {}; - const secure = isSecure(); - const shouldOpenDeeplink = secure && !showInstallModal; - - if (shouldOpenDeeplink) { - setTimeout(async () => { - const session = await this.transport.getActiveSession(); - if (!session) { - throw new Error('No active session found'); - } - - const url = `${METAMASK_DEEPLINK_BASE}/mwp?id=${encodeURIComponent(session.id)}`; - if (mobile?.preferredOpenLink) { - mobile.preferredOpenLink(url, '_self'); - } else { - openDeeplink(this.config, url, METAMASK_CONNECT_BASE_URL); - } - }, 10); // small delay to ensure the message encryption and dispatch completes - } - - const response = await request; - if (response.error) { - throw new RPCInvokeMethodErr(`RPC Request failed with code ${response.error.code}: ${response.error.message}`); - } - - return response.result as Json; - }); - } - - /** - * Wraps execution with analytics tracking. - * - * @param options - The invoke method options - * @param execute - The function to execute - * @returns The result of the execution - */ - async #withAnalyticsTracking( - options: InvokeMethodOptions, - execute: () => Promise, - ): Promise { - await this.#trackWalletActionRequested(options); - - try { - const result = await execute(); - - await this.#trackWalletActionSucceeded(options); - - return result; - } catch (error) { - const isRejection = isRejectionError(error); - - if (isRejection) { - await this.#trackWalletActionRejected(options); - } else { - await this.#trackWalletActionFailed(options); - } - throw new RPCInvokeMethodErr(error.message); - } - } - - /** - * Tracks wallet action requested event. - */ - async #trackWalletActionRequested(options: InvokeMethodOptions): Promise { - const props = await getWalletActionAnalyticsProperties(this.config, this.config.storage, options); - analytics.track('mmconnect_wallet_action_requested', props); - } - - /** - * Tracks wallet action succeeded event. - */ - async #trackWalletActionSucceeded(options: InvokeMethodOptions): Promise { - const props = await getWalletActionAnalyticsProperties(this.config, this.config.storage, options); - analytics.track('mmconnect_wallet_action_succeeded', props); - } - - /** - * Tracks wallet action failed event. - */ - async #trackWalletActionFailed(options: InvokeMethodOptions): Promise { - const props = await getWalletActionAnalyticsProperties(this.config, this.config.storage, options); - analytics.track('mmconnect_wallet_action_failed', props); - } - - /** - * Tracks wallet action rejected event. - */ - async #trackWalletActionRejected(options: InvokeMethodOptions): Promise { - const props = await getWalletActionAnalyticsProperties(this.config, this.config.storage, options); - analytics.track('mmconnect_wallet_action_rejected', props); - } - - /** - * Routes the request to a configured RPC node. - */ - private async handleWithRpcNode(options: InvokeMethodOptions): Promise { - return this.#withAnalyticsTracking(options, async () => { - try { - return await this.rpcClient.request(options); - } catch (error) { - if (error instanceof MissingRpcEndpointErr) { - return this.handleWithWallet(options); - } - throw error; - } - }); - } - - /** - * Responds directly from the SDK's session state. - */ - private async handleWithSdkState(options: InvokeMethodOptions): Promise { - // TODO: to be implemented - console.warn(`Method "${options.request.method}" is configured for SDK state handling, but this is not yet implemented. Falling back to wallet passthrough.`); - // Fallback to wallet - return this.handleWithWallet(options); - } + constructor( + private readonly transport: ExtendedTransport, + private readonly rpcClient: RpcClient, + private readonly config: MultichainOptions, + ) {} + + /** + * The main entry point for invoking an RPC method. + * This method acts as a router, determining the correct handling strategy + * for the request and delegating to the appropriate private handler. + * + * @param options + */ + async invokeMethod(options: InvokeMethodOptions): Promise { + const { method } = options.request; + if (RPC_HANDLED_METHODS.has(method)) { + return this.handleWithRpcNode(options); + } + if (SDK_HANDLED_METHODS.has(method)) { + return this.handleWithSdkState(options); + } + return this.handleWithWallet(options); + } + + /** + * Forwards the request directly to the wallet via the transport. + * + * @param options + */ + private async handleWithWallet(options: InvokeMethodOptions): Promise { + return this.#withAnalyticsTracking(options, async () => { + const request = this.transport.request({ + method: 'wallet_invokeMethod', + params: options, + }); + + const { ui, mobile } = this.config; + const { showInstallModal = false } = ui ?? {}; + const secure = isSecure(); + const shouldOpenDeeplink = secure && !showInstallModal; + + if (shouldOpenDeeplink) { + setTimeout(async () => { + const session = await this.transport.getActiveSession(); + if (!session) { + throw new Error('No active session found'); + } + + const url = `${METAMASK_DEEPLINK_BASE}/mwp?id=${encodeURIComponent(session.id)}`; + if (mobile?.preferredOpenLink) { + mobile.preferredOpenLink(url, '_self'); + } else { + openDeeplink(this.config, url, METAMASK_CONNECT_BASE_URL); + } + }, 10); // small delay to ensure the message encryption and dispatch completes + } + + const response = await request; + if (response.error) { + const { error } = response; + throw new RPCInvokeMethodErr( + `RPC Request failed with code ${error.code}: ${error.message}`, + ); + } + + return response.result as Json; + }); + } + + /** + * Wraps execution with analytics tracking. + * + * @param options - The invoke method options + * @param execute - The function to execute + * @returns The result of the execution + */ + async #withAnalyticsTracking( + options: InvokeMethodOptions, + execute: () => Promise, + ): Promise { + await this.#trackWalletActionRequested(options); + + try { + const result = await execute(); + + await this.#trackWalletActionSucceeded(options); + + return result; + } catch (error) { + const isRejection = isRejectionError(error); + + if (isRejection) { + await this.#trackWalletActionRejected(options); + } else { + await this.#trackWalletActionFailed(options); + } + if (error instanceof RPCInvokeMethodErr) { + throw error; + } + throw new RPCInvokeMethodErr(error.message); + } + } + + /** + * Tracks wallet action requested event. + * + * @param options + */ + async #trackWalletActionRequested( + options: InvokeMethodOptions, + ): Promise { + const props = await getWalletActionAnalyticsProperties( + this.config, + this.config.storage, + options, + ); + analytics.track('mmconnect_wallet_action_requested', props); + } + + /** + * Tracks wallet action succeeded event. + * + * @param options + */ + async #trackWalletActionSucceeded( + options: InvokeMethodOptions, + ): Promise { + const props = await getWalletActionAnalyticsProperties( + this.config, + this.config.storage, + options, + ); + analytics.track('mmconnect_wallet_action_succeeded', props); + } + + /** + * Tracks wallet action failed event. + * + * @param options + */ + async #trackWalletActionFailed(options: InvokeMethodOptions): Promise { + const props = await getWalletActionAnalyticsProperties( + this.config, + this.config.storage, + options, + ); + analytics.track('mmconnect_wallet_action_failed', props); + } + + /** + * Tracks wallet action rejected event. + * + * @param options + */ + async #trackWalletActionRejected( + options: InvokeMethodOptions, + ): Promise { + const props = await getWalletActionAnalyticsProperties( + this.config, + this.config.storage, + options, + ); + analytics.track('mmconnect_wallet_action_rejected', props); + } + + /** + * Routes the request to a configured RPC node. + * + * @param options + */ + private async handleWithRpcNode(options: InvokeMethodOptions): Promise { + return this.#withAnalyticsTracking(options, async () => { + try { + return await this.rpcClient.request(options); + } catch (error) { + if (error instanceof MissingRpcEndpointErr) { + return this.handleWithWallet(options); + } + throw error; + } + }); + } + + /** + * Responds directly from the SDK's session state. + * + * @param options + */ + private async handleWithSdkState( + options: InvokeMethodOptions, + ): Promise { + // TODO: to be implemented + console.warn( + `Method "${options.request.method}" is configured for SDK state handling, but this is not yet implemented. Falling back to wallet passthrough.`, + ); + // Fallback to wallet + return this.handleWithWallet(options); + } } diff --git a/packages/connect-multichain/src/multichain/transports/default/index.ts b/packages/connect-multichain/src/multichain/transports/default/index.ts index 59f31d13..aa3993ca 100644 --- a/packages/connect-multichain/src/multichain/transports/default/index.ts +++ b/packages/connect-multichain/src/multichain/transports/default/index.ts @@ -1,7 +1,8 @@ +import type { Session } from '@metamask/mobile-wallet-protocol-core'; import { + type SessionProperties, type CreateSessionParams, getDefaultTransport, - SessionProperties, type Transport, type TransportRequest, type TransportResponse, @@ -15,7 +16,6 @@ import { getValidAccounts, isSameScopesAndAccounts, } from '../../utils'; -import { Session } from '@metamask/mobile-wallet-protocol-core'; const DEFAULT_REQUEST_TIMEOUT = 60 * 1000; @@ -314,10 +314,12 @@ export class DefaultTransport implements ExtendedTransport { }; } - getActiveSession(): Promise { + async getActiveSession(): Promise { // This code path should never be triggered when the DefaultTransport is being used // It's only purpose is for exposing the session ID used for deeplinking to the mobile app // and so it is only implemented for the MWPTransport. - throw new Error('getActiveSession is purposely not implemented for the DefaultTransport'); + throw new Error( + 'getActiveSession is purposely not implemented for the DefaultTransport', + ); } } diff --git a/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts b/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts index 2f0e126f..10a6e1f9 100644 --- a/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts +++ b/packages/connect-multichain/src/multichain/transports/multichainApiClientWrapper/index.ts @@ -1,3 +1,8 @@ +/* eslint-disable no-restricted-syntax -- Private class properties use established patterns */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Inferred types are sufficient */ +/* eslint-disable @typescript-eslint/parameter-properties -- Constructor shorthand is intentional */ +/* eslint-disable no-plusplus -- Increment operator is safe here */ +/* eslint-disable @typescript-eslint/no-floating-promises -- Promise is intentionally not awaited */ import type { CreateSessionParams, Transport, @@ -22,31 +27,33 @@ const getUniqueId = (): number => { type TransportRequestWithId = TransportRequest & { id: number }; export class MultichainApiClientWrapperTransport implements Transport { - private requestId = getUniqueId(); + #requestId = getUniqueId(); - private readonly notificationCallbacks = new Set<(data: unknown) => void>(); + readonly #notificationCallbacks = new Set<(data: unknown) => void>(); - constructor(private readonly metamaskConnectMultichain: MetaMaskConnectMultichain) {} + constructor( + private readonly metamaskConnectMultichain: MetaMaskConnectMultichain, + ) {} isTransportDefined(): boolean { try { return Boolean(this.metamaskConnectMultichain.transport); - } catch (error) { + } catch (_error) { return false; } } - clearNotificationCallbacks() { - this.notificationCallbacks.clear(); + clearNotificationCallbacks(): void { + this.#notificationCallbacks.clear(); } - notifyCallbacks(data: unknown) { - this.notificationCallbacks.forEach((callback) => { + notifyCallbacks(data: unknown): void { + this.#notificationCallbacks.forEach((callback) => { callback(data); }); } - setupNotifcationListener() { + setupNotifcationListener(): void { this.metamaskConnectMultichain.transport.onNotification( this.notifyCallbacks.bind(this), ); @@ -73,7 +80,7 @@ export class MultichainApiClientWrapperTransport implements Transport { params: ParamsType, _options: { timeout?: number } = {}, ): Promise { - const id = this.requestId++; + const id = this.#requestId++; const requestPayload = { id, jsonrpc: '2.0', @@ -96,11 +103,11 @@ export class MultichainApiClientWrapperTransport implements Transport { throw new Error(`Unknown method: ${requestPayload.method}`); } - onNotification(callback: (data: unknown) => void) { + onNotification(callback: (data: unknown) => void): () => void { if (!this.isTransportDefined()) { - this.notificationCallbacks.add(callback); + this.#notificationCallbacks.add(callback); return () => { - this.notificationCallbacks.delete(callback); + this.#notificationCallbacks.delete(callback); }; } @@ -164,7 +171,7 @@ export class MultichainApiClientWrapperTransport implements Transport { try { this.metamaskConnectMultichain.disconnect(); return { jsonrpc: '2.0', id: request.id, result: true }; - } catch (error) { + } catch (_error) { return { jsonrpc: '2.0', id: request.id, result: false }; } } @@ -179,6 +186,6 @@ export class MultichainApiClientWrapperTransport implements Transport { return { result, - } + }; } } diff --git a/packages/connect-multichain/src/multichain/transports/mwp/KeyManager.ts b/packages/connect-multichain/src/multichain/transports/mwp/KeyManager.ts index d37b54c2..ac79379b 100644 --- a/packages/connect-multichain/src/multichain/transports/mwp/KeyManager.ts +++ b/packages/connect-multichain/src/multichain/transports/mwp/KeyManager.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-restricted-globals -- Buffer is polyfilled for browser/RN environments */ +/* eslint-disable @typescript-eslint/await-thenable -- decrypt returns Promise in some implementations */ import type { IKeyManager, KeyPair, diff --git a/packages/connect-multichain/src/multichain/transports/mwp/index.ts b/packages/connect-multichain/src/multichain/transports/mwp/index.ts index 7eca47e3..2d4c5619 100644 --- a/packages/connect-multichain/src/multichain/transports/mwp/index.ts +++ b/packages/connect-multichain/src/multichain/transports/mwp/index.ts @@ -12,6 +12,7 @@ /* eslint-disable @typescript-eslint/prefer-readonly */ /* eslint-disable no-restricted-syntax */ /* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-async-promise-executor -- Async promise executor needed for complex flow */ import type { Session, SessionRequest, @@ -19,8 +20,8 @@ import type { import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; import { + type SessionProperties, type CreateSessionParams, - SessionProperties, type TransportRequest, type TransportResponse, TransportTimeoutError, @@ -102,7 +103,11 @@ export class MWPTransport implements ExtendedTransport { constructor( private dappClient: DappClient, private kvstore: StoreAdapter, - private options: { requestTimeout: number; connectionTimeout: number; resumeTimeout: number } = { + private options: { + requestTimeout: number; + connectionTimeout: number; + resumeTimeout: number; + } = { requestTimeout: DEFAULT_REQUEST_TIMEOUT, connectionTimeout: DEFAULT_CONNECTION_TIMEOUT, resumeTimeout: DEFAULT_RESUME_TIMEOUT, @@ -272,7 +277,7 @@ export class MWPTransport implements ExtendedTransport { walletSession = response.result as SessionData; } } else if (!walletSession) { - // TODO: verify if this branching logic can ever be hit + // TODO: verify if this branching logic can ever be hit const optionalScopes = addValidAccounts( getOptionalScopes(options?.scopes ?? []), getValidAccounts(options?.caipAccountIds ?? []), @@ -293,7 +298,7 @@ export class MWPTransport implements ExtendedTransport { }); return resumeResolve(); } catch (err) { - return resumeReject(err); + return resumeReject(err as Error); } } @@ -664,7 +669,8 @@ export class MWPTransport implements ExtendedTransport { // for the initial wallet_createSession connection request to not have been handled and cached yet. This results // in the wallet_getSession request never resolving unless we wait for it explicitly as done in this method. private async waitForWalletSessionIfNotCached() { - const cachedWalletGetSessionResponse = await this.kvstore.get(SESSION_STORE_KEY); + const cachedWalletGetSessionResponse = + await this.kvstore.get(SESSION_STORE_KEY); if (cachedWalletGetSessionResponse) { return; } @@ -674,7 +680,10 @@ export class MWPTransport implements ExtendedTransport { if (typeof message === 'object' && message !== null) { if ('data' in message) { const messagePayload = message.data as Record; - if (messagePayload.method === 'wallet_getSession' || messagePayload.method === 'wallet_sessionChanged') { + if ( + messagePayload.method === 'wallet_getSession' || + messagePayload.method === 'wallet_sessionChanged' + ) { unsubscribe(); resolve(); } diff --git a/packages/connect-multichain/src/multichain/utils/analytics.ts b/packages/connect-multichain/src/multichain/utils/analytics.ts index 31a92732..d5ea9aef 100644 --- a/packages/connect-multichain/src/multichain/utils/analytics.ts +++ b/packages/connect-multichain/src/multichain/utils/analytics.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-unused-vars -- Scope type used in JSDoc */ import { getDappId } from '.'; import type { InvokeMethodOptions, diff --git a/packages/connect-multichain/src/multichain/utils/index.test.ts b/packages/connect-multichain/src/multichain/utils/index.test.ts index a2287bba..3725f4d6 100644 --- a/packages/connect-multichain/src/multichain/utils/index.test.ts +++ b/packages/connect-multichain/src/multichain/utils/index.test.ts @@ -1,381 +1,499 @@ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test functions */ +/* eslint-disable @typescript-eslint/await-thenable -- Mock functions may be thenable */ import type { CaipAccountId } from '@metamask/utils'; import * as t from 'vitest'; import { vi } from 'vitest'; + +import * as utils from '.'; import { getVersion, type Scope } from '../../domain'; import type { MultichainOptions } from '../../domain/multichain'; import { getPlatformType, PlatformType } from '../../domain/platform'; -import * as utils from '.'; vi.mock('../../domain/platform', async () => { - const actual = (await vi.importActual('../../domain/platform')) as any; - return { - ...actual, - getPlatformType: vi.fn(), + const actual = (await vi.importActual('../../domain/platform')) as any; + return { + ...actual, + getPlatformType: vi.fn(), getVersion: t.vi.fn(() => '0.0.0'), - }; + }; }); t.describe('Utils', () => { - let options: MultichainOptions; - - t.beforeEach(() => { - t.vi.clearAllMocks(); - options = { - dapp: { - name: 'test', - url: 'test', - }, - api: { - }, - } as MultichainOptions; - }); - - t.describe('getDappId', () => { - const mockDappName = 'Mock DApp Name'; - const mockDappUrl = 'http://mockdapp.com'; - - t.it('should return dappMetadata.name if defined and url is not', () => { - global.window = undefined as any; - const dappSettings = { name: mockDappName }; - t.expect(utils.getDappId(dappSettings)).toBe(mockDappName); - }); - - t.it('should return dappMetadata.url if defined', () => { - global.window = undefined as any; - const dappSettings = { url: mockDappUrl, name: mockDappName }; - t.expect(utils.getDappId(dappSettings)).toBe(mockDappUrl); - }); - - }); - - t.describe('getSDKVersion', () => { - t.it('should get SDK version', () => { - t.expect(getVersion()).toBe('0.0.0'); - }); - }); - - 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('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 undefined 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('throw if platform is not browser and dapp url is missing', async () => { - (options.dapp as any) = { name: 'test' }; - await t.expect(() => utils.setupDappMetadata(options)).toThrow('You must provide dapp url'); - }); - - t.it('throw if platform dapp name is missing', async () => { - (options.dapp as any) = { url: 'https://example.com' }; - await t.expect(() => utils.setupDappMetadata(options)).toThrow('You must provide dapp name'); - }); - - t.it('should set the dapp url if not provided and platform is browser', async () => { - const mockGetPlatformType = t.vi.mocked(getPlatformType); - mockGetPlatformType.mockReturnValue(PlatformType.DesktopWeb); - t.vi.stubGlobal('window', { - location: { - protocol: 'https:', - host: 'example.com', - }, - }); - (options.dapp as any) = { - name: 'test', - }; - 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 = { - name: 'test', - 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 = { - name: 'test', - 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('isSameScopesAndAccounts', () => { - const mockWalletSession = { - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['chainChanged'], - accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], - }, - 'eip155:137': { - methods: ['eth_sendTransaction'], - notifications: ['chainChanged'], - accounts: ['eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], - }, - }, - } as any; - - t.it('should return true when scopes and accounts match exactly', () => { - const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890', 'eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'] as CaipAccountId[]; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); - - t.expect(result).toBe(true); - }); - - t.it('should return false when scopes do not match', () => { - const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedScopes: Scope[] = ['eip155:1', 'eip155:56']; // Different scope - const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890'] as CaipAccountId[]; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); - - t.expect(result).toBe(false); - }); - - t.it('should return false when proposed accounts are not included in existing session', () => { - const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedCaipAccountIds = [ - 'eip155:1:0x1234567890123456789012345678901234567890', - 'eip155:1:0x9999999999999999999999999999999999999999', // Not in session - ] as CaipAccountId[]; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); - - t.expect(result).toBe(false); - }); - - t.it('should return true when proposed accounts are subset of existing session accounts', () => { - const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedCaipAccountIds = [ - 'eip155:1:0x1234567890123456789012345678901234567890', // Only one account - ] as CaipAccountId[]; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); - - t.expect(result).toBe(true); - }); - - t.it('should return true when no accounts are proposed and scopes match', () => { - const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedCaipAccountIds: CaipAccountId[] = []; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); - - t.expect(result).toBe(true); - }); - - t.it('should handle empty session scopes', () => { - const emptySession = { sessionScopes: {} } as any; - const currentScopes: Scope[] = []; - const proposedScopes: Scope[] = []; - const proposedCaipAccountIds: CaipAccountId[] = []; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, emptySession, proposedCaipAccountIds); - - t.expect(result).toBe(true); - }); - - t.it('should handle scope objects without accounts property', () => { - const sessionWithoutAccounts = { - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['chainChanged'], - // No accounts property - }, - }, - } as any; - - const currentScopes: Scope[] = ['eip155:1']; - const proposedScopes: Scope[] = ['eip155:1']; - const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890'] as CaipAccountId[]; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, sessionWithoutAccounts, proposedCaipAccountIds); - - t.expect(result).toBe(false); - }); - - t.it('should handle scope objects with empty accounts array', () => { - const sessionWithEmptyAccounts = { - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['chainChanged'], - accounts: [], - }, - }, - } as any; - - const currentScopes: Scope[] = ['eip155:1']; - const proposedScopes: Scope[] = ['eip155:1']; - const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890'] as CaipAccountId[]; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, sessionWithEmptyAccounts, proposedCaipAccountIds); - - t.expect(result).toBe(false); - }); - - t.it('should return true when scopes have different order but same content', () => { - const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; - const proposedScopes: Scope[] = ['eip155:137', 'eip155:1']; // Different order - const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890', 'eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'] as CaipAccountId[]; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, mockWalletSession, proposedCaipAccountIds); - - t.expect(result).toBe(true); - }); - - t.it('should handle multiple accounts in same scope', () => { - const sessionWithMultipleAccounts = { - sessionScopes: { - 'eip155:1': { - methods: ['eth_sendTransaction'], - notifications: ['chainChanged'], - accounts: ['eip155:1:0x1234567890123456789012345678901234567890', 'eip155:1:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], - }, - }, - } as any; - - const currentScopes: Scope[] = ['eip155:1']; - const proposedScopes: Scope[] = ['eip155:1']; - const proposedCaipAccountIds = ['eip155:1:0x1234567890123456789012345678901234567890'] as CaipAccountId[]; - - const result = utils.isSameScopesAndAccounts(currentScopes, proposedScopes, sessionWithMultipleAccounts, proposedCaipAccountIds); - - t.expect(result).toBe(true); - }); - }); + let options: MultichainOptions; + + t.beforeEach(() => { + t.vi.clearAllMocks(); + options = { + dapp: { + name: 'test', + url: 'test', + }, + api: {}, + } as MultichainOptions; + }); + + t.describe('getDappId', () => { + const mockDappName = 'Mock DApp Name'; + const mockDappUrl = 'http://mockdapp.com'; + + t.it('should return dappMetadata.name if defined and url is not', () => { + global.window = undefined as any; + const dappSettings = { name: mockDappName }; + t.expect(utils.getDappId(dappSettings)).toBe(mockDappName); + }); + + t.it('should return dappMetadata.url if defined', () => { + global.window = undefined as any; + const dappSettings = { url: mockDappUrl, name: mockDappName }; + t.expect(utils.getDappId(dappSettings)).toBe(mockDappUrl); + }); + }); + + t.describe('getSDKVersion', () => { + t.it('should get SDK version', () => { + t.expect(getVersion()).toBe('0.0.0'); + }); + }); + + 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('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 undefined 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( + 'throw if platform is not browser and dapp url is missing', + async () => { + (options.dapp as any) = { name: 'test' }; + await t + .expect(() => utils.setupDappMetadata(options)) + .toThrow('You must provide dapp url'); + }, + ); + + t.it('throw if platform dapp name is missing', async () => { + (options.dapp as any) = { url: 'https://example.com' }; + await t + .expect(() => utils.setupDappMetadata(options)) + .toThrow('You must provide dapp name'); + }); + + t.it( + 'should set the dapp url if not provided and platform is browser', + async () => { + const mockGetPlatformType = t.vi.mocked(getPlatformType); + mockGetPlatformType.mockReturnValue(PlatformType.DesktopWeb); + t.vi.stubGlobal('window', { + location: { + protocol: 'https:', + host: 'example.com', + }, + }); + (options.dapp as any) = { + name: 'test', + }; + 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 = { + name: 'test', + 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 = { + name: 'test', + 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('isSameScopesAndAccounts', () => { + const mockWalletSession = { + sessionScopes: { + 'eip155:1': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: ['eip155:1:0x1234567890123456789012345678901234567890'], + }, + 'eip155:137': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: ['eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'], + }, + }, + } as any; + + t.it('should return true when scopes and accounts match exactly', () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedCaipAccountIds = [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ] as CaipAccountId[]; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + mockWalletSession, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(true); + }); + + t.it('should return false when scopes do not match', () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:1', 'eip155:56']; // Different scope + const proposedCaipAccountIds = [ + 'eip155:1:0x1234567890123456789012345678901234567890', + ] as CaipAccountId[]; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + mockWalletSession, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(false); + }); + + t.it( + 'should return false when proposed accounts are not included in existing session', + () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedCaipAccountIds = [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:1:0x9999999999999999999999999999999999999999', // Not in session + ] as CaipAccountId[]; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + mockWalletSession, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(false); + }, + ); + + t.it( + 'should return true when proposed accounts are subset of existing session accounts', + () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedCaipAccountIds = [ + 'eip155:1:0x1234567890123456789012345678901234567890', // Only one account + ] as CaipAccountId[]; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + mockWalletSession, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(true); + }, + ); + + t.it( + 'should return true when no accounts are proposed and scopes match', + () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedCaipAccountIds: CaipAccountId[] = []; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + mockWalletSession, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(true); + }, + ); + + t.it('should handle empty session scopes', () => { + const emptySession = { sessionScopes: {} } as any; + const currentScopes: Scope[] = []; + const proposedScopes: Scope[] = []; + const proposedCaipAccountIds: CaipAccountId[] = []; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + emptySession, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(true); + }); + + t.it('should handle scope objects without accounts property', () => { + const sessionWithoutAccounts = { + sessionScopes: { + 'eip155:1': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + // No accounts property + }, + }, + } as any; + + const currentScopes: Scope[] = ['eip155:1']; + const proposedScopes: Scope[] = ['eip155:1']; + const proposedCaipAccountIds = [ + 'eip155:1:0x1234567890123456789012345678901234567890', + ] as CaipAccountId[]; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + sessionWithoutAccounts, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(false); + }); + + t.it('should handle scope objects with empty accounts array', () => { + const sessionWithEmptyAccounts = { + sessionScopes: { + 'eip155:1': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: [], + }, + }, + } as any; + + const currentScopes: Scope[] = ['eip155:1']; + const proposedScopes: Scope[] = ['eip155:1']; + const proposedCaipAccountIds = [ + 'eip155:1:0x1234567890123456789012345678901234567890', + ] as CaipAccountId[]; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + sessionWithEmptyAccounts, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(false); + }); + + t.it( + 'should return true when scopes have different order but same content', + () => { + const currentScopes: Scope[] = ['eip155:1', 'eip155:137']; + const proposedScopes: Scope[] = ['eip155:137', 'eip155:1']; // Different order + const proposedCaipAccountIds = [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:137:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ] as CaipAccountId[]; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + mockWalletSession, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(true); + }, + ); + + t.it('should handle multiple accounts in same scope', () => { + const sessionWithMultipleAccounts = { + sessionScopes: { + 'eip155:1': { + methods: ['eth_sendTransaction'], + notifications: ['chainChanged'], + accounts: [ + 'eip155:1:0x1234567890123456789012345678901234567890', + 'eip155:1:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + ], + }, + }, + } as any; + + const currentScopes: Scope[] = ['eip155:1']; + const proposedScopes: Scope[] = ['eip155:1']; + const proposedCaipAccountIds = [ + 'eip155:1:0x1234567890123456789012345678901234567890', + ] as CaipAccountId[]; + + const result = utils.isSameScopesAndAccounts( + currentScopes, + proposedScopes, + sessionWithMultipleAccounts, + proposedCaipAccountIds, + ); + + t.expect(result).toBe(true); + }); + }); }); diff --git a/packages/connect-multichain/src/multichain/utils/index.ts b/packages/connect-multichain/src/multichain/utils/index.ts index c136a765..aecb2a6a 100644 --- a/packages/connect-multichain/src/multichain/utils/index.ts +++ b/packages/connect-multichain/src/multichain/utils/index.ts @@ -1,10 +1,15 @@ -import { deflate } from 'pako'; +/* eslint-disable no-restricted-globals -- Browser APIs are intentionally used */ +/* eslint-disable jsdoc/require-param-description -- Auto-generated JSDoc */ +/* eslint-disable jsdoc/require-returns -- Auto-generated JSDoc */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Inferred types are sufficient */ import { type CaipAccountId, type CaipChainId, parseCaipAccountId, parseCaipChainId, } from '@metamask/utils'; +import { deflate } from 'pako'; + import { type DappSettings, getPlatformType, @@ -19,6 +24,8 @@ export type OptionalScopes = Record; /** * Cross-platform base64 encoding * Works in browser, Node.js, and React Native environments + * + * @param str */ function base64Encode(str: string): string { if (typeof btoa !== 'undefined') { @@ -34,6 +41,8 @@ function base64Encode(str: string): string { /** * Compress a string using pako (deflateRaw) * Returns a base64-encoded compressed string + * + * @param str */ export function compressString(str: string): string { const compressed = deflate(str); @@ -43,18 +52,27 @@ export function compressString(str: string): string { return base64Encode(binaryString); } +/** + * + * @param dapp + */ export function getDappId(dapp: DappSettings) { return dapp.url ?? dapp.name; } +/** + * + * @param options + * @param deeplink + * @param universalLink + */ export function openDeeplink( options: MultichainOptions, deeplink: string, universalLink: string, ) { const { mobile } = options; - const useDeeplink = - mobile && mobile.useDeeplink !== undefined ? mobile.useDeeplink : true; + const useDeeplink = mobile?.useDeeplink ?? true; if (useDeeplink) { if (typeof window !== 'undefined') { // We don't need to open a deeplink in a new tab @@ -79,6 +97,10 @@ export function openDeeplink( } } +/** + * + * @param scopes + */ export function getOptionalScopes(scopes: Scope[]) { return scopes.reduce( (prev, scope) => ({ @@ -113,6 +135,10 @@ export const extractFavicon = () => { return favicon; }; +/** + * + * @param options + */ export function setupDappMetadata( options: MultichainOptions, ): MultichainOptions { @@ -136,7 +162,7 @@ export function setupDappMetadata( } 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:// + const urlPattern = /^(http|https):\/\/[^\s]*$/u; // 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)) { @@ -173,7 +199,7 @@ export function setupDappMetadata( !('base64Icon' in options.dapp) ) { const faviconUrl = `${window.location.protocol}//${window.location.host}${favicon}`; - // @ts-ignore + // @ts-expect-error -- iconUrl may not exist on all dapp types options.dapp.iconUrl = faviconUrl; } } @@ -182,6 +208,7 @@ export function setupDappMetadata( /** * Enhanced scope checking function that validates both scopes and accounts + * * @param currentScopes - Current scopes from the existing session * @param proposedScopes - Proposed scopes from the connect options * @param walletSession - The existing wallet session data @@ -215,15 +242,22 @@ export function isSameScopesAndAccounts( return allProposedAccountsIncluded; } +/** + * + * @param caipAccountIds + */ export function getValidAccounts(caipAccountIds: CaipAccountId[]) { return caipAccountIds.reduce[]>( (caipAccounts, caipAccountId) => { try { // biome-ignore lint/performance/noAccumulatingSpread: Needed return [...caipAccounts, parseCaipAccountId(caipAccountId)]; - } catch (err) { + } catch (error) { const stringifiedAccountId = JSON.stringify(caipAccountId); - console.error(`Invalid CAIP account ID: ${stringifiedAccountId}`, err); + console.error( + `Invalid CAIP account ID: ${stringifiedAccountId}`, + error, + ); return caipAccounts; } }, @@ -262,7 +296,7 @@ export function addValidAccounts( const accountsByChain = new Map(); for (const account of validAccounts) { const chainKey = `${account.chain.namespace}:${account.chain.reference}`; - const accountId = `${account.chainId}:${account.address}` as CaipAccountId; + const accountId: CaipAccountId = `${account.chainId}:${account.address}`; if (!accountsByChain.has(chainKey)) { accountsByChain.set(chainKey, []); diff --git a/packages/connect-multichain/src/polyfills/buffer-shim.ts b/packages/connect-multichain/src/polyfills/buffer-shim.ts index c1edda66..9fa04274 100644 --- a/packages/connect-multichain/src/polyfills/buffer-shim.ts +++ b/packages/connect-multichain/src/polyfills/buffer-shim.ts @@ -1,3 +1,7 @@ +/* eslint-disable no-restricted-globals -- Polyfill intentionally uses global/window */ +/* eslint-disable no-negated-condition -- Ternary chain is clearer here */ +/* eslint-disable no-nested-ternary -- Environment detection requires chained ternary */ +/* eslint-disable import-x/no-nodejs-modules -- Buffer polyfill requires Node.js module */ /** * Buffer polyfill for browser and React Native environments. * @@ -9,7 +13,7 @@ import { Buffer } from 'buffer'; // Get the appropriate global object for the current environment -const g = +const globalObj = typeof globalThis !== 'undefined' ? globalThis : typeof global !== 'undefined' @@ -19,6 +23,6 @@ const g = : ({} as typeof globalThis); // Only set Buffer if it's not already defined (avoid overwriting Node.js native Buffer) -if (!g.Buffer) { - g.Buffer = Buffer; +if (!globalObj.Buffer) { + globalObj.Buffer = Buffer; } diff --git a/packages/connect-multichain/src/session.test.ts b/packages/connect-multichain/src/session.test.ts index 75fb13c6..70799385 100644 --- a/packages/connect-multichain/src/session.test.ts +++ b/packages/connect-multichain/src/session.test.ts @@ -1,6 +1,14 @@ - - +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ +/* eslint-disable jsdoc/require-param-description -- Test helpers */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test functions */ +/* eslint-disable promise/param-names -- Test promise patterns */ +/* eslint-disable no-negated-condition -- Test assertions */ +/* eslint-disable @typescript-eslint/unbound-method -- Mock assertions */ +/* eslint-disable @typescript-eslint/naming-convention -- Test type parameters */ +import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; import * as t from 'vitest'; + import type { MultichainOptions, MultichainCore, @@ -8,20 +16,24 @@ import type { SessionData, } from './domain'; // Careful, order of import matters to keep mocks working +import { Store } from './store'; +import { mockSessionData, mockSessionRequestData } from '../tests/data'; import { runTestsInNodeEnv, runTestsInRNEnv, runTestsInWebEnv, runTestsInWebMobileEnv, } from '../tests/fixtures.test'; - -import { Store } from './store'; - import type { TestSuiteOptions, MockedData } from '../tests/types'; -import { mockSessionData, mockSessionRequestData } from '../tests/data'; -import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; import { MULTICHAIN_PROVIDER_STREAM_NAME } from './multichain/transports/constants'; +/** + * + * @param options0 + * @param options0.platform + * @param options0.createSDK + * @param options0.options + */ function testSuite({ platform, createSDK, @@ -60,15 +72,15 @@ function testSuite({ storage: new Store({ platform: platform as 'web' | 'rn' | 'node', - get(key) { + async get(key) { return Promise.resolve(mockedData.nativeStorageStub.getItem(key)); }, - set(key, value) { + async set(key, value) { return Promise.resolve( mockedData.nativeStorageStub.setItem(key, value), ); }, - delete(key) { + async delete(key) { return Promise.resolve( mockedData.nativeStorageStub.removeItem(key), ); @@ -110,7 +122,7 @@ function testSuite({ JSON.stringify({ jsonrpc: '2.0', method: 'wallet_sessionChanged', - result: mockSessionData + result: mockSessionData, }), ); } @@ -170,7 +182,7 @@ function testSuite({ { timeout: 60 * 1000 }, ); } else { - //Session is cached in storage so we don't need to call the getSession method + // Session is cached in storage so we don't need to call the getSession method t.expect( mockedData.mockDappClient.sendRequest, ).not.toHaveBeenCalledWith( @@ -290,11 +302,14 @@ function testSuite({ // Use a shorter timeout and handle both success and timeout cases let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error('Connect timeout')), 3000); + timeoutId = setTimeout( + () => reject(new Error('Connect timeout')), + 3000, + ); }); - + const connectPromise = sdk.connect(scopes, caipAccountIds); - + // Ensure both promises have catch handlers BEFORE racing to prevent unhandled rejections // This ensures that even if one promise rejects after the race resolves, it won't be unhandled connectPromise.catch(() => { @@ -303,17 +318,17 @@ function testSuite({ timeoutPromise.catch(() => { // Silently handle - timeout will be processed by race or ignored if connect wins }); - + let connectError: any; let timedOut = false; - + try { await Promise.race([connectPromise, timeoutPromise]); // If we get here without timeout, connect succeeded unexpectedly t.expect.fail('Expected connect to throw an error'); } catch (error) { clearTimeout(timeoutId); - + if (error instanceof Error && error.message === 'Connect timeout') { // For web-mobile, timeout might be expected due to deeplink hanging // Verify state instead @@ -332,13 +347,15 @@ function testSuite({ t.expect(connectError).toBeDefined(); } else { // If timed out, at least verify it's not connected - t.expect(['loaded', 'disconnected', 'connecting']).toContain(sdk.status); + t.expect(['loaded', 'disconnected', 'connecting']).toContain( + sdk.status, + ); } - + // Ensure both promises are fully handled to prevent unhandled rejections // Wait a tick to ensure any pending rejections are caught await new Promise((resolve) => setTimeout(resolve, 0)); - + // Disconnect SDK to clean up any ongoing async operations try { if (sdk.status !== 'disconnected' && sdk.status !== 'pending') { diff --git a/packages/connect-multichain/src/store/adapters/node.ts b/packages/connect-multichain/src/store/adapters/node.ts index 32bca6a7..46ee67e9 100644 --- a/packages/connect-multichain/src/store/adapters/node.ts +++ b/packages/connect-multichain/src/store/adapters/node.ts @@ -2,17 +2,18 @@ import { StoreAdapter } from '../../domain'; export class StoreAdapterNode extends StoreAdapter { readonly platform = 'node'; - private storage = new Map(); + + readonly #storage = new Map(); async get(key: string): Promise { - return this.storage.get(key) ?? null; + return this.#storage.get(key) ?? null; } async set(key: string, value: string): Promise { - this.storage.set(key, value); + this.#storage.set(key, value); } async delete(key: string): Promise { - this.storage.delete(key); + this.#storage.delete(key); } } diff --git a/packages/connect-multichain/src/store/adapters/rn.ts b/packages/connect-multichain/src/store/adapters/rn.ts index 90ac59ff..f1ff85cc 100644 --- a/packages/connect-multichain/src/store/adapters/rn.ts +++ b/packages/connect-multichain/src/store/adapters/rn.ts @@ -1,8 +1,11 @@ +// eslint-disable-next-line @typescript-eslint/naming-convention -- AsyncStorage is an external library import AsyncStorage from '@react-native-async-storage/async-storage'; + import { StoreAdapter } from '../../domain'; export class StoreAdapterRN extends StoreAdapter { readonly platform = 'rn'; + async get(key: string): Promise { return AsyncStorage.getItem(key); } diff --git a/packages/connect-multichain/src/store/adapters/web.ts b/packages/connect-multichain/src/store/adapters/web.ts index 2364f006..42ed869e 100644 --- a/packages/connect-multichain/src/store/adapters/web.ts +++ b/packages/connect-multichain/src/store/adapters/web.ts @@ -1,15 +1,23 @@ +/* eslint-disable no-restricted-globals -- Browser storage adapter uses window.indexedDB */ +/* eslint-disable @typescript-eslint/naming-convention -- DB_NAME is a constant */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Inferred types are sufficient */ +/* eslint-disable no-restricted-syntax -- Private class properties use established patterns */ +/* eslint-disable @typescript-eslint/parameter-properties -- Constructor shorthand is intentional */ +/* eslint-disable @typescript-eslint/prefer-promise-reject-errors -- Custom error objects */ import { StoreAdapter } from '../../domain'; -type kvStores = 'sdk-kv-store' | 'key-value-pairs'; +type KvStores = 'sdk-kv-store' | 'key-value-pairs'; export class StoreAdapterWeb extends StoreAdapter { - static readonly stores: kvStores[] = ['sdk-kv-store', 'key-value-pairs']; + static readonly stores: KvStores[] = ['sdk-kv-store', 'key-value-pairs']; + static readonly DB_NAME = 'mmsdk'; readonly platform = 'web'; + readonly dbPromise: Promise; - private get internal() { + private get internal(): IDBFactory { if (typeof window === 'undefined' || !window.indexedDB) { throw new Error('indexedDB is not available in this environment'); } @@ -18,7 +26,7 @@ export class StoreAdapterWeb extends StoreAdapter { constructor( dbNameSuffix: `-${string}` = '-kv-store', - private storeName: kvStores = StoreAdapterWeb.stores[0], + private readonly storeName: KvStores = StoreAdapterWeb.stores[0], ) { super(); diff --git a/packages/connect-multichain/src/store/index.test.ts b/packages/connect-multichain/src/store/index.test.ts index 7c3e1170..196131f8 100644 --- a/packages/connect-multichain/src/store/index.test.ts +++ b/packages/connect-multichain/src/store/index.test.ts @@ -1,7 +1,16 @@ +/* eslint-disable import-x/no-unassigned-import -- Fake IndexedDB setup */ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/naming-convention -- AsyncStorage external library */ +/* eslint-disable @typescript-eslint/no-shadow -- IDBFactory shadows global */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- Test patterns */ +/* eslint-disable jsdoc/require-param-description -- Test helpers */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test functions */ import 'fake-indexeddb/auto'; -import { IDBFactory } from 'fake-indexeddb'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { IDBFactory } from 'fake-indexeddb'; import * as t from 'vitest'; + +import { Store } from '.'; import type { StoreAdapter } from '../domain'; import { StorageDeleteErr, @@ -12,7 +21,6 @@ import { TransportType } from '../domain/multichain'; import { StoreAdapterNode } from './adapters/node'; import { StoreAdapterRN } from './adapters/rn'; import { StoreAdapterWeb } from './adapters/web'; -import { Store } from './index'; /** * Dummy mocked storage to keep track of data between tests @@ -32,6 +40,13 @@ const nativeStorageStub = { }; // Reusable test function that can be used with any adapter +/** + * + * @param adapterName + * @param createAdapter + * @param setupMocks + * @param cleanupMocks + */ function createStoreTests( adapterName: string, createAdapter: () => StoreAdapter, @@ -249,7 +264,7 @@ t.describe(`Store with NodeAdapter`, () => { }); t.describe(`Store with WebAdapter`, () => { - //Test browser storage with mocked local storage + // Test browser storage with mocked local storage createStoreTests( 'WebAdapter', () => new StoreAdapterWeb(), @@ -269,7 +284,7 @@ t.describe(`Store with WebAdapter`, () => { indexedDB: undefined, }); const store = new Store(new StoreAdapterWeb()); - await t.expect(() => store.getAnonId()).rejects.toThrow(); + await t.expect(async () => store.getAnonId()).rejects.toThrow(); }, ); }); diff --git a/packages/connect-multichain/src/store/index.ts b/packages/connect-multichain/src/store/index.ts index dca1d8d8..78b0e6cf 100644 --- a/packages/connect-multichain/src/store/index.ts +++ b/packages/connect-multichain/src/store/index.ts @@ -1,3 +1,5 @@ +/* eslint-disable id-denylist -- 'err' is a common pattern for catch clauses */ +/* eslint-disable @typescript-eslint/parameter-properties -- Constructor shorthand */ import * as uuid from 'uuid'; import type { StoreAdapter, TransportType } from '../domain'; diff --git a/packages/connect-multichain/src/ui/ModalFactory.ts b/packages/connect-multichain/src/ui/ModalFactory.ts index a1b13c5f..183e440b 100644 --- a/packages/connect-multichain/src/ui/ModalFactory.ts +++ b/packages/connect-multichain/src/ui/ModalFactory.ts @@ -1,12 +1,11 @@ /* eslint-disable @typescript-eslint/no-shadow */ -/* eslint-disable @typescript-eslint/no-misused-promises */ + /* eslint-disable no-restricted-globals */ /* eslint-disable jsdoc/require-returns */ /* eslint-disable @typescript-eslint/parameter-properties */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable no-restricted-syntax */ -/* eslint-disable promise/no-return-wrap */ -/* eslint-disable require-atomic-updates */ + /* eslint-disable @typescript-eslint/naming-convention */ import MetaMaskOnboarding from '@metamask/onboarding'; @@ -32,7 +31,9 @@ export type PreloadFn = () => Promise; * Base ModalFactory class that accepts a preload function. * Platform-specific implementations should extend this class. */ -export abstract class BaseModalFactory { +export abstract class BaseModalFactory< + T extends FactoryModals = FactoryModals, +> { public modal!: Modal; private readonly platform: PlatformType = getPlatformType(); @@ -117,7 +118,9 @@ export abstract class BaseModalFactory createConnectionDeeplink(connectionRequest?: ConnectionRequest) { if (!connectionRequest) { - throw new Error('createConnectionDeeplink can only be called with a connection request'); + throw new Error( + 'createConnectionDeeplink can only be called with a connection request', + ); } const json = JSON.stringify(connectionRequest); const compressed = compressString(json); diff --git a/packages/connect-multichain/src/ui/index.native.ts b/packages/connect-multichain/src/ui/index.native.ts index 9af2e9f4..e61cf71f 100644 --- a/packages/connect-multichain/src/ui/index.native.ts +++ b/packages/connect-multichain/src/ui/index.native.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Type parameter T is a standard convention */ /** * React Native UI module entry point */ @@ -12,5 +13,7 @@ export class ModalFactory< T extends FactoryModals = FactoryModals, > extends BaseModalFactory { // No-op for React Native - web components are not applicable - protected async preload(): Promise {} + protected async preload(): Promise { + // No-op: React Native does not use web components + } } diff --git a/packages/connect-multichain/src/ui/index.test.ts b/packages/connect-multichain/src/ui/index.test.ts index 32871d97..622521e3 100644 --- a/packages/connect-multichain/src/ui/index.test.ts +++ b/packages/connect-multichain/src/ui/index.test.ts @@ -1,5 +1,17 @@ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/naming-convention -- Test naming and JSDOM alias */ +/* eslint-disable @typescript-eslint/no-unused-vars -- Test imports */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- Test patterns */ +/* eslint-disable no-restricted-globals -- Test DOM mocking */ +/* eslint-disable no-empty-function -- Empty mock functions */ +/* eslint-disable @typescript-eslint/unbound-method -- Mock assertions */ +/* eslint-disable @typescript-eslint/no-shadow -- Test scopes */ +import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; import { JSDOM as Page } from 'jsdom'; +import { v4 } from 'uuid'; import * as t from 'vitest'; + +import { ModalFactory } from '.'; import { type ConnectionRequest, getPlatformType, @@ -11,10 +23,8 @@ import { PlatformType, type QRLink, } from '../domain'; -import { ModalFactory } from './index'; -import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; import type { FactoryModals } from './modals/types'; -import { v4 } from 'uuid'; + // Mock external dependencies t.vi.mock('@metamask/onboarding', () => ({ default: class MockMetaMaskOnboarding { @@ -41,9 +51,7 @@ t.vi.mock('../domain', async () => { }); t.describe('ModalFactory', () => { - let mockModal: - | Modal - | Modal; + let mockModal: Modal | Modal; let mockModalOptions: t.Mock<() => InstallWidgetProps | OTPCodeWidgetProps>; let mockData: t.Mock<() => QRLink | OTPCode>; @@ -198,7 +206,7 @@ t.describe('ModalFactory', () => { mockGetPlatformType.mockReturnValue(PlatformType.ReactNative); t.vi.resetModules(); - const { ModalFactory: TestModalFactory } = await import('./index'); + const { ModalFactory: TestModalFactory } = await import('.'); const modalFactory = new TestModalFactory(mockFactoryOptions); t.expect(modalFactory.isMobile).toBe(true); }, @@ -208,7 +216,7 @@ t.describe('ModalFactory', () => { mockGetPlatformType.mockReturnValue(PlatformType.NonBrowser); t.vi.resetModules(); - const { ModalFactory: TestModalFactory } = await import('./index'); + const { ModalFactory: TestModalFactory } = await import('.'); const modalFactory = new TestModalFactory(mockFactoryOptions); t.expect(modalFactory.isNode).toBe(true); }); @@ -224,7 +232,7 @@ t.describe('ModalFactory', () => { mockGetPlatformType.mockReturnValue(platform); t.vi.resetModules(); - const { ModalFactory: TestModalFactory } = await import('./index'); + const { ModalFactory: TestModalFactory } = await import('.'); const modalFactory = new TestModalFactory(mockFactoryOptions); t.expect(modalFactory.isWeb).toBe(true); } @@ -258,7 +266,6 @@ t.describe('ModalFactory', () => { }, }; uiModule = new ModalFactory(mockFactoryOptions); - //uiModule.modal = mockModal; mockContainer = document.createElement('div'); }); @@ -271,7 +278,7 @@ t.describe('ModalFactory', () => { await uiModule.renderInstallModal( showInstallModal, - () => Promise.resolve(connectionRequest), + async () => Promise.resolve(connectionRequest), async () => {}, ); @@ -311,7 +318,7 @@ t.describe('ModalFactory', () => { }, }; - const createSessionRequestMock = t.vi.fn(() => { + const createSessionRequestMock = t.vi.fn(async () => { connectionRequest = { ...connectionRequest, sessionRequest: { @@ -366,7 +373,7 @@ t.describe('ModalFactory', () => { await uiModule.renderInstallModal( false, - () => Promise.resolve(connectionRequest), + async () => Promise.resolve(connectionRequest), async () => {}, ); @@ -402,7 +409,7 @@ t.describe('ModalFactory', () => { await uiModule.renderInstallModal( false, - () => Promise.resolve(connectionRequest), + async () => Promise.resolve(connectionRequest), async () => {}, ); @@ -418,7 +425,7 @@ t.describe('ModalFactory', () => { t.describe('renderOTPCodeModal', () => { t.it('should render OTP code modal with placeholder props', async () => { await uiModule.renderOTPCodeModal( - () => Promise.resolve('123456' as OTPCode), + async () => Promise.resolve('123456' as OTPCode), async () => {}, () => {}, ); @@ -468,7 +475,7 @@ t.describe('ModalFactory', () => { .expect( uiModule.renderInstallModal( false, - () => Promise.resolve(connectionRequest), + async () => Promise.resolve(connectionRequest), async () => {}, ), ) @@ -508,7 +515,7 @@ t.describe('ModalFactory', () => { }; await uiModule.renderInstallModal( false, - () => Promise.resolve(connectionRequest), + async () => Promise.resolve(connectionRequest), async () => {}, ); const firstModal = mockModal; @@ -522,7 +529,7 @@ t.describe('ModalFactory', () => { // Render second modal await uiModule.renderOTPCodeModal( - () => Promise.resolve('123456' as OTPCode), + async () => Promise.resolve('123456' as OTPCode), async () => {}, () => {}, ); @@ -553,7 +560,7 @@ t.describe('ModalFactory', () => { }); t.vi.resetModules(); - const { preload: TestPreload } = await import('./index'); + const { preload: TestPreload } = await import('.'); await TestPreload(); // Verify that the error was logged @@ -577,7 +584,7 @@ t.describe('ModalFactory', () => { }); t.vi.resetModules(); - const { preload: TestPreload } = await import('./index'); + const { preload: TestPreload } = await import('.'); await TestPreload(); // Verify the exact format: first argument should be the message string, @@ -585,9 +592,7 @@ t.describe('ModalFactory', () => { t.expect(consoleErrorSpy).toHaveBeenCalledTimes(1); const [firstArg, secondArg] = consoleErrorSpy.mock.calls[0]; - t.expect(firstArg).toBe( - 'Failed to load customElements:', - ); + t.expect(firstArg).toBe('Failed to load customElements:'); t.expect(secondArg).toBeInstanceOf(Error); t.expect(secondArg).toBe(testError); @@ -628,7 +633,7 @@ t.describe('ModalFactory', () => { .expect( uiModule.renderInstallModal( false, - () => Promise.resolve(connectionRequest), + async () => Promise.resolve(connectionRequest), async () => {}, ), ) diff --git a/packages/connect-multichain/src/ui/index.ts b/packages/connect-multichain/src/ui/index.ts index b755a367..10b53a09 100644 --- a/packages/connect-multichain/src/ui/index.ts +++ b/packages/connect-multichain/src/ui/index.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-restricted-globals -- Web UI uses document */ +/* eslint-disable @typescript-eslint/naming-convention -- Type parameter T is a standard convention */ /** * Browser/Web UI module entry point */ diff --git a/packages/connect-multichain/src/ui/modals/base/AbstractInstallModal.test.ts b/packages/connect-multichain/src/ui/modals/base/AbstractInstallModal.test.ts index 9f8acdba..ada76574 100644 --- a/packages/connect-multichain/src/ui/modals/base/AbstractInstallModal.test.ts +++ b/packages/connect-multichain/src/ui/modals/base/AbstractInstallModal.test.ts @@ -1,8 +1,9 @@ +/* eslint-disable id-length -- vitest alias */ /** biome-ignore-all lint/suspicious/noExplicitAny: ok in tests */ import * as t from 'vitest'; -import type { ConnectionRequest, QRLink } from '../../../domain'; import { AbstractInstallModal } from './AbstractInstallModal'; +import type { ConnectionRequest, QRLink } from '../../../domain'; const mountMock = t.vi.fn(); const unmountMock = t.vi.fn(); @@ -16,7 +17,6 @@ class TestInstallModal extends AbstractInstallModal { super.updateExpiresIn(expiresIn); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars renderQRCode(): void { // mock } diff --git a/packages/connect-multichain/src/ui/modals/base/AbstractInstallModal.ts b/packages/connect-multichain/src/ui/modals/base/AbstractInstallModal.ts index cc7a8056..f1ed9207 100644 --- a/packages/connect-multichain/src/ui/modals/base/AbstractInstallModal.ts +++ b/packages/connect-multichain/src/ui/modals/base/AbstractInstallModal.ts @@ -1,3 +1,7 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Getters/setters have inferred types */ +/* eslint-disable require-atomic-updates -- False positive: connectionRequest is reassigned atomically */ +/* eslint-disable @typescript-eslint/no-misused-promises -- setInterval callback is async intentionally */ +import { formatRemainingTime, shouldLogCountdown } from './utils'; import { type ConnectionRequest, createLogger, @@ -5,17 +9,15 @@ import { Modal, type QRLink, } from '../../../domain'; -import { formatRemainingTime, shouldLogCountdown } from './utils'; const logger = createLogger('metamask-sdk:ui'); -export abstract class AbstractInstallModal extends Modal< - InstallWidgetProps, - QRLink -> { +export abstract class AbstractInstallModal extends Modal { protected instance?: HTMLMmInstallModalElement | undefined; - private expirationInterval: NodeJS.Timeout | null = null; - private lastLoggedCountdown: number = -1; + + #expirationInterval: NodeJS.Timeout | null = null; + + #lastLoggedCountdown: number = -1; abstract renderQRCode( link: QRLink, @@ -51,12 +53,12 @@ export abstract class AbstractInstallModal extends Modal< } } - protected startExpirationCheck(connectionRequest: ConnectionRequest) { + protected startExpirationCheck(connectionRequest: ConnectionRequest): void { this.stopExpirationCheck(); let currentConnectionRequest: ConnectionRequest = connectionRequest; - this.expirationInterval = setInterval(async () => { + this.#expirationInterval = setInterval(async () => { const { sessionRequest } = currentConnectionRequest; const now = Date.now(); const remainingMs = sessionRequest.expiresAt - now; @@ -66,13 +68,13 @@ export abstract class AbstractInstallModal extends Modal< if ( remainingMs > 0 && shouldLogCountdown(remainingSeconds) && - this.lastLoggedCountdown !== remainingSeconds + this.#lastLoggedCountdown !== remainingSeconds ) { const formattedTime = formatRemainingTime(remainingMs); logger( `[UI: InstallModal-nodejs()] QR code expires in: ${formattedTime} (${remainingSeconds}s)`, ); - this.lastLoggedCountdown = remainingSeconds; + this.#lastLoggedCountdown = remainingSeconds; } if (now >= sessionRequest.expiresAt) { @@ -87,9 +89,9 @@ export abstract class AbstractInstallModal extends Modal< const generateQRCode = await this.options.generateQRCode( currentConnectionRequest, ); - this.lastLoggedCountdown = -1; // Reset countdown logging + this.#lastLoggedCountdown = -1; // Reset countdown logging - //Update local instances with new data + // Update local instances with new data this.updateLink(generateQRCode); this.updateExpiresIn(remainingSeconds); @@ -104,10 +106,10 @@ export abstract class AbstractInstallModal extends Modal< }, 1000); } - protected stopExpirationCheck() { - if (this.expirationInterval) { - clearInterval(this.expirationInterval); - this.expirationInterval = null; + protected stopExpirationCheck(): void { + if (this.#expirationInterval) { + clearInterval(this.#expirationInterval); + this.#expirationInterval = null; logger( '[UI: InstallModal-nodejs()] 🛑 Stopped QR code expiration checking', ); diff --git a/packages/connect-multichain/src/ui/modals/base/AbstractOTPModal.ts b/packages/connect-multichain/src/ui/modals/base/AbstractOTPModal.ts index da6d58ab..40972eeb 100644 --- a/packages/connect-multichain/src/ui/modals/base/AbstractOTPModal.ts +++ b/packages/connect-multichain/src/ui/modals/base/AbstractOTPModal.ts @@ -1,12 +1,9 @@ -import { Modal, type OTPCode, type OTPCodeWidgetProps } from '../../../domain'; +import { Modal, type OTPCodeWidgetProps } from '../../../domain'; -export abstract class AbstractOTPCodeModal extends Modal< - OTPCodeWidgetProps, - OTPCode -> { +export abstract class AbstractOTPCodeModal extends Modal { protected instance?: HTMLMmOtpModalElement | undefined; - get otpCode() { + get otpCode(): string { return this.data; } @@ -14,7 +11,7 @@ export abstract class AbstractOTPCodeModal extends Modal< this.data = code; } - updateOTPCode(code: string) { + updateOTPCode(code: string): void { this.otpCode = code; if (this.instance) { this.instance.otpCode = code; diff --git a/packages/connect-multichain/src/ui/modals/base/utils.ts b/packages/connect-multichain/src/ui/modals/base/utils.ts index fc43e7d7..edf143f5 100644 --- a/packages/connect-multichain/src/ui/modals/base/utils.ts +++ b/packages/connect-multichain/src/ui/modals/base/utils.ts @@ -1,9 +1,23 @@ +/** + * Formats remaining time in a human-readable format. + * + * @param milliseconds - The remaining time in milliseconds. + * @returns A formatted string representing the remaining time. + */ export function formatRemainingTime(milliseconds: number): string { - if (milliseconds <= 0) return 'EXPIRED'; + if (milliseconds <= 0) { + return 'EXPIRED'; + } const seconds = Math.floor(milliseconds / 1000); return `${seconds}s`; } +/** + * Determines whether to log the countdown at the current remaining seconds. + * + * @param remainingSeconds - The remaining seconds until expiration. + * @returns True if the countdown should be logged, false otherwise. + */ export function shouldLogCountdown(remainingSeconds: number): boolean { // Log at specific intervals to avoid spam if (remainingSeconds <= 10) { @@ -18,8 +32,7 @@ export function shouldLogCountdown(remainingSeconds: number): boolean { } else if (remainingSeconds <= 300) { // Log every 30 seconds for the last 5 minutes return remainingSeconds % 30 === 0; - } else { - // Log every minute for longer durations - return remainingSeconds % 60 === 0; } + // Log every minute for longer durations + return remainingSeconds % 60 === 0; } diff --git a/packages/connect-multichain/src/ui/modals/node/index.test.ts b/packages/connect-multichain/src/ui/modals/node/index.test.ts index 2ecd1510..e43f2577 100644 --- a/packages/connect-multichain/src/ui/modals/node/index.test.ts +++ b/packages/connect-multichain/src/ui/modals/node/index.test.ts @@ -1,15 +1,20 @@ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/no-shadow -- Test scopes */ +/* eslint-disable @typescript-eslint/unbound-method -- Mock assertions */ +/* eslint-disable @typescript-eslint/naming-convention -- NodeModals import */ +/* eslint-disable no-empty-function -- Empty mock functions */ +import encodeQR from '@paulmillr/qr'; +import { v4 } from 'uuid'; import * as t from 'vitest'; import { vi } from 'vitest'; +import * as NodeModals from '.'; import { type ConnectionRequest, getVersion, type Modal, PlatformType, } from '../../../domain'; -import * as NodeModals from './'; -import { v4 } from 'uuid'; -import encodeQR from '@paulmillr/qr'; vi.mock('@paulmillr/qr', () => { return { @@ -178,7 +183,7 @@ t.describe('Node Modals', () => { t.it('Rendering OTPCodeModal on Node', async () => { const otpCode = '123456'; - //TODO: Modal is currently not doing much but will be a placeholder for the future 2fa modal + // TODO: Modal is currently not doing much but will be a placeholder for the future 2fa modal const otpCodeModal = new NodeModals.OTPCodeModal({ otpCode, parentElement: undefined, diff --git a/packages/connect-multichain/src/ui/modals/node/install.ts b/packages/connect-multichain/src/ui/modals/node/install.ts index 79d9d1e4..4d3339e8 100644 --- a/packages/connect-multichain/src/ui/modals/node/install.ts +++ b/packages/connect-multichain/src/ui/modals/node/install.ts @@ -1,11 +1,12 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable no-restricted-syntax */ +import encodeQR from '@paulmillr/qr'; + import { type ConnectionRequest, createLogger, type QRLink, } from '../../../domain'; -import encodeQR from '@paulmillr/qr'; import { AbstractInstallModal } from '../base/AbstractInstallModal'; import { formatRemainingTime, shouldLogCountdown } from '../base/utils'; diff --git a/packages/connect-multichain/src/ui/modals/node/otp.ts b/packages/connect-multichain/src/ui/modals/node/otp.ts index 17c5dee9..48955f69 100644 --- a/packages/connect-multichain/src/ui/modals/node/otp.ts +++ b/packages/connect-multichain/src/ui/modals/node/otp.ts @@ -5,7 +5,11 @@ import { AbstractOTPCodeModal } from '../base/AbstractOTPModal'; * It will be replaced with the actual OTP code modal once it is implemented. */ export class OTPCodeModal extends AbstractOTPCodeModal { - mount() {} + mount(): void { + // No-op: placeholder implementation + } - unmount() {} + unmount(): void { + // No-op: placeholder implementation + } } diff --git a/packages/connect-multichain/src/ui/modals/rn/index.test.ts b/packages/connect-multichain/src/ui/modals/rn/index.test.ts index 9333339b..ba4b1711 100644 --- a/packages/connect-multichain/src/ui/modals/rn/index.test.ts +++ b/packages/connect-multichain/src/ui/modals/rn/index.test.ts @@ -1,14 +1,15 @@ - - +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/unbound-method -- Mock assertions */ +/* eslint-disable @typescript-eslint/naming-convention -- RNModals import */ +import { v4 } from 'uuid'; import * as t from 'vitest'; +import * as RNModals from '.'; import { type ConnectionRequest, PlatformType, type Modal, } from '../../../domain'; -import * as RNModals from './'; -import { v4 } from 'uuid'; t.describe('RN Modals', () => { let connectionRequest: ConnectionRequest; @@ -61,7 +62,7 @@ t.describe('RN Modals', () => { }); t.it('Rendering OTPCodeModal on RN', async () => { - //TODO: Modal is currently not doing much but will be a placeholder for the future 2fa modal + // TODO: Modal is currently not doing much but will be a placeholder for the future 2fa modal const otpCodeModal = new RNModals.OTPCodeModal({ sdkVersion: '1.0.0', onClose: t.vi.fn(), diff --git a/packages/connect-multichain/src/ui/modals/rn/install.ts b/packages/connect-multichain/src/ui/modals/rn/install.ts index a127582e..e01755b0 100644 --- a/packages/connect-multichain/src/ui/modals/rn/install.ts +++ b/packages/connect-multichain/src/ui/modals/rn/install.ts @@ -2,10 +2,14 @@ import { AbstractInstallModal } from '../base/AbstractInstallModal'; export class InstallModal extends AbstractInstallModal { renderQRCode(): void { - //Not needed for RN (WORK IN Progress) + // Not needed for RN (WORK IN Progress) } - mount() {} + mount(): void { + // No-op: React Native modal mounting handled by RN component + } - unmount() {} + unmount(): void { + // No-op: React Native modal unmounting handled by RN component + } } diff --git a/packages/connect-multichain/src/ui/modals/rn/otp.ts b/packages/connect-multichain/src/ui/modals/rn/otp.ts index 17c5dee9..48955f69 100644 --- a/packages/connect-multichain/src/ui/modals/rn/otp.ts +++ b/packages/connect-multichain/src/ui/modals/rn/otp.ts @@ -5,7 +5,11 @@ import { AbstractOTPCodeModal } from '../base/AbstractOTPModal'; * It will be replaced with the actual OTP code modal once it is implemented. */ export class OTPCodeModal extends AbstractOTPCodeModal { - mount() {} + mount(): void { + // No-op: placeholder implementation + } - unmount() {} + unmount(): void { + // No-op: placeholder implementation + } } diff --git a/packages/connect-multichain/src/ui/modals/web/index.test.ts b/packages/connect-multichain/src/ui/modals/web/index.test.ts index 1359575c..dcc722db 100644 --- a/packages/connect-multichain/src/ui/modals/web/index.test.ts +++ b/packages/connect-multichain/src/ui/modals/web/index.test.ts @@ -1,22 +1,28 @@ - - +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/naming-convention -- JSDOM alias */ +/* eslint-disable @typescript-eslint/unbound-method -- Mock assertions */ +/* eslint-disable no-restricted-globals -- DOM testing */ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions */ +/* eslint-disable @typescript-eslint/no-shadow -- CustomEvent shadows global */ +/* eslint-disable n/no-unsupported-features/node-builtins -- CustomEvent for testing */ import { JSDOM as Page } from 'jsdom'; +import { v4 } from 'uuid'; import * as t from 'vitest'; import { vi } from 'vitest'; +import * as WebModals from '.'; import { type ConnectionRequest, PlatformType, type Modal, } from '../../../domain'; -import * as WebModals from './'; -import { v4 } from 'uuid'; const dom = new Page("
", { url: 'https://dapp.io/', }); class CustomEvent extends dom.window.Event { detail: any; + constructor(type: string, options?: CustomEventInit) { super(type, options); this.detail = options?.detail; diff --git a/packages/connect-multichain/src/ui/modals/web/install.ts b/packages/connect-multichain/src/ui/modals/web/install.ts index 95b878d0..367a1cea 100644 --- a/packages/connect-multichain/src/ui/modals/web/install.ts +++ b/packages/connect-multichain/src/ui/modals/web/install.ts @@ -1,13 +1,14 @@ +/* eslint-disable no-restricted-globals -- Web modal uses document */ import type { MmInstallModalCustomEvent } from '@metamask/multichain-ui'; import { AbstractInstallModal } from '../base/AbstractInstallModal'; export class InstallModal extends AbstractInstallModal { renderQRCode(): void { - //Not needed for web as its using install Modal + // Not needed for web as its using install Modal } - mount() { + mount(): void { const { options } = this; const modal = document.createElement( 'mm-install-modal', @@ -33,7 +34,7 @@ export class InstallModal extends AbstractInstallModal { this.startExpirationCheck(options.connectionRequest); } - unmount() { + unmount(): void { const { options, instance: modal } = this; this.stopExpirationCheck(); if (modal && options.parentElement?.contains(modal)) { diff --git a/packages/connect-multichain/src/ui/modals/web/otp.ts b/packages/connect-multichain/src/ui/modals/web/otp.ts index 17c5dee9..48955f69 100644 --- a/packages/connect-multichain/src/ui/modals/web/otp.ts +++ b/packages/connect-multichain/src/ui/modals/web/otp.ts @@ -5,7 +5,11 @@ import { AbstractOTPCodeModal } from '../base/AbstractOTPModal'; * It will be replaced with the actual OTP code modal once it is implemented. */ export class OTPCodeModal extends AbstractOTPCodeModal { - mount() {} + mount(): void { + // No-op: placeholder implementation + } - unmount() {} + unmount(): void { + // No-op: placeholder implementation + } } diff --git a/packages/connect-multichain/tests/data.ts b/packages/connect-multichain/tests/data.ts index b65d9db6..c0c8290a 100644 --- a/packages/connect-multichain/tests/data.ts +++ b/packages/connect-multichain/tests/data.ts @@ -1,5 +1,5 @@ -import { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; -import { SessionData } from '@metamask/multichain-api-client'; +import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; +import type { SessionData } from '@metamask/multichain-api-client'; // Mock session data for testing export const mockSessionData: SessionData = { diff --git a/packages/connect-multichain/tests/env/index.ts b/packages/connect-multichain/tests/env/index.ts index 804b72e9..a6db6892 100644 --- a/packages/connect-multichain/tests/env/index.ts +++ b/packages/connect-multichain/tests/env/index.ts @@ -1,11 +1,18 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Test mocks use __prefixed naming */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test setup functions */ +/* eslint-disable jsdoc/require-param-description -- Test file */ +/* eslint-disable no-empty-function -- Mock implementations can be empty */ +/* eslint-disable @typescript-eslint/no-shadow -- navigator shadow is intentional for mocking */ +/* eslint-disable accessor-pairs -- Setter-only is intentional for mock */ /** biome-ignore-all lint/suspicious/noAsyncPromiseExecutor: ok for tests */ -import { vi } from 'vitest'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { JSDOM as Page } from 'jsdom'; -import { NativeStorageStub } from 'tests/types'; -import * as t from 'vitest'; +import type { NativeStorageStub } from 'tests/types'; +import { vi } from 'vitest'; +import * as vitest from 'vitest'; + import * as nodeStorage from '../../src/store/adapters/node'; import * as webStorage from '../../src/store/adapters/web'; @@ -13,6 +20,8 @@ export const TRANSPORT_REQUEST_RESPONSE_DELAY = 25; /** * Virtualize nodejs environments, mocking everything needed to run the tests in Node + * + * @param nativeStorageStub */ export const setupNodeMocks = (nativeStorageStub: NativeStorageStub) => { // Mock console.log to prevent QR codes from displaying in test output @@ -20,23 +29,25 @@ export const setupNodeMocks = (nativeStorageStub: NativeStorageStub) => { vi.spyOn(console, 'clear').mockImplementation(() => {}); vi.spyOn(nodeStorage, 'StoreAdapterNode').mockImplementation(() => { - const __storage = { - get: t.vi.fn((key: string) => nativeStorageStub.getItem(key)), - set: t.vi.fn((key: string, value: string) => + const mockStorage = { + get: vitest.vi.fn((key: string) => nativeStorageStub.getItem(key)), + set: vitest.vi.fn((key: string, value: string) => nativeStorageStub.setItem(key, value), ), - delete: t.vi.fn((key: string) => nativeStorageStub.removeItem(key)), + delete: vitest.vi.fn((key: string) => nativeStorageStub.removeItem(key)), platform: 'node' as const, get storage() { - return __storage; + return mockStorage; }, } as any; - return __storage; + return mockStorage; }); }; /** * Virtualize nodejs environments, mocking everything needed to run the tests in React Native + * + * @param nativeStorageStub */ export const setupRNMocks = (nativeStorageStub: NativeStorageStub) => { // Mock console.log to prevent QR codes from displaying in test output (for consistency) @@ -56,6 +67,9 @@ export const setupRNMocks = (nativeStorageStub: NativeStorageStub) => { /** * Virtualize wev environments, mocking everything needed to run the tests in Web with Chrome extension available + * + * @param nativeStorageStub + * @param dappUrl */ export const setupWebMocks = ( nativeStorageStub: NativeStorageStub, @@ -85,17 +99,17 @@ export const setupWebMocks = ( vi.stubGlobal('document', dom.window.document); vi.stubGlobal('HTMLElement', dom.window.HTMLElement); vi.stubGlobal('Event', dom.window.Event); - vi.stubGlobal('requestAnimationFrame', t.vi.fn()); + vi.stubGlobal('requestAnimationFrame', vitest.vi.fn()); vi.stubGlobal('dispatchEvent', dom.window.dispatchEvent.bind(dom.window)); vi.spyOn(webStorage, 'StoreAdapterWeb').mockImplementation(() => { const __storage = { - get: t.vi.fn((key: string) => { + get: vitest.vi.fn((key: string) => { return nativeStorageStub.getItem(key); }), - set: t.vi.fn((key: string, value: string) => { + set: vitest.vi.fn((key: string, value: string) => { return nativeStorageStub.setItem(key, value); }), - delete: t.vi.fn((key: string) => { + delete: vitest.vi.fn((key: string) => { return nativeStorageStub.removeItem(key); }), platform: 'web' as const, @@ -109,6 +123,9 @@ export const setupWebMocks = ( /** * Virtualize wev environments, mocking everything needed to run the tests in Web with Chrome extension available + * + * @param nativeStorageStub + * @param dappUrl */ export const setupWebMobileMocks = ( nativeStorageStub: NativeStorageStub, @@ -154,17 +171,17 @@ export const setupWebMobileMocks = ( vi.stubGlobal('document', dom.window.document); vi.stubGlobal('HTMLElement', dom.window.HTMLElement); vi.stubGlobal('Event', dom.window.Event); - vi.stubGlobal('requestAnimationFrame', t.vi.fn()); + vi.stubGlobal('requestAnimationFrame', vitest.vi.fn()); vi.stubGlobal('dispatchEvent', dom.window.dispatchEvent.bind(dom.window)); vi.spyOn(webStorage, 'StoreAdapterWeb').mockImplementation(() => { const __storage = { - get: t.vi.fn((key: string) => { + get: vitest.vi.fn((key: string) => { return nativeStorageStub.getItem(key); }), - set: t.vi.fn((key: string, value: string) => { + set: vitest.vi.fn((key: string, value: string) => { return nativeStorageStub.setItem(key, value); }), - delete: t.vi.fn((key: string) => { + delete: vitest.vi.fn((key: string) => { return nativeStorageStub.removeItem(key); }), platform: 'web' as const, diff --git a/packages/connect-multichain/tests/fixtures.test.ts b/packages/connect-multichain/tests/fixtures.test.ts index 0e610cee..2af9cae6 100644 --- a/packages/connect-multichain/tests/fixtures.test.ts +++ b/packages/connect-multichain/tests/fixtures.test.ts @@ -1,7 +1,24 @@ +/* eslint-disable id-length -- vitest alias */ +/* eslint-disable @typescript-eslint/naming-convention -- Test naming */ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ +/* eslint-disable jsdoc/require-param-description -- Test helpers */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test functions */ +/* eslint-disable @typescript-eslint/no-misused-promises -- Test handlers */ +/* eslint-disable id-denylist -- Test error patterns */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- Test assertions */ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions */ +/* eslint-disable import-x/no-unassigned-import -- Polyfill imports */ +/* eslint-disable @typescript-eslint/no-unused-vars -- Test helper types */ +/* eslint-disable import-x/order -- Mock imports need specific order */ +/* eslint-disable @typescript-eslint/no-use-before-define -- Function hoisting */ +/* eslint-disable @typescript-eslint/prefer-promise-reject-errors -- Test rejection patterns */ +/* eslint-disable jsdoc/require-returns -- Test helpers */ +/* eslint-disable no-plusplus -- Test loops */ +/* eslint-disable no-invalid-this -- Test context */ +/* eslint-disable no-useless-catch -- Test error handling */ /** biome-ignore-all lint/suspicious/noAsyncPromiseExecutor: ok for tests */ - /** * Fixtures files, allows us to create a standardized test configuration for each platform * Allows us to run the tests in Node, React Native and Web without changing the overall logic or having to add manual testing @@ -16,37 +33,34 @@ */ import './mocks'; +import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; +import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; +import type { TransportResponse } from '@metamask/multichain-api-client'; +import { getDefaultTransport } from '@metamask/multichain-api-client'; import * as t from 'vitest'; import { vi } from 'vitest'; -import type { MultichainOptions } from '../src/domain'; -import { MetaMaskConnectMultichain } from '../src/multichain'; -// Import createSDK functions for convenience -import { createMultichainClient as createMetamaskSDKWeb } from '../src/index.browser'; -import { createMultichainClient as createMetamaskSDKRN } from '../src/index.native'; -import { createMultichainClient as createMetamaskSDKNode } from '../src/index.node'; +import { + setupNodeMocks, + setupRNMocks, + setupWebMobileMocks, + setupWebMocks, +} from './env'; import type { NativeStorageStub, MockedData, TestSuiteOptions, CreateTestFN, -} from '../tests/types'; +} from './types'; +import type { MultichainOptions } from '../src/domain'; -import { - getDefaultTransport, - TransportResponse, -} from '@metamask/multichain-api-client'; -import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; +// Import createSDK functions for convenience +import { createMultichainClient as createMetamaskSDKWeb } from '../src/index.browser'; +import { createMultichainClient as createMetamaskSDKRN } from '../src/index.native'; +import { createMultichainClient as createMetamaskSDKNode } from '../src/index.node'; +import { MetaMaskConnectMultichain } from '../src/multichain'; import { MULTICHAIN_PROVIDER_STREAM_NAME } from '../src/multichain/transports/constants'; -import { - setupNodeMocks, - setupRNMocks, - setupWebMobileMocks, - setupWebMocks, -} from './env'; -import { SessionStore } from '@metamask/mobile-wallet-protocol-core'; - export const TRANSPORT_REQUEST_RESPONSE_DELAY = 50; // Helper functions to create standardized test configurations @@ -113,16 +127,16 @@ export const createTest: CreateTestFN = ({ cleanupMocks, tests, }) => { - const mockWalletGetSession = t.vi.fn(() => + const mockWalletGetSession = t.vi.fn(async () => Promise.reject('Please mock mockWalletGetSession'), ) as any; - const mockWalletCreateSession = t.vi.fn(() => + const mockWalletCreateSession = t.vi.fn(async () => Promise.reject('Please mock mockWalletCreateSession'), ) as any; - const mockSessionRequest = t.vi.fn(() => + const mockSessionRequest = t.vi.fn(async () => Promise.reject('Please mock mockSessionRequest'), ) as any; - const mockWalletInvokeMethod = t.vi.fn(() => + const mockWalletInvokeMethod = t.vi.fn(async () => Promise.reject('Please mock mockWalletInvokeMethod'), ) as any; const mockWalletRevokeSession = t.vi.fn() as any; @@ -150,12 +164,15 @@ export const createTest: CreateTestFN = ({ let pendingRequests: Map< string, { - resolve: (value: TransportResponse) => void; + resolve: (value: TransportResponse) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; } >; + /** + * + */ async function beforeEach() { try { pendingRequests = new Map(); @@ -267,7 +284,7 @@ export const createTest: CreateTestFN = ({ const eventListeners = new Map< string, - Array<{ handler: (...args: any[]) => void; once: boolean }> + { handler: (...args: any[]) => void; once: boolean }[] >(); const mockDappClient = { __state: 'DISCONNECTED' as any, @@ -297,11 +314,11 @@ export const createTest: CreateTestFN = ({ return Promise.resolve(); }), connect: t.vi.fn(async (data: any) => { - //Establish the connection automatically + // Establish the connection automatically mockDappClient.emit('connected'); - (mockDappClient as any).state = 'CONNECTED' as any; + mockDappClient.state = 'CONNECTED' as any; - //Send session request for mwp + // Send session request for mwp const sessionRequest = mockSessionRequest(); mockDappClient.emit('session_request', sessionRequest); @@ -319,7 +336,9 @@ export const createTest: CreateTestFN = ({ // Handle new nested structure: { name: MULTICHAIN_PROVIDER_STREAM_NAME, data: request } const { name, data: request } = message; if (name !== MULTICHAIN_PROVIDER_STREAM_NAME) { - return Promise.reject('only MULTICHAIN_PROVIDER_STREAM_NAME is supported in the mock'); + return Promise.reject( + 'only MULTICHAIN_PROVIDER_STREAM_NAME is supported in the mock', + ); } const id = `${request.id ?? requestId++}`; if (request.method === 'wallet_getSession') { @@ -336,7 +355,7 @@ export const createTest: CreateTestFN = ({ }); resolve(); }, - reject: reject, + reject, timeout: null as any, }; pendingRequests.set(id, req); @@ -403,7 +422,7 @@ export const createTest: CreateTestFN = ({ }); resolve(); }, - reject: reject, + reject, timeout: null as any, }; pendingRequests.set(id, req); @@ -432,7 +451,7 @@ export const createTest: CreateTestFN = ({ }); resolve(); }, - reject: reject, + reject, timeout: null as any, }; pendingRequests.set(id, req); @@ -469,7 +488,9 @@ export const createTest: CreateTestFN = ({ }), off: t.vi.fn((event: string, handler?: (...args: any[]) => void) => { - if (!eventListeners.has(event)) return; + if (!eventListeners.has(event)) { + return; + } if (handler) { // Remove specific handler @@ -488,7 +509,9 @@ export const createTest: CreateTestFN = ({ // Method to emit events (for testing purposes) emit: t.vi.fn((event: string, ...args: any[]) => { - if (!eventListeners.has(event)) return; + if (!eventListeners.has(event)) { + return; + } const listeners = eventListeners.get(event)!; // Create a copy to iterate over, as 'once' handlers will modify the original array @@ -557,6 +580,10 @@ export const createTest: CreateTestFN = ({ } } + /** + * + * @param mocks + */ async function afterEach(mocks: MockedData) { // Clear storage mocks.nativeStorageStub.data.clear(); diff --git a/packages/connect-multichain/tests/mocks/analytics.ts b/packages/connect-multichain/tests/mocks/analytics.ts index 24107c31..f9087f91 100644 --- a/packages/connect-multichain/tests/mocks/analytics.ts +++ b/packages/connect-multichain/tests/mocks/analytics.ts @@ -2,12 +2,12 @@ * This file mocks Analytics package in the SDK * Allowing us to know if specific events triggered or not */ -import * as t from 'vitest'; +import * as vitest from 'vitest'; -t.vi.mock('@metamask/analytics', () => ({ +vitest.vi.mock('@metamask/analytics', () => ({ analytics: { - setGlobalProperty: t.vi.fn(), - enable: t.vi.fn(), - track: t.vi.fn(), + setGlobalProperty: vitest.vi.fn(), + enable: vitest.vi.fn(), + track: vitest.vi.fn(), }, })); diff --git a/packages/connect-multichain/tests/mocks/apiClient.ts b/packages/connect-multichain/tests/mocks/apiClient.ts index cb5cfdf1..d06ebe48 100644 --- a/packages/connect-multichain/tests/mocks/apiClient.ts +++ b/packages/connect-multichain/tests/mocks/apiClient.ts @@ -1,15 +1,16 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports -- Dynamic import required for mock */ /** * This file mocks API Client package in the SDK * We just wrap the getDefaultTransport to have the ability to mock it after in fixtures file */ -import * as t from 'vitest'; +import * as vitest from 'vitest'; -t.vi.mock('@metamask/multichain-api-client', async (importOriginal) => { +vitest.vi.mock('@metamask/multichain-api-client', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - getDefaultTransport: t.vi.fn(() => { + getDefaultTransport: vitest.vi.fn(() => { return actual.getDefaultTransport(); }), }; diff --git a/packages/connect-multichain/tests/mocks/dappClient.ts b/packages/connect-multichain/tests/mocks/dappClient.ts index d642f08b..b58d8ffe 100644 --- a/packages/connect-multichain/tests/mocks/dappClient.ts +++ b/packages/connect-multichain/tests/mocks/dappClient.ts @@ -2,5 +2,6 @@ * This file mocks Dapp Client package in the SDK * Allowing us to mock DappClient completelly in our tests (see fixtures.test.ts) */ -import * as t from 'vitest'; -t.vi.mock('@metamask/mobile-wallet-protocol-dapp-client'); +import * as vitest from 'vitest'; + +vitest.vi.mock('@metamask/mobile-wallet-protocol-dapp-client'); diff --git a/packages/connect-multichain/tests/mocks/index.ts b/packages/connect-multichain/tests/mocks/index.ts index c5260e4d..2c9b256c 100644 --- a/packages/connect-multichain/tests/mocks/index.ts +++ b/packages/connect-multichain/tests/mocks/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable import-x/no-unassigned-import -- Mock setup imports */ import './logger'; import './analytics'; import './apiClient'; diff --git a/packages/connect-multichain/tests/mocks/logger.ts b/packages/connect-multichain/tests/mocks/logger.ts index b67c02d7..789478f6 100644 --- a/packages/connect-multichain/tests/mocks/logger.ts +++ b/packages/connect-multichain/tests/mocks/logger.ts @@ -1,11 +1,15 @@ -import * as t from 'vitest'; +/* eslint-disable @typescript-eslint/naming-convention -- Test mocks use __prefixed naming */ -t.vi.mock('../../src/domain/logger', () => { - const __mockLogger = t.vi.fn(); +import * as vitest from 'vitest'; + +vitest.vi.mock('../../src/domain/logger', () => { + const mockLogger = vitest.vi.fn(); return { - createLogger: t.vi.fn(() => __mockLogger), - enableDebug: t.vi.fn(() => {}), - isEnabled: t.vi.fn(() => true), - __mockLogger, + createLogger: vitest.vi.fn(() => mockLogger), + enableDebug: vitest.vi.fn(() => { + // No-op mock + }), + isEnabled: vitest.vi.fn(() => true), + __mockLogger: mockLogger, }; }); diff --git a/packages/connect-multichain/tests/mocks/mwp.ts b/packages/connect-multichain/tests/mocks/mwp.ts index 8cfcc82c..be9e53f1 100644 --- a/packages/connect-multichain/tests/mocks/mwp.ts +++ b/packages/connect-multichain/tests/mocks/mwp.ts @@ -1,35 +1,42 @@ -import * as t from 'vitest'; +/* eslint-disable @typescript-eslint/naming-convention -- Test mocks use __prefixed naming */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Mock implementations */ +/* eslint-disable @typescript-eslint/consistent-type-imports -- Dynamic import required for mock */ +import * as vitest from 'vitest'; type PendingRequests = { - resolve: (value: any) => void; + resolve: (value: unknown) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout; }; -t.vi.mock('../../src/multichain/transports/mwp', async (importOriginal) => { - const { MWPTransport } = - await importOriginal< - typeof import('../../src/multichain/transports/mwp') - >(); +vitest.vi.mock( + '../../src/multichain/transports/mwp', + async (importOriginal) => { + const { MWPTransport } = + await importOriginal< + typeof import('../../src/multichain/transports/mwp') + >(); - // Create a mock Map to store pending requests - const mockPendingRequestsMap = new Map(); + // Create a mock Map to store pending requests + const mockPendingRequestsMap = new Map(); - // Create a mock class that extends the original MWPTransport - class MockMWPTransport extends MWPTransport { - private __mockPendingRequests = mockPendingRequestsMap; + // Create a mock class that extends the original MWPTransport + class MockMWPTransport extends MWPTransport { + #mockPendingRequests = mockPendingRequestsMap; - get pendingRequests() { - return this.__mockPendingRequests; - } + get pendingRequests() { + return this.#mockPendingRequests; + } - set pendingRequests(pendingRequests: Map) { - this.__mockPendingRequests = pendingRequests; + set pendingRequests(pendingRequests: Map) { + this.#mockPendingRequests = pendingRequests; + } } - } - return { - MWPTransport: MockMWPTransport, - __mockPendingRequestsMap: mockPendingRequestsMap, - }; -}); + return { + MWPTransport: MockMWPTransport, + __mockPendingRequestsMap: mockPendingRequestsMap, + }; + }, +); diff --git a/packages/connect-multichain/tests/mocks/platform.ts b/packages/connect-multichain/tests/mocks/platform.ts index 26cc79d5..732b9e17 100644 --- a/packages/connect-multichain/tests/mocks/platform.ts +++ b/packages/connect-multichain/tests/mocks/platform.ts @@ -1,15 +1,17 @@ +/* eslint-disable no-restricted-globals -- Test mocks window intentionally */ +/* eslint-disable @typescript-eslint/consistent-type-imports -- Dynamic import required for mock */ /** * This file mocks the platform detection module * We mock hasExtension to return based on window.ethereum.isMetaMask */ -import * as t from 'vitest'; +import * as vitest from 'vitest'; -t.vi.mock('../../src/domain/platform', async (importOriginal) => { +vitest.vi.mock('../../src/domain/platform', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - hasExtension: t.vi.fn(async () => { + hasExtension: vitest.vi.fn(async () => { if (typeof window === 'undefined') { return false; } diff --git a/packages/connect-multichain/tests/mocks/uiPackage.ts b/packages/connect-multichain/tests/mocks/uiPackage.ts index b531dad2..b175eeb9 100644 --- a/packages/connect-multichain/tests/mocks/uiPackage.ts +++ b/packages/connect-multichain/tests/mocks/uiPackage.ts @@ -1,5 +1,5 @@ -import * as t from 'vitest'; +import * as vitest from 'vitest'; -t.vi.mock('@metamask/multichain-ui/loader', () => ({ - defineCustomElements: t.vi.fn(), +vitest.vi.mock('@metamask/multichain-ui/loader', () => ({ + defineCustomElements: vitest.vi.fn(), })); diff --git a/packages/connect-multichain/tests/mocks/ws.ts b/packages/connect-multichain/tests/mocks/ws.ts index 9548ebab..19720c8a 100644 --- a/packages/connect-multichain/tests/mocks/ws.ts +++ b/packages/connect-multichain/tests/mocks/ws.ts @@ -1,4 +1,6 @@ -import * as t from 'vitest'; +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Mock factory function */ +/* eslint-disable n/no-unsupported-features/node-builtins -- CloseEvent used for type annotation */ +import * as vitest from 'vitest'; // Mock WebSocket at the top level const createMockWebSocket = () => { @@ -17,24 +19,24 @@ const createMockWebSocket = () => { onmessage: null as ((event: MessageEvent) => void) | null, onerror: null as ((event: Event) => void) | null, onclose: null as ((event: CloseEvent) => void) | null, - send: t.vi.fn(), - close: t.vi.fn(), - addEventListener: t.vi.fn(), - removeEventListener: t.vi.fn(), - dispatchEvent: t.vi.fn(), + send: vitest.vi.fn(), + close: vitest.vi.fn(), + addEventListener: vitest.vi.fn(), + removeEventListener: vitest.vi.fn(), + dispatchEvent: vitest.vi.fn(), }; return mockWS; }; -t.vi.mock('ws', () => { +vitest.vi.mock('ws', () => { return { - default: t.vi.fn().mockImplementation(() => createMockWebSocket()), - WebSocket: t.vi.fn().mockImplementation(() => createMockWebSocket()), + default: vitest.vi.fn().mockImplementation(() => createMockWebSocket()), + WebSocket: vitest.vi.fn().mockImplementation(() => createMockWebSocket()), }; }); // Mock native WebSocket for browser environments -const mockWebSocketConstructor = t.vi +const mockWebSocketConstructor = vitest.vi .fn() .mockImplementation(() => createMockWebSocket()); -t.vi.stubGlobal('WebSocket', mockWebSocketConstructor); +vitest.vi.stubGlobal('WebSocket', mockWebSocketConstructor); diff --git a/packages/connect-multichain/tests/setup.ts b/packages/connect-multichain/tests/setup.ts index 046b6ed8..4c959fc2 100644 --- a/packages/connect-multichain/tests/setup.ts +++ b/packages/connect-multichain/tests/setup.ts @@ -1,3 +1,6 @@ +/* eslint-disable import-x/unambiguous -- Setup file has side effects only */ + +/* eslint-disable @typescript-eslint/prefer-promise-reject-errors -- Passing through original reason */ // Setup file to handle unhandled promise rejections in tests // This prevents CI failures from expected unhandled rejections in web-mobile timeout tests @@ -18,7 +21,7 @@ process.on('unhandledRejection', (reason) => { ]; // If it's an expected error from our timeout tests, ignore it - if (expectedErrors.some((msg) => errorMessage.includes(msg))) { + if (expectedErrors.some((errorMsg) => errorMessage.includes(errorMsg))) { // Silently handle - these are expected in web-mobile timeout scenarios return; } @@ -28,4 +31,3 @@ process.on('unhandledRejection', (reason) => { handler(reason, Promise.reject(reason)); }); }); - diff --git a/packages/connect-multichain/tests/types.ts b/packages/connect-multichain/tests/types.ts index b421a01a..5f462b2e 100644 --- a/packages/connect-multichain/tests/types.ts +++ b/packages/connect-multichain/tests/types.ts @@ -1,10 +1,11 @@ -import * as t from 'vitest'; +/* eslint-disable @typescript-eslint/no-unused-vars -- Transport import used for type reference */ +import type { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; +import type { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; +import type { SessionData, Transport } from '@metamask/multichain-api-client'; +import type * as vitest from 'vitest'; -import { SessionRequest } from '@metamask/mobile-wallet-protocol-core'; -import { DappClient } from '@metamask/mobile-wallet-protocol-dapp-client'; -import { Transport, SessionData } from '@metamask/multichain-api-client'; -import { MultichainOptions, MultichainCore } from '../src/domain'; -import { MetaMaskConnectMultichain } from '../src/multichain'; +import type { MultichainOptions, MultichainCore } from '../src/domain'; +import type { MetaMaskConnectMultichain } from '../src/multichain'; type GetItem = (key: string) => string | null; type SetItem = (key: string, value: string) => void; @@ -13,53 +14,55 @@ type Clear = () => void; export type NativeStorageStub = { data: Map; - getItem: t.Mock; - setItem: t.Mock; - removeItem: t.Mock; - clear: t.Mock; + getItem: vitest.Mock; + setItem: vitest.Mock; + removeItem: vitest.Mock; + clear: vitest.Mock; }; export type MockedData = { - initSpy: t.MockInstance; - setupAnalyticsSpy: t.MockInstance; - emitSpy: t.MockInstance; - showInstallModalSpy: t.MockInstance; + initSpy: vitest.MockInstance; + setupAnalyticsSpy: vitest.MockInstance; + emitSpy: vitest.MockInstance; + showInstallModalSpy: vitest.MockInstance; nativeStorageStub: NativeStorageStub; - mockDappClient: t.Mocked; - mockDefaultTransport: t.Mocked; - mockLogger: t.MockInstance; + mockDappClient: vitest.Mocked; + mockDefaultTransport: vitest.Mocked; + mockLogger: vitest.MockInstance; // Mocking RPC method responses for all transports - mockWalletGetSession: t.MockInstance<(request: any) => Promise>; - mockWalletCreateSession: t.MockInstance< + mockWalletGetSession: vitest.MockInstance< (request: any) => Promise >; - mockWalletRevokeSession: t.MockInstance<(request: any) => Promise>; - mockWalletInvokeMethod: t.MockInstance<(request: any) => Promise>; + mockWalletCreateSession: vitest.MockInstance< + (request: any) => Promise + >; + mockWalletRevokeSession: vitest.MockInstance<(request: any) => Promise>; + mockWalletInvokeMethod: vitest.MockInstance<(request: any) => Promise>; // Mocking MWP session request - mockSessionRequest: t.MockInstance<() => Promise>; + mockSessionRequest: vitest.MockInstance<() => Promise>; }; -export type TestSuiteOptions = { +export type TestSuiteOptions = { platform: string; - createSDK: Options['createSDK']; - options: Options['options']; + createSDK: Options['createSDK']; + options: Options['options']; beforeEach: () => Promise; afterEach: (mocks: MockedData) => Promise; storage: NativeStorageStub; }; -export type Options = { +export type Options = { platform: 'web' | 'node' | 'rn' | 'web-mobile'; - options: T; - createSDK: (options: T) => Promise; + options: TOptions; + createSDK: (options: TOptions) => Promise; setupMocks?: (options: NativeStorageStub) => void; cleanupMocks?: () => void; - tests: (options: TestSuiteOptions) => void; + tests: (options: TestSuiteOptions) => void; }; -export type CreateTestFN = ( - options: Options, +export type CreateTestFN = ( + options: Options, ) => void; diff --git a/packages/connect-multichain/tsconfig.build.json b/packages/connect-multichain/tsconfig.build.json index 22d0b4d8..ae757670 100644 --- a/packages/connect-multichain/tsconfig.build.json +++ b/packages/connect-multichain/tsconfig.build.json @@ -17,10 +17,16 @@ "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo", "lib": ["DOM", "ES2020"], "skipLibCheck": true, - "types": ["node"], + "types": ["node"] }, "include": ["./src"], - "exclude": ["node_modules", "dist", "./tests", "**/*.spec.ts", "**/*.test.ts"], + "exclude": [ + "node_modules", + "dist", + "./tests", + "**/*.spec.ts", + "**/*.test.ts" + ], "references": [ { "path": "../multichain-ui/tsconfig.build.json" }, { "path": "../analytics/tsconfig.build.json" } diff --git a/packages/connect-multichain/tsconfig.json b/packages/connect-multichain/tsconfig.json index 5eda9d52..c52cda2a 100644 --- a/packages/connect-multichain/tsconfig.json +++ b/packages/connect-multichain/tsconfig.json @@ -21,10 +21,7 @@ "@metamask/multichain-ui/loader": ["../multichain-ui/dist/loader"] } }, - "references": [ - { "path": "../multichain-ui" }, - { "path": "../analytics" } - ], + "references": [{ "path": "../multichain-ui" }, { "path": "../analytics" }], "include": ["./src", "./tests"], - "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] + "exclude": ["node_modules", "dist"] } diff --git a/packages/connect-multichain/tsconfig.types.json b/packages/connect-multichain/tsconfig.types.json index abc6dc31..7f5baf00 100644 --- a/packages/connect-multichain/tsconfig.types.json +++ b/packages/connect-multichain/tsconfig.types.json @@ -11,5 +11,3 @@ "include": ["src/**/*"], "exclude": ["node_modules", "dist", "tests", "**/*.spec.ts", "**/*.test.ts"] } - - diff --git a/packages/connect-multichain/tsup.config.ts b/packages/connect-multichain/tsup.config.ts index d9b191a3..572b10d7 100644 --- a/packages/connect-multichain/tsup.config.ts +++ b/packages/connect-multichain/tsup.config.ts @@ -1,16 +1,26 @@ -import { defineConfig, type Options } from 'tsup'; +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Tsup config */ import { umdWrapper } from 'esbuild-plugin-umd-wrapper'; +import { defineConfig, type Options } from 'tsup'; + import packageJson from './package.json'; + const pkg: any = packageJson as any; // Dependencies categorization (same as rollup config) -const peerDependencies = Object.keys(pkg.peerDependencies || {}); -const optionalDependencies = Object.keys(pkg.optionalDependencies || {}); +const peerDependencies = Object.keys(pkg.peerDependencies ?? {}); +const optionalDependencies = Object.keys(pkg.optionalDependencies ?? {}); // Dependencies that should be bundled const bundledDeps = ['readable-stream']; // Shared dependencies that should be deduplicated -const sharedDeps = ['eventemitter2', 'socket.io-client', 'debug', 'uuid', 'cross-fetch', '@metamask/sdk-analytics']; +const sharedDeps = [ + 'eventemitter2', + 'socket.io-client', + 'debug', + 'uuid', + 'cross-fetch', + '@metamask/sdk-analytics', +]; // Filter function to exclude bundled dependencies const excludeBundledDeps = (dep: string) => !bundledDeps.includes(dep); @@ -44,7 +54,6 @@ const baseConfig: Partial = { splitting: false, // Keep bundle as single file to match rollup, }; - const entryName = packageJson.name.replace('@metamask/', ''); // TSUP Configuration @@ -61,11 +70,11 @@ export default defineConfig([ options.platform = 'browser'; options.mainFields = ['browser', 'module', 'main']; options.conditions = ['browser']; - options.outExtension = { ".js": '.mjs' }; + options.outExtension = { '.js': '.mjs' }; }, banner: { js: '/* Browser ES build */', - } + }, }, { ...baseConfig, @@ -73,12 +82,10 @@ export default defineConfig([ outDir: 'dist/browser/umd', platform: 'browser', external: [...baseExternalDeps, ...peerDependencies], - esbuildPlugins: [ - umdWrapper({}) as any, - ], + esbuildPlugins: [umdWrapper({}) as any], esbuildOptions: (options) => { options.metafile = true; - options.outExtension = { ".js": '.js' }; + options.outExtension = { '.js': '.js' }; options.platform = 'browser'; options.mainFields = ['browser', 'module', 'main']; options.conditions = ['browser']; @@ -97,7 +104,7 @@ export default defineConfig([ globalName: 'MetaMaskSDK', // Matches rollup IIFE config esbuildOptions: (options) => { options.metafile = true; - options.outExtension = { ".js": '.js' }; + options.outExtension = { '.js': '.js' }; options.platform = 'browser'; options.mainFields = ['browser', 'module', 'main']; options.conditions = ['browser']; @@ -118,7 +125,7 @@ export default defineConfig([ options.platform = 'node'; options.mainFields = ['module', 'main']; options.conditions = ['node']; - options.outExtension = { ".js": '.js' }; + options.outExtension = { '.js': '.js' }; }, banner: { js: '/* Node.js CJS build */', @@ -136,7 +143,7 @@ export default defineConfig([ options.platform = 'node'; options.mainFields = ['module', 'main']; options.conditions = ['node']; - options.outExtension = { ".js": '.mjs' }; + options.outExtension = { '.js': '.mjs' }; }, banner: { js: '/* Node.js ES build */', @@ -153,7 +160,7 @@ export default defineConfig([ options.metafile = true; options.mainFields = ['react-native', 'node', 'browser']; options.conditions = ['react-native', 'node', 'browser']; - options.outExtension = { ".js": '.mjs' }; + options.outExtension = { '.js': '.mjs' }; }, banner: { js: '/* React Native ES build */', @@ -164,5 +171,5 @@ export default defineConfig([ entry: { [entryName]: 'src/index.browser.ts' }, outDir: 'dist/types', dts: { only: true }, - } + }, ]); diff --git a/packages/connect-multichain/vitest.config.ts b/packages/connect-multichain/vitest.config.ts index a94d9365..5f79ad41 100644 --- a/packages/connect-multichain/vitest.config.ts +++ b/packages/connect-multichain/vitest.config.ts @@ -1,16 +1,16 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { - exclude: [ - "**/node_modules/**", - "**/dist/**", - "**/.{idea,git,cache,output,temp}/**", - "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*", - "**/fixtures.test.ts", // Exclude fixtures helper file - ], - include: ["**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], - // Use setup file to handle unhandled rejections gracefully - setupFiles: ["./tests/setup.ts"], - }, + test: { + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/.{idea,git,cache,output,temp}/**', + '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*', + '**/fixtures.test.ts', // Exclude fixtures helper file + ], + include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + // Use setup file to handle unhandled rejections gracefully + setupFiles: ['./tests/setup.ts'], + }, }); diff --git a/packages/connect/src/evm/index.ts b/packages/connect/src/evm/index.ts index 3fd49e2f..7a64c17e 100644 --- a/packages/connect/src/evm/index.ts +++ b/packages/connect/src/evm/index.ts @@ -1,2 +1 @@ export * from '@metamask/connect-evm'; - diff --git a/packages/multichain-ui/README.md b/packages/multichain-ui/README.md index 21e93749..de565145 100644 --- a/packages/multichain-ui/README.md +++ b/packages/multichain-ui/README.md @@ -6,6 +6,7 @@ This project includes fully functional InstallModal web used in the Multichain S Untrusted flows use the OTPModal which exists but is not yet fully supported. n example of how to install the package usin + ## Development ```bash diff --git a/packages/multichain-ui/jest-preload.js b/packages/multichain-ui/jest-preload.js index ac2409d0..c1aec60f 100644 --- a/packages/multichain-ui/jest-preload.js +++ b/packages/multichain-ui/jest-preload.js @@ -1,3 +1,4 @@ +/* eslint-disable no-undef -- Jest globals are available in test setup */ global.console = { ...console, log: jest.fn(), diff --git a/packages/multichain-ui/src/components/misc/simple-i18n.ts b/packages/multichain-ui/src/components/misc/simple-i18n.ts index a794b2b3..13a63717 100644 --- a/packages/multichain-ui/src/components/misc/simple-i18n.ts +++ b/packages/multichain-ui/src/components/misc/simple-i18n.ts @@ -1,60 +1,81 @@ -interface I18nInstance { +/* eslint-disable no-restricted-syntax -- Private class properties use established patterns */ +/* eslint-disable no-restricted-globals -- i18n uses navigator for locale detection */ +/* eslint-disable id-length -- 't' is standard i18n function name */ +type I18nInstance = { t: (key: string) => string; init: (config: { fallbackLng: string }) => Promise; -} +}; -interface TranslationDict { +type TranslationDict = { [key: string]: string | TranslationDict; -} +}; const defaultTranslations: TranslationDict = { - /** * In use */ - "CONNECT_WITH_METAMASK": "Connect with Metamask", - "USE_EXTENSION": "Use extension", - "USE_MOBILE":"Use mobile", - "ONE_CLICK_CONNECT": "Connect in one click to the MetaMask extension.", - "CONNECT_WITH_EXTENSION": "Connect With Extension", - "SCAN_TO_CONNECT": "Scan to connect with the MetaMask mobile app.", + CONNECT_WITH_METAMASK: 'Connect with Metamask', + USE_EXTENSION: 'Use extension', + USE_MOBILE: 'Use mobile', + ONE_CLICK_CONNECT: 'Connect in one click to the MetaMask extension.', + CONNECT_WITH_EXTENSION: 'Connect With Extension', + SCAN_TO_CONNECT: 'Scan to connect with the MetaMask mobile app.', /** * In use */ - "DESKTOP": "Desktop", - "MOBILE": "Mobile", - "META_MASK_MOBILE_APP": "MetaMask mobile app", - "INSTALL_MODAL": { - "TRUSTED_BY_USERS": "Trusted by over 30 million users to buy, store, send and swap crypto securely", - "LEADING_CRYPTO_WALLET": "The leading crypto wallet & gateway to blockchain apps built on Ethereum Mainnet, Polygon, Optimism, and many other networks", - "CONTROL_DIGITAL_INTERACTIONS": "Puts you in control of your digital interactions by making power of cryptography more accessible", - "INSTALL_META_MASK_EXTENSION_BUTTON": "Install MetaMask Extension", - "INSTALL_META_MASK_EXTENSION_TEXT": "Install and try the MetaMask browser extension." + DESKTOP: 'Desktop', + MOBILE: 'Mobile', + META_MASK_MOBILE_APP: 'MetaMask mobile app', + INSTALL_MODAL: { + TRUSTED_BY_USERS: + 'Trusted by over 30 million users to buy, store, send and swap crypto securely', + LEADING_CRYPTO_WALLET: + 'The leading crypto wallet & gateway to blockchain apps built on Ethereum Mainnet, Polygon, Optimism, and many other networks', + CONTROL_DIGITAL_INTERACTIONS: + 'Puts you in control of your digital interactions by making power of cryptography more accessible', + INSTALL_META_MASK_EXTENSION_BUTTON: 'Install MetaMask Extension', + INSTALL_META_MASK_EXTENSION_TEXT: + 'Install and try the MetaMask browser extension.', }, - "PENDING_MODAL": { - "OPEN_META_MASK_SELECT_CODE": "Please open the MetaMask wallet app and select the code on the screen OR disconnect", - "OPEN_META_MASK_CONTINUE": "Open the MetaMask app to continue with your session.", - "NUMBER_AFTER_OPEN_NOTICE": "If a number doesn't appear after opening MetaMask, please click disconnect and re-scan the QRCode.", - "DISCONNECT": "Disconnect" + PENDING_MODAL: { + OPEN_META_MASK_SELECT_CODE: + 'Please open the MetaMask wallet app and select the code on the screen OR disconnect', + OPEN_META_MASK_CONTINUE: + 'Open the MetaMask app to continue with your session.', + NUMBER_AFTER_OPEN_NOTICE: + "If a number doesn't appear after opening MetaMask, please click disconnect and re-scan the QRCode.", + DISCONNECT: 'Disconnect', }, - "SELECT_MODAL": { - "CRYPTO_TAKE_CONTROL_TEXT": "Take control of your crypto and explore the blockchain with the wallet trusted by over 30 million people worldwide" + SELECT_MODAL: { + CRYPTO_TAKE_CONTROL_TEXT: + 'Take control of your crypto and explore the blockchain with the wallet trusted by over 30 million people worldwide', + }, + META_MASK_MODAL: { + ADDRESS_COPIED: 'Address copied to clipboard!', + DISCONNECT: 'Disconnect', + ACTIVE_NETWORK: 'Active Network', }, - "META_MASK_MODAL": { - "ADDRESS_COPIED": "Address copied to clipboard!", - "DISCONNECT": "Disconnect", - "ACTIVE_NETWORK": "Active Network" - } } as const; export class SimpleI18n implements I18nInstance { private translations: TranslationDict = defaultTranslations; - private supportedLocales: string[] = ['es', 'fr', 'he', 'it', 'pt', 'tr']; - private baseUrl: string; + + private readonly supportedLocales: string[] = [ + 'es', + 'fr', + 'he', + 'it', + 'pt', + 'tr', + ]; + + private readonly baseUrl: string; constructor(config?: { baseUrl?: string }) { - this.baseUrl = config?.baseUrl ?? 'https://raw.githubusercontent.com/MetaMask/metamask-sdk/refs/heads/gh-pages/locales'; + this.baseUrl = + config?.baseUrl ?? + 'https://raw.githubusercontent.com/MetaMask/metamask-sdk/refs/heads/gh-pages/locales'; } private getBrowserLanguage(): string { @@ -62,8 +83,8 @@ export class SimpleI18n implements I18nInstance { const browserLanguages = navigator.languages || [navigator.language]; // Check if English is one of the preferred languages - const hasEnglish = browserLanguages.some(lang => - lang.toLowerCase().startsWith('en') + const hasEnglish = browserLanguages.some((lang) => + lang.toLowerCase().startsWith('en'), ); // If user understands English, use it @@ -91,19 +112,24 @@ export class SimpleI18n implements I18nInstance { const shortLocale = locale.split('-')[0]; if (shortLocale === 'en' || !this.supportedLocales.includes(shortLocale)) { - this.translations = defaultTranslations; - return; + this.translations = defaultTranslations; + return; } try { - const url = `${this.baseUrl}/${shortLocale}.json`; - const response = await fetch(url); + const url = `${this.baseUrl}/${shortLocale}.json`; + const response = await fetch(url); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - this.translations = await response.json(); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + this.translations = await response.json(); } catch (error) { - console.warn(`❌ Failed to load ${shortLocale} translations, falling back to English:`, error); - this.translations = defaultTranslations; + console.warn( + `❌ Failed to load ${shortLocale} translations, falling back to English:`, + error, + ); + this.translations = defaultTranslations; } } @@ -116,7 +142,9 @@ export class SimpleI18n implements I18nInstance { let current: TranslationDict | string = dict; for (const part of parts) { - if (typeof current !== 'object') return ''; + if (typeof current !== 'object') { + return ''; + } current = current[part]; } diff --git a/packages/multichain-ui/tsconfig.build.json b/packages/multichain-ui/tsconfig.build.json index cfa52653..2af8a835 100644 --- a/packages/multichain-ui/tsconfig.build.json +++ b/packages/multichain-ui/tsconfig.build.json @@ -13,15 +13,6 @@ "types": ["@stencil/core"], "strictPropertyInitialization": false }, - "include": [ - "./src/index.ts", - "./src/components.d.ts" - ], - "exclude": [ - "**/*.tsx", - "**/*.jsx", - "**/*.css", - "**/*.scss", - "**/*.svg" - ] + "include": ["./src/index.ts", "./src/components.d.ts"], + "exclude": ["**/*.tsx", "**/*.jsx", "**/*.css", "**/*.scss", "**/*.svg"] } diff --git a/packages/multichain-ui/tsconfig.json b/packages/multichain-ui/tsconfig.json index 8f487d6d..a62713c5 100644 --- a/packages/multichain-ui/tsconfig.json +++ b/packages/multichain-ui/tsconfig.json @@ -27,5 +27,4 @@ }, "include": ["./src"], "exclude": ["node_modules", "dist", "loader"] - } diff --git a/playground/browser-playground/README.md b/playground/browser-playground/README.md index be5f3610..aa511353 100644 --- a/playground/browser-playground/README.md +++ b/playground/browser-playground/README.md @@ -60,6 +60,7 @@ This launches the development server at `http://localhost:3000`. ### Multichain Connection Connect to multiple blockchain networks in a single session: + - Ethereum Mainnet & Testnets - Layer 2 networks (Linea, Arbitrum, Polygon, etc.) - Solana diff --git a/playground/browser-playground/craco.config.js b/playground/browser-playground/craco.config.js index 0b4ba1d7..9158aa8f 100644 --- a/playground/browser-playground/craco.config.js +++ b/playground/browser-playground/craco.config.js @@ -1,3 +1,7 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- Build tool */ +/* eslint-disable n/no-extraneous-require -- Build tool */ +/* eslint-disable require-unicode-regexp -- Webpack config */ +/* eslint-disable n/no-process-env -- Build tool env */ require('dotenv').config(); const webpack = require('webpack'); @@ -25,9 +29,7 @@ module.exports = { // === SUPPRESS SOURCE MAP WARNINGS === // Ignore source map warnings from node_modules dependencies - webpackConfig.ignoreWarnings = [ - /Failed to parse source map/, - ]; + webpackConfig.ignoreWarnings = [/Failed to parse source map/]; // === PROVIDE PLUGINS === webpackConfig.plugins.push( @@ -35,7 +37,9 @@ module.exports = { process: 'process/browser.js', }), new webpack.DefinePlugin({ - 'process.env.INFURA_API_KEY': JSON.stringify(process.env.INFURA_API_KEY), + 'process.env.INFURA_API_KEY': JSON.stringify( + process.env.INFURA_API_KEY, + ), }), ); diff --git a/playground/browser-playground/package.json b/playground/browser-playground/package.json index 0c71cc09..fae7dc5e 100644 --- a/playground/browser-playground/package.json +++ b/playground/browser-playground/package.json @@ -24,12 +24,12 @@ "build/" ], "scripts": { - "copy-wagmi-connector": "node scripts/copy-wagmi-connector.js", "build": "yarn copy-wagmi-connector && DISABLE_ESLINT_PLUGIN=true craco build", "build:docs": "typedoc", "changelog:format": "../../scripts/format-changelog.sh @metamask/browser-playground", "changelog:update": "../../scripts/update-changelog.sh @metamask/browser-playground", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/browser-playground", + "copy-wagmi-connector": "node scripts/copy-wagmi-connector.js", "deploy": "serve -s build", "prepack": "./scripts/prepack.sh", "start": "yarn copy-wagmi-connector && DISABLE_ESLINT_PLUGIN=true craco start", diff --git a/playground/browser-playground/postcss.config.js b/playground/browser-playground/postcss.config.js index af477ee3..0ab78a46 100644 --- a/playground/browser-playground/postcss.config.js +++ b/playground/browser-playground/postcss.config.js @@ -1,7 +1,11 @@ +const path = require('path'); + module.exports = { plugins: { // Explicitly set config to ensure Tailwind uses this package's local config instead of a parent monorepo config - '@tailwindcss/postcss': { config: `${__dirname}/tailwind.config.js` }, + '@tailwindcss/postcss': { + config: path.join(__dirname, 'tailwind.config.js'), + }, autoprefixer: {}, }, }; diff --git a/playground/browser-playground/scripts/README.md b/playground/browser-playground/scripts/README.md index 92738e2d..fa5578c6 100644 --- a/playground/browser-playground/scripts/README.md +++ b/playground/browser-playground/scripts/README.md @@ -34,10 +34,12 @@ There are several reasons why we cannot directly import the connector from its o ### Usage The script runs automatically during: + - `yarn build` - Before building the production bundle - `yarn start` - Before starting the development server You can also run it manually: + ```bash yarn copy-wagmi-connector ``` diff --git a/playground/browser-playground/scripts/copy-wagmi-connector.js b/playground/browser-playground/scripts/copy-wagmi-connector.js index cf972c53..f7131daa 100644 --- a/playground/browser-playground/scripts/copy-wagmi-connector.js +++ b/playground/browser-playground/scripts/copy-wagmi-connector.js @@ -16,8 +16,9 @@ if (!fs.existsSync(DEST_DIR)) { // Read the original file const originalContent = fs.readFileSync(SOURCE_FILE, 'utf8'); -// Add auto-generated header comment with ts-nocheck -const autoGeneratedHeader = `/** +// Add auto-generated header comment with eslint-disable and ts-nocheck +const autoGeneratedHeader = `/* eslint-disable -- AUTO-GENERATED FILE */ +/** * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY * * This file is automatically generated from: diff --git a/playground/browser-playground/src/helpers/IdHelpers.ts b/playground/browser-playground/src/helpers/IdHelpers.ts index 7a62e7b2..314e691f 100644 --- a/playground/browser-playground/src/helpers/IdHelpers.ts +++ b/playground/browser-playground/src/helpers/IdHelpers.ts @@ -1,6 +1,7 @@ /** * Escapes special characters in strings to make them valid HTML IDs. * Currently replaces colons with dashes, but can be extended for other characters. + * * @param value - The string to be escaped. * @returns The escaped string that is safe to use as an HTML ID. */ diff --git a/playground/browser-playground/src/helpers/SignHelpers.ts b/playground/browser-playground/src/helpers/SignHelpers.ts index 888d2667..917c556d 100644 --- a/playground/browser-playground/src/helpers/SignHelpers.ts +++ b/playground/browser-playground/src/helpers/SignHelpers.ts @@ -1,9 +1,19 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Method names match RPC methods */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Demo helpers */ +/* eslint-disable id-denylist -- 'err' is clear in catch context */ +/* eslint-disable @typescript-eslint/restrict-template-expressions -- Error logging */ +/* eslint-disable no-alert -- Browser playground uses alert */ +/* eslint-disable no-restricted-globals -- Browser uses alert for feedback */ +/* eslint-disable consistent-return -- Error handling */ +/* eslint-disable id-length -- Short error variable */ +/* eslint-disable camelcase -- RPC method names */ +/* eslint-disable import-x/no-nodejs-modules -- Buffer polyfill */ import type { EIP1193Provider } from '@metamask/connect/evm'; -import { Buffer } from 'buffer'; import { createSignTypedDataParams, getDefaultPersonalSignMessage, } from '@metamask/playground-ui/helpers'; +import { Buffer } from 'buffer'; /** * Sends an eth_signTypedData_v4 request to the provider. @@ -36,7 +46,7 @@ export const send_eth_signTypedData_v4 = async ( return await provider?.request({ method, params }); } catch (e: unknown) { console.log(`eth_signTypedData_v4 error: ${e}`); - return 'Error: ' + e; + return `Error: ${e}`; } }; @@ -50,7 +60,7 @@ export const send_personal_sign = async (provider: EIP1193Provider) => { try { const from = provider.selectedAccount; const message = getDefaultPersonalSignMessage('Create React dapp'); - const hexMessage = '0x' + Buffer.from(message, 'utf8').toString('hex'); + const hexMessage = `0x${Buffer.from(message, 'utf8').toString('hex')}`; const sign = await provider.request({ method: 'personal_sign', @@ -59,6 +69,6 @@ export const send_personal_sign = async (provider: EIP1193Provider) => { return sign; } catch (err: unknown) { console.log(`personal_sign error: ${err}`); - return 'Error: ' + err; + return `Error: ${err}`; } }; diff --git a/playground/browser-playground/src/helpers/solana-method-signatures.ts b/playground/browser-playground/src/helpers/solana-method-signatures.ts index 397bb69a..cbad5a18 100644 --- a/playground/browser-playground/src/helpers/solana-method-signatures.ts +++ b/playground/browser-playground/src/helpers/solana-method-signatures.ts @@ -1,18 +1,20 @@ -import type { Commitment } from '@solana/web3.js'; -import { - Connection, - PublicKey, - SystemProgram, - Transaction, -} from '@solana/web3.js'; +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Demo helpers */ +/* eslint-disable no-restricted-globals -- Browser playground uses process.env */ -import { FEATURED_NETWORKS } from '@metamask/playground-ui/constants'; import { getConfig, stringToBase64, uint8ArrayToBase64, getHostname, } from '@metamask/playground-ui/config'; +import { FEATURED_NETWORKS } from '@metamask/playground-ui/constants'; +import type { Commitment } from '@solana/web3.js'; +import { + Connection, + PublicKey, + SystemProgram, + Transaction, +} from '@solana/web3.js'; const getSolanaRpcConfig = () => { const config = getConfig(); diff --git a/playground/browser-playground/src/setupTests.ts b/playground/browser-playground/src/setupTests.ts index 75b7e443..7fd96250 100644 --- a/playground/browser-playground/src/setupTests.ts +++ b/playground/browser-playground/src/setupTests.ts @@ -2,5 +2,5 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -// eslint-disable-next-line import/no-unassigned-import +// eslint-disable-next-line import-x/no-unassigned-import import '@testing-library/jest-dom'; diff --git a/playground/browser-playground/src/wagmi/config.ts b/playground/browser-playground/src/wagmi/config.ts index e6bd9cc4..1b3a4674 100644 --- a/playground/browser-playground/src/wagmi/config.ts +++ b/playground/browser-playground/src/wagmi/config.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-globals -- Browser playground uses window */ import { createConfig, http } from 'wagmi'; import { mainnet, sepolia, optimism, celo } from 'wagmi/chains'; @@ -22,6 +23,7 @@ export const wagmiConfig = createConfig({ }); declare module 'wagmi' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Register { config: typeof wagmiConfig; } diff --git a/playground/browser-playground/src/wagmi/metamask-connector.ts b/playground/browser-playground/src/wagmi/metamask-connector.ts index a5799919..57f5b361 100644 --- a/playground/browser-playground/src/wagmi/metamask-connector.ts +++ b/playground/browser-playground/src/wagmi/metamask-connector.ts @@ -1,3 +1,4 @@ +/* eslint-disable -- AUTO-GENERATED FILE */ /** * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY * @@ -16,6 +17,17 @@ */ // @ts-nocheck +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Wagmi connector API */ +/* eslint-disable no-restricted-globals -- Browser connector uses window */ +/* eslint-disable @typescript-eslint/no-misused-promises -- Event handlers are async */ +/* eslint-disable require-atomic-updates -- Race conditions are acceptable for caching */ +/* eslint-disable id-denylist -- 'err' is clear in catch context */ +/* eslint-disable id-length -- 'x' is clear in lambda context */ +/* eslint-disable @typescript-eslint/no-shadow -- accounts shadow is intentional */ +/* eslint-disable no-nested-ternary -- Ternary chain is clearer here */ +/* eslint-disable jsdoc/require-param-description -- Wagmi connector API */ +/* eslint-disable jsdoc/require-returns -- Wagmi connector API */ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Provider is guaranteed after check */ import type { createEVMClient, EIP1193Provider, @@ -64,6 +76,10 @@ export type MetaMaskParameters = UnionCompute< type CreateEVMClientParameters = Parameters[0]; metaMask.type = 'metaMask' as const; +/** + * + * @param parameters + */ export function metaMask(parameters: MetaMaskParameters = {}) { type Provider = EIP1193Provider; type Properties = { @@ -85,7 +101,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { const provider = instance.getProvider(); let accounts: readonly Address[] = []; - if (isReconnecting) accounts = await this.getAccounts().catch(() => []); + if (isReconnecting) { + accounts = await this.getAccounts().catch(() => []); + } try { let signResponse: string | undefined; @@ -93,17 +111,18 @@ export function metaMask(parameters: MetaMaskParameters = {}) { if (!accounts?.length) { const chainIds = config.chains.map((chain) => chain.id); if (parameters.connectAndSign || parameters.connectWith) { - if (parameters.connectAndSign) + if (parameters.connectAndSign) { signResponse = await instance.connectAndSign({ chainIds, message: parameters.connectAndSign, }); - else if (parameters.connectWith) + } else if (parameters.connectWith) { connectWithResponse = await instance.connectWith({ chainIds, method: parameters.connectWith.method, params: parameters.connectWith.params, }); + } accounts = await this.getAccounts(); } else { @@ -112,27 +131,30 @@ export function metaMask(parameters: MetaMaskParameters = {}) { } } // Switch to chain if provided - let currentChainId = (await this.getChainId()) as number; + let currentChainId = await this.getChainId(); if (chainId && currentChainId !== chainId) { const chain = await this.switchChain!({ chainId }).catch((error) => { - if (error.code === UserRejectedRequestError.code) throw error; + if (error.code === UserRejectedRequestError.code) { + throw error; + } return { id: currentChainId }; }); currentChainId = chain?.id ?? currentChainId; } - if (signResponse) + if (signResponse) { provider.emit('connectAndSign', { accounts, chainId: currentChainId, signResponse, }); - else if (connectWithResponse) + } else if (connectWithResponse) { provider.emit('connectWith', { accounts, chainId: currentChainId, connectWithResponse, }); + } return { // TODO(v3): Make `withCapabilities: true` default behavior @@ -143,10 +165,12 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }; } catch (err) { const error = err as RpcError; - if (error.code === UserRejectedRequestError.code) + if (error.code === UserRejectedRequestError.code) { throw new UserRejectedRequestError(error); - if (error.code === ResourceUnavailableRpcError.code) + } + if (error.code === ResourceUnavailableRpcError.code) { throw new ResourceUnavailableRpcError(error); + } throw error; } }, @@ -156,8 +180,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async getAccounts() { const instance = await this.getInstance(); - if (instance.accounts.length) + if (instance.accounts.length) { return instance.accounts.map((x) => getAddress(x)); + } // Fallback to provider if SDK doesn't return accounts const provider = instance.getProvider(); const accounts = (await provider.request({ @@ -167,7 +192,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async getChainId() { const instance = await this.getInstance(); - if (instance.getChainId()) return Number(instance.getChainId()); + if (instance.getChainId()) { + return Number(instance.getChainId()); + } // Fallback to provider if SDK doesn't return chainId const provider = instance.getProvider(); const chainId = await provider.request({ method: 'eth_chainId' }); @@ -183,25 +210,29 @@ export function metaMask(parameters: MetaMaskParameters = {}) { // JSON-RPC requests on page load const timeout = 10; const accounts = await withRetry( - () => + async () => withTimeout( async () => { const accounts = await this.getAccounts(); - if (!accounts.length) throw new Error('try again'); + if (!accounts.length) { + throw new Error('try again'); + } return accounts; }, { timeout }, ), { delay: timeout + 1, retryCount: 3 }, ); - return !!accounts.length; + return Boolean(accounts.length); } catch { return false; } }, async switchChain({ addEthereumChainParameter, chainId }) { const chain = config.chains.find(({ id }) => id === chainId); - if (!chain) throw new SwitchChainError(new ChainNotConfiguredError()); + if (!chain) { + throw new SwitchChainError(new ChainNotConfiguredError()); + } try { const instance = await this.getInstance(); @@ -229,8 +260,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { } catch (err) { const error = err as RpcError; - if (error.code === UserRejectedRequestError.code) + if (error.code === UserRejectedRequestError.code) { throw new UserRejectedRequestError(error); + } throw new SwitchChainError(error); } @@ -246,7 +278,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async onConnect(connectInfo) { const accounts = await this.getAccounts(); - if (accounts.length === 0) return; + if (accounts.length === 0) { + return; + } const chainId = Number(connectInfo.chainId); config.emitter.emit('connect', { accounts, chainId }); @@ -256,7 +290,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { // https://github.com/MetaMask/providers/pull/120 if (error && (error as RpcError<1013>).code === 1013) { const provider = await this.getProvider(); - if (provider && !!(await this.getAccounts()).length) return; + if (provider && Boolean((await this.getAccounts()).length)) { + return; + } } config.emitter.emit('disconnect'); @@ -267,7 +303,7 @@ export function metaMask(parameters: MetaMaskParameters = {}) { async getInstance() { if (!metamask) { if (!metamaskPromise) { - const { createEVMClient } = await (() => { + const { createEVMClient } = await (async () => { try { return import('@metamask/connect-evm'); } catch { @@ -284,16 +320,24 @@ export function metaMask(parameters: MetaMaskParameters = {}) { ), }, dapp: (() => { - if (parameters.dappMetadata) return parameters.dappMetadata; - if (parameters.dapp) return parameters.dapp; - if (typeof window === 'undefined') return { name: 'wagmi' }; + if (parameters.dappMetadata) { + return parameters.dappMetadata; + } + if (parameters.dapp) { + return parameters.dapp; + } + if (typeof window === 'undefined') { + return { name: 'wagmi' }; + } return { name: window.location.hostname, url: window.location.href, }; })(), debug: (() => { - if (parameters.logging) return true; + if (parameters.logging) { + return true; + } return parameters.debug; })(), eventHandlers: { diff --git a/playground/browser-playground/tsconfig.json b/playground/browser-playground/tsconfig.json index d9ca4a06..b68b20fe 100644 --- a/playground/browser-playground/tsconfig.json +++ b/playground/browser-playground/tsconfig.json @@ -16,7 +16,9 @@ "target": "es2020", "jsx": "react-jsx", "paths": { - "@metamask/playground-ui": ["../playground-ui/dist/es/playground-ui.d.mts"], + "@metamask/playground-ui": [ + "../playground-ui/dist/es/playground-ui.d.mts" + ], "@metamask/playground-ui/*": ["../playground-ui/dist/es/*.d.mts"], "@metamask/*": ["../../*/src"] } diff --git a/playground/node-playground/README.md b/playground/node-playground/README.md index 97edbc38..6c8ee47b 100644 --- a/playground/node-playground/README.md +++ b/playground/node-playground/README.md @@ -72,11 +72,13 @@ When you start the playground, you can choose between: Once connected, the available actions depend on your connector type: **Multichain API:** + - Sign Ethereum Message (`personal_sign`) - Sign Solana Message (`signMessage`) - Disconnect **Legacy EVM Connector:** + - Sign Ethereum Message (`personal_sign`) - Switch Chain (Ethereum, Polygon, Linea, Sepolia) - Disconnect @@ -103,6 +105,7 @@ node-playground/ ## Dependencies The playground uses: + - `@metamask/connect-multichain` - For multichain API connections - `@metamask/connect-evm` - For legacy EVM connections - `inquirer` - Interactive CLI prompts diff --git a/playground/node-playground/src/index.ts b/playground/node-playground/src/index.ts index f04f4e32..37ae99cf 100644 --- a/playground/node-playground/src/index.ts +++ b/playground/node-playground/src/index.ts @@ -1,17 +1,26 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Interactive CLI demo */ +/* eslint-disable no-restricted-globals -- Node.js playground uses process */ +/* eslint-disable require-atomic-updates -- Interactive CLI state updates are sequential */ + +/* eslint-disable import-x/no-extraneous-dependencies -- Playground dependencies */ +/* eslint-disable import-x/no-named-as-default-member -- Library APIs */ +/* eslint-disable @typescript-eslint/no-use-before-define -- Function hoisting in CLI */ +/* eslint-disable guard-for-in -- CLI demo iteration */ + +import { + createEVMClient, + type MetamaskConnectEVM, +} from '@metamask/connect-evm'; import { createMultichainClient, getInfuraRpcUrls, type SessionData, } from '@metamask/connect-multichain'; -import { - createEVMClient, - type MetamaskConnectEVM, -} from '@metamask/connect-evm'; import { hexToNumber } from '@metamask/utils'; import chalk from 'chalk'; +import dotenv from 'dotenv'; import inquirer from 'inquirer'; import ora, { type Ora } from 'ora'; -import dotenv from 'dotenv'; dotenv.config(); @@ -30,7 +39,9 @@ const AVAILABLE_CHAINS = [ const state: { app: AppState; connectorType: ConnectorType | null; - metamaskConnectMultichain: Awaited> | null; + metamaskConnectMultichain: Awaited< + ReturnType + > | null; evmSdk: MetamaskConnectEVM | null; accounts: { [chainId: string]: string[] }; // Group accounts by chain spinner: Ora | null; @@ -283,7 +294,7 @@ const handleSwitchChain = async () => { try { await state.evmSdk.switchChain({ chainId: chain }); const chainName = - AVAILABLE_CHAINS.find((chainOption) => chainOption.id === chain)?.name || + AVAILABLE_CHAINS.find((chainOption) => chainOption.id === chain)?.name ?? 'chain'; state.spinner.succeed(`Successfully switched to ${chainName}.`); } catch (error: unknown) { @@ -363,7 +374,7 @@ const main = async (): Promise => { console.log(chalk.bold.cyan('MetaMask SDK Node.js Playground')); console.log('------------------------------------'); - const infuraApiKey = process.env.INFURA_API_KEY || 'demo'; + const infuraApiKey = process.env.INFURA_API_KEY ?? 'demo'; const supportedNetworks = getInfuraRpcUrls(infuraApiKey); // Initialize Multichain SDK @@ -452,47 +463,50 @@ const main = async (): Promise => { }); // --- Multichain SDK Event Handler --- - state.metamaskConnectMultichain.on('wallet_sessionChanged', (session?: SessionData) => { - if (state.app !== 'CONNECTING') { - // Only clear the console if we are not in the middle of a connection flow - console.clear(); - console.log(chalk.bold.cyan('MetaMask SDK Node.js Playground')); - console.log('------------------------------------'); - } + state.metamaskConnectMultichain.on( + 'wallet_sessionChanged', + (session?: SessionData) => { + if (state.app !== 'CONNECTING') { + // Only clear the console if we are not in the middle of a connection flow + console.clear(); + console.log(chalk.bold.cyan('MetaMask SDK Node.js Playground')); + console.log('------------------------------------'); + } - if (state.spinner && state.app === 'CONNECTING') { - state.spinner.stop(); - state.spinner = null; - } + if (state.spinner && state.app === 'CONNECTING') { + state.spinner.stop(); + state.spinner = null; + } - if (session?.sessionScopes) { - const groupedAccounts: { [chainId: string]: string[] } = {}; - for (const scope of Object.values(session.sessionScopes) as { - accounts?: string[]; - }[]) { - if (scope.accounts) { - for (const acc of scope.accounts) { - const [namespace, reference] = acc.split(':'); - const chainId = `${namespace}:${reference}`; - if (!groupedAccounts[chainId]) { - groupedAccounts[chainId] = []; + if (session?.sessionScopes) { + const groupedAccounts: { [chainId: string]: string[] } = {}; + for (const scope of Object.values(session.sessionScopes) as { + accounts?: string[]; + }[]) { + if (scope.accounts) { + for (const acc of scope.accounts) { + const [namespace, reference] = acc.split(':'); + const chainId = `${namespace}:${reference}`; + if (!groupedAccounts[chainId]) { + groupedAccounts[chainId] = []; + } + groupedAccounts[chainId].push(acc); } - groupedAccounts[chainId].push(acc); } } + state.accounts = groupedAccounts; + state.app = 'CONNECTED'; + } else { + state.accounts = {}; + state.app = 'DISCONNECTED'; + state.connectorType = null; + console.log(chalk.yellow('Session ended. You are now disconnected.')); } - state.accounts = groupedAccounts; - state.app = 'CONNECTED'; - } else { - state.accounts = {}; - state.app = 'DISCONNECTED'; - state.connectorType = null; - console.log(chalk.yellow('Session ended. You are now disconnected.')); - } - }); + }, + ); // --- Main application loop --- - // eslint-disable-next-line no-constant-condition + while (true) { try { await showMenu(); diff --git a/playground/node-playground/tsconfig.json b/playground/node-playground/tsconfig.json index 53ba944c..e94da3fd 100644 --- a/playground/node-playground/tsconfig.json +++ b/playground/node-playground/tsconfig.json @@ -7,20 +7,28 @@ "outDir": "./dist", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "strict": false, + "strict": true, "skipDefaultLibCheck": true, "skipLibCheck": true, "resolveJsonModule": true, "baseUrl": "./", "paths": { - "@metamask/connect-multichain": ["../../packages/connect-multichain/src/index.node.ts"], - "@metamask/multichain-ui/loader": ["../../packages/multichain-ui/dist/loader"], + "@metamask/connect-multichain": [ + "../../packages/connect-multichain/src/index.node.ts" + ], + "@metamask/multichain-ui/loader": [ + "../../packages/multichain-ui/dist/loader" + ], "@metamask/*": ["../../*/src"], "src/*": ["../../packages/connect-multichain/src/*"] } }, - "references": [ - { "path": "../../packages/connect-multichain" }, - ], + "ts-node": { + "transpileOnly": true, + "compilerOptions": { + "module": "commonjs" + } + }, + "references": [{ "path": "../../packages/connect-multichain" }], "include": ["./src"] } diff --git a/playground/playground-ui/README.md b/playground/playground-ui/README.md index 13550b34..20d5dfec 100644 --- a/playground/playground-ui/README.md +++ b/playground/playground-ui/README.md @@ -52,7 +52,12 @@ import type { ### Configuration ```typescript -import { setConfig, getConfig, setPlatformAdapter, getPlatformAdapter } from '@metamask/playground-ui/config'; +import { + setConfig, + getConfig, + setPlatformAdapter, + getPlatformAdapter, +} from '@metamask/playground-ui/config'; // Set configuration at app startup setConfig({ diff --git a/playground/playground-ui/src/config/config.test.ts b/playground/playground-ui/src/config/config.test.ts index c453d086..4bf01cde 100644 --- a/playground/playground-ui/src/config/config.test.ts +++ b/playground/playground-ui/src/config/config.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test mock functions */ import { describe, it, expect, beforeEach } from 'vitest'; import { @@ -9,7 +11,7 @@ import { resetPlatformAdapter, stringToBase64, getHostname, -} from './index'; +} from '.'; describe('config', () => { beforeEach(() => { diff --git a/playground/playground-ui/src/config/index.ts b/playground/playground-ui/src/config/index.ts index 06ed35ff..b57680e0 100644 --- a/playground/playground-ui/src/config/index.ts +++ b/playground/playground-ui/src/config/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-restricted-globals -- Browser utilities use window */ import type { PlaygroundConfig, PlatformAdapter } from '../types/config'; /** diff --git a/playground/playground-ui/src/constants/methods.ts b/playground/playground-ui/src/constants/methods.ts index c02f86f3..695cd94c 100644 --- a/playground/playground-ui/src/constants/methods.ts +++ b/playground/playground-ui/src/constants/methods.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention -- RPC method names use snake_case */ + import { MetaMaskOpenRPCDocument } from '@metamask/api-specs'; import { parseCaipAccountId, @@ -20,6 +22,7 @@ export const METHODS_REQUIRING_PARAM_INJECTION = { /** * Injects address and chainId (where applicable) into example params for a given method. + * * @param method - The method to inject the address into. * @param exampleParams - The example params to inject the address into. * @param addressToInject - The address to inject. diff --git a/playground/playground-ui/src/constants/networks.ts b/playground/playground-ui/src/constants/networks.ts index 98cf1a6c..98a222f3 100644 --- a/playground/playground-ui/src/constants/networks.ts +++ b/playground/playground-ui/src/constants/networks.ts @@ -19,6 +19,7 @@ export const FEATURED_NETWORKS = { /** * Gets the human-readable network name from a CAIP-2 chain ID. + * * @param chainId - The CAIP-2 chain ID (e.g., "eip155:1") * @returns The network name if found, otherwise returns the chain ID as-is */ diff --git a/playground/playground-ui/src/helpers/address.test.ts b/playground/playground-ui/src/helpers/address.test.ts index d7637fc4..50668349 100644 --- a/playground/playground-ui/src/helpers/address.test.ts +++ b/playground/playground-ui/src/helpers/address.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ import { describe, it, expect } from 'vitest'; import { getCaip25FormattedAddresses } from './address'; diff --git a/playground/playground-ui/src/helpers/json.test.ts b/playground/playground-ui/src/helpers/json.test.ts index 8a36e770..bf4297d5 100644 --- a/playground/playground-ui/src/helpers/json.test.ts +++ b/playground/playground-ui/src/helpers/json.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ import { describe, it, expect } from 'vitest'; import { openRPCExampleToJSON, truncateJSON } from './json'; diff --git a/playground/playground-ui/src/helpers/methodInvocation.test.ts b/playground/playground-ui/src/helpers/methodInvocation.test.ts index 642a12db..0ca94405 100644 --- a/playground/playground-ui/src/helpers/methodInvocation.test.ts +++ b/playground/playground-ui/src/helpers/methodInvocation.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ +/* eslint-disable @typescript-eslint/naming-convention -- RPC method names use snake_case */ import { describe, it, expect } from 'vitest'; import { @@ -103,8 +105,8 @@ describe('updateInvokeMethodResults', () => { { method: 'eth_getBalance', params: ['0x456'] }, ); - expect(result['eip155:1']['eth_getBalance']).toHaveLength(2); - expect(result['eip155:1']['eth_getBalance'][1]).toEqual({ + expect(result['eip155:1'].eth_getBalance).toHaveLength(2); + expect(result['eip155:1'].eth_getBalance[1]).toEqual({ result: '0x200', request: { method: 'eth_getBalance', params: ['0x456'] }, }); @@ -129,8 +131,8 @@ describe('updateInvokeMethodResults', () => { ); expect(result['eip155:137']).toEqual(previousResults['eip155:137']); - expect(result['eip155:1']['eth_getBalance']).toEqual( - previousResults['eip155:1']['eth_getBalance'], + expect(result['eip155:1'].eth_getBalance).toEqual( + previousResults['eip155:1'].eth_getBalance, ); }); }); diff --git a/playground/playground-ui/src/helpers/methodInvocation.ts b/playground/playground-ui/src/helpers/methodInvocation.ts index 8a2280e7..0dfdc3c6 100644 --- a/playground/playground-ui/src/helpers/methodInvocation.ts +++ b/playground/playground-ui/src/helpers/methodInvocation.ts @@ -1,3 +1,5 @@ +/* eslint-disable jsdoc/require-param-description -- Complex nested params */ + import type { CaipAccountId, CaipChainId, Json } from '@metamask/utils'; import type { MethodObject } from '@open-rpc/meta-schema'; import type { Dispatch, SetStateAction } from 'react'; @@ -96,6 +98,9 @@ export const updateInvokeMethodResults = ( * Extracts the params from a wallet_invokeMethod request object. * * @param finalRequestObject - The full request object + * @param finalRequestObject.params + * @param finalRequestObject.params.request + * @param finalRequestObject.params.request.params * @returns The params from the nested request */ export const extractRequestParams = (finalRequestObject: { @@ -108,6 +113,8 @@ export const extractRequestParams = (finalRequestObject: { * Extracts the request object for storage from a wallet_invokeMethod request. * * @param finalRequestObject - The full request object + * @param finalRequestObject.params + * @param finalRequestObject.params.request * @returns The nested request object */ export const extractRequestForStorage = (finalRequestObject: { @@ -167,6 +174,7 @@ export const autoSelectAccountForScope = ( * @param caipChainId - The CAIP chain ID. * @param selectedAccount - The selected account for this scope. * @param metamaskOpenrpcDocument - The MetaMask OpenRPC document. + * @param metamaskOpenrpcDocument.methods * @param injectParamsFn - Function to inject parameters for specific methods. * @param openRPCExampleToJSONFn - Function to convert OpenRPC examples to JSON. * @param methodsRequiringInjection - Object containing methods that require parameter injection. @@ -195,7 +203,7 @@ export const prepareMethodRequest = ( return null; } - let exampleParams: Json = openRPCExampleToJSONFn(example as MethodObject); + let exampleParams: Json = openRPCExampleToJSONFn(example); if (method in methodsRequiringInjection && selectedAccount) { exampleParams = injectParamsFn( diff --git a/playground/playground-ui/src/helpers/sign.ts b/playground/playground-ui/src/helpers/sign.ts index 937fba9c..f4d7e1c2 100644 --- a/playground/playground-ui/src/helpers/sign.ts +++ b/playground/playground-ui/src/helpers/sign.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Demo helpers */ /** * EIP-712 typed data domain configuration. */ @@ -88,7 +89,7 @@ export const createPersonalSignMessage = ( message: string, hexEncoder: (input: string) => string, ): string => { - return '0x' + hexEncoder(message); + return `0x${hexEncoder(message)}`; }; /** diff --git a/playground/playground-ui/src/index.ts b/playground/playground-ui/src/index.ts index ae6c1d45..89d599d2 100644 --- a/playground/playground-ui/src/index.ts +++ b/playground/playground-ui/src/index.ts @@ -1,5 +1,6 @@ +/* eslint-disable import-x/export -- Re-export pattern for package */ /** - * @metamask/playground-ui + * @module playground-ui * * Shared UI logic and utilities for MetaMask playground applications. * This package provides common constants, helpers, types, and configuration diff --git a/playground/playground-ui/src/testIds/index.ts b/playground/playground-ui/src/testIds/index.ts index bc7982ba..211f736a 100644 --- a/playground/playground-ui/src/testIds/index.ts +++ b/playground/playground-ui/src/testIds/index.ts @@ -53,10 +53,14 @@ export const TEST_IDS = { // DYNAMIC INPUTS // ============================================ dynamicInputs: { - container: (label: string) => createTestId('dynamic-inputs', 'container', label), - heading: (label: string) => createTestId('dynamic-inputs', 'heading', label), - checkbox: (value: string) => createTestId('dynamic-inputs', 'checkbox', value), - checkboxLabel: (value: string) => createTestId('dynamic-inputs', 'label', value), + container: (label: string) => + createTestId('dynamic-inputs', 'container', label), + heading: (label: string) => + createTestId('dynamic-inputs', 'heading', label), + checkbox: (value: string) => + createTestId('dynamic-inputs', 'checkbox', value), + checkboxLabel: (value: string) => + createTestId('dynamic-inputs', 'label', value), }, // ============================================ @@ -64,10 +68,12 @@ export const TEST_IDS = { // ============================================ featuredNetworks: { container: 'featured-networks-container', - networkItem: (chainId: string) => createTestId('featured-networks', 'item', chainId), + networkItem: (chainId: string) => + createTestId('featured-networks', 'item', chainId), networkCheckbox: (chainId: string) => createTestId('featured-networks', 'checkbox', chainId), - networkLabel: (chainId: string) => createTestId('featured-networks', 'label', chainId), + networkLabel: (chainId: string) => + createTestId('featured-networks', 'label', chainId), }, // ============================================ @@ -75,29 +81,40 @@ export const TEST_IDS = { // ============================================ scopeCard: { card: (scope: string) => createTestId('scope-card', scope), - networkName: (scope: string) => createTestId('scope-card', 'network-name', scope), + networkName: (scope: string) => + createTestId('scope-card', 'network-name', scope), // Account section - accountsLabel: (scope: string) => createTestId('scope-card', 'accounts-label', scope), - accountsBadge: (scope: string) => createTestId('scope-card', 'accounts-badge', scope), - accountSelect: (scope: string) => createTestId('scope-card', 'account-select', scope), + accountsLabel: (scope: string) => + createTestId('scope-card', 'accounts-label', scope), + accountsBadge: (scope: string) => + createTestId('scope-card', 'accounts-badge', scope), + accountSelect: (scope: string) => + createTestId('scope-card', 'account-select', scope), accountOption: (scope: string, account: string) => createTestId('scope-card', 'account-option', scope, account), - activeAccount: (scope: string) => createTestId('scope-card', 'active-account', scope), + activeAccount: (scope: string) => + createTestId('scope-card', 'active-account', scope), // Method section - methodsLabel: (scope: string) => createTestId('scope-card', 'methods-label', scope), - methodsBadge: (scope: string) => createTestId('scope-card', 'methods-badge', scope), - methodSelect: (scope: string) => createTestId('scope-card', 'method-select', scope), + methodsLabel: (scope: string) => + createTestId('scope-card', 'methods-label', scope), + methodsBadge: (scope: string) => + createTestId('scope-card', 'methods-badge', scope), + methodSelect: (scope: string) => + createTestId('scope-card', 'method-select', scope), methodOption: (scope: string, method: string) => createTestId('scope-card', 'method-option', scope, method), - selectedMethod: (scope: string) => createTestId('scope-card', 'selected-method', scope), + selectedMethod: (scope: string) => + createTestId('scope-card', 'selected-method', scope), // Invoke section invokeCollapsible: (scope: string) => createTestId('scope-card', 'invoke-collapsible', scope), - invokeTextarea: (scope: string) => createTestId('scope-card', 'invoke-textarea', scope), - invokeBtn: (scope: string) => createTestId('scope-card', 'invoke-btn', scope), + invokeTextarea: (scope: string) => + createTestId('scope-card', 'invoke-textarea', scope), + invokeBtn: (scope: string) => + createTestId('scope-card', 'invoke-btn', scope), // Results resultContainer: (scope: string, method: string, index: number) => @@ -203,8 +220,10 @@ export const TEST_IDS = { walletName: (uuid: string) => createTestId('wallet-list', 'name', uuid), walletUuid: (uuid: string) => createTestId('wallet-list', 'uuid', uuid), walletRdns: (uuid: string) => createTestId('wallet-list', 'rdns', uuid), - walletExtensionId: (uuid: string) => createTestId('wallet-list', 'extension-id', uuid), - btnConnect: (uuid: string) => createTestId('wallet-list', 'btn-connect', uuid), + walletExtensionId: (uuid: string) => + createTestId('wallet-list', 'extension-id', uuid), + btnConnect: (uuid: string) => + createTestId('wallet-list', 'btn-connect', uuid), }, } as const; diff --git a/playground/playground-ui/src/testIds/selectors.ts b/playground/playground-ui/src/testIds/selectors.ts index c614b738..090b0b88 100644 --- a/playground/playground-ui/src/testIds/selectors.ts +++ b/playground/playground-ui/src/testIds/selectors.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test helpers */ + /** * Platform-specific selector helpers for e2e tests. * @@ -5,7 +7,7 @@ * in e2e tests across different testing frameworks and platforms. */ -import { TEST_IDS } from './index'; +import { TEST_IDS } from '.'; /** * Browser-specific selector helpers for e2e tests. @@ -14,6 +16,7 @@ import { TEST_IDS } from './index'; export const browserSelectors = { /** * Creates a CSS selector for data-testid attribute. + * * @param testId - The test ID to select * @returns CSS selector string */ @@ -21,14 +24,17 @@ export const browserSelectors = { /** * Creates a CSS selector for id attribute. + * * @param id - The element ID to select * @returns CSS selector string */ byId: (id: string) => `#${id}`, // Convenience methods for common patterns - scopeCard: (scope: string) => `[data-testid="${TEST_IDS.scopeCard.card(scope)}"]`, - connectBtn: (type?: string) => `[data-testid="${TEST_IDS.app.btnConnect(type)}"]`, + scopeCard: (scope: string) => + `[data-testid="${TEST_IDS.scopeCard.card(scope)}"]`, + connectBtn: (type?: string) => + `[data-testid="${TEST_IDS.app.btnConnect(type)}"]`, disconnectBtn: () => `[data-testid="${TEST_IDS.app.btnDisconnect}"]`, }; @@ -39,6 +45,7 @@ export const browserSelectors = { export const rnSelectors = { /** * Returns the testID string for React Native element selection. + * * @param testId - The test ID to select * @returns The testID string */ diff --git a/playground/playground-ui/src/types/components.ts b/playground/playground-ui/src/types/components.ts index f9addeb8..796dd3e6 100644 --- a/playground/playground-ui/src/types/components.ts +++ b/playground/playground-ui/src/types/components.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention -- UI component naming convention */ /** * Input label types for dynamic input components. */ diff --git a/playground/playground-ui/src/types/index.ts b/playground/playground-ui/src/types/index.ts index 62c932f1..f47ebda6 100644 --- a/playground/playground-ui/src/types/index.ts +++ b/playground/playground-ui/src/types/index.ts @@ -1,5 +1,9 @@ // Re-export all types -export type { PlaygroundConfig, PlatformAdapter, Base64Encoder } from './config'; +export type { + PlaygroundConfig, + PlatformAdapter, + Base64Encoder, +} from './config'; export type { SessionScopeData, @@ -10,9 +14,7 @@ export type { SetSelectedAccountsFn, } from './sdk'; -export { - INPUT_LABEL_TYPE, -} from './components'; +export { INPUT_LABEL_TYPE } from './components'; export type { DynamicInputsProps, diff --git a/playground/playground-ui/src/utils/testId.test.ts b/playground/playground-ui/src/utils/testId.test.ts index 28a87f56..d12b7719 100644 --- a/playground/playground-ui/src/utils/testId.test.ts +++ b/playground/playground-ui/src/utils/testId.test.ts @@ -1,4 +1,6 @@ +/* eslint-disable @typescript-eslint/no-shadow -- Vitest globals */ import { describe, it, expect } from 'vitest'; + import { escapeTestId, createTestId } from './testId'; describe('escapeTestId', () => { @@ -49,7 +51,9 @@ describe('escapeTestId', () => { describe('createTestId', () => { it('should join parts with dashes', () => { - expect(createTestId('scope-card', 'container')).toBe('scope-card-container'); + expect(createTestId('scope-card', 'container')).toBe( + 'scope-card-container', + ); expect(createTestId('app', 'btn', 'connect')).toBe('app-btn-connect'); }); @@ -86,8 +90,8 @@ describe('createTestId', () => { const scope = 'eip155:1'; const method = 'eth_getBalance'; const index = 0; - expect(createTestId('scope-card', 'result', scope, method, String(index))).toBe( - 'scope-card-result-eip155-1-eth-getbalance-0', - ); + expect( + createTestId('scope-card', 'result', scope, method, String(index)), + ).toBe('scope-card-result-eip155-1-eth-getbalance-0'); }); }); diff --git a/playground/playground-ui/src/utils/testId.ts b/playground/playground-ui/src/utils/testId.ts index d7e39990..e0f1dc9d 100644 --- a/playground/playground-ui/src/utils/testId.ts +++ b/playground/playground-ui/src/utils/testId.ts @@ -1,3 +1,4 @@ +/* eslint-disable require-unicode-regexp -- Simple character replacement */ /** * Test ID utilities for consistent cross-platform testing. * Provides functions for escaping and creating test IDs that work diff --git a/playground/playground-ui/tsconfig.build.json b/playground/playground-ui/tsconfig.build.json index d06170c1..77a27b65 100644 --- a/playground/playground-ui/tsconfig.build.json +++ b/playground/playground-ui/tsconfig.build.json @@ -21,11 +21,5 @@ "noEmit": false }, "include": ["./src"], - "exclude": [ - "node_modules", - "dist", - "./tests", - "**/*.spec.ts", - "**/*.test.ts" - ] + "exclude": ["node_modules", "dist", "./tests", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/playground/playground-ui/tsconfig.json b/playground/playground-ui/tsconfig.json index 32e49689..e5663dad 100644 --- a/playground/playground-ui/tsconfig.json +++ b/playground/playground-ui/tsconfig.json @@ -15,5 +15,5 @@ "declarationMap": true }, "include": ["./src"], - "exclude": ["node_modules", "dist", "**/*.spec.ts", "**/*.test.ts"] + "exclude": ["node_modules", "dist"] } diff --git a/playground/playground-ui/tsconfig.types.json b/playground/playground-ui/tsconfig.types.json index 12b290cf..18ac6611 100644 --- a/playground/playground-ui/tsconfig.types.json +++ b/playground/playground-ui/tsconfig.types.json @@ -7,11 +7,5 @@ "outDir": "dist/types" }, "include": ["./src"], - "exclude": [ - "node_modules", - "dist", - "./tests", - "**/*.spec.ts", - "**/*.test.ts" - ] + "exclude": ["node_modules", "dist", "./tests", "**/*.spec.ts", "**/*.test.ts"] } diff --git a/playground/playground-ui/tsup.config.ts b/playground/playground-ui/tsup.config.ts index 9fcace79..38ba80bc 100644 --- a/playground/playground-ui/tsup.config.ts +++ b/playground/playground-ui/tsup.config.ts @@ -1,9 +1,10 @@ import { defineConfig } from 'tsup'; + import pkg from './package.json'; -const deps = Object.keys((pkg as Record).dependencies || {}); +const deps = Object.keys((pkg as Record).dependencies ?? {}); const peerDeps = Object.keys( - (pkg as Record).peerDependencies || {}, + (pkg as Record).peerDependencies ?? {}, ); const external = [...deps, ...peerDeps]; @@ -32,8 +33,8 @@ export default defineConfig([ }, }, external, - esbuildOptions: (o) => { - o.outExtension = { '.js': '.mjs' }; + esbuildOptions: (options): void => { + options.outExtension = { '.js': '.mjs' }; }, }, // CJS build (no types needed, ESM build has them) @@ -53,8 +54,8 @@ export default defineConfig([ splitting: false, sourcemap: true, external, - esbuildOptions: (o) => { - o.outExtension = { '.js': '.cjs' }; + esbuildOptions: (options): void => { + options.outExtension = { '.js': '.cjs' }; }, }, ]); diff --git a/playground/react-native-playground/README.md b/playground/react-native-playground/README.md index 0c4e85fc..0f9b6e41 100644 --- a/playground/react-native-playground/README.md +++ b/playground/react-native-playground/README.md @@ -67,6 +67,7 @@ yarn web ### Multichain Connection Connect to multiple blockchain networks in a single session: + - Ethereum Mainnet & Testnets - Layer 2 networks (Linea, Arbitrum, Polygon, etc.) - Solana @@ -143,8 +144,8 @@ See [scripts/README.md](./scripts/README.md) for detailed polyfill documentation ## Environment Variables -| Variable | Description | -|----------|-------------| +| Variable | Description | +| ---------------------------- | ----------------------------- | | `EXPO_PUBLIC_INFURA_API_KEY` | Infura API key for RPC access | ## Troubleshooting diff --git a/playground/react-native-playground/app.json b/playground/react-native-playground/app.json index 0ee00ade..cb179a39 100644 --- a/playground/react-native-playground/app.json +++ b/playground/react-native-playground/app.json @@ -1,42 +1,42 @@ { - "expo": { - "name": "multichain-rn-playground", - "slug": "multichain-rn-playground", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "multichainrn", - "userInterfaceStyle": "automatic", - "newArchEnabled": true, - "ios": { - "supportsTablet": true - }, - "android": { - "adaptiveIcon": { - "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#ffffff" - }, - "edgeToEdgeEnabled": true - }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "image": "./assets/images/splash-icon.png", - "imageWidth": 200, - "resizeMode": "contain", - "backgroundColor": "#ffffff" - } - ] - ], - "experiments": { - "typedRoutes": true - } - } + "expo": { + "name": "multichain-rn-playground", + "slug": "multichain-rn-playground", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "multichainrn", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff" + } + ] + ], + "experiments": { + "typedRoutes": true + } + } } diff --git a/playground/react-native-playground/polyfills.ts b/playground/react-native-playground/polyfills.ts index 51b7e4c8..670d7aea 100644 --- a/playground/react-native-playground/polyfills.ts +++ b/playground/react-native-playground/polyfills.ts @@ -1,3 +1,10 @@ +/* eslint-disable no-restricted-globals -- Polyfill intentionally uses global/window */ +/* eslint-disable import-x/no-nodejs-modules -- Buffer polyfill */ +/* eslint-disable no-negated-condition -- Clearer pattern for undefined checks */ +/* eslint-disable no-empty-function -- Stub implementations */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Polyfill stubs */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- Polyfill assignments */ + import { Buffer } from 'buffer'; // NOTE: Buffer polyfill is now handled by @metamask/connect-multichain @@ -62,12 +69,19 @@ if (typeof global !== 'undefined') { if (typeof global.Event === 'undefined' && typeof Event === 'undefined') { class EventPolyfill { type: string; + bubbles: boolean; + cancelable: boolean; + defaultPrevented: boolean; + eventPhase: number; + timeStamp: number; + target: EventTarget | null; + currentTarget: EventTarget | null; constructor(type: string, options?: EventInit) { @@ -105,17 +119,28 @@ if (typeof global.Event === 'undefined' && typeof Event === 'undefined') { // Polyfill CustomEvent (used by wagmi and other libraries) // React Native doesn't have CustomEvent, so we create a simple polyfill -if (typeof global.CustomEvent === 'undefined' && typeof CustomEvent === 'undefined') { +if ( + typeof global.CustomEvent === 'undefined' && + typeof CustomEvent === 'undefined' +) { // Get Event class (either the polyfill we just created or the native one) - const EventClass = (typeof global !== 'undefined' && global.Event) || - (typeof Event !== 'undefined' ? Event : - class { - type: string; - constructor(type: string) { this.type = type; } - preventDefault() {} - stopPropagation() {} - stopImmediatePropagation() {} - }); + const EventClass = + (typeof global !== 'undefined' && global.Event) || + (typeof Event !== 'undefined' + ? Event + : class { + type: string; + + constructor(type: string) { + this.type = type; + } + + preventDefault() {} + + stopPropagation() {} + + stopImmediatePropagation() {} + }); class CustomEventPolyfill extends EventClass { detail: any; diff --git a/playground/react-native-playground/scripts/README.md b/playground/react-native-playground/scripts/README.md index 9392bdb1..df7bd4a1 100644 --- a/playground/react-native-playground/scripts/README.md +++ b/playground/react-native-playground/scripts/README.md @@ -34,6 +34,7 @@ There are several reasons why we cannot directly import the connector from its o ### Usage The script runs automatically during: + - `yarn start` - Before starting the development server - `yarn ios` - Before starting iOS development - `yarn android` - Before starting Android development @@ -41,6 +42,7 @@ The script runs automatically during: - `yarn build` - Before building the production bundle You can also run it manually: + ```bash yarn copy-wagmi-connector ``` @@ -57,17 +59,20 @@ yarn copy-wagmi-connector Since React Native doesn't have a `window` object, a polyfill has been added in `polyfills.ts` to provide the following properties and methods that the wagmi connector and connect-multichain libraries expect: **Window Object Properties:** + - `window.location.hostname` - Default value: `'react-native-playground'` - `window.location.href` - Default value: `'react-native-playground://'` **Note:** Deeplinks in React Native are handled via the `mobile.preferredOpenLink` option passed to the wagmi connector, not through `window.location.href`. This is configured in `src/wagmi/config.ts` using React Native's `Linking.openURL()`. **Window Object Methods:** + - `window.addEventListener()` - No-op function (browser events don't exist in React Native) - `window.removeEventListener()` - No-op function for cleanup - `window.dispatchEvent()` - No-op function (returns `true`) **Event Classes:** + - `Event` - Polyfill for the Event class with basic properties and methods - `CustomEvent` - Polyfill for CustomEvent that extends Event, used by wagmi and other libraries diff --git a/playground/react-native-playground/scripts/copy-wagmi-connector.js b/playground/react-native-playground/scripts/copy-wagmi-connector.js index 9f1023ed..88e33dc3 100644 --- a/playground/react-native-playground/scripts/copy-wagmi-connector.js +++ b/playground/react-native-playground/scripts/copy-wagmi-connector.js @@ -16,8 +16,9 @@ if (!fs.existsSync(DEST_DIR)) { // Read the original file const originalContent = fs.readFileSync(SOURCE_FILE, 'utf8'); -// Add auto-generated header comment with ts-nocheck -const autoGeneratedHeader = `/** +// Add auto-generated header comment with eslint-disable and ts-nocheck +const autoGeneratedHeader = `/* eslint-disable -- AUTO-GENERATED FILE */ +/** * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY * * This file is automatically generated from: diff --git a/playground/react-native-playground/src/helpers/SignHelpers.ts b/playground/react-native-playground/src/helpers/SignHelpers.ts index 96ec98a5..722e6b4b 100644 --- a/playground/react-native-playground/src/helpers/SignHelpers.ts +++ b/playground/react-native-playground/src/helpers/SignHelpers.ts @@ -1,9 +1,16 @@ +/* eslint-disable @typescript-eslint/naming-convention -- Method names match RPC methods */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Demo helpers */ +/* eslint-disable id-denylist -- 'err' is clear in catch context */ +/* eslint-disable @typescript-eslint/restrict-template-expressions -- Error logging */ +/* eslint-disable import-x/no-nodejs-modules -- Buffer polyfill */ +/* eslint-disable camelcase -- RPC method names use snake_case */ +/* eslint-disable id-length -- 'e' is clear in catch context */ import type { EIP1193Provider } from '@metamask/connect-evm'; -import { Buffer } from 'buffer'; import { createSignTypedDataParams, getDefaultPersonalSignMessage, } from '@metamask/playground-ui/helpers'; +import { Buffer } from 'buffer'; /** * Sends an eth_signTypedData_v4 request to the provider. @@ -33,7 +40,7 @@ export const send_eth_signTypedData_v4 = async ( return await provider?.request({ method, params }); } catch (e: unknown) { console.log(`eth_signTypedData_v4 error: ${e}`); - return 'Error: ' + e; + return `Error: ${e}`; } }; @@ -47,7 +54,7 @@ export const send_personal_sign = async (provider: EIP1193Provider) => { try { const from = provider.selectedAccount; const message = getDefaultPersonalSignMessage('React Native playground'); - const hexMessage = '0x' + Buffer.from(message, 'utf8').toString('hex'); + const hexMessage = `0x${Buffer.from(message, 'utf8').toString('hex')}`; const sign = await provider.request({ method: 'personal_sign', @@ -56,6 +63,6 @@ export const send_personal_sign = async (provider: EIP1193Provider) => { return sign; } catch (err: unknown) { console.log(`personal_sign error: ${err}`); - return 'Error: ' + err; + return `Error: ${err}`; } }; diff --git a/playground/react-native-playground/src/helpers/solana-method-signatures.ts b/playground/react-native-playground/src/helpers/solana-method-signatures.ts index 7c4818de..977e4db5 100644 --- a/playground/react-native-playground/src/helpers/solana-method-signatures.ts +++ b/playground/react-native-playground/src/helpers/solana-method-signatures.ts @@ -1,9 +1,19 @@ -import { Transaction, PublicKey, SystemProgram, Connection } from '@solana/web3.js'; -import type { Commitment } from '@solana/web3.js'; -import { Buffer } from 'buffer'; +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Demo helpers */ +/* eslint-disable no-restricted-globals -- React Native polyfills process */ -import { FEATURED_NETWORKS } from '@metamask/playground-ui/constants'; +/* eslint-disable jsdoc/require-param-description -- Demo helpers */ +/* eslint-disable jsdoc/require-returns -- Demo helpers */ +/* eslint-disable import-x/no-nodejs-modules -- Buffer polyfill */ import { getConfig } from '@metamask/playground-ui/config'; +import { FEATURED_NETWORKS } from '@metamask/playground-ui/constants'; +import { + Transaction, + PublicKey, + SystemProgram, + Connection, +} from '@solana/web3.js'; +import type { Commitment } from '@solana/web3.js'; +import { Buffer } from 'buffer'; const getSolanaRpcConfig = () => { const config = getConfig(); @@ -68,7 +78,9 @@ const generateBase64Transaction = async (address: string) => { }); // React Native uses Buffer for base64 encoding - const base64Transaction = Buffer.from(serializedTransaction).toString('base64'); + const base64Transaction = Buffer.from(serializedTransaction).toString( + 'base64', + ); return base64Transaction; }; @@ -76,6 +88,8 @@ const generateBase64Transaction = async (address: string) => { /** * Converts a string to base64 for React Native. * Uses Buffer which is available in React Native via the buffer polyfill. + * + * @param str */ const stringToBase64 = (str: string): string => { const buffer = Buffer.from(str, 'utf8'); diff --git a/playground/react-native-playground/src/sdk/index.ts b/playground/react-native-playground/src/sdk/index.ts index bf45ccd7..8ad7513c 100644 --- a/playground/react-native-playground/src/sdk/index.ts +++ b/playground/react-native-playground/src/sdk/index.ts @@ -1,3 +1,2 @@ export { useSDK } from './SDKProvider'; export { useLegacyEVMSDK } from './LegacyEVMSDKProvider'; - diff --git a/playground/react-native-playground/src/styles/shared.ts b/playground/react-native-playground/src/styles/shared.ts index 5a5290fc..3fa134f0 100644 --- a/playground/react-native-playground/src/styles/shared.ts +++ b/playground/react-native-playground/src/styles/shared.ts @@ -1,306 +1,306 @@ +// eslint-disable-next-line @typescript-eslint/no-shadow -- StyleSheet is not a global in React Native import { StyleSheet } from 'react-native'; // Color palette export const colors = { - // Primary colors - blue500: '#3B82F6', - blue600: '#2563EB', - blue700: '#1D4ED8', + // Primary colors + blue500: '#3B82F6', + blue600: '#2563EB', + blue700: '#1D4ED8', - // Purple colors - purple50: '#FAF5FF', - purple600: '#9333EA', - purple700: '#7E22CE', + // Purple colors + purple50: '#FAF5FF', + purple600: '#9333EA', + purple700: '#7E22CE', - // Gray colors - gray50: '#F9FAFB', - gray100: '#F3F4F6', - gray200: '#E5E7EB', - gray300: '#D1D5DB', - gray400: '#9CA3AF', - gray500: '#6B7280', - gray600: '#4B5563', - gray700: '#374151', - gray800: '#1F2937', + // Gray colors + gray50: '#F9FAFB', + gray100: '#F3F4F6', + gray200: '#E5E7EB', + gray300: '#D1D5DB', + gray400: '#9CA3AF', + gray500: '#6B7280', + gray600: '#4B5563', + gray700: '#374151', + gray800: '#1F2937', - // Green colors - green50: '#F0FDF4', - green100: '#DCFCE7', - green200: '#BBF7D0', - green600: '#16A34A', - green700: '#15803D', - green800: '#166534', + // Green colors + green50: '#F0FDF4', + green100: '#DCFCE7', + green200: '#BBF7D0', + green600: '#16A34A', + green700: '#15803D', + green800: '#166534', - // Red colors - red50: '#FEF2F2', - red100: '#FEE2E2', - red200: '#FECACA', - red600: '#DC2626', - red700: '#B91C1C', + // Red colors + red50: '#FEF2F2', + red100: '#FEE2E2', + red200: '#FECACA', + red600: '#DC2626', + red700: '#B91C1C', - // White and black - white: '#FFFFFF', - black: '#000000', + // White and black + white: '#FFFFFF', + black: '#000000', - // Slate colors - slate800: '#1E293B', + // Slate colors + slate800: '#1E293B', }; export const sharedStyles = StyleSheet.create({ - // Container styles - container: { - flex: 1, - backgroundColor: colors.gray50, - }, - safeArea: { - flex: 1, - backgroundColor: colors.gray50, - }, - scrollContainer: { - padding: 16, - }, + // Container styles + container: { + flex: 1, + backgroundColor: colors.gray50, + }, + safeArea: { + flex: 1, + backgroundColor: colors.gray50, + }, + scrollContainer: { + padding: 16, + }, - // Card styles - card: { - backgroundColor: colors.white, - borderRadius: 8, - padding: 16, - marginBottom: 16, - shadowColor: colors.black, - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.05, - shadowRadius: 2, - elevation: 1, - }, + // Card styles + card: { + backgroundColor: colors.white, + borderRadius: 8, + padding: 16, + marginBottom: 16, + shadowColor: colors.black, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, - // Text styles - heading1: { - fontSize: 28, - fontWeight: 'bold', - color: colors.slate800, - marginBottom: 16, - textAlign: 'center', - }, - heading2: { - fontSize: 20, - fontWeight: 'bold', - color: colors.gray800, - marginBottom: 12, - }, - heading3: { - fontSize: 16, - fontWeight: '600', - color: colors.gray800, - marginBottom: 8, - }, - text: { - fontSize: 14, - color: colors.gray700, - }, - textSmall: { - fontSize: 12, - color: colors.gray600, - }, - textMono: { - fontFamily: 'monospace', - fontSize: 12, - }, + // Text styles + heading1: { + fontSize: 28, + fontWeight: 'bold', + color: colors.slate800, + marginBottom: 16, + textAlign: 'center', + }, + heading2: { + fontSize: 20, + fontWeight: 'bold', + color: colors.gray800, + marginBottom: 12, + }, + heading3: { + fontSize: 16, + fontWeight: '600', + color: colors.gray800, + marginBottom: 8, + }, + text: { + fontSize: 14, + color: colors.gray700, + }, + textSmall: { + fontSize: 12, + color: colors.gray600, + }, + textMono: { + fontFamily: 'monospace', + fontSize: 12, + }, - // Button styles - button: { - backgroundColor: colors.blue500, - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 6, - alignItems: 'center', - justifyContent: 'center', - }, + // Button styles + button: { + backgroundColor: colors.blue500, + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, buttonCancel: { - backgroundColor: colors.red600, - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 6, - alignItems: 'center', - justifyContent: 'center', - }, - buttonText: { - color: colors.white, - fontSize: 16, - fontWeight: '500', - }, - buttonDisabled: { - backgroundColor: colors.gray300, - }, - buttonTextDisabled: { - color: colors.gray500, - }, + backgroundColor: colors.red600, + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + color: colors.white, + fontSize: 16, + fontWeight: '500', + }, + buttonDisabled: { + backgroundColor: colors.gray300, + }, + buttonTextDisabled: { + color: colors.gray500, + }, - // Input styles - input: { - borderWidth: 1, - borderColor: colors.gray300, - borderRadius: 6, - padding: 12, - fontSize: 14, - backgroundColor: colors.white, - color: colors.gray800, - }, - textArea: { - borderWidth: 1, - borderColor: colors.gray300, - borderRadius: 6, - padding: 12, - fontSize: 12, - backgroundColor: colors.gray50, - fontFamily: 'monospace', - minHeight: 200, - textAlignVertical: 'top', - }, + // Input styles + input: { + borderWidth: 1, + borderColor: colors.gray300, + borderRadius: 6, + padding: 12, + fontSize: 14, + backgroundColor: colors.white, + color: colors.gray800, + }, + textArea: { + borderWidth: 1, + borderColor: colors.gray300, + borderRadius: 6, + padding: 12, + fontSize: 12, + backgroundColor: colors.gray50, + fontFamily: 'monospace', + minHeight: 200, + textAlignVertical: 'top', + }, - // Badge styles - badge: { - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 12, - alignSelf: 'flex-start', - }, - badgeText: { - fontSize: 12, - fontWeight: '500', - }, - badgeBlue: { - backgroundColor: colors.blue500, - }, - badgeTextBlue: { - color: colors.blue700, - }, - badgePurple: { - backgroundColor: colors.purple50, - }, - badgeTextPurple: { - color: colors.purple700, - }, - badgeGreen: { - backgroundColor: colors.green100, - }, - badgeTextGreen: { - color: colors.green600, - }, - badgeRed: { - backgroundColor: colors.red100, - }, - badgeTextRed: { - color: colors.red600, - }, + // Badge styles + badge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + alignSelf: 'flex-start', + }, + badgeText: { + fontSize: 12, + fontWeight: '500', + }, + badgeBlue: { + backgroundColor: colors.blue500, + }, + badgeTextBlue: { + color: colors.blue700, + }, + badgePurple: { + backgroundColor: colors.purple50, + }, + badgeTextPurple: { + color: colors.purple700, + }, + badgeGreen: { + backgroundColor: colors.green100, + }, + badgeTextGreen: { + color: colors.green600, + }, + badgeRed: { + backgroundColor: colors.red100, + }, + badgeTextRed: { + color: colors.red600, + }, - // Checkbox/Switch styles - checkboxContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 8, - paddingHorizontal: 8, - borderRadius: 6, - marginRight: 8, - marginBottom: 8, - }, - checkboxLabel: { - marginLeft: 8, - fontSize: 14, - color: colors.gray700, - }, + // Checkbox/Switch styles + checkboxContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 8, + borderRadius: 6, + marginRight: 8, + marginBottom: 8, + }, + checkboxLabel: { + marginLeft: 8, + fontSize: 14, + color: colors.gray700, + }, - // Layout helpers - row: { - flexDirection: 'row', - alignItems: 'center', - }, - rowWrap: { - flexDirection: 'row', - flexWrap: 'wrap', - }, - spaceBetween: { - justifyContent: 'space-between', - }, - marginBottom: { - marginBottom: 12, - }, - marginTop: { - marginTop: 12, - }, + // Layout helpers + row: { + flexDirection: 'row', + alignItems: 'center', + }, + rowWrap: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + spaceBetween: { + justifyContent: 'space-between', + }, + marginBottom: { + marginBottom: 12, + }, + marginTop: { + marginTop: 12, + }, - // Picker/Select styles - pickerContainer: { - borderWidth: 1, - borderColor: colors.gray300, - borderRadius: 6, - backgroundColor: colors.white, - marginBottom: 12, - }, - picker: { - height: 50, - color: colors.gray800, - }, + // Picker/Select styles + pickerContainer: { + borderWidth: 1, + borderColor: colors.gray300, + borderRadius: 6, + backgroundColor: colors.white, + marginBottom: 12, + }, + picker: { + height: 50, + color: colors.gray800, + }, - // Collapsible styles - collapsibleHeader: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 16, - backgroundColor: colors.gray50, - borderRadius: 6, - borderWidth: 1, - borderColor: colors.gray200, - }, - collapsibleHeaderText: { - fontSize: 14, - fontWeight: '500', - color: colors.gray700, - marginLeft: 8, - }, - collapsibleContent: { - padding: 16, - backgroundColor: colors.white, - borderWidth: 1, - borderTopWidth: 0, - borderColor: colors.gray200, - borderBottomLeftRadius: 6, - borderBottomRightRadius: 6, - }, + // Collapsible styles + collapsibleHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + backgroundColor: colors.gray50, + borderRadius: 6, + borderWidth: 1, + borderColor: colors.gray200, + }, + collapsibleHeaderText: { + fontSize: 14, + fontWeight: '500', + color: colors.gray700, + marginLeft: 8, + }, + collapsibleContent: { + padding: 16, + backgroundColor: colors.white, + borderWidth: 1, + borderTopWidth: 0, + borderColor: colors.gray200, + borderBottomLeftRadius: 6, + borderBottomRightRadius: 6, + }, - // Result styles - resultContainer: { - marginTop: 12, - borderRadius: 6, - borderWidth: 1, - borderColor: colors.gray200, - backgroundColor: colors.white, - }, - resultHeader: { - padding: 12, - borderBottomWidth: 1, - borderBottomColor: colors.gray200, - }, - resultHeaderError: { - backgroundColor: colors.red50, - }, - resultContent: { - padding: 12, - }, - resultCode: { - backgroundColor: colors.gray50, - padding: 12, - borderRadius: 4, - borderWidth: 1, - borderColor: colors.gray200, - }, - resultCodeText: { - fontFamily: 'monospace', - fontSize: 12, - color: colors.gray800, - }, - resultCodeTextError: { - color: colors.red600, - }, + // Result styles + resultContainer: { + marginTop: 12, + borderRadius: 6, + borderWidth: 1, + borderColor: colors.gray200, + backgroundColor: colors.white, + }, + resultHeader: { + padding: 12, + borderBottomWidth: 1, + borderBottomColor: colors.gray200, + }, + resultHeaderError: { + backgroundColor: colors.red50, + }, + resultContent: { + padding: 12, + }, + resultCode: { + backgroundColor: colors.gray50, + padding: 12, + borderRadius: 4, + borderWidth: 1, + borderColor: colors.gray200, + }, + resultCodeText: { + fontFamily: 'monospace', + fontSize: 12, + color: colors.gray800, + }, + resultCodeTextError: { + color: colors.red600, + }, }); - diff --git a/playground/react-native-playground/src/wagmi/config.ts b/playground/react-native-playground/src/wagmi/config.ts index d215a18d..ea16b304 100644 --- a/playground/react-native-playground/src/wagmi/config.ts +++ b/playground/react-native-playground/src/wagmi/config.ts @@ -1,3 +1,7 @@ +/* eslint-disable no-restricted-globals -- React Native polyfills window */ +/* eslint-disable no-negated-condition -- Clearer pattern for undefined checks */ +/* eslint-disable import-x/no-unassigned-import -- Polyfill import */ + // Ensure polyfills are loaded first (especially window.addEventListener) import '../../polyfills'; @@ -10,8 +14,14 @@ import { metaMask } from './metamask-connector'; // Use window polyfill for React Native // The polyfill is set up in polyfills.ts -const windowHostname = typeof window !== 'undefined' ? window.location.hostname : 'react-native-playground'; -const windowHref = typeof window !== 'undefined' ? window.location.href : 'react-native-playground://'; +const windowHostname = + typeof window !== 'undefined' + ? window.location.hostname + : 'react-native-playground'; +const windowHref = + typeof window !== 'undefined' + ? window.location.href + : 'react-native-playground://'; export const wagmiConfig = createConfig({ chains: [mainnet, sepolia, optimism, celo], @@ -24,8 +34,8 @@ export const wagmiConfig = createConfig({ // React Native: use Linking.openURL for deeplinks instead of window.location.href mobile: { preferredOpenLink: (deeplink: string) => { - Linking.openURL(deeplink).catch((err) => { - console.error('Failed to open deeplink:', err); + Linking.openURL(deeplink).catch((error) => { + console.error('Failed to open deeplink:', error); }); }, }, @@ -40,6 +50,7 @@ export const wagmiConfig = createConfig({ }); declare module 'wagmi' { + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Register { config: typeof wagmiConfig; } diff --git a/playground/react-native-playground/src/wagmi/metamask-connector.ts b/playground/react-native-playground/src/wagmi/metamask-connector.ts index 34106cb0..739dd7d9 100644 --- a/playground/react-native-playground/src/wagmi/metamask-connector.ts +++ b/playground/react-native-playground/src/wagmi/metamask-connector.ts @@ -1,3 +1,4 @@ +/* eslint-disable -- AUTO-GENERATED FILE */ /** * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY * @@ -16,6 +17,17 @@ */ // @ts-nocheck +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Wagmi connector API */ +/* eslint-disable no-restricted-globals -- Browser connector uses window */ +/* eslint-disable @typescript-eslint/no-misused-promises -- Event handlers are async */ +/* eslint-disable require-atomic-updates -- Race conditions are acceptable for caching */ +/* eslint-disable id-denylist -- 'err' is clear in catch context */ +/* eslint-disable id-length -- 'x' is clear in lambda context */ +/* eslint-disable @typescript-eslint/no-shadow -- accounts shadow is intentional */ +/* eslint-disable no-nested-ternary -- Ternary chain is clearer here */ +/* eslint-disable jsdoc/require-param-description -- Wagmi connector API */ +/* eslint-disable jsdoc/require-returns -- Wagmi connector API */ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- Provider is guaranteed after check */ import type { createEVMClient, EIP1193Provider, @@ -64,6 +76,10 @@ export type MetaMaskParameters = UnionCompute< type CreateEVMClientParameters = Parameters[0]; metaMask.type = 'metaMask' as const; +/** + * + * @param parameters + */ export function metaMask(parameters: MetaMaskParameters = {}) { type Provider = EIP1193Provider; type Properties = { @@ -85,7 +101,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { const provider = instance.getProvider(); let accounts: readonly Address[] = []; - if (isReconnecting) accounts = await this.getAccounts().catch(() => []); + if (isReconnecting) { + accounts = await this.getAccounts().catch(() => []); + } try { let signResponse: string | undefined; @@ -93,17 +111,18 @@ export function metaMask(parameters: MetaMaskParameters = {}) { if (!accounts?.length) { const chainIds = config.chains.map((chain) => chain.id); if (parameters.connectAndSign || parameters.connectWith) { - if (parameters.connectAndSign) + if (parameters.connectAndSign) { signResponse = await instance.connectAndSign({ chainIds, message: parameters.connectAndSign, }); - else if (parameters.connectWith) + } else if (parameters.connectWith) { connectWithResponse = await instance.connectWith({ chainIds, method: parameters.connectWith.method, params: parameters.connectWith.params, }); + } accounts = await this.getAccounts(); } else { @@ -112,27 +131,30 @@ export function metaMask(parameters: MetaMaskParameters = {}) { } } // Switch to chain if provided - let currentChainId = (await this.getChainId()) as number; + let currentChainId = await this.getChainId(); if (chainId && currentChainId !== chainId) { const chain = await this.switchChain!({ chainId }).catch((error) => { - if (error.code === UserRejectedRequestError.code) throw error; + if (error.code === UserRejectedRequestError.code) { + throw error; + } return { id: currentChainId }; }); currentChainId = chain?.id ?? currentChainId; } - if (signResponse) + if (signResponse) { provider.emit('connectAndSign', { accounts, chainId: currentChainId, signResponse, }); - else if (connectWithResponse) + } else if (connectWithResponse) { provider.emit('connectWith', { accounts, chainId: currentChainId, connectWithResponse, }); + } return { // TODO(v3): Make `withCapabilities: true` default behavior @@ -143,10 +165,12 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }; } catch (err) { const error = err as RpcError; - if (error.code === UserRejectedRequestError.code) + if (error.code === UserRejectedRequestError.code) { throw new UserRejectedRequestError(error); - if (error.code === ResourceUnavailableRpcError.code) + } + if (error.code === ResourceUnavailableRpcError.code) { throw new ResourceUnavailableRpcError(error); + } throw error; } }, @@ -156,8 +180,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async getAccounts() { const instance = await this.getInstance(); - if (instance.accounts.length) + if (instance.accounts.length) { return instance.accounts.map((x) => getAddress(x)); + } // Fallback to provider if SDK doesn't return accounts const provider = instance.getProvider(); const accounts = (await provider.request({ @@ -167,7 +192,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async getChainId() { const instance = await this.getInstance(); - if (instance.getChainId()) return Number(instance.getChainId()); + if (instance.getChainId()) { + return Number(instance.getChainId()); + } // Fallback to provider if SDK doesn't return chainId const provider = instance.getProvider(); const chainId = await provider.request({ method: 'eth_chainId' }); @@ -183,25 +210,29 @@ export function metaMask(parameters: MetaMaskParameters = {}) { // JSON-RPC requests on page load const timeout = 10; const accounts = await withRetry( - () => + async () => withTimeout( async () => { const accounts = await this.getAccounts(); - if (!accounts.length) throw new Error('try again'); + if (!accounts.length) { + throw new Error('try again'); + } return accounts; }, { timeout }, ), { delay: timeout + 1, retryCount: 3 }, ); - return !!accounts.length; + return Boolean(accounts.length); } catch { return false; } }, async switchChain({ addEthereumChainParameter, chainId }) { const chain = config.chains.find(({ id }) => id === chainId); - if (!chain) throw new SwitchChainError(new ChainNotConfiguredError()); + if (!chain) { + throw new SwitchChainError(new ChainNotConfiguredError()); + } try { const instance = await this.getInstance(); @@ -229,8 +260,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { } catch (err) { const error = err as RpcError; - if (error.code === UserRejectedRequestError.code) + if (error.code === UserRejectedRequestError.code) { throw new UserRejectedRequestError(error); + } throw new SwitchChainError(error); } @@ -246,7 +278,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { }, async onConnect(connectInfo) { const accounts = await this.getAccounts(); - if (accounts.length === 0) return; + if (accounts.length === 0) { + return; + } const chainId = Number(connectInfo.chainId); config.emitter.emit('connect', { accounts, chainId }); @@ -256,7 +290,9 @@ export function metaMask(parameters: MetaMaskParameters = {}) { // https://github.com/MetaMask/providers/pull/120 if (error && (error as RpcError<1013>).code === 1013) { const provider = await this.getProvider(); - if (provider && !!(await this.getAccounts()).length) return; + if (provider && Boolean((await this.getAccounts()).length)) { + return; + } } config.emitter.emit('disconnect'); @@ -267,7 +303,7 @@ export function metaMask(parameters: MetaMaskParameters = {}) { async getInstance() { if (!metamask) { if (!metamaskPromise) { - const { createEVMClient } = await (() => { + const { createEVMClient } = await (async () => { try { return import('@metamask/connect-evm'); } catch { @@ -284,16 +320,24 @@ export function metaMask(parameters: MetaMaskParameters = {}) { ), }, dapp: (() => { - if (parameters.dappMetadata) return parameters.dappMetadata; - if (parameters.dapp) return parameters.dapp; - if (typeof window === 'undefined') return { name: 'wagmi' }; + if (parameters.dappMetadata) { + return parameters.dappMetadata; + } + if (parameters.dapp) { + return parameters.dapp; + } + if (typeof window === 'undefined') { + return { name: 'wagmi' }; + } return { name: window.location.hostname, url: window.location.href, }; })(), debug: (() => { - if (parameters.logging) return true; + if (parameters.logging) { + return true; + } return parameters.debug; })(), eventHandlers: { diff --git a/playground/react-native-playground/tsconfig.json b/playground/react-native-playground/tsconfig.json index 79604062..934e6df6 100644 --- a/playground/react-native-playground/tsconfig.json +++ b/playground/react-native-playground/tsconfig.json @@ -1,20 +1,22 @@ { - "extends": "expo/tsconfig.base", + "extends": "expo/tsconfig.base", "compilerOptions": { - "composite": true, - "noEmit": false, - "strict": true, - "jsx": "react-native", + "composite": true, + "noEmit": false, + "strict": true, + "jsx": "react-native", "paths": { - "@/*": ["./*"], - "@metamask/connect-multichain": ["../../packages/connect-multichain/src/index.native.ts"], - "@metamask/playground-ui": ["../playground-ui/dist/es/playground-ui.d.mts"], + "@/*": ["./*"], + "@metamask/connect-multichain": [ + "../../packages/connect-multichain/src/index.native.ts" + ], + "@metamask/playground-ui": [ + "../playground-ui/dist/es/playground-ui.d.mts" + ], "@metamask/playground-ui/*": ["../playground-ui/dist/es/*.d.mts"], "@metamask/*": ["../../*/src"] } - }, - "references": [ - { "path": "../../packages/connect-multichain" } - ], - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] + }, + "references": [{ "path": "../../packages/connect-multichain" }], + "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] } diff --git a/scripts/create-package/cli.ts b/scripts/create-package/cli.ts index dbff88cd..9b437f0e 100644 --- a/scripts/create-package/cli.ts +++ b/scripts/create-package/cli.ts @@ -27,7 +27,7 @@ export default async function cli( // Trim all strings and ensure they are not empty. for (const key in args) { if (typeof args[key] === 'string') { - args[key] = (args[key] as string).trim(); + args[key] = args[key].trim(); if (args[key] === '') { throw new Error(