diff --git a/demo/vue-app-new/package-lock.json b/demo/vue-app-new/package-lock.json index 8aa06df76..7e6523d65 100644 --- a/demo/vue-app-new/package-lock.json +++ b/demo/vue-app-new/package-lock.json @@ -137,9 +137,9 @@ "version": "11.0.0-beta.2", "license": "ISC", "dependencies": { - "@metamask/connect-evm": "^1.3.0", - "@metamask/connect-multichain": "^0.14.0", - "@metamask/connect-solana": "^1.1.0", + "@metamask/connect-evm": "^1.4.0", + "@metamask/connect-multichain": "^0.15.0", + "@metamask/connect-solana": "^1.2.0", "@segment/analytics-next": "^1.83.0", "@solana/client": "^1.7.0", "@solana/kit": "^6.8.0", diff --git a/package-lock.json b/package-lock.json index 7b0499522..ccd45076b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29313,7 +29313,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -29323,7 +29322,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -30984,8 +30982,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/schema-utils": { "version": "3.3.0", diff --git a/packages/modal/src/modalManager.ts b/packages/modal/src/modalManager.ts index 93c2ae2c5..f9d520da5 100644 --- a/packages/modal/src/modalManager.ts +++ b/packages/modal/src/modalManager.ts @@ -1,6 +1,5 @@ import { AuthConnectionConfigItem, serializeError } from "@web3auth/auth"; import { - AccountLinkingError, ANALYTICS_EVENTS, ANALYTICS_SDK_TYPE, type AUTH_CONNECTION_TYPE, @@ -47,6 +46,7 @@ import { Web3AuthNoModal, withAbort, } from "@web3auth/no-modal"; +import { AccountLinkingError, formatAccountLinkingErrorMessage } from "@web3auth/no-modal/account-linking"; import deepmerge from "deepmerge"; import { defaultConnectorsModalConfig } from "./config"; @@ -133,6 +133,8 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { super.checkInitRequirements(); // get project config and wallet registry const { projectConfig, walletRegistry } = await this.getProjectAndWalletConfig(); + // cache project config so that it can be re-used later + this.projectConfig = projectConfig; // init config this.initUIConfig(projectConfig); @@ -381,14 +383,6 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { this.loginModal.resetAccountLinkingSession(); } - protected formatAccountLinkingErrorMessage(error: unknown): string | undefined { - if (error instanceof AccountLinkingError) { - const isUnlink = error.code >= 5406 && error.code <= 5408; - return isUnlink ? `[${error.code}] Account unlinking failed` : `[${error.code}] Account linking failed`; - } - return (error as Error)?.message; - } - protected async prepareAccountLinkingConnector(connectorName: WALLET_CONNECTOR_TYPE | string, chainId: string): Promise> { const { projectConfig } = await this.getProjectAndWalletConfig(); const connector = await super.createLinkingWalletConnector(connectorName, chainId, projectConfig); @@ -1046,7 +1040,7 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { this.resetAccountLinkingModalSession(); } else { const fallbackMessage = params.intent === ACCOUNT_LINKING_INTENT.SWITCH ? "Failed to switch wallet." : undefined; - const errorMessage = this.formatAccountLinkingErrorMessage(error) || fallbackMessage; + const errorMessage = formatAccountLinkingErrorMessage(error, fallbackMessage); this.resetAccountLinkingModalSession(); this.loginModal.endConnectingLoader({ success: false, errorMessage }); } @@ -1149,9 +1143,9 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal { this.loginModal.endConnectingLoader({ success: true, skipSuccessScreen: options.skipSuccessScreen }); return result; } catch (error) { - const message = this.formatAccountLinkingErrorMessage(error); + const errorMessage = formatAccountLinkingErrorMessage(error); this.resetAccountLinkingModalSession(); - this.loginModal.endConnectingLoader({ success: false, errorMessage: message }); + this.loginModal.endConnectingLoader({ success: false, errorMessage }); throw error; } finally { if (options.connector) { diff --git a/packages/modal/src/react/wagmi/provider.ts b/packages/modal/src/react/wagmi/provider.ts index 3aed9b48f..f8f28f63c 100644 --- a/packages/modal/src/react/wagmi/provider.ts +++ b/packages/modal/src/react/wagmi/provider.ts @@ -1,4 +1,4 @@ -import { CHAIN_NAMESPACES, type CustomChainConfig, log, WalletInitializationError } from "@web3auth/no-modal"; +import { CHAIN_NAMESPACES, type CustomChainConfig, IProvider, log, WalletInitializationError } from "@web3auth/no-modal"; import { connectWeb3AuthWithWagmi, disconnectWeb3AuthFromWagmi, @@ -24,17 +24,13 @@ import { WagmiProviderProps } from "./interface"; // TODO: re-use the provider from the no-modal package function Web3AuthWagmiProvider({ children }: PropsWithChildren) { - const { - isConnected, - connection, - chainNamespace, - web3Auth: { primaryConnectorName }, - } = useWeb3Auth(); + const { isConnected, connection, chainNamespace } = useWeb3Auth(); const { disconnect } = useWeb3AuthDisconnect(); const wagmiConfig = useWagmiConfig(); const { mutate: reconnect } = useReconnect(); const suppressWagmiDisconnect = useRef(false); - const lastSyncedWeb3AuthConnection = useRef(null); + const lastSyncedProvider = useRef(connection?.ethereumProvider ?? null); + const lastSyncedConnectorName = useRef(connection?.connectorName ?? null); useConnectionEffect({ onDisconnect: async () => { @@ -56,31 +52,39 @@ function Web3AuthWagmiProvider({ children }: PropsWithChildren) { const newConnection = connection ?? null; const newEth = connection?.ethereumProvider ?? null; const w3aWagmiConnector = getWeb3authConnector(wagmiConfig); - const shouldBindToWagmi = - isConnected && - chainNamespace === CHAIN_NAMESPACES.EIP155 && - Boolean(newConnection && newEth) && - newConnection?.connectorName === primaryConnectorName; + const shouldBindToWagmi = isConnected && chainNamespace === CHAIN_NAMESPACES.EIP155 && Boolean(newConnection && newEth); if (shouldBindToWagmi) { + const hasSameBinding = + lastSyncedProvider.current === newEth && + lastSyncedConnectorName.current === newConnection.connectorName && + wagmiConfig.state.status === "connected"; + + if (hasSameBinding) { + // rehydration: already connected to the same provider, so no need to reconnect + return; + } + // `ethereumProvider` is a stable proxy (`commonJRPCProvider`) across account switches, // so key wagmi resyncs off the Web3Auth connection object instead of provider identity. - if (lastSyncedWeb3AuthConnection.current !== newConnection) { - if (w3aWagmiConnector) { - resetConnectorState(wagmiConfig); - } - lastSyncedWeb3AuthConnection.current = newConnection; - const connector = setupConnector(newEth, wagmiConfig); - if (!connector) { - log.error("Failed to setup react wagmi connector"); - throw new Error("Failed to setup connector"); - } - - await connectWeb3AuthWithWagmi(connector, wagmiConfig); - reconnect(); + if (w3aWagmiConnector) { + resetConnectorState(wagmiConfig); } + + lastSyncedProvider.current = newEth; + lastSyncedConnectorName.current = newConnection.connectorName; + + const connector = setupConnector(newEth, wagmiConfig); + if (!connector) { + log.error("Failed to setup react wagmi connector"); + throw new Error("Failed to setup connector"); + } + + await connectWeb3AuthWithWagmi(connector, wagmiConfig); + reconnect(); } else if (!isConnected || chainNamespace !== CHAIN_NAMESPACES.EIP155) { - lastSyncedWeb3AuthConnection.current = null; + lastSyncedProvider.current = null; + lastSyncedConnectorName.current = null; if (wagmiConfig.state.status === "connected") { suppressWagmiDisconnect.current = true; await disconnectWeb3AuthFromWagmi(wagmiConfig); @@ -89,7 +93,7 @@ function Web3AuthWagmiProvider({ children }: PropsWithChildren) { } } })(); - }, [chainNamespace, isConnected, wagmiConfig, connection, reconnect, primaryConnectorName]); + }, [chainNamespace, isConnected, wagmiConfig, connection, reconnect]); return createElement(Fragment, null, children); } diff --git a/packages/modal/src/vue/wagmi/provider.ts b/packages/modal/src/vue/wagmi/provider.ts index 6324c80da..be1cd65c9 100644 --- a/packages/modal/src/vue/wagmi/provider.ts +++ b/packages/modal/src/vue/wagmi/provider.ts @@ -1,7 +1,7 @@ import { Config, CreateConfigParameters, hydrate } from "@wagmi/core"; import { configKey, createConfig as createWagmiConfig, useConfig as useWagmiConfig, useConnectionEffect, useReconnect } from "@wagmi/vue"; import { randomId } from "@web3auth/auth"; -import { CHAIN_NAMESPACES, type CustomChainConfig, log, WalletInitializationError } from "@web3auth/no-modal"; +import { CHAIN_NAMESPACES, type CustomChainConfig, IProvider, log, WalletInitializationError } from "@web3auth/no-modal"; import { connectWeb3AuthWithWagmi, disconnectWeb3AuthFromWagmi, @@ -21,11 +21,12 @@ import { WagmiProviderProps } from "./interface"; const Web3AuthWagmiProvider = defineComponent({ name: "Web3AuthWagmiProvider", setup() { - const { isConnected, connection, web3Auth, chainNamespace } = useWeb3Auth(); + const { isConnected, connection, chainNamespace, web3Auth } = useWeb3Auth(); const { disconnect } = useWeb3AuthDisconnect(); const wagmiConfig = useWagmiConfig(); const { mutate: reconnect } = useReconnect(); - const lastSyncedWeb3AuthConnection = shallowRef(null); + const lastSyncedProvider = shallowRef(connection.value?.ethereumProvider ?? null); + const lastSyncedConnectorName = ref(connection.value?.connectorName ?? null); const suppressWagmiDisconnect = ref(false); useConnectionEffect({ @@ -52,31 +53,39 @@ const Web3AuthWagmiProvider = defineComponent({ const newEth = newConnection?.ethereumProvider ?? null; const w3aWagmiConnector = getWeb3authConnector(wagmiConfig); - const shouldBindToWagmi = - newIsConnected && - chainNamespace.value === CHAIN_NAMESPACES.EIP155 && - Boolean(newConnection && newEth) && - newConnection?.connectorName === web3Auth.value?.primaryConnectorName; + const shouldBindToWagmi = newIsConnected && chainNamespace.value === CHAIN_NAMESPACES.EIP155 && Boolean(newConnection && newEth); + + if (shouldBindToWagmi) { + const hasSameBinding = + lastSyncedProvider.value === newEth && + lastSyncedConnectorName.value === newConnection.connectorName && + newConnection?.connectorName === web3Auth.value?.connection.connectorName && + wagmiConfig.state.status === "connected"; + + if (hasSameBinding) { + // rehydration: already connected to the same provider, so no need to reconnect + return; + } - if (shouldBindToWagmi && newConnection && newEth) { // `ethereumProvider` is a stable proxy (`commonJRPCProvider`) across account switches, // so key wagmi resyncs off the Web3Auth connection object instead of provider identity. - if (lastSyncedWeb3AuthConnection.value !== newConnection) { - if (w3aWagmiConnector) { - resetConnectorState(wagmiConfig); - } - lastSyncedWeb3AuthConnection.value = newConnection; - const connector = setupConnector(newEth, wagmiConfig); - if (!connector) { - log.error("Failed to setup vue wagmi connector"); - throw new Error("Failed to setup connector"); - } - - await connectWeb3AuthWithWagmi(connector, wagmiConfig); - reconnect(); + if (w3aWagmiConnector) { + resetConnectorState(wagmiConfig); } + + lastSyncedProvider.value = newEth; + lastSyncedConnectorName.value = newConnection.connectorName; + const connector = setupConnector(newEth, wagmiConfig); + if (!connector) { + log.error("Failed to setup vue wagmi connector"); + throw new Error("Failed to setup connector"); + } + + await connectWeb3AuthWithWagmi(connector, wagmiConfig); + reconnect(); } else if (!newIsConnected || chainNamespace.value !== CHAIN_NAMESPACES.EIP155) { - lastSyncedWeb3AuthConnection.value = null; + lastSyncedProvider.value = null; + lastSyncedConnectorName.value = null; if (wagmiConfig.state.status === "connected") { suppressWagmiDisconnect.value = true; await disconnectWeb3AuthFromWagmi(wagmiConfig); diff --git a/packages/no-modal/src/account-linking/errors.ts b/packages/no-modal/src/account-linking/errors.ts new file mode 100644 index 000000000..3874c5fa6 --- /dev/null +++ b/packages/no-modal/src/account-linking/errors.ts @@ -0,0 +1,100 @@ +import { ErrorCodes, Web3AuthError } from "../base"; + +export class AccountLinkingError extends Web3AuthError { + protected static messages: ErrorCodes = { + 5000: "Custom", + 5401: "Account linking request failed", + 5402: "Citadel server URL is not configured", + 5403: "Primary identity token is not available", + 5404: "Failed to obtain wallet proof token", + 5405: "Connector is not supported for wallet linking", + 5406: "Cannot unlink active account", + 5407: "Account not linked", + 5408: "Cannot unlink primary account", + }; + + public constructor(code: number, message?: string, cause?: unknown) { + super(code, message, cause); + Object.defineProperty(this, "name", { value: "AccountLinkingError", configurable: true }); + } + + public static fromCode(code: number, extraMessage = "", cause?: unknown): AccountLinkingError { + return new AccountLinkingError(code, `${AccountLinkingError.messages[code]}. ${extraMessage}`, cause); + } + + public static requestFailed(extraMessage = "", cause?: unknown): AccountLinkingError { + return AccountLinkingError.fromCode(5401, extraMessage, cause); + } + + public static serverNotConfigured(extraMessage = "", cause?: unknown): AccountLinkingError { + return AccountLinkingError.fromCode(5402, extraMessage, cause); + } + + public static primaryTokenNotAvailable(extraMessage = "", cause?: unknown): AccountLinkingError { + return AccountLinkingError.fromCode(5403, extraMessage, cause); + } + + public static walletProofFailed(extraMessage = "", cause?: unknown): AccountLinkingError { + return AccountLinkingError.fromCode(5404, extraMessage, cause); + } + + public static unsupportedConnector(extraMessage = "", cause?: unknown): AccountLinkingError { + return AccountLinkingError.fromCode(5405, extraMessage, cause); + } + + public static cannotUnlinkActiveAccount(): AccountLinkingError { + return AccountLinkingError.fromCode(5406); + } + + public static accountNotLinked(message = "", cause?: unknown): AccountLinkingError { + return AccountLinkingError.fromCode(5407, message, cause); + } + + public static cannotUnlinkPrimaryAccount(): AccountLinkingError { + return AccountLinkingError.fromCode(5408); + } + + public toString(): string { + return `[${this.code}] ${this.message}`; + } +} + +export async function getAccountLinkingRequestError(error: unknown): Promise { + if (error instanceof AccountLinkingError) { + return error; + } + + if (error instanceof Response) { + if (error.status === 409) { + return AccountLinkingError.requestFailed("This wallet address is already registered on this dApp"); + } + + if (error.json && typeof error.json === "function") { + try { + const json = await error.json(); + return AccountLinkingError.requestFailed(json.message ?? "Failed to link account"); + } catch { + // continue + } + } + } + + return AccountLinkingError.requestFailed(error instanceof Error ? error.message : JSON.stringify(error), error); +} + +export function formatAccountLinkingErrorMessage(error: unknown, fallbackMessage: string = "Unknown error during the operation."): string { + if (error instanceof AccountLinkingError) { + return error.toString(); + } + + if (error instanceof Error) { + return error.message || fallbackMessage; + } + + try { + const stringifiedError = JSON.stringify(error); + return stringifiedError; + } catch { + return fallbackMessage; + } +} diff --git a/packages/no-modal/src/account-linking/index.ts b/packages/no-modal/src/account-linking/index.ts index 95db5c73f..bb6c0e91a 100644 --- a/packages/no-modal/src/account-linking/index.ts +++ b/packages/no-modal/src/account-linking/index.ts @@ -1,2 +1,3 @@ +export * from "./errors"; export * from "./interfaces"; export * from "./rest"; diff --git a/packages/no-modal/src/account-linking/rest.ts b/packages/no-modal/src/account-linking/rest.ts index f69bb884e..8eafb8649 100644 --- a/packages/no-modal/src/account-linking/rest.ts +++ b/packages/no-modal/src/account-linking/rest.ts @@ -1,6 +1,6 @@ import { post } from "@toruslabs/http-helpers"; -import { AccountLinkingError } from "../base/errors"; +import { AccountLinkingError, getAccountLinkingRequestError } from "./errors"; import { CitadelLinkAccountPayload, LinkAccountResult, UnlinkAccountPayload, UnlinkAccountResult } from "./interfaces"; /** @@ -24,8 +24,8 @@ export async function makeAccountLinkingRequest( }, }); } catch (cause: unknown) { - const message = cause instanceof Error ? cause.message : String(cause); - throw AccountLinkingError.requestFailed(message, cause); + const accountLinkingError = await getAccountLinkingRequestError(cause); + throw accountLinkingError; } if (!result.success) { diff --git a/packages/no-modal/src/account-linking/vue.ts b/packages/no-modal/src/account-linking/vue.ts index 53c47c869..34f5bf94d 100644 --- a/packages/no-modal/src/account-linking/vue.ts +++ b/packages/no-modal/src/account-linking/vue.ts @@ -45,7 +45,6 @@ export const useLinkAccount = (): IUseLinkAccount => { linkedAccounts.value = result.linkedAccounts; return result; } catch (err) { - log.error("Error linking account", err); error.value = err as Web3AuthError; } finally { loading.value = false; diff --git a/packages/no-modal/src/base/errors/index.ts b/packages/no-modal/src/base/errors/index.ts index d58507641..b6d4b3dd2 100644 --- a/packages/no-modal/src/base/errors/index.ts +++ b/packages/no-modal/src/base/errors/index.ts @@ -200,6 +200,8 @@ export class WalletOperationsError extends Web3AuthError { 5000: "Custom", 5201: "Provided chainId is not allowed", 5202: "This operation is not allowed", + 5203: "Chain namespace is not allowed", + 5204: "User rejected the request", }; public constructor(code: number, message?: string, cause?: unknown) { @@ -211,7 +213,11 @@ export class WalletOperationsError extends Web3AuthError { } public static fromCode(code: number, extraMessage = "", cause?: unknown): IWeb3AuthError { - return new WalletOperationsError(code, `${WalletOperationsError.messages[code]}, ${extraMessage}`, cause); + let message = WalletOperationsError.messages[code]; + if (extraMessage) { + message = `${message}, ${extraMessage}`; + } + return new WalletOperationsError(code, message, cause); } // Custom methods @@ -226,60 +232,9 @@ export class WalletOperationsError extends Web3AuthError { public static chainNamespaceNotAllowed(extraMessage = "", cause?: unknown): IWeb3AuthError { return WalletOperationsError.fromCode(5203, extraMessage, cause); } -} - -export class AccountLinkingError extends Web3AuthError { - protected static messages: ErrorCodes = { - 5000: "Custom", - 5401: "Account linking request failed", - 5402: "Citadel server URL is not configured", - 5403: "Primary identity token is not available", - 5404: "Failed to obtain wallet proof token", - 5405: "Connector is not supported for wallet linking", - 5406: "Cannot unlink active account", - 5407: "Account not linked", - 5408: "Cannot unlink primary account", - }; - - public constructor(code: number, message?: string, cause?: unknown) { - super(code, message, cause); - Object.defineProperty(this, "name", { value: "AccountLinkingError", configurable: true }); - } - - public static fromCode(code: number, extraMessage = "", cause?: unknown): AccountLinkingError { - return new AccountLinkingError(code, `${AccountLinkingError.messages[code]}. ${extraMessage}`, cause); - } - - public static requestFailed(extraMessage = "", cause?: unknown): AccountLinkingError { - return AccountLinkingError.fromCode(5401, extraMessage, cause); - } - - public static serverNotConfigured(extraMessage = "", cause?: unknown): AccountLinkingError { - return AccountLinkingError.fromCode(5402, extraMessage, cause); - } - public static primaryTokenNotAvailable(extraMessage = "", cause?: unknown): AccountLinkingError { - return AccountLinkingError.fromCode(5403, extraMessage, cause); - } - - public static walletProofFailed(extraMessage = "", cause?: unknown): AccountLinkingError { - return AccountLinkingError.fromCode(5404, extraMessage, cause); - } - - public static unsupportedConnector(extraMessage = "", cause?: unknown): AccountLinkingError { - return AccountLinkingError.fromCode(5405, extraMessage, cause); - } - - public static cannotUnlinkActiveAccount(): AccountLinkingError { - return AccountLinkingError.fromCode(5406); - } - - public static accountNotLinked(message = "", cause?: unknown): AccountLinkingError { - return AccountLinkingError.fromCode(5407, message, cause); - } - - public static cannotUnlinkPrimaryAccount(): AccountLinkingError { - return AccountLinkingError.fromCode(5408); + public static userRejected(extraMessage = "", cause?: unknown): IWeb3AuthError { + return WalletOperationsError.fromCode(5204, extraMessage, cause); } } @@ -316,3 +271,14 @@ export class WalletProviderError extends Web3AuthError { return WalletOperationsError.fromCode(5303, extraMessage, cause); } } + +export function isUserRejectedError(error: unknown): boolean { + if (error instanceof Web3AuthError && error.code === 5203) return true; + + if (error instanceof Error) { + const normalizedMessage = error.message?.toLowerCase() || ""; + return normalizedMessage.includes("user rejected the request"); + } + + return false; +} diff --git a/packages/no-modal/src/connectors/auth-connector/authConnector.ts b/packages/no-modal/src/connectors/auth-connector/authConnector.ts index d19de08d8..00798b328 100644 --- a/packages/no-modal/src/connectors/auth-connector/authConnector.ts +++ b/packages/no-modal/src/connectors/auth-connector/authConnector.ts @@ -26,6 +26,7 @@ import deepmerge from "deepmerge"; import { numberToHex } from "viem"; import { + AccountLinkingError, CITADEL_NETWORK, LinkAccountResult, makeAccountLinkingRequest, @@ -33,7 +34,6 @@ import { UnlinkAccountResult, } from "../../account-linking"; import { - AccountLinkingError, Analytics, ANALYTICS_EVENTS, AuthLoginParams, @@ -376,12 +376,27 @@ class AuthConnector extends BaseConnector implements IAuthConne Authorization: `Bearer ${accessToken}`, }, }); - const linkedAccounts = citadelUserInfo?.accounts || []; - return linkedAccounts.map((account) => ({ - ...account, - // by default, the primary account is the active account - active: account.isPrimary, - })); + if (!citadelUserInfo?.accounts?.length) return []; + + const currentChainNamespace = this.solanaWallet.accounts.length > 0 ? CHAIN_NAMESPACES.SOLANA : "evm"; // Note: citadel chain namespace is "evm" for EVM chains + const filteredLinkedAccounts: LinkedAccountInfo[] = []; + for (const account of citadelUserInfo.accounts) { + const { chainNamespace, isPrimary, accountType } = account; + + // for now, we will take all primary accounts as a **SINGLE** linked account + // we don't wanna populate the multiple primary accounts as different linked accounts + // so, we hide the primary accounts for other chain namespaces + // also, linked `account_abstraction` accounts are derived from the primary account, so we don't need to show them separately + // TODO: revisit this logic once we have a concrete plan for handling multiple primary accounts + if ((isPrimary && chainNamespace && chainNamespace !== currentChainNamespace) || accountType === "account_abstraction") continue; + + filteredLinkedAccounts.push({ + ...account, + // by default, the primary account is the active account + active: isPrimary, + }); + } + return filteredLinkedAccounts; } public async switchChain(params: { chainId: string }, init = false): Promise { @@ -519,9 +534,7 @@ class AuthConnector extends BaseConnector implements IAuthConne } } } catch (error) { - if (error instanceof AccountLinkingError) { - throw error; - } + if (error instanceof Web3AuthError) throw error; throw AccountLinkingError.walletProofFailed(error instanceof Error ? error.message : String(error), error); } diff --git a/packages/no-modal/src/connectors/metamask-connector/metamaskConnector.ts b/packages/no-modal/src/connectors/metamask-connector/metamaskConnector.ts index a9dedaceb..a85aa9e77 100644 --- a/packages/no-modal/src/connectors/metamask-connector/metamaskConnector.ts +++ b/packages/no-modal/src/connectors/metamask-connector/metamaskConnector.ts @@ -33,10 +33,12 @@ import { getCaipChainId, getSolanaChainByChainConfig, type IProvider, + isUserRejectedError, type UserInfo, WALLET_CONNECTOR_TYPE, WALLET_CONNECTORS, WalletLoginError, + WalletOperationsError, walletSignMessage, Web3AuthError, } from "../../base"; @@ -191,13 +193,17 @@ class MetaMaskConnector extends BaseConnector { dapp, eventHandlers: { accountsChanged: (_accounts: string[]) => { - if (_accounts.length === 0) { + if (_accounts.length === 0 && this.connected) { this.disconnect(); } }, chainChanged: (_chainId: string) => {}, connect: (_result: { chainId: string; accounts: string[] }) => {}, - disconnect: () => this.disconnect(), + disconnect: () => { + if (this.connected) { + this.disconnect(); + } + }, }, api: { supportedNetworks: hexSupportedNetworks }, ui, @@ -288,10 +294,14 @@ class MetaMaskConnector extends BaseConnector { this.emit(CONNECTOR_EVENTS.CONNECTING, { connector: WALLET_CONNECTORS.METAMASK }); const evmConnectedPromise = new Promise((resolve) => { - // Wait for EVM provider to be ready - this.evmProvider?.once("connect", () => { + if (this.evmClient.status === "connected") { resolve(); - }); + } else { + // Wait for EVM provider to be ready + this.evmProvider?.once("connect", () => { + resolve(); + }); + } }); // Connect using the multichain client @@ -362,6 +372,8 @@ class MetaMaskConnector extends BaseConnector { duration: Date.now() - startTime, }); } + if (isUserRejectedError(error)) throw WalletOperationsError.userRejected(); + if (error instanceof Web3AuthError) throw error; throw WalletLoginError.connectionError("Failed to login with MetaMask wallet", error); } diff --git a/packages/no-modal/src/noModal.ts b/packages/no-modal/src/noModal.ts index c8f0f9327..200fb93fd 100644 --- a/packages/no-modal/src/noModal.ts +++ b/packages/no-modal/src/noModal.ts @@ -19,7 +19,7 @@ import { import { WsEmbedParams } from "@web3auth/ws-embed"; import deepmerge from "deepmerge"; -import { type LinkAccountParams, type LinkAccountResult, UnlinkAccountResult } from "./account-linking"; +import { AccountLinkingError, type LinkAccountParams, type LinkAccountResult, UnlinkAccountResult } from "./account-linking"; import { Analytics, ANALYTICS_EVENTS, @@ -85,7 +85,6 @@ import { withAbort, } from "./base"; import { deserialize } from "./base/deserialize"; -import { AccountLinkingError } from "./base/errors"; import { assertAuthConnector, authConnector, @@ -1105,7 +1104,8 @@ export class Web3AuthNoModal extends SafeEventEmitter imp // The following block only hits during rehydration const { activeAccount, currentChainId } = this.state; - // if the active account is not the primary account, i.e. not `null`, create an isolated connector and connect to the chain + 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) { const accountLinkingConnector = isAuthConnector(connector) ? connector : this.getConnector(WALLET_CONNECTORS.AUTH); assertAuthConnector(accountLinkingConnector, "Account switching requires the AUTH connector to be available."); @@ -1129,6 +1129,7 @@ export class Web3AuthNoModal extends SafeEventEmitter imp }); this.setConnectedWalletConnectorState(connectedWalletState, activeAccount); this.setActiveWalletConnectorKey(activeAccount); + rehydrateWithLinkedAccount = true; } if (ethereumProvider) { @@ -1167,9 +1168,16 @@ export class Web3AuthNoModal extends SafeEventEmitter imp if (!pendingUserConsent) { this.connectToPlugins({ ...data, connector: data.connectorName as WALLET_CONNECTOR_TYPE }); } + // `pendingUserConsent` signals listeners (LoginModal, React/Vue contexts) to skip processing this CONNECTED event, // so the upcoming AUTHORIZED -> CONSENT_REQUIRING transition is not overridden by a late CONNECTED handler in CONNECT_AND_SIGN mode. this.emit(CONNECTOR_EVENTS.CONNECTED, { ...data, loginMode: this.loginMode, pendingUserConsent }); + + // if we're rehydrating with a linked account, we need to emit a CONNECTION_UPDATED event + // so that upstream listeners and context are updated with the linked connection. + if (rehydrateWithLinkedAccount) { + this.emit(CONNECTOR_EVENTS.CONNECTION_UPDATED, this.connection); + } } }); @@ -1401,7 +1409,15 @@ export class Web3AuthNoModal extends SafeEventEmitter imp chainId: string, config?: ProjectConfig ): Promise> { - return this.createIsolatedWalletConnector(connectorName, chainId, config); + try { + const linkingConnector = await this.createIsolatedWalletConnector(connectorName, chainId, config); + return linkingConnector; + } catch (error) { + if (error instanceof AccountLinkingError && error.code === 5405) { + throw error; + } + throw AccountLinkingError.walletProofFailed(error instanceof Error ? error.message : String(error), error); + } } protected async createSwitchingWalletConnector( @@ -1647,7 +1663,6 @@ export class Web3AuthNoModal extends SafeEventEmitter imp options: { walletConnector?: IConnector; projectConfig?: ProjectConfig } = {} ): Promise { const resolvedSwitchChainId = this.resolveSwitchAccountChainId(switchResult.targetAccount, switchResult.activeChainId); - if (switchResult.kind === "primary") { const existingPrimaryConnectedWalletState = this.getConnectedWalletConnectorState(); const primaryConnectedWalletState = diff --git a/packages/no-modal/src/react/wagmi/provider.ts b/packages/no-modal/src/react/wagmi/provider.ts index e31619abe..327942b95 100644 --- a/packages/no-modal/src/react/wagmi/provider.ts +++ b/packages/no-modal/src/react/wagmi/provider.ts @@ -17,7 +17,7 @@ import { } from "wagmi"; import { injected } from "wagmi/connectors"; -import { CHAIN_NAMESPACES, CustomChainConfig, log, WalletInitializationError, WEB3AUTH_CONNECTOR_ID } from "../../base"; +import { CHAIN_NAMESPACES, CustomChainConfig, IProvider, log, WalletInitializationError, WEB3AUTH_CONNECTOR_ID } from "../../base"; import { useWeb3Auth, useWeb3AuthDisconnect } from "../hooks"; import { defaultWagmiConfig } from "./constants"; import { WagmiProviderProps } from "./interface"; @@ -120,20 +120,13 @@ export async function disconnectWeb3AuthFromWagmi(config: Config) { } function Web3AuthWagmiProvider({ children }: PropsWithChildren) { - const { - isConnected, - connection, - chainNamespace, - web3Auth: { primaryConnectorName }, - } = useWeb3Auth(); + const { isConnected, connection, chainNamespace } = useWeb3Auth(); const { disconnect } = useWeb3AuthDisconnect(); const wagmiConfig = useWagmiConfig(); const { mutate: reconnect } = useReconnect(); const suppressWagmiDisconnect = useRef(false); - const lastSyncedBinding = useRef<{ provider: unknown | null; connectorName: string | null }>({ - provider: null, - connectorName: null, - }); + const lastSyncedProvider = useRef(connection?.ethereumProvider ?? null); + const lastSyncedConnectorName = useRef(connection?.connectorName ?? null); useConnectionEffect({ onDisconnect: async () => { @@ -155,40 +148,39 @@ function Web3AuthWagmiProvider({ children }: PropsWithChildren) { const newConnection = connection ?? null; const newEth = connection?.ethereumProvider ?? null; const w3aWagmiConnector = getWeb3authConnector(wagmiConfig); - const shouldBindToWagmi = - isConnected && - chainNamespace === CHAIN_NAMESPACES.EIP155 && - Boolean(newConnection && newEth) && - newConnection?.connectorName === primaryConnectorName; + const shouldBindToWagmi = isConnected && chainNamespace === CHAIN_NAMESPACES.EIP155 && Boolean(newConnection && newEth); if (shouldBindToWagmi) { - const hasSameBinding = lastSyncedBinding.current.provider === newEth && lastSyncedBinding.current.connectorName === connection.connectorName; + const hasSameBinding = + lastSyncedProvider.current === newEth && + lastSyncedConnectorName.current === newConnection.connectorName && + wagmiConfig.state.status === "connected"; - if (hasSameBinding && wagmiConfig.state.status === "connected") { + if (hasSameBinding) { + // rehydration: already connected to the same provider, so no need to reconnect return; } - if (!hasSameBinding && w3aWagmiConnector) { - if (wagmiConfig.state.status === "connected") { - suppressWagmiDisconnect.current = true; - await disconnectWeb3AuthFromWagmi(wagmiConfig); - } else { - resetConnectorState(wagmiConfig); - } + + // `ethereumProvider` is a stable proxy (`commonJRPCProvider`) across account switches, + // so key wagmi resyncs off the Web3Auth connection object instead of provider identity. + if (w3aWagmiConnector) { + resetConnectorState(wagmiConfig); } + lastSyncedProvider.current = newEth; + lastSyncedConnectorName.current = newConnection.connectorName; + const connector = setupConnector(newEth, wagmiConfig); if (!connector) { + log.error("Failed to setup react wagmi connector"); throw new Error("Failed to setup connector"); } await connectWeb3AuthWithWagmi(connector, wagmiConfig); - lastSyncedBinding.current = { - provider: newEth, - connectorName: connection.connectorName, - }; reconnect(); } else { - lastSyncedBinding.current = { provider: null, connectorName: null }; + lastSyncedProvider.current = null; + lastSyncedConnectorName.current = null; if (wagmiConfig.state.status === "connected") { suppressWagmiDisconnect.current = true; await disconnectWeb3AuthFromWagmi(wagmiConfig); diff --git a/packages/no-modal/src/vue/wagmi/provider.ts b/packages/no-modal/src/vue/wagmi/provider.ts index 23f4b81df..9281f8490 100644 --- a/packages/no-modal/src/vue/wagmi/provider.ts +++ b/packages/no-modal/src/vue/wagmi/provider.ts @@ -149,16 +149,18 @@ const Web3AuthWagmiProvider = defineComponent({ const newIsConnected = isConnected.value; const newConnection = connection.value; const newEth = newConnection?.ethereumProvider ?? null; - const shouldBindToWagmi = newIsConnected && chainNamespace.value === CHAIN_NAMESPACES.EIP155 && Boolean(newConnection && newEth); const w3aWagmiConnector = getWeb3authConnector(wagmiConfig); - if (shouldBindToWagmi && newConnection && newEth) { + const shouldBindToWagmi = newIsConnected && chainNamespace.value === CHAIN_NAMESPACES.EIP155 && Boolean(newConnection && newEth); + + if (shouldBindToWagmi) { const hasSameBinding = lastSyncedProvider.value === newEth && lastSyncedConnectorName.value === newConnection.connectorName && - newConnection?.connectorName === web3Auth.value?.primaryConnectorName; + newConnection?.connectorName === web3Auth.value?.connection.connectorName && + wagmiConfig.state.status === "connected"; - if (hasSameBinding && wagmiConfig.state.status === "connected") { + if (hasSameBinding) { return; } @@ -171,14 +173,14 @@ const Web3AuthWagmiProvider = defineComponent({ } } + lastSyncedProvider.value = newEth; + lastSyncedConnectorName.value = newConnection.connectorName; const connector = setupConnector(newEth, wagmiConfig); if (!connector) { throw new Error("Failed to setup connector"); } await connectWeb3AuthWithWagmi(connector, wagmiConfig); - lastSyncedProvider.value = newEth; - lastSyncedConnectorName.value = newConnection.connectorName; reconnect(); } else if (!newIsConnected || chainNamespace.value !== CHAIN_NAMESPACES.EIP155) { lastSyncedProvider.value = null; diff --git a/packages/no-modal/test/accountLinkingErrors.test.ts b/packages/no-modal/test/accountLinkingErrors.test.ts new file mode 100644 index 000000000..6336de01b --- /dev/null +++ b/packages/no-modal/test/accountLinkingErrors.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { AccountLinkingError, formatAccountLinkingErrorMessage, getAccountLinkingRequestError } from "../src/account-linking/errors"; + +describe("account-linking errors", () => { + it("creates typed account-linking errors", () => { + const error = AccountLinkingError.walletProofFailed("Signature request failed"); + + expect(error).toBeInstanceOf(AccountLinkingError); + expect(error.name).toBe("AccountLinkingError"); + expect(error.code).toBe(5404); + expect(error.message).toBe("Failed to obtain wallet proof token. Signature request failed"); + expect(error.toString()).toBe("[5404] Failed to obtain wallet proof token. Signature request failed"); + }); + + it("returns the same account-linking error instance", async () => { + const error = AccountLinkingError.cannotUnlinkActiveAccount(); + + await expect(getAccountLinkingRequestError(error)).resolves.toBe(error); + }); + + it("maps 409 responses to an already-registered wallet message", async () => { + const response = new Response(null, { status: 409 }); + + await expect(getAccountLinkingRequestError(response)).resolves.toMatchObject({ + name: "AccountLinkingError", + code: 5401, + message: "Account linking request failed. This wallet address is already registered on this dApp", + }); + }); + + it("uses the response json message when available", async () => { + const response = new Response(JSON.stringify({ message: "Server rejected the wallet proof" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + + await expect(getAccountLinkingRequestError(response)).resolves.toMatchObject({ + name: "AccountLinkingError", + code: 5401, + message: "Account linking request failed. Server rejected the wallet proof", + }); + }); + + it("falls back to the original error message for regular errors", async () => { + const error = new Error("MetaMask request was rejected"); + + await expect(getAccountLinkingRequestError(error)).resolves.toMatchObject({ + name: "AccountLinkingError", + code: 5401, + message: "Account linking request failed. MetaMask request was rejected", + cause: error, + }); + }); + + it("formats account-linking errors with code and message", () => { + const error = AccountLinkingError.accountNotLinked("Wallet not found"); + + expect(formatAccountLinkingErrorMessage(error)).toBe("[5407] Account not linked. Wallet not found"); + }); + + it("formats regular errors using the error message", () => { + expect(formatAccountLinkingErrorMessage(new Error("Something failed"))).toBe("Something failed"); + }); + + it("formats plain objects as json and uses the fallback for circular values", () => { + expect(formatAccountLinkingErrorMessage({ reason: "bad request" })).toBe('{"reason":"bad request"}'); + + const circular: { self?: unknown } = {}; + circular.self = circular; + + expect(formatAccountLinkingErrorMessage(circular)).toBe("Unknown error during the operation."); + expect(formatAccountLinkingErrorMessage(circular, "Custom fallback")).toBe("Custom fallback"); + }); +}); diff --git a/packages/no-modal/test/accountLinkingRest.test.ts b/packages/no-modal/test/accountLinkingRest.test.ts index e8efbef01..850698f3d 100644 --- a/packages/no-modal/test/accountLinkingRest.test.ts +++ b/packages/no-modal/test/accountLinkingRest.test.ts @@ -3,9 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { makeAccountLinkingRequest, makeAccountUnlinkingRequest } from "../src/account-linking/rest"; -vi.mock("@toruslabs/http-helpers", () => ({ - post: vi.fn(), -})); +vi.mock("@toruslabs/http-helpers", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + post: vi.fn(), + }; +}); const mockPost = vi.mocked(post);