Skip to content

[SDK] Add required email verification for in-app wallet #7130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
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
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"use client";

import { inAppWallet } from "thirdweb/wallets/in-app";
import { StyledConnectEmbed } from "../styled-connect-embed";
import { StyledConnectButton } from "../styled-connect-button";

export function InAppConnectEmbed() {
return (
<StyledConnectEmbed
<StyledConnectButton
wallets={[
inAppWallet({
auth: {
Expand All @@ -24,6 +24,7 @@ export function InAppConnectEmbed() {
"passkey",
"guest",
],
required: ["email"],
},
}),
]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function SponsoredInAppTxPreview() {
"passkey",
"guest",
],
required: ["email"],
},
// TODO (7702): update to 7702 once pectra is out
executionMode: {
Expand Down
1 change: 1 addition & 0 deletions apps/playground-web/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const WALLETS = [
"farcaster",
"line",
],
required: ["email"],
mode: "redirect",
passkeyDomain: getDomain(),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccoun
import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js";
import { useIsAutoConnecting } from "../../../../core/hooks/wallets/useIsAutoConnecting.js";
import { useConnectionManager } from "../../../../core/providers/connection-manager.js";
import { useProfiles } from "../../../hooks/wallets/useProfiles.js";
import { WalletUIStatesProvider } from "../../../providers/wallet-ui-states-provider.js";
import { canFitWideModal } from "../../../utils/canFitWideModal.js";
import { usePreloadWalletProviders } from "../../../utils/usePreloadWalletProviders.js";
Expand Down Expand Up @@ -184,8 +185,38 @@ export function ConnectEmbed(props: ConnectEmbedProps) {
const activeWallet = useActiveWallet();
const activeAccount = useActiveAccount();
const siweAuth = useSiweAuth(activeWallet, activeAccount, props.auth);

// profiles for linking requirement
const { data: profiles } = useProfiles({ client: props.client });

// Determine if wallet requires an email and user doesn't have one linked
const needsEmailLink = (() => {
if (!activeWallet || !profiles) {
return false;
}

const walletConfig = (
activeWallet as unknown as {
getConfig?: () => { auth?: { required?: string[] } } | undefined;
}
).getConfig?.();

const required = walletConfig?.auth?.required || [];
const requiresEmail = required.includes("email");

if (!requiresEmail) {
return false;
}

const hasEmail = profiles.some((p) => !!p.details.email);

return !hasEmail;
})();

const show =
!activeAccount || (siweAuth.requiresAuth && !siweAuth.isLoggedIn);
!activeAccount ||
(siweAuth.requiresAuth && !siweAuth.isLoggedIn) ||
needsEmailLink;
const connectionManager = useConnectionManager();

// Add props.chain and props.chains to defined chains store
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
"use client";
import { Suspense, lazy, useCallback } from "react";
import { Suspense, lazy, useCallback, useEffect, useState } from "react";
import type { Chain } from "../../../../../chains/types.js";
import type { ThirdwebClient } from "../../../../../client/client.js";
import { isEcosystemWallet } from "../../../../../wallets/ecosystem/is-ecosystem-wallet.js";
import type { Profile } from "../../../../../wallets/in-app/core/authentication/types.js";
import { getProfiles } from "../../../../../wallets/in-app/web/lib/auth/index.js";
import type { Wallet } from "../../../../../wallets/interfaces/wallet.js";
import type { SmartWalletOptions } from "../../../../../wallets/smart/types.js";
import type { WalletId } from "../../../../../wallets/wallet-types.js";
Expand All @@ -13,11 +16,13 @@ import { useActiveAccount } from "../../../../core/hooks/wallets/useActiveAccoun
import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js";
import { useSetActiveWallet } from "../../../../core/hooks/wallets/useSetActiveWallet.js";
import { useConnectionManager } from "../../../../core/providers/connection-manager.js";
import { useProfiles } from "../../../hooks/wallets/useProfiles.js";
import { useSetSelectionData } from "../../../providers/wallet-ui-states-provider.js";
import { LoadingScreen } from "../../../wallets/shared/LoadingScreen.js";
import { WalletSelector } from "../WalletSelector.js";
import { onModalUnmount, reservedScreens } from "../constants.js";
import type { ConnectLocale } from "../locale/types.js";
import { LinkProfileScreen } from "../screens/LinkProfileScreen.js";
import { SignatureScreen } from "../screens/SignatureScreen.js";
import { StartScreen } from "../screens/StartScreen.js";
import type { WelcomeScreen } from "../screens/types.js";
Expand Down Expand Up @@ -84,45 +89,147 @@ export const ConnectModalContent = (props: {
const showSignatureScreen = siweAuth.requiresAuth && !siweAuth.isLoggedIn;
const connectionManager = useConnectionManager();

// state to hold wallet awaiting email link
const [pendingWallet, setPendingWallet] = useState<Wallet | undefined>();

// get profiles to observe email linking
const profilesQuery = useProfiles({ client: props.client });

const handleConnected = useCallback(
(wallet: Wallet) => {
if (shouldSetActive) {
setActiveWallet(wallet);
} else {
connectionManager.addConnectedWallet(wallet);
}
async (wallet: Wallet) => {
// we will only set active wallet and call onConnect once requirements are met
const finalizeConnection = (w: Wallet) => {
if (shouldSetActive) {
setActiveWallet(w);
} else {
connectionManager.addConnectedWallet(w);
}

if (props.onConnect) {
props.onConnect(w);
}

onModalUnmount(() => {
setSelectionData({});
setScreen(initialScreen);
setModalVisibility(true);
});
};

// ----------------------------------------------------------------
// Enforce required profile linking (currently only "email")
// ----------------------------------------------------------------
type WalletConfig = {
auth?: {
required?: string[];
};
partnerId?: string;
};

const walletWithConfig = wallet as unknown as {
getConfig?: () => WalletConfig | undefined;
};

const walletConfig = walletWithConfig.getConfig
? walletWithConfig.getConfig()
: undefined;
const required = walletConfig?.auth?.required as string[] | undefined;
const requiresEmail = required?.includes("email");

Comment on lines +123 to 138
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Harden the required-field parsing

walletConfig?.auth?.required is cast to string[] | undefined without validation and then used directly. If a malformed wallet config (e.g. required: "email") is returned, the subsequent .includes() will throw or yield unexpected results.

-      const required = walletConfig?.auth?.required as string[] | undefined;
-      const requiresEmail = required?.includes("email");
+      const required = walletConfig?.auth?.required;
+      const requiresEmail =
+        Array.isArray(required) && required.includes("email");

A simple guard prevents runtime errors and removes the need for a type-cast.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
auth?: {
required?: string[];
};
partnerId?: string;
};
const walletWithConfig = wallet as unknown as {
getConfig?: () => WalletConfig | undefined;
};
const walletConfig = walletWithConfig.getConfig
? walletWithConfig.getConfig()
: undefined;
const required = walletConfig?.auth?.required as string[] | undefined;
const requiresEmail = required?.includes("email");
const walletConfig = walletWithConfig.getConfig
? walletWithConfig.getConfig()
: undefined;
const required = walletConfig?.auth?.required;
const requiresEmail =
Array.isArray(required) && required.includes("email");
🤖 Prompt for AI Agents
In
packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModalContent.tsx
around lines 123 to 138, the code casts walletConfig?.auth?.required to string[]
without validating its type, which can cause runtime errors if the value is not
an array. Fix this by adding a type guard to check if
walletConfig?.auth?.required is an array before using it, avoiding the unsafe
type cast and preventing errors when calling includes().

if (props.onConnect) {
props.onConnect(wallet);
console.log("wallet", walletConfig);

console.log("requiresEmail", requiresEmail);

if (requiresEmail) {
try {
const ecosystem = isEcosystemWallet(wallet)
? { id: wallet.id, partnerId: walletConfig?.partnerId }
: undefined;

const profiles = await getProfiles({
client: props.client,
ecosystem,
});

console.log("profiles", profiles);

const hasEmail = (profiles as Profile[]).some(
(p) => !!p.details.email,
);

console.log("hasEmail", hasEmail);

if (!hasEmail) {
setPendingWallet(wallet);
setScreen(reservedScreens.linkProfile);
return; // defer activation until linked
}
} catch (err) {
console.error("Failed to fetch profiles for required linking", err);
// if fetching profiles fails, just continue the normal flow
}
}

onModalUnmount(() => {
setSelectionData({});
setModalVisibility(true);
});
// ----------------------------------------------------------------
// Existing behavior (sign in step / close modal)
// ----------------------------------------------------------------

// show sign in screen if required
if (showSignatureScreen) {
setScreen(reservedScreens.signIn);
} else {
setScreen(initialScreen);
finalizeConnection(wallet);
onClose?.();
}
},
[
setModalVisibility,
onClose,
props.onConnect,
shouldSetActive,
setActiveWallet,
showSignatureScreen,
setScreen,
connectionManager,
props.onConnect,
setSelectionData,
shouldSetActive,
setModalVisibility,
props.client,
setScreen,
showSignatureScreen,
initialScreen,
connectionManager,
onClose,
],
);

// Effect to watch for email linking completion
useEffect(() => {
if (!pendingWallet) {
return;
}
const profiles = profilesQuery.data;
if (!profiles) {
return;
}
const hasEmail = profiles.some((p) => !!p.details.email);
if (hasEmail) {
// finalize connection now
if (shouldSetActive) {
setActiveWallet(pendingWallet);
} else {
connectionManager.addConnectedWallet(pendingWallet);
}
props.onConnect?.(pendingWallet);
setPendingWallet(undefined);
setScreen(initialScreen);
onClose?.();
}
}, [
profilesQuery.data,
pendingWallet,
shouldSetActive,
setActiveWallet,
connectionManager,
props.onConnect,
setScreen,
initialScreen,
onClose,
]);
Comment on lines +199 to +231
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consolidate duplicated “finalize connection” logic

handleConnected defines finalizeConnection, but the useEffect block re-implements the same steps (setting active wallet, calling onConnect, etc.).
Duplicating critical flows increases maintenance overhead and risks divergence.

Extract finalizeConnection with useCallback at component scope and invoke it from both places:

const finalizeConnection = useCallback((wallet: Wallet) => {
  if (shouldSetActive) {
    setActiveWallet(wallet);
  } else {
    connectionManager.addConnectedWallet(wallet);
  }
  props.onConnect?.(wallet);
}, [shouldSetActive, setActiveWallet, connectionManager, props.onConnect]);

Then call finalizeConnection(...) inside handleConnected and inside the effect.

🤖 Prompt for AI Agents
In
packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectModalContent.tsx
around lines 199 to 231, the logic to finalize the wallet connection is
duplicated both in the handleConnected function and inside the useEffect hook.
To fix this, extract the finalize connection steps into a useCallback function
named finalizeConnection at the component scope, which takes a wallet parameter
and performs setting the active wallet or adding the connected wallet, then
calls props.onConnect. Replace the duplicated code in both handleConnected and
the useEffect hook by calling finalizeConnection with the appropriate wallet
argument.


const handleBack = useCallback(() => {
setSelectionData({});
setScreen(initialScreen);
Expand All @@ -145,7 +252,9 @@ export const ConnectModalContent = (props: {
onShowAll={() => {
setScreen(reservedScreens.showAll);
}}
done={handleConnected}
done={async (w) => {
await handleConnected(w);
}}
goBack={props.wallets.length > 1 ? handleBack : undefined}
setModalVisibility={setModalVisibility}
client={props.client}
Expand Down Expand Up @@ -195,8 +304,8 @@ export const ConnectModalContent = (props: {
<SmartConnectUI
key={wallet.id}
accountAbstraction={props.accountAbstraction}
done={(smartWallet) => {
handleConnected(smartWallet);
done={async (smartWallet) => {
await handleConnected(smartWallet);
}}
personalWallet={wallet}
onBack={goBack}
Expand All @@ -217,8 +326,8 @@ export const ConnectModalContent = (props: {
key={wallet.id}
wallet={wallet}
onBack={goBack}
done={() => {
handleConnected(wallet);
done={async () => {
await handleConnected(wallet);
}}
setModalVisibility={props.setModalVisibility}
chain={props.chain}
Expand All @@ -245,6 +354,16 @@ export const ConnectModalContent = (props: {
/>
);

const linkProfileScreen = (
<LinkProfileScreen
onBack={handleBack}
locale={props.connectLocale}
client={props.client}
walletConnect={props.walletConnect}
wallet={pendingWallet}
/>
);

return (
<ScreenSetupContext.Provider value={props.screenSetup}>
{props.size === "wide" ? (
Expand All @@ -256,6 +375,7 @@ export const ConnectModalContent = (props: {
{screen === reservedScreens.main && getStarted}
{screen === reservedScreens.getStarted && getStarted}
{screen === reservedScreens.showAll && showAll}
{screen === reservedScreens.linkProfile && linkProfileScreen}
{typeof screen !== "string" && getWalletUI(screen)}
</>
}
Expand All @@ -266,6 +386,7 @@ export const ConnectModalContent = (props: {
{screen === reservedScreens.main && walletList}
{screen === reservedScreens.getStarted && getStarted}
{screen === reservedScreens.showAll && showAll}
{screen === reservedScreens.linkProfile && linkProfileScreen}
{typeof screen !== "string" && getWalletUI(screen)}
</ConnectModalCompactLayout>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const reservedScreens = {
getStarted: "getStarted",
signIn: "signIn",
showAll: "showAll",
linkProfile: "linkProfile",
};

export const modalMaxWidthCompact = "360px";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ export function LinkProfileScreen(props: {
locale: ConnectLocale;
client: ThirdwebClient;
walletConnect: { projectId?: string } | undefined;
wallet?: Wallet;
}) {
const adminWallet = useAdminWallet();
const activeWallet = useActiveWallet();
const chain = useActiveWalletChain();
const queryClient = useQueryClient();

const wallet = adminWallet || activeWallet;
const wallet = props.wallet || adminWallet || activeWallet;

if (!wallet) {
return <LoadingScreen />;
Expand Down
Loading
Loading