diff --git a/demo/vite-react-app-solana/package-lock.json b/demo/vite-react-app-solana/package-lock.json index 68e5f97e5..055ff77c2 100644 --- a/demo/vite-react-app-solana/package-lock.json +++ b/demo/vite-react-app-solana/package-lock.json @@ -27,13 +27,13 @@ }, "../../packages/modal": { "name": "@web3auth/modal", - "version": "11.0.0-beta.2", + "version": "11.0.1", "dependencies": { "@hcaptcha/react-hcaptcha": "^2.0.2", - "@toruslabs/base-controllers": "^9.8.0", + "@toruslabs/base-controllers": "^9.10.0", "@toruslabs/http-helpers": "^9.0.0", - "@web3auth/auth": "^11.8.0", - "@web3auth/no-modal": "^11.0.0-beta.2", + "@web3auth/auth": "^11.8.1", + "@web3auth/no-modal": "^11.0.1", "@web3auth/ws-embed": "^6.0.4", "bowser": "^2.14.1", "classnames": "^2.5.1", @@ -43,44 +43,44 @@ "deepmerge": "^4.3.1", "i18next": "^25.10.9", "react-i18next": "^16.6.6", - "react-qrcode-logo": "^4.0.0", - "tailwind-merge": "^3.5.0", - "vitest": "^4.1.5" + "react-qrcode-logo": "^4.1.0", + "tailwind-merge": "^3.6.0", + "vitest": "^4.1.8" }, "devDependencies": { - "@babel/preset-react": "^7.28.5", + "@babel/preset-react": "^7.29.7", "@coinbase/wallet-sdk": "^4.3.7", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-url": "^8.0.2", "@solana/client": "^1.7.0", - "@solana/kit": "^6.8.0", + "@solana/kit": "^6.9.0", "@svgr/rollup": "^8.1.0", "@svgr/webpack": "^8.1.0", - "@tailwindcss/postcss": "^4.2.4", - "@toruslabs/eslint-config-react": "^5.0.1", - "@toruslabs/eslint-config-vue": "^5.0.1", + "@tailwindcss/postcss": "^4.3.0", + "@toruslabs/eslint-config-react": "^5.0.2", + "@toruslabs/eslint-config-vue": "^5.0.2", "@toruslabs/isomorphic-style-loader": "^5.4.0", "@toruslabs/vue-components": "^9.0.1", - "@types/react": "^19.2.14", + "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", - "@wagmi/core": "^3.4.7", - "@wagmi/vue": "^0.5.9", + "@wagmi/core": "^3.5.0", + "@wagmi/vue": "^0.5.17", "css-loader": "^7.1.4", "live-server": "^1.2.2", - "postcss": "^8.5.12", + "postcss": "^8.5.15", "postcss-loader": "^8.2.1", "postcss-prefix-selector": "^2.1.1", - "react": "^19.2.5", - "react-dom": "^19.2.5", + "react": "^19.2.7", + "react-dom": "^19.2.7", "rollup-plugin-postcss": "^4.0.2", "rollup-preserve-directives": "^1.1.3", "style-loader": "^4.0.0", - "tailwindcss": "^4.2.4", + "tailwindcss": "^4.3.0", "url-loader": "^4.1.1", - "viem": "^2.48.4", - "vue": "^3.5.33", - "wagmi": "^3.6.8" + "viem": "^2.52.0", + "vue": "^3.5.35", + "wagmi": "^3.6.16" }, "engines": { "node": ">=22.x", diff --git a/demo/vue-app-new/src/components/AppDashboard.vue b/demo/vue-app-new/src/components/AppDashboard.vue index b804971ca..36441d27a 100644 --- a/demo/vue-app-new/src/components/AppDashboard.vue +++ b/demo/vue-app-new/src/components/AppDashboard.vue @@ -17,7 +17,15 @@ import { CONNECTOR_INITIAL_AUTHENTICATION_MODE } from "@web3auth/no-modal"; import { useI18n } from "petite-vue-i18n"; import { useSignMessage as useSolanaSignMessage, useSolanaWallet, useSolanaClient } from "@web3auth/modal/vue/solana"; -import { useConnection, useBalance, useSignMessage, useSignTypedData, useSwitchChain as useWagmiSwitchChain, useConfig } from "@wagmi/vue"; +import { + useConnection, + useBalance, + useSignMessage, + useSignTypedData, + useSwitchChain as useWagmiSwitchChain, + useConfig, + useChainId, +} from "@wagmi/vue"; import { getCapabilities, getCallsStatus, sendCalls, showCallsStatus } from "@wagmi/core"; import { parseEther } from "viem"; import { createWalletTransactionSigner, toAddress } from "@solana/client"; @@ -28,6 +36,7 @@ import AccountLinkingSection from "./AccountLinkingSection.vue"; import X402Tester from "./X402Tester.vue"; import { getPrivateKey, sendEth, sendEthWithSmartAccount, signTransaction as signEthTransaction } from "../services/ethHandlers"; import { formDataStore } from "../store/form"; +import { numberToHex } from "viem/utils"; const { t } = useI18n({ useScope: "global" }); @@ -38,7 +47,7 @@ const { loading: userInfoLoading, getUserInfo } = useWeb3AuthUser(); const { enableMFA } = useEnableMFA(); const { manageMFA } = useManageMFA(); const { mutateAsync: switchChainAsync } = useWagmiSwitchChain(); - +const wagmiConnectedChainId = useChainId(); const { showWalletUI, loading: showWalletUILoading } = useWalletUI(); const { showWalletConnectScanner, loading: showWalletConnectScannerLoading } = useWalletConnectScanner(); const { showCheckout, loading: showCheckoutLoading } = useCheckout(); @@ -174,7 +183,10 @@ const onGetPrivateKey = async () => { }; const getConnectedChainId = async () => { - printToConsole("chainId", web3Auth.value?.currentChain?.chainId); + printToConsole("chainId", { + web3AuthChainId: web3Auth.value?.currentChain?.chainId, + wagmiChainId: numberToHex(wagmiConnectedChainId.value), + }); }; const onGetBalance = async () => { @@ -375,7 +387,6 @@ const canSwitchChainNamespace = computed(() => { }); const onSwitchChain = async () => { - log.info("switching chain"); try { const chainId = connection.value?.ethereumProvider?.chainId; if (!chainId) throw new Error("No ethereum provider chainId"); @@ -383,6 +394,7 @@ const onSwitchChain = async () => { const newChain = eip155Chains.value.find((c) => c.chainId !== chainId); if (!newChain) throw new Error("Please configure at least 2 EVM chains in the config"); + log.info("switching chain", newChain.chainId); const data = await switchChainAsync({ chainId: Number(newChain.chainId) }); printToConsole("switchedChain", { chainId: data.id }); } catch (error) { @@ -429,7 +441,7 @@ const onSwitchChainNamespace = async () => { {{ $t("app.buttons.btnClearConsole") }} -
+
diff --git a/demo/vue-app-new/src/components/X402Tester.vue b/demo/vue-app-new/src/components/X402Tester.vue index cd7a23088..86049e38c 100644 --- a/demo/vue-app-new/src/components/X402Tester.vue +++ b/demo/vue-app-new/src/components/X402Tester.vue @@ -9,7 +9,7 @@ import { computed, ref, watch } from "vue"; const BASE_SEPOLIA_CHAIN_ID = "0x14a34"; // 84532 const SOLANA_DEVNET_CHAIN_ID = "0x67"; // 103 const SOLANA_DEVNET_CAIP_CHAIN_ID = `solana:${Number(SOLANA_DEVNET_CHAIN_ID)}`; -const DEFAULT_X402_URL = "https://web3auth-dev-demo-x420.sapphire-dev-2-1.authnetwork.dev"; +const DEFAULT_X402_URL = "https://web3auth-dev-demo-x402.node-1.dev-node.web3auth.io/weather-plain"; const { isConnected, connection, web3Auth } = useWeb3Auth(); const { chainId, chainNamespace } = useChain(); diff --git a/demo/wagmi-react-app/package-lock.json b/demo/wagmi-react-app/package-lock.json index 231e1aae1..a255134b7 100644 --- a/demo/wagmi-react-app/package-lock.json +++ b/demo/wagmi-react-app/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@tanstack/react-query": "^5.95.2", - "@web3auth/auth": "^11.6.0", + "@web3auth/auth": "^11.8.1", "@web3auth/modal": "file:../../packages/modal", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -32,13 +32,13 @@ }, "../../packages/modal": { "name": "@web3auth/modal", - "version": "11.0.0-beta.1", + "version": "11.0.1", "dependencies": { "@hcaptcha/react-hcaptcha": "^2.0.2", - "@toruslabs/base-controllers": "^9.8.0", + "@toruslabs/base-controllers": "^9.10.0", "@toruslabs/http-helpers": "^9.0.0", - "@web3auth/auth": "^11.8.0", - "@web3auth/no-modal": "^11.0.0-beta.1", + "@web3auth/auth": "^11.8.1", + "@web3auth/no-modal": "^11.0.1", "@web3auth/ws-embed": "^6.0.4", "bowser": "^2.14.1", "classnames": "^2.5.1", @@ -48,44 +48,44 @@ "deepmerge": "^4.3.1", "i18next": "^25.10.9", "react-i18next": "^16.6.6", - "react-qrcode-logo": "^4.0.0", - "tailwind-merge": "^3.5.0", - "vitest": "^4.1.5" + "react-qrcode-logo": "^4.1.0", + "tailwind-merge": "^3.6.0", + "vitest": "^4.1.8" }, "devDependencies": { - "@babel/preset-react": "^7.28.5", + "@babel/preset-react": "^7.29.7", "@coinbase/wallet-sdk": "^4.3.7", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-url": "^8.0.2", "@solana/client": "^1.7.0", - "@solana/kit": "^6.8.0", + "@solana/kit": "^6.9.0", "@svgr/rollup": "^8.1.0", "@svgr/webpack": "^8.1.0", - "@tailwindcss/postcss": "^4.2.4", - "@toruslabs/eslint-config-react": "^5.0.1", - "@toruslabs/eslint-config-vue": "^5.0.1", + "@tailwindcss/postcss": "^4.3.0", + "@toruslabs/eslint-config-react": "^5.0.2", + "@toruslabs/eslint-config-vue": "^5.0.2", "@toruslabs/isomorphic-style-loader": "^5.4.0", "@toruslabs/vue-components": "^9.0.1", - "@types/react": "^19.2.14", + "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", - "@wagmi/core": "^3.4.7", - "@wagmi/vue": "^0.5.9", + "@wagmi/core": "^3.5.0", + "@wagmi/vue": "^0.5.17", "css-loader": "^7.1.4", "live-server": "^1.2.2", - "postcss": "^8.5.12", + "postcss": "^8.5.15", "postcss-loader": "^8.2.1", "postcss-prefix-selector": "^2.1.1", - "react": "^19.2.5", - "react-dom": "^19.2.5", + "react": "^19.2.7", + "react-dom": "^19.2.7", "rollup-plugin-postcss": "^4.0.2", "rollup-preserve-directives": "^1.1.3", "style-loader": "^4.0.0", - "tailwindcss": "^4.2.4", + "tailwindcss": "^4.3.0", "url-loader": "^4.1.1", - "viem": "^2.48.4", - "vue": "^3.5.33", - "wagmi": "^3.6.8" + "viem": "^2.52.0", + "vue": "^3.5.35", + "wagmi": "^3.6.16" }, "engines": { "node": ">=22.x", @@ -2481,9 +2481,9 @@ } }, "node_modules/@web3auth/auth": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@web3auth/auth/-/auth-11.6.0.tgz", - "integrity": "sha512-yprtRG0RoGLK6cEdOfOnVIgqprfc1LWLoIgs5u+IegYOEvPPLE/1v4KzvtiFRwEdUfghmVCHgn6p4TNgUxpiCw==", + "version": "11.8.1", + "resolved": "https://registry.npmjs.org/@web3auth/auth/-/auth-11.8.1.tgz", + "integrity": "sha512-ei+PYJ2BYFWuibrKx/cwbx7ndpC24RTVcHxdJ/uzaCzVtx2vc7cbZu6Uh4HdZFyHM5w2Ct0dzlFX6xmd7rG2TQ==", "license": "MIT", "dependencies": { "@toruslabs/constants": "^16.1.1", diff --git a/demo/wagmi-react-app/package.json b/demo/wagmi-react-app/package.json index af7d74147..028d8750f 100644 --- a/demo/wagmi-react-app/package.json +++ b/demo/wagmi-react-app/package.json @@ -4,7 +4,7 @@ "private": true, "dependencies": { "@tanstack/react-query": "^5.95.2", - "@web3auth/auth": "^11.6.0", + "@web3auth/auth": "^11.8.1", "@web3auth/modal": "file:../../packages/modal", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/demo/wagmi-react-app/src/components/X402.tsx b/demo/wagmi-react-app/src/components/X402.tsx index a79ce1075..509fb9977 100644 --- a/demo/wagmi-react-app/src/components/X402.tsx +++ b/demo/wagmi-react-app/src/components/X402.tsx @@ -7,7 +7,7 @@ import { useWalletClient } from "wagmi"; import styles from "../styles/Home.module.css"; -const X402_URL = import.meta.env.VITE_APP_X402_TEST_CONTENT_URL || "https://x402.org/protected"; +const X402_URL = "https://web3auth-dev-demo-x402.node-1.dev-node.web3auth.io/weather-plain"; const FETCH_OPTIONS: RequestInit = { method: "GET", headers: { "Content-Type": "application/json" } }; // ─── Shared response renderer ──────────────────────────────────────────────── diff --git a/packages/modal/src/modalManager.ts b/packages/modal/src/modalManager.ts index 97a054636..9d57ce68a 100644 --- a/packages/modal/src/modalManager.ts +++ b/packages/modal/src/modalManager.ts @@ -89,7 +89,9 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { private removeAccountLinkingResetOnCloseListener: (() => void) | null = null; - private accountLinkingPickerResolver: ((connectorName: WALLET_CONNECTOR_TYPE | string) => void) | null = null; + private accountLinkingPickerResolver: + | ((selection: { connectorName: WALLET_CONNECTOR_TYPE | string; chainNamespace?: ChainNamespaceType }) => void) + | null = null; private removeAccountLinkingPickerCloseListener: (() => void) | null = null; @@ -365,14 +367,23 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { // Pre-flight: ensure user is connected via AUTH so we fail fast before opening the modal. this.getMainAuthConnector(); - const chainId = this.resolveLinkAccountChainId(params?.chainId); + const resolvedChainConfig = this.resolveLinkAccountChainConfig(params?.chainId); + let resolvedChainId = resolvedChainConfig.chainId; if (!params?.connectorName) { - const pickedConnector = await this.pickWalletForAccountLinking(chainId); - return this.linkAccountWithChosenConnector(pickedConnector, chainId); + const pickedSelection = await this.pickWalletForAccountLinking(resolvedChainId); + // if user picked a different chain namespace from the wallet picker, then we will used the picked chain + if (pickedSelection.chainNamespace) { + if (resolvedChainConfig.chainNamespace !== pickedSelection.chainNamespace) { + resolvedChainId = + this.coreOptions.chains?.find((chain) => chain.chainNamespace === pickedSelection.chainNamespace)?.chainId || resolvedChainId; + return this.linkAccountWithChosenConnector(pickedSelection.connectorName, resolvedChainId); + } + } + return this.linkAccountWithChosenConnector(pickedSelection.connectorName, resolvedChainId); } - return this.linkAccountWithChosenConnector(params.connectorName, chainId); + return this.linkAccountWithChosenConnector(params.connectorName, resolvedChainId); } protected startAccountLinkingModalSession(params: { @@ -868,7 +879,7 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { // to the linking flow instead of running the regular login `connectTo`. if (this.accountLinkingPickerResolver) { const resolver = this.accountLinkingPickerResolver; - resolver(params.connector); + resolver({ connectorName: params.connector, chainNamespace: params.loginParams?.chainNamespace }); return; } try { @@ -1099,7 +1110,9 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { }); } - private pickWalletForAccountLinking(chainId: string): Promise { + private pickWalletForAccountLinking( + chainId: string + ): Promise<{ connectorName: WALLET_CONNECTOR_TYPE | string; chainNamespace?: ChainNamespaceType }> { if (!this.loginModal) throw WalletInitializationError.notReady("Login modal is not initialized"); if (this.accountLinkingPickerResolver) { throw AccountLinkingError.requestFailed("Another account linking picker is already in progress."); @@ -1107,7 +1120,7 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { this.loginModal.startAccountLinkingPicker({ chainId }); - return new Promise((resolve, reject) => { + return new Promise<{ connectorName: WALLET_CONNECTOR_TYPE | string; chainNamespace?: ChainNamespaceType }>((resolve, reject) => { const cleanup = () => { this.accountLinkingPickerResolver = null; if (this.removeAccountLinkingPickerCloseListener) { @@ -1123,9 +1136,9 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { reject(WalletLoginError.popupClosed("User closed the modal")); }; - this.accountLinkingPickerResolver = (connector) => { + this.accountLinkingPickerResolver = (selection) => { cleanup(); - resolve(connector); + resolve(selection); }; this.on(LOGIN_MODAL_EVENTS.MODAL_VISIBILITY, handleVisibility); this.removeAccountLinkingPickerCloseListener = () => { diff --git a/packages/modal/test/modalManager.test.ts b/packages/modal/test/modalManager.test.ts index 888d64c60..d652fb1ed 100644 --- a/packages/modal/test/modalManager.test.ts +++ b/packages/modal/test/modalManager.test.ts @@ -476,6 +476,61 @@ describe("Web3Auth (modal)", () => { expect(response).toEqual(result); }); + it("linkAccount uses the picker-selected namespace chain when multi-chain selection is present", async () => { + const sdk = createSdk({ + chains: [ + { + chainNamespace: CHAIN_NAMESPACES.EIP155, + chainId: "0x1", + rpcTarget: "https://rpc.ankr.com/eth", + displayName: "Ethereum Mainnet", + blockExplorerUrl: "https://etherscan.io", + logo: "https://example.com/eth.png", + ticker: "ETH", + tickerName: "Ethereum", + }, + { + chainNamespace: CHAIN_NAMESPACES.SOLANA, + chainId: "solana-devnet", + rpcTarget: "https://api.devnet.solana.com", + displayName: "Solana Devnet", + blockExplorerUrl: "https://solscan.io", + logo: "https://example.com/sol.png", + ticker: "SOL", + tickerName: "Solana", + }, + ], + }); + const result: LinkAccountResult = { + success: true, + idToken: "linked-id-token", + linkedAccounts: [], + }; + + vi.spyOn(sdk as unknown as { getMainAuthConnector: () => unknown }, "getMainAuthConnector").mockReturnValue({} as never); + vi.spyOn( + sdk as unknown as { + pickWalletForAccountLinking: (chainId: string) => Promise<{ connectorName: string; chainNamespace?: string }>; + }, + "pickWalletForAccountLinking" + ).mockResolvedValue({ + connectorName: "phantom", + chainNamespace: CHAIN_NAMESPACES.SOLANA, + }); + const linkAccountWithChosenConnectorSpy = vi.spyOn( + sdk as unknown as { + linkAccountWithChosenConnector: (connectorName: string, chainId: string) => Promise; + }, + "linkAccountWithChosenConnector" + ); + linkAccountWithChosenConnectorSpy.mockResolvedValue(result); + + const response = await sdk.linkAccount({ chainId: "0x1" } as never); + + expect(linkAccountWithChosenConnectorSpy).toHaveBeenCalledWith("phantom", "solana-devnet"); + expect(response).toEqual(result); + }); + it("initUIConfig merges whitelabel + ui config and deduplicates loginMethodsOrder", () => { const sdk = createSdk({ uiConfig: { diff --git a/packages/no-modal/src/base/connector/interfaces.ts b/packages/no-modal/src/base/connector/interfaces.ts index e7b5b0232..019100c05 100644 --- a/packages/no-modal/src/base/connector/interfaces.ts +++ b/packages/no-modal/src/base/connector/interfaces.ts @@ -122,10 +122,15 @@ export interface IConnector extends SafeEventEmitter { enableMFA(params?: T): Promise; manageMFA(params?: T): Promise; switchChain(params: { chainId: string }): Promise; - getAuthTokenInfo(): Promise; + /** + * `chainId` is optional to keep the connector API backward compatible while still + * allowing multichain connectors to bind auth/signing to the caller's selected chain. + */ + getAuthTokenInfo(chainId?: string): Promise; generateChallengeAndSign( authServerUrl?: string, - accounts?: string[] + accounts?: string[], + chainId?: string ): Promise<{ challenge: string; signature: string; chainNamespace: ChainNamespaceType }>; cleanup?(): Promise; } diff --git a/packages/no-modal/src/connectors/auth-connector/authConnector.ts b/packages/no-modal/src/connectors/auth-connector/authConnector.ts index 16ce8acd4..31b66cfc8 100644 --- a/packages/no-modal/src/connectors/auth-connector/authConnector.ts +++ b/packages/no-modal/src/connectors/auth-connector/authConnector.ts @@ -544,7 +544,7 @@ class AuthConnector extends BaseConnector implements IAuthConne await this.analytics.track(ANALYTICS_EVENTS.ACCOUNT_LINKING_STARTED, trackData); const { accessToken, idToken } = await this.getPrimaryAuthSession(params.authSessionTokens); - const walletProof = await this.createWalletLinkingProof(params.connectorToLink); + const walletProof = await this.createWalletLinkingProof(params.connectorToLink, chainId); const authServerUrl = citadelServerUrl(this.coreOptions.authBuildEnv); const result = await makeAccountLinkingRequest(authServerUrl, accessToken, { @@ -773,7 +773,10 @@ class AuthConnector extends BaseConnector implements IAuthConne throw AccountLinkingError.requestFailed(`Unsupported chain namespace "${matchedAccount.chainNamespace}" for address "${address}".`); } - private async createWalletLinkingProof(connector: IConnector): Promise<{ + private async createWalletLinkingProof( + connector: IConnector, + chainId?: string + ): Promise<{ address: string; challenge: string; signature: string; @@ -785,7 +788,9 @@ class AuthConnector extends BaseConnector implements IAuthConne // user reviews the signature request inside their wallet. Emitted on the isolated wallet // connector (not the auth connector) so it doesn't mutate the global SDK status. connector.emit(CONNECTOR_EVENTS.AUTHORIZING, { connector: connector.name as WALLET_CONNECTOR_TYPE }); - const { challenge, signature, chainNamespace } = await connector.generateChallengeAndSign(); + // Reuse the caller's target chain so multichain wallets generate the linking + // proof for the same namespace they were connected for. + const { challenge, signature, chainNamespace } = await connector.generateChallengeAndSign(undefined, undefined, chainId); const address = await this.getLinkingWalletAddress(connector, chainNamespace); if (chainNamespace === CHAIN_NAMESPACES.EIP155) { diff --git a/packages/no-modal/src/connectors/metamask-connector/metamaskConnector.ts b/packages/no-modal/src/connectors/metamask-connector/metamaskConnector.ts index a85aa9e77..5d8bed065 100644 --- a/packages/no-modal/src/connectors/metamask-connector/metamaskConnector.ts +++ b/packages/no-modal/src/connectors/metamask-connector/metamaskConnector.ts @@ -30,6 +30,7 @@ import { type ConnectorInitOptions, type ConnectorNamespaceType, type ConnectorParams, + CustomChainConfig, getCaipChainId, getSolanaChainByChainConfig, type IProvider, @@ -197,7 +198,10 @@ class MetaMaskConnector extends BaseConnector { this.disconnect(); } }, - chainChanged: (_chainId: string) => {}, + chainChanged: (_chainId: string) => { + // Keep Web3Auth state aligned with the wallet's actual EVM chain after connect/switch. + this.updateConnectorData({ chainId: _chainId }); + }, connect: (_result: { chainId: string; accounts: string[] }) => {}, disconnect: () => { if (this.connected) { @@ -255,7 +259,7 @@ class MetaMaskConnector extends BaseConnector { ethereumProvider: this.evmProvider, solanaWallet: this.solanaProvider, }); - if (options.getAuthTokenInfo) await this.getAuthTokenInfo(); + if (options.getAuthTokenInfo) await this.getAuthTokenInfo(options.chainId); } else if (coreStatus === "connected" || coreStatus === "loaded" || coreStatus === "disconnected" || coreStatus === "pending") { this.status = CONNECTOR_STATUS.READY; this.emit(CONNECTOR_EVENTS.READY, WALLET_CONNECTORS.METAMASK); @@ -321,13 +325,9 @@ class MetaMaskConnector extends BaseConnector { } } - // // Switch EVM chain if not connected to the right one (Solana chains are handled by the wallet-standard provider) - // if (chainConfig.chainNamespace === CHAIN_NAMESPACES.EIP155) { - // const currentChainId = this.evmClient!.getChainId(); - // if (currentChainId !== chainId) { - // await this.switchChain(chainConfig, true); - // } - // } + // sync the chain state after connect + // metamask might not be connected to the requested chain, so we need to sync the chain state to/from Web3Auth state after connect. + await this.syncChainStateAfterConnect(chainConfig); // check if connected if (this.multichainClient.status !== "connected") { @@ -353,7 +353,7 @@ class MetaMaskConnector extends BaseConnector { } as CONNECTED_EVENT_DATA); if (getAuthTokenInfo) { - await this.getAuthTokenInfo(); + await this.getAuthTokenInfo(chainId); } return { ethereumProvider: this.evmProvider, solanaWallet: this.solanaProvider, connectorName: this.name }; @@ -403,20 +403,13 @@ class MetaMaskConnector extends BaseConnector { this.emit(CONNECTOR_EVENTS.DISCONNECTED, { connector: WALLET_CONNECTORS.METAMASK }); } - async getAuthTokenInfo(): Promise { + async getAuthTokenInfo(chainId?: string): Promise { if (!this.canAuthorize) throw WalletLoginError.notConnectedError(); - - // Determine the active chain: prefer Solana if no EVM provider, otherwise use EVM provider's chain - const evmChainId = this.evmProvider?.chainId || this.coreOptions.chains.find((x) => x.chainNamespace === CHAIN_NAMESPACES.EIP155)?.chainId; - const isSolanaOnly = !this.evmProvider && !!this.solanaProvider; - const activeChainConfig = isSolanaOnly - ? this.coreOptions.chains.find((x) => x.chainNamespace === CHAIN_NAMESPACES.SOLANA) - : this.evmProvider - ? this.coreOptions.chains.find((x) => x.chainId === evmChainId) - : undefined; + // In multichain sessions both providers can exist at the same time, so auth must + // follow the caller-selected chain instead of inferring from provider availability. + const activeChainConfig = this.resolveAuthChainConfig(chainId); if (!activeChainConfig) throw WalletLoginError.connectionError("Chain config is not available"); - const { chainNamespace } = activeChainConfig; const accounts = chainNamespace === CHAIN_NAMESPACES.SOLANA && this.solanaProvider @@ -433,7 +426,7 @@ class MetaMaskConnector extends BaseConnector { this.emit(CONNECTOR_EVENTS.AUTHORIZING, { connector: WALLET_CONNECTORS.METAMASK }); const authServer = citadelServerUrl(this.coreOptions.authBuildEnv); - const { challenge, signature } = await this.generateChallengeAndSign(authServer, accounts); + const { challenge, signature } = await this.generateChallengeAndSign(authServer, accounts, activeChainConfig.chainId); return this.verifyAndAuthorize({ chainNamespace, signedMessage: signature, challenge, authServer }); } @@ -481,15 +474,10 @@ class MetaMaskConnector extends BaseConnector { public async generateChallengeAndSign( authServerUrl?: string, - accounts?: string[] + accounts?: string[], + chainId?: string ): Promise<{ challenge: string; signature: string; chainNamespace: ChainNamespaceType }> { - const evmChainId = this.evmProvider?.chainId || this.coreOptions.chains.find((x) => x.chainNamespace === CHAIN_NAMESPACES.EIP155)?.chainId; - const isSolanaOnly = !this.evmProvider && !!this.solanaProvider; - const activeChainConfig = isSolanaOnly - ? this.coreOptions.chains.find((x) => x.chainNamespace === CHAIN_NAMESPACES.SOLANA) - : this.evmProvider - ? this.coreOptions.chains.find((x) => x.chainId === evmChainId) - : undefined; + const activeChainConfig = this.resolveAuthChainConfig(chainId); if (!activeChainConfig) throw WalletLoginError.connectionError("Chain config is not available"); const { chainNamespace } = activeChainConfig; @@ -550,6 +538,49 @@ class MetaMaskConnector extends BaseConnector { } await this.initializationPromise; } + + private async syncChainStateAfterConnect(chainConfig: CustomChainConfig) { + // EVM connectors can switch chains, so align the wallet with the requested chain + // before Web3Auth persists the active chain in controller state. + if (chainConfig.chainNamespace === CHAIN_NAMESPACES.EIP155 && this.evmProvider?.chainId !== chainConfig.chainId) { + await this.switchChain({ chainId: chainConfig.chainId }, true); + } else if (chainConfig.chainNamespace === CHAIN_NAMESPACES.SOLANA) { + // For solana case, metamask connect the first available scope in priority order: mainnet > devnet > testnet. + // So, if the user requested chain is different from the connected chain, + // we need to update the connector data with the connected chain id to keep the Web3Auth state aligned. + if ("scope" in this.solanaProvider && typeof this.solanaProvider.scope === "string") { + const connectedSolChain = this.solanaProvider.scope; + const connectedChainConfig = this.coreOptions.chains?.find((chain) => { + return getCaipChainId(chain) === connectedSolChain && chain.chainNamespace === CHAIN_NAMESPACES.SOLANA; + }); + if (!connectedChainConfig) { + throw WalletLoginError.connectionError("Connected chain is not available in the chains config"); + } + + if (connectedChainConfig.chainId !== chainConfig.chainId) { + // since, switchChain is not supported for solana (in metamask connect), + // we will make use of the connector data to update the Web3Auth state. + this.updateConnectorData({ chainId: connectedChainConfig.chainId }); + } + } + } + } + + private resolveAuthChainConfig(chainId?: string) { + if (chainId) { + return this.coreOptions.chains.find((x) => x.chainId === chainId); + } + + const evmChainId = this.evmProvider?.chainId || this.coreOptions.chains.find((x) => x.chainNamespace === CHAIN_NAMESPACES.EIP155)?.chainId; + const isSolanaOnly = !this.evmProvider && !!this.solanaProvider; + + // Keep the old fallback for callers that do not pass a chainId yet. + return isSolanaOnly + ? this.coreOptions.chains.find((x) => x.chainNamespace === CHAIN_NAMESPACES.SOLANA) + : this.evmProvider + ? this.coreOptions.chains.find((x) => x.chainId === evmChainId) + : undefined; + } } /** diff --git a/packages/no-modal/src/connectors/wallet-connect-v2-connector/config.ts b/packages/no-modal/src/connectors/wallet-connect-v2-connector/config.ts index c6b9a4a57..46ff00c1e 100644 --- a/packages/no-modal/src/connectors/wallet-connect-v2-connector/config.ts +++ b/packages/no-modal/src/connectors/wallet-connect-v2-connector/config.ts @@ -16,6 +16,9 @@ export enum DEFAULT_EIP155_METHODS { SWITCH_ETHEREUM_CHAIN = "wallet_switchEthereumChain", } +// methods that return `null` on success +export const NULL_ON_SUCCESS_METHODS: string[] = [DEFAULT_EIP155_METHODS.SWITCH_ETHEREUM_CHAIN, DEFAULT_EIP155_METHODS.ADD_ETHEREUM_CHAIN]; + export enum DEFAULT_SOLANA_METHODS { SIGN_TRANSACTION = "solana_signTransaction", SIGN_MESSAGE = "solana_signMessage", diff --git a/packages/no-modal/src/noModal.ts b/packages/no-modal/src/noModal.ts index 4bd1bf4d5..ac02a2265 100644 --- a/packages/no-modal/src/noModal.ts +++ b/packages/no-modal/src/noModal.ts @@ -638,7 +638,9 @@ export class Web3AuthNoModal extends SafeEventEmitter imp const trackData = { connector: this.primaryConnector.name }; try { this.analytics.track(ANALYTICS_EVENTS.IDENTITY_TOKEN_STARTED, trackData); - const authTokenInfo = await this.primaryConnector.getAuthTokenInfo(); + // Thread the controller's active chain into connector auth so multichain + // connectors sign for the same chain the app/session is currently using. + const authTokenInfo = await this.primaryConnector.getAuthTokenInfo(this.currentChainId); this.analytics.track(ANALYTICS_EVENTS.IDENTITY_TOKEN_COMPLETED, trackData); return { idToken: authTokenInfo.idToken }; } catch (error) { @@ -677,7 +679,7 @@ export class Web3AuthNoModal extends SafeEventEmitter imp if (!params?.connectorName) { throw WalletInitializationError.invalidParams("connectorName is required when calling linkAccount on the no-modal SDK"); } - const chainId = this.resolveLinkAccountChainId(params.chainId); + const { chainId } = this.resolveLinkAccountChainConfig(params.chainId); const isolatedConnector = await this.createLinkingWalletConnector(params.connectorName, chainId); return this.linkAccountWithConnector(params.connectorName, chainId, isolatedConnector); } @@ -1086,10 +1088,12 @@ export class Web3AuthNoModal extends SafeEventEmitter imp this.setActiveWalletConnectorKey(); this.connectionReconnected = data.reconnected; + const { activeAccount, currentChainId } = this.state; + // when ssr is enabled, we need to get the idToken from the connector. if (this.coreOptions.ssr) { try { - const data = await connector.getAuthTokenInfo(); + const data = await connector.getAuthTokenInfo(currentChainId); if (!data.idToken) throw WalletLoginError.connectionError("No idToken found"); await this.setState({ idToken: data.idToken, @@ -1107,7 +1111,6 @@ export class Web3AuthNoModal extends SafeEventEmitter imp } // The following block only hits during rehydration - const { activeAccount, currentChainId } = this.state; let rehydrateWithLinkedAccount = false; // for rehydration, if the active account is not the primary account, i.e. not `null`, create an isolated connector and connect to the chain if (activeAccount && !activeAccount.isPrimary && activeAccount.connector !== WALLET_CONNECTORS.AUTH) { @@ -1276,7 +1279,13 @@ export class Web3AuthNoModal extends SafeEventEmitter imp this.emit(CONNECTOR_EVENTS.REHYDRATION_ERROR, error); }); - connector.on(CONNECTOR_EVENTS.CONNECTOR_DATA_UPDATED, (data) => { + connector.on(CONNECTOR_EVENTS.CONNECTOR_DATA_UPDATED, async (data) => { + if (this.shouldIgnoreInactiveConnectorEvent(connector, CONNECTOR_EVENTS.CONNECTOR_DATA_UPDATED)) return; + // External wallets can resolve to a different active chain than the requested one, + // so let connector-reported chain updates reconcile Web3Auth state after connect. + if (typeof data?.data === "object" && data?.data !== null && "chainId" in data.data && typeof data.data.chainId === "string") { + await this.setCurrentChain(data.data.chainId); + } log.debug("connector data updated", data); this.emit(CONNECTOR_EVENTS.CONNECTOR_DATA_UPDATED, data); }); @@ -1380,14 +1389,15 @@ export class Web3AuthNoModal extends SafeEventEmitter imp this.emit(CONNECTOR_EVENTS.CONSENT_ACCEPTED, { reconnected: this.connectionReconnected }); } - protected resolveLinkAccountChainId(chainId?: string | null): string { + protected resolveLinkAccountChainConfig(chainId?: string | null): CustomChainConfig { const finalChainId = chainId || this.state.currentChainId; - if (!finalChainId) { + const chainConfig = this.coreOptions.chains?.find((chain) => chain.chainId === finalChainId); + if (!chainConfig) { throw AccountLinkingError.walletProofFailed( "No chainId is available. Please specify chainId in LinkAccountParams or ensure the SDK has an active chain." ); } - return finalChainId; + return chainConfig; } /** diff --git a/packages/no-modal/src/providers/account-abstraction-provider/rpc/ethRpcMiddlewares.ts b/packages/no-modal/src/providers/account-abstraction-provider/rpc/ethRpcMiddlewares.ts index d74b139a4..885cca555 100644 --- a/packages/no-modal/src/providers/account-abstraction-provider/rpc/ethRpcMiddlewares.ts +++ b/packages/no-modal/src/providers/account-abstraction-provider/rpc/ethRpcMiddlewares.ts @@ -2,6 +2,7 @@ import { EIP_5792_METHODS, EIP_7702_METHODS, METHOD_TYPES } from "@toruslabs/eth import { createScaffoldMiddlewareV2, type JRPCRequest, + Json, type MiddlewareConstraint, type MiddlewareParams, providerErrors, @@ -9,6 +10,7 @@ import { } from "@web3auth/auth"; import { IProvider } from "../../../base"; +import { NULL_ON_SUCCESS_METHODS } from "../../../connectors"; import { IEthProviderHandlers, MessageParams, TransactionParams, TypedMessageParams } from "../../ethereum-provider"; export async function createAaMiddleware({ @@ -231,6 +233,13 @@ export async function createEip7702And5792MiddlewareForAaProvider(): Promise { - return provider.request({ method: request.method, params: request.params }); + const result = await provider.request({ method: request.method, params: request.params }); + if (result === undefined && NULL_ON_SUCCESS_METHODS.includes(request.method)) { + // For some RPC requests, such as `wallet_switchEthereumChain`, the standard rpc result is `null`. + // However, some wallet providers might return `undefined` instead and causing the JRPCEngineV2 to throw `Nothing ended the request` error. + // So, we handle this case by returning `null` instead, so that JRPCEngineV2 won't throw `Nothing ended the request` error + return null; + } + return result as Readonly; }; } diff --git a/packages/no-modal/test/accountAbstractionEthRpcMiddlewares.test.ts b/packages/no-modal/test/accountAbstractionEthRpcMiddlewares.test.ts new file mode 100644 index 000000000..6ea37c1dc --- /dev/null +++ b/packages/no-modal/test/accountAbstractionEthRpcMiddlewares.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { IProvider } from "../src/base"; +import { DEFAULT_EIP155_METHODS } from "../src/connectors/wallet-connect-v2-connector/config"; +import { providerAsMiddleware } from "../src/providers/account-abstraction-provider/rpc/ethRpcMiddlewares"; + +function createProvider(result: unknown): IProvider { + return { + chainId: "0x1", + request: vi.fn().mockResolvedValue(result), + sendAsync: vi.fn(), + send: vi.fn(), + on: vi.fn(), + once: vi.fn(), + emit: vi.fn(), + removeListener: vi.fn(), + off: vi.fn(), + } as unknown as IProvider; +} + +describe("providerAsMiddleware", () => { + it("normalizes undefined to null for wallet_switchEthereumChain", async () => { + const provider = createProvider(undefined); + const middleware = providerAsMiddleware(provider); + + await expect( + middleware({ + request: { + method: DEFAULT_EIP155_METHODS.SWITCH_ETHEREUM_CHAIN, + params: [{ chainId: "0x1" }], + }, + } as never) + ).resolves.toBeNull(); + + expect(provider.request).toHaveBeenCalledWith({ + method: DEFAULT_EIP155_METHODS.SWITCH_ETHEREUM_CHAIN, + params: [{ chainId: "0x1" }], + }); + }); + + it("normalizes undefined to null for wallet_addEthereumChain", async () => { + const provider = createProvider(undefined); + const middleware = providerAsMiddleware(provider); + + await expect( + middleware({ + request: { + method: DEFAULT_EIP155_METHODS.ADD_ETHEREUM_CHAIN, + params: [{ chainId: "0x1", chainName: "Ethereum", rpcUrls: ["https://rpc.example.com"] }], + }, + } as never) + ).resolves.toBeNull(); + }); + + it("preserves null results for allowlisted methods", async () => { + const provider = createProvider(null); + const middleware = providerAsMiddleware(provider); + + await expect( + middleware({ + request: { + method: DEFAULT_EIP155_METHODS.SWITCH_ETHEREUM_CHAIN, + params: [{ chainId: "0x1" }], + }, + } as never) + ).resolves.toBeNull(); + }); + + it("does not coerce undefined for non-allowlisted methods", async () => { + const provider = createProvider(undefined); + const middleware = providerAsMiddleware(provider); + + await expect( + middleware({ + request: { + method: "eth_accounts", + params: [], + }, + } as never) + ).resolves.toBeUndefined(); + }); + + it("propagates provider errors", async () => { + const error = new Error("switch failed"); + const provider = createProvider(null); + vi.mocked(provider.request).mockRejectedValueOnce(error); + const middleware = providerAsMiddleware(provider); + + await expect( + middleware({ + request: { + method: DEFAULT_EIP155_METHODS.SWITCH_ETHEREUM_CHAIN, + params: [{ chainId: "0x1" }], + }, + } as never) + ).rejects.toThrow("switch failed"); + }); +});