Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
206 changes: 206 additions & 0 deletions packages/modal/src/ui/containers/LinkWallet/LinkWallet.tsx
Original file line number Diff line number Diff line change
@@ -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<string, boolean>;
externalWalletsConfig: Record<string, BaseConnectorConfig>;
}

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<LinkWalletStep>("wallet_list");
const [stepError, setStepError] = useState(false);
const [selectedWallet, setSelectedWallet] = useState<ExternalButton | null>(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<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,
});
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 (
<LinkWalletConnecting
walletName={walletName}
walletId={walletId}
imgExtension={imgExtension}
stepError={stepError}
onSimulateSuccess={() => {
setStepError(false);
setStep("sign_verify");
}}
onSimulateError={() => setStepError(true)}
onRetry={() => setStepError(false)}
/>
);
}

if (step === "sign_verify") {
return (
<LinkWalletSignVerify
walletName={walletName}
walletId={walletId}
imgExtension={imgExtension}
stepError={stepError}
onSimulateSuccess={() => {
setStepError(false);
setStep("success");
}}
onSimulateError={() => setStepError(true)}
onRetry={() => setStepError(false)}
/>
);
}

if (step === "success") {
return <LinkWalletSuccess walletName={walletName} walletId={walletId} imgExtension={imgExtension} />;
}

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;
Original file line number Diff line number Diff line change
@@ -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 (
<div className="w3a--flex w3a--flex-1 w3a--flex-col w3a--items-center w3a--justify-center w3a--gap-y-4 w3a--px-6 w3a--py-8 w3a--text-center">
{stepError ? (
<>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20" className="w3a--error-logo">
<path
fill="currentColor"
fillRule="evenodd"
d="M18 10a8 8 0 1 1-16.001 0A8 8 0 0 1 18 10m-7 4a1 1 0 1 1-2 0 1 1 0 0 1 2 0m-1-9a1 1 0 0 0-1 1v4a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1"
clipRule="evenodd"
/>
</svg>
<p className="w3a--text-lg w3a--font-semibold w3a--text-app-gray-900 dark:w3a--text-app-white">Connection failed</p>
<p className="w3a--text-sm w3a--text-app-gray-400">Unable to connect to {walletName}. Please try again.</p>
<button
type="button"
onClick={onRetry}
className="w3a--w-full w3a--rounded-xl w3a--bg-app-gray-100 w3a--p-2 w3a--py-3 w3a--text-center w3a--text-sm w3a--text-app-gray-900 dark:w3a--bg-app-gray-800 dark:w3a--text-app-white"
>
Retry
</button>
</>
) : (
<>
<Image
imageId={`login-${walletId}`}
hoverImageId={`login-${walletId}`}
fallbackImageId="wallet"
height="60"
width="60"
isButton
extension={imgExtension}
/>
<PulseLoader />
<p className="w3a--text-lg w3a--font-semibold w3a--text-app-gray-900 dark:w3a--text-app-white">Waiting for {walletName}</p>
<p className="w3a--text-sm w3a--text-app-gray-400">Please approve the connection request in your wallet.</p>
</>
)}
{/* TODO: remove -- temporary test buttons */}
<div className="w3a--mt-4 w3a--flex w3a--w-full w3a--gap-x-2">
<button
type="button"
onClick={onSimulateSuccess}
className="w3a--flex-1 w3a--rounded-xl w3a--bg-app-gray-50 w3a--p-2 w3a--text-xs w3a--text-app-gray-400 dark:w3a--bg-app-gray-700 dark:w3a--text-app-gray-300"
>
Simulate success
</button>
<button
type="button"
onClick={onSimulateError}
className="w3a--flex-1 w3a--rounded-xl w3a--bg-app-gray-50 w3a--p-2 w3a--text-xs w3a--text-app-gray-400 dark:w3a--bg-app-gray-700 dark:w3a--text-app-gray-300"
>
Simulate error
</button>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Temporary simulate buttons committed in production components

High Severity

LinkWalletConnecting and LinkWalletSignVerify both include "Simulate success" and "Simulate error" buttons marked with TODO: remove -- temporary test buttons. These are visible to end users and allow bypassing the wallet connection and signature verification steps entirely — advancing through the link-wallet flow without any real wallet interaction.

Additional Locations (1)
Fix in Cursor Fix in Web

</div>
);
}

export default LinkWalletConnecting;
Loading
Loading