Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions demo/vue-app-new/src/components/AppDashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CHAIN_NAMESPACES, IProvider, log, WALLET_CONNECTORS, WALLET_PLUGINS } f
import {
useCheckout,
useFunding,
useLinkWallet,
useReceive,
useEnableMFA,
useIdentityToken,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -351,7 +353,7 @@ const onSwitchChainNamespace = async () => {
</Button>
</div>
<div class="mb-2">
<Button :loading="userInfoLoading" block size="xs" pill @click="onGetUserInfo">
<Button :loading="userInfoLoading.value" block size="xs" pill @click="onGetUserInfo">
{{ $t("app.buttons.btnGetUserInfo") }}
</Button>

Expand All @@ -372,23 +374,27 @@ const onSwitchChainNamespace = async () => {
>
{{ isMFAEnabled ? "Manage MFA" : "Enable MFA" }}
</Button>

<Button :loading="linkWalletLoading.value" block size="xs" pill class="my-2" @click="() => linkWallet()">
Link Wallet
</Button>
</div>
<!-- Wallet Services -->
<Card v-if="isDisplay('walletServices')" class="!h-auto lg:!h-[calc(100dvh_-_240px)] gap-4 px-4 py-4 mb-2" :shadow="false">
<div class="mb-2 text-xl font-bold leading-tight text-left">Wallet Service</div>
<Button :loading="showWalletUILoading" block size="xs" pill class="mb-2" @click="() => showWalletUI()">
<Button :loading="showWalletUILoading.value" block size="xs" pill class="mb-2" @click="() => showWalletUI()">
{{ $t("app.buttons.btnShowWalletUI") }}
</Button>
<Button :loading="showWalletConnectScannerLoading" block size="xs" pill class="mb-2" @click="() => showWalletConnectScanner()">
<Button :loading="showWalletConnectScannerLoading.value" block size="xs" pill class="mb-2" @click="() => showWalletConnectScanner()">
{{ $t("app.buttons.btnShowWalletConnectScanner") }}
</Button>
<Button :loading="showFundingLoading" block size="xs" pill class="mb-2" @click="() => showFunding()">
<Button :loading="showFundingLoading.value" block size="xs" pill class="mb-2" @click="() => showFunding()">
{{ $t("app.buttons.btnShowFunding") }}
</Button>
<Button :loading="showCheckoutLoading" block size="xs" pill class="mb-2" @click="() => showCheckout()">
<Button :loading="showCheckoutLoading.value" block size="xs" pill class="mb-2" @click="() => showCheckout()">
{{ $t("app.buttons.btnShowCheckout") }}
</Button>
<Button :loading="showReceiveLoading" block size="xs" pill class="mb-2" @click="() => showReceive()">
<Button :loading="showReceiveLoading.value" block size="xs" pill class="mb-2" @click="() => showReceive()">
{{ $t("app.buttons.btnShowReceive") }}
</Button>
<!-- <Button v-if="isDisplay('ethServices')" block size="xs" pill class="mb-2" @click="onWalletSignPersonalMessage">
Expand Down Expand Up @@ -438,7 +444,7 @@ const onSwitchChainNamespace = async () => {
<Button block size="xs" pill class="mb-2" @click="onSignPersonalMsg">
{{ t("app.buttons.btnSignPersonalMsg") }}
</Button>
<Button :loading="getIdentityTokenLoading" block size="xs" pill class="mb-2" @click="ongetIdentityToken">Get id token</Button>
<Button :loading="getIdentityTokenLoading.value" block size="xs" pill class="mb-2" @click="ongetIdentityToken">Get id token</Button>
</Card>

<!-- SOLANA -->
Expand All @@ -460,7 +466,7 @@ const onSwitchChainNamespace = async () => {
<Button block size="xs" pill class="mb-2" @click="onSignAllTransactions">
{{ t("app.buttons.btnSignAllTransactions") }}
</Button>
<Button :loading="getIdentityTokenLoading" block size="xs" pill class="mb-2" @click="ongetIdentityToken">Get id token</Button>
<Button :loading="getIdentityTokenLoading.value" block size="xs" pill class="mb-2" @click="ongetIdentityToken">Get id token</Button>
</Card>
</Card>
<Card
Expand Down
1 change: 1 addition & 0 deletions packages/modal/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface ConnectorsModalConfig {
}
export interface IWeb3AuthModal extends IWeb3Auth {
connect(): Promise<IProvider | null>;
linkWallet(): Promise<void>;
}
8 changes: 8 additions & 0 deletions packages/modal/src/modalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ export class Web3Auth extends Web3AuthNoModal implements IWeb3AuthModal {
});
}

public async linkWallet(): Promise<void> {
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 || {});
Expand Down
2 changes: 2 additions & 0 deletions packages/modal/src/ui/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
153 changes: 153 additions & 0 deletions packages/modal/src/ui/containers/LinkWallet/LinkWallet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
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";

export interface LinkWalletProps {
allRegistryButtons: ExternalButton[];
customConnectorButtons: ExternalButton[];
connectorVisibilityMap: Record<string, boolean>;
externalWalletsConfig: Record<string, BaseConnectorConfig>;
}

function LinkWallet(props: LinkWalletProps) {
const { allRegistryButtons, customConnectorButtons, connectorVisibilityMap } = props;

const { isDark, uiConfig } = useWidget();
const { walletRegistry } = uiConfig;

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<string>();
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<string>();
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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing auto-expand effect from duplicated wallet logic

Low Severity

LinkWallet duplicates the wallet discovery logic from ConnectWallet but omits the useEffect that auto-sets isShowAllWallets to true when totalExternalWalletsCount <= 15. In ConnectWallet, this effect ensures small wallet lists are fully expanded immediately. Without it, LinkWallet always starts with only the "default" subset visible and shows a "More Wallets" button — even when there are only a handful of wallets — creating a UX inconsistency. Extracting the shared filtering/sorting logic into a common hook would prevent such drift.

Fix in Cursor Fix in Web


const totalExternalWalletsCount = filteredButtons.length;

const initialWalletCount = useMemo(() => {
if (isShowAllWallets) return totalExternalWalletsCount;
return walletDiscoverySupported ? defaultButtons.length : installedWalletButtons.length;
}, [walletDiscoverySupported, defaultButtons, installedWalletButtons, isShowAllWallets, totalExternalWalletsCount]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extensive duplicated wallet filtering logic across components

Medium Severity

LinkWallet.tsx duplicates nine memoized wallet filtering/sorting computations (~70 lines) nearly verbatim from ConnectWallet.tsx: walletDiscoverySupported, allUniqueButtons, defaultButtonKeys, defaultButtons, installedWalletButtons, filteredButtons, externalButtons, totalExternalWalletsCount, and initialWalletCount. No shared hook exists for this logic. A bug fix in one location risks being missed in the other, which is especially concerning given the complexity of the filtering/sorting chains.

Fix in Cursor Fix in Web


const handleWalletSearch = useCallback((e: FormEvent<HTMLInputElement>) => {
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,
});
}, []);

const handleMoreWallets = useCallback(() => {
setIsShowAllWallets(true);
}, []);

return (
<div className="w3a--relative w3a--flex w3a--flex-1 w3a--flex-col w3a--gap-y-4">
<div className="w3a--flex w3a--items-center w3a--justify-center">
<p className="w3a--text-base w3a--font-medium w3a--text-app-gray-900 dark:w3a--text-app-white">Link a wallet</p>
</div>
<div className="w3a--flex w3a--flex-col w3a--gap-y-2">
<ConnectWalletChainFilter isDark={isDark} isLoading={false} selectedChain={selectedChain} setSelectedChain={setSelectedChain} />
<ConnectWalletSearch
totalExternalWalletCount={totalExternalWalletsCount}
isLoading={false}
walletSearch={walletSearch}
handleWalletSearch={handleWalletSearch}
/>
<ConnectWalletList
externalButtons={externalButtons}
isLoading={false}
totalExternalWalletsCount={totalExternalWalletsCount}
initialWalletCount={initialWalletCount}
handleWalletClick={handleWalletClick}
handleMoreWallets={handleMoreWallets}
isDark={isDark}
walletConnectUri=""
isShowAllWallets={isShowAllWallets}
/>
</div>
</div>
);
}

export default LinkWallet;
2 changes: 2 additions & 0 deletions packages/modal/src/ui/containers/LinkWallet/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { LinkWalletProps } from "./LinkWallet";
export { default } from "./LinkWallet";
10 changes: 10 additions & 0 deletions packages/modal/src/ui/containers/Root/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -233,6 +234,15 @@ function RootContent(props: RootProps) {
isExternalWalletModeOnly={isExternalWalletModeOnly}
/>
)}
{/* Link Wallet Screen */}
{modalState.currentPage === PAGES.LINK_WALLET && modalState.status === MODAL_STATUS.INITIALIZED && (
<LinkWallet
allRegistryButtons={allRegistryButtons}
customConnectorButtons={customConnectorButtons}
connectorVisibilityMap={connectorVisibilityMap}
externalWalletsConfig={modalState.externalWalletsConfig}
/>
)}
</>
)}

Expand Down
11 changes: 11 additions & 0 deletions packages/modal/src/ui/loginModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/modal/src/vue/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
36 changes: 36 additions & 0 deletions packages/modal/src/vue/composables/useLinkWallet.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
error: Ref<Web3AuthError | null>;
linkWallet(): Promise<void>;
}

export const useLinkWallet = (): IUseLinkWallet => {
const { web3Auth } = useWeb3AuthInner();
const loading = ref(false);
const error = ref<Web3AuthError | null>(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,
};
};
Loading