diff --git a/demo/vue-app-new/src/components/AppDashboard.vue b/demo/vue-app-new/src/components/AppDashboard.vue
index 8aacc11ae..091eea56e 100644
--- a/demo/vue-app-new/src/components/AppDashboard.vue
+++ b/demo/vue-app-new/src/components/AppDashboard.vue
@@ -4,6 +4,7 @@ import { CHAIN_NAMESPACES, IProvider, log, WALLET_CONNECTORS, WALLET_PLUGINS } f
import {
useCheckout,
useFunding,
+ useLinkWallet,
useReceive,
useEnableMFA,
useIdentityToken,
@@ -51,6 +52,7 @@ const { showCheckout, loading: showCheckoutLoading } = useCheckout();
const { showFunding, loading: showFundingLoading } = useFunding();
const { showReceive, loading: showReceiveLoading } = useReceive();
const { getIdentityToken, loading: getIdentityTokenLoading } = useIdentityToken();
+const { linkWallet, loading: linkWalletLoading } = useLinkWallet();
const { status, address } = useConnection();
const { mutateAsync: signTypedDataAsync } = useSignTypedData();
const { mutateAsync: signMessageAsync } = useSignMessage();
@@ -351,7 +353,7 @@ const onSwitchChainNamespace = async () => {
-
+
+ linkWallet()">
+ Link Wallet
+
Wallet Service
- showWalletUI()">
+ showWalletUI()">
{{ $t("app.buttons.btnShowWalletUI") }}
- showWalletConnectScanner()">
+ showWalletConnectScanner()">
{{ $t("app.buttons.btnShowWalletConnectScanner") }}
- showFunding()">
+ showFunding()">
{{ $t("app.buttons.btnShowFunding") }}
- showCheckout()">
+ showCheckout()">
{{ $t("app.buttons.btnShowCheckout") }}
- showReceive()">
+ showReceive()">
{{ $t("app.buttons.btnShowReceive") }}
@@ -460,7 +466,7 @@ const onSwitchChainNamespace = async () => {
{{ t("app.buttons.btnSignAllTransactions") }}
- Get id token
+ Get id token
;
+ linkWallet(): Promise;
}
diff --git a/packages/modal/src/modalManager.ts b/packages/modal/src/modalManager.ts
index 0d4512d63..dfa0d4bd8 100644
--- a/packages/modal/src/modalManager.ts
+++ b/packages/modal/src/modalManager.ts
@@ -220,6 +220,14 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal {
});
}
+ public async linkWallet(): Promise {
+ if (!this.loginModal) throw WalletInitializationError.notReady("Login modal is not initialized");
+ if (!this.connectedConnectorName || !CONNECTED_STATUSES.includes(this.status)) {
+ throw WalletInitializationError.notReady("Must be connected before linking a wallet");
+ }
+ this.loginModal.openLinkWallet();
+ }
+
protected initUIConfig(projectConfig: ProjectConfig) {
super.initUIConfig(projectConfig);
this.options.uiConfig = deepmerge(cloneDeep(projectConfig.whitelabel || {}), this.options.uiConfig || {});
diff --git a/packages/modal/src/ui/constants.ts b/packages/modal/src/ui/constants.ts
index e1afe1cd3..c40a400ac 100644
--- a/packages/modal/src/ui/constants.ts
+++ b/packages/modal/src/ui/constants.ts
@@ -7,6 +7,8 @@ export const PAGES = {
WALLET_LIST: "connect_wallet",
/** QR code or instructions for connecting to the selected wallet */
WALLET_CONNECTION_DETAILS: "selected_wallet",
+ /** Link an external wallet to the logged-in account */
+ LINK_WALLET: "link_wallet",
};
export const CONNECT_WALLET_PAGES = {
diff --git a/packages/modal/src/ui/containers/LinkWallet/LinkWallet.tsx b/packages/modal/src/ui/containers/LinkWallet/LinkWallet.tsx
new file mode 100644
index 000000000..3536e1302
--- /dev/null
+++ b/packages/modal/src/ui/containers/LinkWallet/LinkWallet.tsx
@@ -0,0 +1,206 @@
+import { type BaseConnectorConfig, type ChainNamespaceType, log, WALLET_CONNECTORS } from "@web3auth/no-modal";
+import { FormEvent, useCallback, useMemo, useState } from "react";
+
+import { useWidget } from "../../context/WidgetContext";
+import { ExternalButton } from "../../interfaces";
+import ConnectWalletChainFilter from "../ConnectWallet/ConnectWalletChainFilter";
+import ConnectWalletList from "../ConnectWallet/ConnectWalletList";
+import ConnectWalletSearch from "../ConnectWallet/ConnectWalletSearch";
+import LinkWalletConnecting from "./LinkWalletConnecting";
+import LinkWalletSignVerify from "./LinkWalletSignVerify";
+import LinkWalletSuccess from "./LinkWalletSuccess";
+
+export interface LinkWalletProps {
+ allRegistryButtons: ExternalButton[];
+ customConnectorButtons: ExternalButton[];
+ connectorVisibilityMap: Record;
+ externalWalletsConfig: Record;
+}
+
+type LinkWalletStep = "wallet_list" | "connecting" | "sign_verify" | "success";
+
+function LinkWallet(props: LinkWalletProps) {
+ const { allRegistryButtons, customConnectorButtons, connectorVisibilityMap } = props;
+
+ const { isDark, uiConfig } = useWidget();
+ const { walletRegistry } = uiConfig;
+
+ const [step, setStep] = useState("wallet_list");
+ const [stepError, setStepError] = useState(false);
+ const [selectedWallet, setSelectedWallet] = useState(null);
+ const [walletSearch, setWalletSearch] = useState("");
+ const [selectedChain, setSelectedChain] = useState("all");
+ const [isShowAllWallets, setIsShowAllWallets] = useState(false);
+
+ const config = useMemo(() => props.externalWalletsConfig ?? {}, [props.externalWalletsConfig]);
+
+ const walletDiscoverySupported = useMemo(
+ () => walletRegistry && (Object.keys(walletRegistry.default || {}).length > 0 || Object.keys(walletRegistry.others || {}).length > 0),
+ [walletRegistry]
+ );
+
+ const allUniqueButtons = useMemo(() => {
+ const seen = new Set();
+ return customConnectorButtons.concat(allRegistryButtons).filter((b: ExternalButton) => {
+ if (seen.has(b.name)) return false;
+ seen.add(b.name);
+ return true;
+ });
+ }, [allRegistryButtons, customConnectorButtons]);
+
+ const defaultButtonKeys = useMemo(() => new Set(Object.keys(walletRegistry.default)), [walletRegistry]);
+
+ const defaultButtons = useMemo(() => {
+ const buttons = [
+ ...allRegistryButtons.filter((b: ExternalButton) => b.hasInjectedWallet && defaultButtonKeys.has(b.name)),
+ ...customConnectorButtons,
+ ...allRegistryButtons.filter((b: ExternalButton) => !b.hasInjectedWallet && defaultButtonKeys.has(b.name)),
+ ].sort((a: ExternalButton, b: ExternalButton) => {
+ if (a.name === WALLET_CONNECTORS.METAMASK && b.name !== WALLET_CONNECTORS.METAMASK) return -1;
+ if (b.name === WALLET_CONNECTORS.METAMASK && a.name !== WALLET_CONNECTORS.METAMASK) return 1;
+ return 0;
+ });
+
+ const seen = new Set();
+ return buttons
+ .filter((b: ExternalButton) => {
+ if (seen.has(b.name)) return false;
+ seen.add(b.name);
+ return true;
+ })
+ .filter((b: ExternalButton) => selectedChain === "all" || b.chainNamespaces?.includes(selectedChain as ChainNamespaceType));
+ }, [allRegistryButtons, customConnectorButtons, defaultButtonKeys, selectedChain]);
+
+ const installedWalletButtons = useMemo(() => {
+ return Object.keys(config).reduce((acc: ExternalButton[], connector: string) => {
+ if (connector !== WALLET_CONNECTORS.WALLET_CONNECT_V2 && connectorVisibilityMap[connector]) {
+ acc.push({
+ name: connector,
+ displayName: config[connector].label || connector,
+ hasInjectedWallet: config[connector].isInjected || false,
+ hasWalletConnect: false,
+ hasInstallLinks: false,
+ });
+ }
+ return acc;
+ }, []);
+ }, [config, connectorVisibilityMap]);
+
+ const filteredButtons = useMemo(() => {
+ if (walletDiscoverySupported) {
+ return [
+ ...allUniqueButtons.filter((b: ExternalButton) => b.hasInjectedWallet),
+ ...allUniqueButtons.filter((b: ExternalButton) => !b.hasInjectedWallet),
+ ]
+ .sort((a: ExternalButton) => (a.name === WALLET_CONNECTORS.METAMASK ? -1 : 1))
+ .filter((b: ExternalButton) => selectedChain === "all" || b.chainNamespaces?.includes(selectedChain as ChainNamespaceType))
+ .filter((b: ExternalButton) => b.name.toLowerCase().includes(walletSearch.toLowerCase()));
+ }
+ return installedWalletButtons;
+ }, [walletDiscoverySupported, installedWalletButtons, walletSearch, allUniqueButtons, selectedChain]);
+
+ const externalButtons = useMemo(() => {
+ if (walletDiscoverySupported && !walletSearch && !isShowAllWallets) return defaultButtons;
+ return filteredButtons;
+ }, [walletDiscoverySupported, walletSearch, filteredButtons, defaultButtons, isShowAllWallets]);
+
+ const totalExternalWalletsCount = filteredButtons.length;
+
+ const initialWalletCount = useMemo(() => {
+ if (isShowAllWallets) return totalExternalWalletsCount;
+ return walletDiscoverySupported ? defaultButtons.length : installedWalletButtons.length;
+ }, [walletDiscoverySupported, defaultButtons, installedWalletButtons, isShowAllWallets, totalExternalWalletsCount]);
+
+ const handleWalletSearch = useCallback((e: FormEvent) => {
+ setWalletSearch((e.target as HTMLInputElement).value);
+ }, []);
+
+ const handleWalletClick = useCallback((button: ExternalButton) => {
+ log.info("linkWallet: wallet selected", {
+ name: button.name,
+ displayName: button.displayName,
+ isInstalled: button.isInstalled,
+ hasInjectedWallet: button.hasInjectedWallet,
+ chainNamespaces: button.chainNamespaces,
+ });
+ setSelectedWallet(button);
+ setStepError(false);
+ setStep("connecting");
+ }, []);
+
+ const handleMoreWallets = useCallback(() => {
+ setIsShowAllWallets(true);
+ }, []);
+
+ const walletName = selectedWallet?.displayName || selectedWallet?.name || "Wallet";
+ const walletId = selectedWallet?.name || "";
+ const imgExtension = selectedWallet?.imgExtension;
+
+ if (step === "connecting") {
+ return (
+ {
+ setStepError(false);
+ setStep("sign_verify");
+ }}
+ onSimulateError={() => setStepError(true)}
+ onRetry={() => setStepError(false)}
+ />
+ );
+ }
+
+ if (step === "sign_verify") {
+ return (
+ {
+ setStepError(false);
+ setStep("success");
+ }}
+ onSimulateError={() => setStepError(true)}
+ onRetry={() => setStepError(false)}
+ />
+ );
+ }
+
+ if (step === "success") {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+export default LinkWallet;
diff --git a/packages/modal/src/ui/containers/LinkWallet/LinkWalletConnecting.tsx b/packages/modal/src/ui/containers/LinkWallet/LinkWalletConnecting.tsx
new file mode 100644
index 000000000..fe7fc0ee1
--- /dev/null
+++ b/packages/modal/src/ui/containers/LinkWallet/LinkWalletConnecting.tsx
@@ -0,0 +1,76 @@
+import Image from "../../components/Image";
+import PulseLoader from "../../components/PulseLoader";
+
+export interface LinkWalletConnectingProps {
+ walletName: string;
+ walletId: string;
+ imgExtension?: string;
+ stepError: boolean;
+ onSimulateSuccess: () => void;
+ onSimulateError: () => void;
+ onRetry: () => void;
+}
+
+function LinkWalletConnecting(props: LinkWalletConnectingProps) {
+ const { walletName, walletId, imgExtension, stepError, onSimulateSuccess, onSimulateError, onRetry } = props;
+
+ return (
+
+ {stepError ? (
+ <>
+
+
Connection failed
+
Unable to connect to {walletName}. Please try again.
+
+ Retry
+
+ >
+ ) : (
+ <>
+
+
+
Waiting for {walletName}
+
Please approve the connection request in your wallet.
+ >
+ )}
+ {/* TODO: remove -- temporary test buttons */}
+
+
+ Simulate success
+
+
+ Simulate error
+
+
+
+ );
+}
+
+export default LinkWalletConnecting;
diff --git a/packages/modal/src/ui/containers/LinkWallet/LinkWalletSignVerify.tsx b/packages/modal/src/ui/containers/LinkWallet/LinkWalletSignVerify.tsx
new file mode 100644
index 000000000..5010ba73f
--- /dev/null
+++ b/packages/modal/src/ui/containers/LinkWallet/LinkWalletSignVerify.tsx
@@ -0,0 +1,76 @@
+import Image from "../../components/Image";
+import PulseLoader from "../../components/PulseLoader";
+
+export interface LinkWalletSignVerifyProps {
+ walletName: string;
+ walletId: string;
+ imgExtension?: string;
+ stepError: boolean;
+ onSimulateSuccess: () => void;
+ onSimulateError: () => void;
+ onRetry: () => void;
+}
+
+function LinkWalletSignVerify(props: LinkWalletSignVerifyProps) {
+ const { walletName, walletId, imgExtension, stepError, onSimulateSuccess, onSimulateError, onRetry } = props;
+
+ return (
+
+ {stepError ? (
+ <>
+
+
Verification failed
+
Unable to verify signature from {walletName}. Please try again.
+
+ Retry
+
+ >
+ ) : (
+ <>
+
+
+
Sign to verify
+
Please sign the verification request in {walletName} to link your wallet.
+ >
+ )}
+ {/* TODO: remove -- temporary test buttons */}
+
+
+ Simulate success
+
+
+ Simulate error
+
+
+
+ );
+}
+
+export default LinkWalletSignVerify;
diff --git a/packages/modal/src/ui/containers/LinkWallet/LinkWalletSuccess.tsx b/packages/modal/src/ui/containers/LinkWallet/LinkWalletSuccess.tsx
new file mode 100644
index 000000000..41869c50f
--- /dev/null
+++ b/packages/modal/src/ui/containers/LinkWallet/LinkWalletSuccess.tsx
@@ -0,0 +1,29 @@
+import Image from "../../components/Image";
+
+export interface LinkWalletSuccessProps {
+ walletName: string;
+ walletId: string;
+ imgExtension?: string;
+}
+
+function LinkWalletSuccess(props: LinkWalletSuccessProps) {
+ const { walletName, walletId, imgExtension } = props;
+
+ return (
+
+
+
Wallet linked successfully
+
{walletName} has been linked to your account.
+
+ );
+}
+
+export default LinkWalletSuccess;
diff --git a/packages/modal/src/ui/containers/LinkWallet/index.ts b/packages/modal/src/ui/containers/LinkWallet/index.ts
new file mode 100644
index 000000000..e4f0024ec
--- /dev/null
+++ b/packages/modal/src/ui/containers/LinkWallet/index.ts
@@ -0,0 +1,2 @@
+export type { LinkWalletProps } from "./LinkWallet";
+export { default } from "./LinkWallet";
diff --git a/packages/modal/src/ui/containers/Root/Root.tsx b/packages/modal/src/ui/containers/Root/Root.tsx
index b3007f0b9..2c6bc6a1e 100644
--- a/packages/modal/src/ui/containers/Root/Root.tsx
+++ b/packages/modal/src/ui/containers/Root/Root.tsx
@@ -11,6 +11,7 @@ import { RootProvider } from "../../context/RootContext";
import { useWidget } from "../../context/WidgetContext";
import { ExternalButton, MODAL_STATUS } from "../../interfaces";
import ConnectWallet from "../ConnectWallet";
+import LinkWallet from "../LinkWallet";
import Login from "../Login";
import { RootProps } from "./Root.type";
import RootBodySheets from "./RootBodySheets/RootBodySheets";
@@ -233,6 +234,15 @@ function RootContent(props: RootProps) {
isExternalWalletModeOnly={isExternalWalletModeOnly}
/>
)}
+ {/* Link Wallet Screen */}
+ {modalState.currentPage === PAGES.LINK_WALLET && modalState.status === MODAL_STATUS.INITIALIZED && (
+
+ )}
>
)}
diff --git a/packages/modal/src/ui/loginModal.tsx b/packages/modal/src/ui/loginModal.tsx
index eee4fb4d7..b616fcfed 100644
--- a/packages/modal/src/ui/loginModal.tsx
+++ b/packages/modal/src/ui/loginModal.tsx
@@ -335,6 +335,17 @@ export class LoginModal {
}
};
+ openLinkWallet = () => {
+ this.setState({
+ modalVisibility: true,
+ currentPage: PAGES.LINK_WALLET,
+ status: MODAL_STATUS.INITIALIZED,
+ });
+ if (this.callbacks.onModalVisibility) {
+ this.callbacks.onModalVisibility(true);
+ }
+ };
+
initExternalWalletContainer = () => {
this.setState({
hasExternalWallets: true,
diff --git a/packages/modal/src/vue/composables/index.ts b/packages/modal/src/vue/composables/index.ts
index e14ac8e2a..0baa024a0 100644
--- a/packages/modal/src/vue/composables/index.ts
+++ b/packages/modal/src/vue/composables/index.ts
@@ -3,6 +3,7 @@ export * from "./useCheckout";
export * from "./useEnableMFA";
export * from "./useFunding";
export * from "./useIdentityToken";
+export * from "./useLinkWallet";
export * from "./useManageMFA";
export * from "./useReceive";
export * from "./useSwap";
diff --git a/packages/modal/src/vue/composables/useLinkWallet.ts b/packages/modal/src/vue/composables/useLinkWallet.ts
new file mode 100644
index 000000000..64a1a7fab
--- /dev/null
+++ b/packages/modal/src/vue/composables/useLinkWallet.ts
@@ -0,0 +1,36 @@
+import { log, WalletInitializationError, Web3AuthError } from "@web3auth/no-modal";
+import { Ref, ref } from "vue";
+
+import { useWeb3AuthInner } from "./useWeb3AuthInner";
+
+export interface IUseLinkWallet {
+ loading: Ref;
+ error: Ref;
+ linkWallet(): Promise;
+}
+
+export const useLinkWallet = (): IUseLinkWallet => {
+ const { web3Auth } = useWeb3AuthInner();
+ const loading = ref(false);
+ const error = ref(null);
+
+ const linkWallet = async () => {
+ try {
+ if (!web3Auth.value) throw WalletInitializationError.notReady();
+ error.value = null;
+ loading.value = true;
+ await web3Auth.value.linkWallet();
+ } catch (err) {
+ log.error("Error opening link wallet", err);
+ error.value = err as Web3AuthError;
+ } finally {
+ loading.value = false;
+ }
+ };
+
+ return {
+ loading,
+ error,
+ linkWallet,
+ };
+};