diff --git a/.changeset/green-rockets-lie.md b/.changeset/green-rockets-lie.md new file mode 100644 index 00000000000..34906c8e3cc --- /dev/null +++ b/.changeset/green-rockets-lie.md @@ -0,0 +1,17 @@ +--- +"thirdweb": minor +--- + +Exposes autoConnect as a standalone function for use outside of react. + +```tsx +import { autoConnect } from "thirdweb/wallets"; + +const autoConnected = await autoConnect({ + client, + onConnect: (wallet) => { + console.log("wallet", wallet); /// wallet that is have been auto connected. + }, +}); +console.log('isAutoConnected', isAutoConnected) // true or false +``` diff --git a/packages/thirdweb/src/exports/react.native.ts b/packages/thirdweb/src/exports/react.native.ts index fbba754f388..89cd7a3e4da 100644 --- a/packages/thirdweb/src/exports/react.native.ts +++ b/packages/thirdweb/src/exports/react.native.ts @@ -86,7 +86,7 @@ export { // Components export { AutoConnect } from "../react/native/ui/AutoConnect/AutoConnect.js"; -export type { AutoConnectProps } from "../react/core/hooks/connection/types.js"; +export type { AutoConnectProps } from "../wallets/connection/types.js"; export { TransactionButton } from "../react/native/ui/transaction/TransactionButton.js"; export type { TransactionButtonProps } from "../react/core/hooks/transaction/transaction-button-utils.js"; diff --git a/packages/thirdweb/src/exports/react.ts b/packages/thirdweb/src/exports/react.ts index 329df064db3..8d41ef0d04a 100644 --- a/packages/thirdweb/src/exports/react.ts +++ b/packages/thirdweb/src/exports/react.ts @@ -121,7 +121,7 @@ export { } from "../react/core/hooks/pay/usePostOnrampQuote.js"; export { AutoConnect } from "../react/web/ui/AutoConnect/AutoConnect.js"; -export type { AutoConnectProps } from "../react/core/hooks/connection/types.js"; +export type { AutoConnectProps } from "../wallets/connection/types.js"; // auth export type { SiweAuthOptions } from "../react/core/hooks/auth/useSiweAuth.js"; diff --git a/packages/thirdweb/src/exports/wallets.ts b/packages/thirdweb/src/exports/wallets.ts index e648e0f9873..71a34221d72 100644 --- a/packages/thirdweb/src/exports/wallets.ts +++ b/packages/thirdweb/src/exports/wallets.ts @@ -161,4 +161,6 @@ export { injectedProvider } from "../wallets/injected/mipdStore.js"; export type { ConnectionManager } from "../wallets/manager/index.js"; +export type { AutoConnectProps } from "../wallets/connection/types.js"; +export { autoConnect } from "../wallets/connection/autoConnect.js"; export { deploySmartAccount } from "../wallets/smart/lib/signing.js"; diff --git a/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts b/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts index 0d450961584..43688d0ef56 100644 --- a/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts +++ b/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnect.ts @@ -1,26 +1,14 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import type { Chain } from "../../../../chains/types.js"; -import type { ThirdwebClient } from "../../../../client/client.js"; import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js"; -import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js"; -import { ClientScopedStorage } from "../../../../wallets/in-app/core/authentication/client-scoped-storage.js"; -import type { AuthStoredTokenWithCookieReturnType } from "../../../../wallets/in-app/core/authentication/types.js"; -import { getUrlToken } from "../../../../wallets/in-app/web/lib/get-url-token.js"; +import { autoConnectCore } from "../../../../wallets/connection/autoConnectCore.js"; +import type { AutoConnectProps } from "../../../../wallets/connection/types.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; -import { - getLastConnectedChain, - getStoredActiveWalletId, - getStoredConnectedWalletIds, -} from "../../../../wallets/manager/index.js"; import type { WalletId } from "../../../../wallets/wallet-types.js"; import { useConnectionManagerCtx } from "../../providers/connection-manager.js"; import { setLastAuthProvider } from "../../utils/storage.js"; -import { timeoutPromise } from "../../utils/timeoutPromise.js"; -import type { AutoConnectProps } from "../connection/types.js"; import { useConnect } from "./useConnect.js"; -import { useSetActiveWalletConnectionStatus } from "./useSetActiveWalletConnectionStatus.js"; export function useAutoConnectCore( storage: AsyncStorage, @@ -29,161 +17,27 @@ export function useAutoConnectCore( getInstalledWallets?: () => Wallet[], ) { const manager = useConnectionManagerCtx("useAutoConnect"); - const setConnectionStatus = useSetActiveWalletConnectionStatus(); const { connect } = useConnect({ client: props.client, accountAbstraction: props.accountAbstraction, }); - const { isAutoConnecting } = manager; - const { wallets, onConnect } = props; - const timeout = props.timeout ?? 15000; - // get the supported wallets from thirdweb provider - // check the storage for last connected wallets and connect them all - // check the storage for last active wallet and set it as active - const autoConnect = async (): Promise => { - let autoConnected = false; - isAutoConnecting.setValue(true); - let [lastConnectedWalletIds, lastActiveWalletId] = await Promise.all([ - getStoredConnectedWalletIds(storage), - getStoredActiveWalletId(storage), - ]); - - const { authResult, walletId, authProvider, authCookie } = getUrlToken(); - const wallet = wallets.find((w) => w.id === walletId); - - // If an auth cookie is found and this site supports the wallet, we'll set the auth cookie in the client storage - if (authCookie && wallet) { - const clientStorage = new ClientScopedStorage({ - storage, - clientId: props.client.clientId, - ecosystem: isEcosystemWallet(wallet) - ? { - id: wallet.id, - partnerId: wallet.getConfig()?.partnerId, - } - : undefined, - }); - await clientStorage.saveAuthCookie(authCookie); - } - - if (walletId) { - lastActiveWalletId = walletId; - lastConnectedWalletIds = lastConnectedWalletIds?.includes(walletId) - ? lastConnectedWalletIds - : [walletId, ...(lastConnectedWalletIds || [])]; - } - if (authProvider) { - await setLastAuthProvider(authProvider, storage); - } - - // if no wallets were last connected or we didn't receive an auth token - if (!lastConnectedWalletIds) { - return autoConnected; - } - - // this flow can actually be used for a first connection in the case of a redirect - // in that case, we default to the passed chain to connect to - const lastConnectedChain = - (await getLastConnectedChain(storage)) || props.chain; - - const availableWallets = [...wallets, ...(getInstalledWallets?.() ?? [])]; - const activeWallet = - lastActiveWalletId && - (availableWallets.find((w) => w.id === lastActiveWalletId) || - createWalletFn(lastActiveWalletId)); - - if (activeWallet) { - try { - setConnectionStatus("connecting"); // only set connecting status if we are connecting the last active EOA - await timeoutPromise( - handleWalletConnection({ - wallet: activeWallet, - client: props.client, - lastConnectedChain, - authResult, - }), - { - ms: timeout, - message: `AutoConnect timeout: ${timeout}ms limit exceeded.`, - }, - ).catch((err) => { - console.warn(err.message); - if (props.onTimeout) { - props.onTimeout(); - } - }); - - // connected wallet could be activeWallet or smart wallet - const connectedWallet = await connect(activeWallet); - - if (connectedWallet) { - if (onConnect) { - try { - onConnect(connectedWallet); - autoConnected = true; - } catch { - // ignore - } - } - } else { - setConnectionStatus("disconnected"); - } - } catch (e) { - if (e instanceof Error) { - console.warn("Error auto connecting wallet:", e.message); - } - setConnectionStatus("disconnected"); - } - } else { - setConnectionStatus("disconnected"); - } - - // then connect wallets that were last connected but were not set as active - const otherWallets = availableWallets.filter( - (w) => - w.id !== lastActiveWalletId && lastConnectedWalletIds.includes(w.id), - ); - - for (const wallet of otherWallets) { - try { - await handleWalletConnection({ - wallet, - client: props.client, - lastConnectedChain, - authResult, - }); - manager.addConnectedWallet(wallet); - } catch { - // no-op - } - } - isAutoConnecting.setValue(false); - return autoConnected; // useQuery needs a return value - }; // trigger the auto connect on first mount only const query = useQuery({ queryKey: ["autoConnect", props.client.clientId], - queryFn: autoConnect, + queryFn: () => + autoConnectCore({ + createWalletFn, + manager, + props, + storage, + connectOverride: connect, + getInstalledWallets, + setLastAuthProvider, + }), refetchOnMount: false, refetchOnWindowFocus: false, }); return query; } - -/** - * @internal - */ -export async function handleWalletConnection(props: { - wallet: Wallet; - client: ThirdwebClient; - authResult: AuthStoredTokenWithCookieReturnType | undefined; - lastConnectedChain: Chain | undefined; -}) { - return props.wallet.autoConnect({ - client: props.client, - chain: props.lastConnectedChain, - authResult: props.authResult, - }); -} diff --git a/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnectCore.test.tsx b/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnectCore.test.tsx index c1ff08248f8..5cb10c093b6 100644 --- a/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnectCore.test.tsx +++ b/packages/thirdweb/src/react/core/hooks/wallets/useAutoConnectCore.test.tsx @@ -6,15 +6,11 @@ import { TEST_CLIENT } from "~test/test-clients.js"; import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; import { createWalletAdapter } from "../../../../adapters/wallet-adapter.js"; import { ethereum } from "../../../../chains/chain-definitions/ethereum.js"; -import { isAddress } from "../../../../utils/address.js"; import { createConnectionManager } from "../../../../wallets/manager/index.js"; import type { WalletId } from "../../../../wallets/wallet-types.js"; import { ThirdwebProvider } from "../../../web/providers/thirdweb-provider.js"; import { ConnectionManagerCtx } from "../../providers/connection-manager.js"; -import { - handleWalletConnection, - useAutoConnectCore, -} from "./useAutoConnect.js"; +import { useAutoConnectCore } from "./useAutoConnect.js"; describe("useAutoConnectCore", () => { const mockStorage = new MockStorage(); @@ -159,29 +155,3 @@ describe("useAutoConnectCore", () => { ); }); }); - -describe("handleWalletConnection", () => { - const wallet = createWalletAdapter({ - adaptedAccount: TEST_ACCOUNT_A, - client: TEST_CLIENT, - chain: ethereum, - onDisconnect: () => {}, - switchChain: () => {}, - }); - it("should return the correct result", async () => { - const result = await handleWalletConnection({ - client: TEST_CLIENT, - lastConnectedChain: ethereum, - authResult: undefined, - wallet, - }); - - expect("address" in result).toBe(true); - expect(isAddress(result.address)).toBe(true); - expect("sendTransaction" in result).toBe(true); - expect(typeof result.sendTransaction).toBe("function"); - expect("signMessage" in result).toBe(true); - expect("signTypedData" in result).toBe(true); - expect("signTransaction" in result).toBe(true); - }); -}); diff --git a/packages/thirdweb/src/react/native/hooks/wallets/useAutoConnect.ts b/packages/thirdweb/src/react/native/hooks/wallets/useAutoConnect.ts index a887a0d7b2b..c437c3586e6 100644 --- a/packages/thirdweb/src/react/native/hooks/wallets/useAutoConnect.ts +++ b/packages/thirdweb/src/react/native/hooks/wallets/useAutoConnect.ts @@ -1,6 +1,6 @@ import { nativeLocalStorage } from "../../../../utils/storage/nativeStorage.js"; +import type { AutoConnectProps } from "../../../../wallets/connection/types.js"; import { createWallet } from "../../../../wallets/native/create-wallet.js"; -import type { AutoConnectProps } from "../../../core/hooks/connection/types.js"; import { useAutoConnectCore } from "../../../core/hooks/wallets/useAutoConnect.js"; import { getDefaultWallets } from "../../wallets/defaultWallets.js"; diff --git a/packages/thirdweb/src/react/native/ui/AutoConnect/AutoConnect.tsx b/packages/thirdweb/src/react/native/ui/AutoConnect/AutoConnect.tsx index 58decba01e0..2598748a793 100644 --- a/packages/thirdweb/src/react/native/ui/AutoConnect/AutoConnect.tsx +++ b/packages/thirdweb/src/react/native/ui/AutoConnect/AutoConnect.tsx @@ -1,5 +1,5 @@ "use client"; -import type { AutoConnectProps } from "../../../core/hooks/connection/types.js"; +import type { AutoConnectProps } from "../../../../wallets/connection/types.js"; import { useAutoConnect } from "../../hooks/wallets/useAutoConnect.js"; /** diff --git a/packages/thirdweb/src/react/web/hooks/wallets/useAutoConnect.ts b/packages/thirdweb/src/react/web/hooks/wallets/useAutoConnect.ts index 97c5432ae7d..b6884c72e68 100644 --- a/packages/thirdweb/src/react/web/hooks/wallets/useAutoConnect.ts +++ b/packages/thirdweb/src/react/web/hooks/wallets/useAutoConnect.ts @@ -1,9 +1,9 @@ import { webLocalStorage } from "../../../../utils/storage/webStorage.js"; +import type { AutoConnectProps } from "../../../../wallets/connection/types.js"; import { createWallet } from "../../../../wallets/create-wallet.js"; +import { getDefaultWallets } from "../../../../wallets/defaultWallets.js"; import { getInstalledWalletProviders } from "../../../../wallets/injected/mipdStore.js"; -import type { AutoConnectProps } from "../../../core/hooks/connection/types.js"; import { useAutoConnectCore } from "../../../core/hooks/wallets/useAutoConnect.js"; -import { getDefaultWallets } from "../../wallets/defaultWallets.js"; /** * Autoconnect the last previously connected wallet. diff --git a/packages/thirdweb/src/react/web/ui/AutoConnect/AutoConnect.tsx b/packages/thirdweb/src/react/web/ui/AutoConnect/AutoConnect.tsx index 96533cf51fd..a943cc15037 100644 --- a/packages/thirdweb/src/react/web/ui/AutoConnect/AutoConnect.tsx +++ b/packages/thirdweb/src/react/web/ui/AutoConnect/AutoConnect.tsx @@ -1,5 +1,5 @@ "use client"; -import type { AutoConnectProps } from "../../../core/hooks/connection/types.js"; +import type { AutoConnectProps } from "../../../../wallets/connection/types.js"; import { useAutoConnect } from "../../hooks/wallets/useAutoConnect.js"; /** diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx index 78c9074e8eb..881b8dac2e5 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/ConnectButton.tsx @@ -2,6 +2,7 @@ import styled from "@emotion/styled"; import { useEffect, useMemo, useState } from "react"; +import { getDefaultWallets } from "../../../../wallets/defaultWallets.js"; import { iconSize } from "../../../core/design-system/index.js"; import { useSiweAuth } from "../../../core/hooks/auth/useSiweAuth.js"; import type { ConnectButtonProps } from "../../../core/hooks/connection/ConnectButtonProps.js"; @@ -16,7 +17,6 @@ import { } from "../../providers/wallet-ui-states-provider.js"; import { canFitWideModal } from "../../utils/canFitWideModal.js"; import { usePreloadWalletProviders } from "../../utils/usePreloadWalletProviders.js"; -import { getDefaultWallets } from "../../wallets/defaultWallets.js"; import { AutoConnect } from "../AutoConnect/AutoConnect.js"; import { Modal } from "../components/Modal.js"; import { Spinner } from "../components/Spinner.js"; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectEmbed.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectEmbed.tsx index d9c7e71d104..d4f2b8d37f5 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectEmbed.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/Modal/ConnectEmbed.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo } from "react"; import type { Chain } from "../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import { getDefaultWallets } from "../../../../../wallets/defaultWallets.js"; import type { Wallet } from "../../../../../wallets/interfaces/wallet.js"; import type { SmartWalletOptions } from "../../../../../wallets/smart/types.js"; import { @@ -21,7 +22,6 @@ import { useConnectionManager } from "../../../../core/providers/connection-mana import { WalletUIStatesProvider } from "../../../providers/wallet-ui-states-provider.js"; import { canFitWideModal } from "../../../utils/canFitWideModal.js"; import { usePreloadWalletProviders } from "../../../utils/usePreloadWalletProviders.js"; -import { getDefaultWallets } from "../../../wallets/defaultWallets.js"; import { LoadingScreen } from "../../../wallets/shared/LoadingScreen.js"; import { AutoConnect } from "../../AutoConnect/AutoConnect.js"; import { DynamicHeight } from "../../components/DynamicHeight.js"; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/WalletSwitcherConnectionScreen.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/WalletSwitcherConnectionScreen.tsx index 5c793937ccb..b59508c9118 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/WalletSwitcherConnectionScreen.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/screens/WalletSwitcherConnectionScreen.tsx @@ -1,11 +1,11 @@ import type { Chain } from "../../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../../client/client.js"; +import { getDefaultWallets } from "../../../../../wallets/defaultWallets.js"; import type { Wallet } from "../../../../../wallets/interfaces/wallet.js"; import type { SmartWalletOptions } from "../../../../../wallets/smart/types.js"; import type { AppMetadata } from "../../../../../wallets/types.js"; import type { WalletId } from "../../../../../wallets/wallet-types.js"; import { useConnectedWallets } from "../../../../core/hooks/wallets/useConnectedWallets.js"; -import { getDefaultWallets } from "../../../wallets/defaultWallets.js"; import { ConnectModalContent } from "../Modal/ConnectModalContent.js"; import { useSetupScreen } from "../Modal/screen.js"; import type { ConnectLocale } from "../locale/types.js"; diff --git a/packages/thirdweb/src/react/web/ui/ConnectWallet/useConnectModal.tsx b/packages/thirdweb/src/react/web/ui/ConnectWallet/useConnectModal.tsx index 5b330adfb99..104b11b97b9 100644 --- a/packages/thirdweb/src/react/web/ui/ConnectWallet/useConnectModal.tsx +++ b/packages/thirdweb/src/react/web/ui/ConnectWallet/useConnectModal.tsx @@ -1,6 +1,7 @@ import { useCallback, useContext, useMemo, useState } from "react"; import type { Chain } from "../../../../chains/types.js"; import type { ThirdwebClient } from "../../../../client/client.js"; +import { getDefaultWallets } from "../../../../wallets/defaultWallets.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; import type { SmartWalletOptions } from "../../../../wallets/smart/types.js"; import type { AppMetadata } from "../../../../wallets/types.js"; @@ -8,7 +9,6 @@ import type { Theme } from "../../../core/design-system/index.js"; import { SetRootElementContext } from "../../../core/providers/RootElementContext.js"; import { WalletUIStatesProvider } from "../../providers/wallet-ui-states-provider.js"; import { canFitWideModal } from "../../utils/canFitWideModal.js"; -import { getDefaultWallets } from "../../wallets/defaultWallets.js"; import type { LocaleId } from "../types.js"; import ConnectModal from "./Modal/ConnectModal.js"; import { getConnectLocale } from "./locale/getConnectLocale.js"; diff --git a/packages/thirdweb/src/react/web/wallets/in-app/WalletAuth.tsx b/packages/thirdweb/src/react/web/wallets/in-app/WalletAuth.tsx index 93f630d4f7a..f6985528296 100644 --- a/packages/thirdweb/src/react/web/wallets/in-app/WalletAuth.tsx +++ b/packages/thirdweb/src/react/web/wallets/in-app/WalletAuth.tsx @@ -1,6 +1,7 @@ import { Suspense, useRef, useState } from "react"; import { defineChain } from "../../../../chains/utils.js"; import type { ThirdwebClient } from "../../../../client/client.js"; +import { getDefaultWallets } from "../../../../wallets/defaultWallets.js"; import { isEcosystemWallet } from "../../../../wallets/ecosystem/is-ecosystem-wallet.js"; import { linkProfile } from "../../../../wallets/in-app/web/lib/auth/index.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; @@ -13,7 +14,6 @@ import type { ConnectLocale } from "../../ui/ConnectWallet/locale/types.js"; import { Spacer } from "../../ui/components/Spacer.js"; import { WalletImage } from "../../ui/components/WalletImage.js"; import { Container, ModalHeader } from "../../ui/components/basic.js"; -import { getDefaultWallets } from "../defaultWallets.js"; import { ErrorState } from "../shared/ErrorState.js"; import { LoadingScreen } from "../shared/LoadingScreen.js"; import { LoadingState } from "../shared/LoadingState.js"; diff --git a/packages/thirdweb/src/react/core/utils/timeoutPromise.test.ts b/packages/thirdweb/src/utils/timeoutPromise.test.ts similarity index 100% rename from packages/thirdweb/src/react/core/utils/timeoutPromise.test.ts rename to packages/thirdweb/src/utils/timeoutPromise.test.ts diff --git a/packages/thirdweb/src/react/core/utils/timeoutPromise.ts b/packages/thirdweb/src/utils/timeoutPromise.ts similarity index 100% rename from packages/thirdweb/src/react/core/utils/timeoutPromise.ts rename to packages/thirdweb/src/utils/timeoutPromise.ts diff --git a/packages/thirdweb/src/wallets/connection/autoConnect.test.ts b/packages/thirdweb/src/wallets/connection/autoConnect.test.ts new file mode 100644 index 00000000000..fe19f1d2e31 --- /dev/null +++ b/packages/thirdweb/src/wallets/connection/autoConnect.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; +import { createWalletAdapter } from "../../adapters/wallet-adapter.js"; +import { ethereum } from "../../chains/chain-definitions/ethereum.js"; +import { webLocalStorage } from "../../utils/storage/webStorage.js"; +import { createWallet } from "../create-wallet.js"; +import { getInstalledWalletProviders } from "../injected/mipdStore.js"; +import { autoConnect } from "./autoConnect.js"; +import { autoConnectCore } from "./autoConnectCore.js"; + +vi.mock("../../utils/storage/webStorage.js"); +vi.mock("../create-wallet.js"); +vi.mock("../injected/mipdStore.js"); +vi.mock("./autoConnectCore.js"); + +describe("autoConnect", () => { + const mockWallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getInstalledWalletProviders).mockReturnValue([]); + vi.mocked(createWallet).mockReturnValue(mockWallet); + vi.mocked(autoConnectCore).mockResolvedValue(true); + }); + + it("should call autoConnectCore with correct parameters when wallets are provided", async () => { + const result = await autoConnect({ + client: TEST_CLIENT, + wallets: [mockWallet], + }); + + expect(autoConnectCore).toHaveBeenCalledWith({ + storage: webLocalStorage, + props: { + client: TEST_CLIENT, + wallets: [mockWallet], + }, + createWalletFn: createWallet, + getInstalledWallets: expect.any(Function), + manager: expect.any(Object), + }); + expect(result).toBe(true); + }); + + it("should use default wallets when no wallets are provided", async () => { + await autoConnect({ + wallets: [], + client: TEST_CLIENT, + }); + + expect(autoConnectCore).toHaveBeenCalledWith( + expect.objectContaining({ + props: { + client: TEST_CLIENT, + wallets: [], + }, + }), + ); + }); +}); diff --git a/packages/thirdweb/src/wallets/connection/autoConnect.ts b/packages/thirdweb/src/wallets/connection/autoConnect.ts new file mode 100644 index 00000000000..bfd6eec98bf --- /dev/null +++ b/packages/thirdweb/src/wallets/connection/autoConnect.ts @@ -0,0 +1,58 @@ +import { webLocalStorage } from "../../utils/storage/webStorage.js"; +import { createWallet } from "../create-wallet.js"; +import { getDefaultWallets } from "../defaultWallets.js"; +import { getInstalledWalletProviders } from "../injected/mipdStore.js"; +import type { Wallet } from "../interfaces/wallet.js"; +import { createConnectionManager } from "../manager/index.js"; +import { autoConnectCore } from "./autoConnectCore.js"; +import type { AutoConnectProps } from "./types.js"; + +/** + * Attempts to automatically connect to the last connected wallet. + * It combines both specified wallets and installed wallet providers that aren't already specified. + * + * @example + * + * ```tsx + * import { autoConnect } from "thirdweb/wallets"; + * + * const autoConnected = await autoConnect({ + * client, + * onConnect: (wallet) => { + * console.log("wallet", wallet); + * }, + * }); + * ``` + * + * @param props - The auto-connect configuration properties + * @param props.wallets - Array of wallet instances to consider for auto-connection + * @returns {boolean} a promise resolving to true or false depending on whether the auto connect function connected to a wallet or not + */ +export const autoConnect = async ( + props: AutoConnectProps & { + wallets?: Wallet[]; + }, +) => { + const wallets = props.wallets || getDefaultWallets(props); + const manager = createConnectionManager(webLocalStorage); + const result = await autoConnectCore({ + storage: webLocalStorage, + props: { + ...props, + wallets, + }, + createWalletFn: createWallet, + getInstalledWallets: () => { + const specifiedWalletIds = new Set(wallets.map((x) => x.id)); + + // pass the wallets that are not already specified but are installed by the user + const installedWallets = getInstalledWalletProviders() + .filter((x) => !specifiedWalletIds.has(x.info.rdns)) + .map((x) => createWallet(x.info.rdns)); + + return installedWallets; + }, + manager, + }); + return result; +}; diff --git a/packages/thirdweb/src/wallets/connection/autoConnectCore.test.ts b/packages/thirdweb/src/wallets/connection/autoConnectCore.test.ts new file mode 100644 index 00000000000..7dfaace29e9 --- /dev/null +++ b/packages/thirdweb/src/wallets/connection/autoConnectCore.test.ts @@ -0,0 +1,403 @@ +import { afterEach } from "node:test"; +import { isAddress } from "ethers6"; +import { describe, expect, it, vi } from "vitest"; +import { MockStorage } from "~test/mocks/storage.js"; +import { TEST_CLIENT } from "~test/test-clients.js"; +import { TEST_ACCOUNT_A } from "~test/test-wallets.js"; +import { createWalletAdapter } from "../../adapters/wallet-adapter.js"; +import { ethereum } from "../../chains/chain-definitions/ethereum.js"; +import { AUTH_TOKEN_LOCAL_STORAGE_NAME } from "../in-app/core/constants/settings.js"; +import { getUrlToken } from "../in-app/web/lib/get-url-token.js"; +import type { Wallet } from "../interfaces/wallet.js"; +import { createConnectionManager } from "../manager/index.js"; +import type { WalletId } from "../wallet-types.js"; +import { autoConnectCore, handleWalletConnection } from "./autoConnectCore.js"; + +vi.mock("../in-app/web/lib/get-url-token.ts"); + +describe("useAutoConnectCore", () => { + const mockStorage = new MockStorage(); + const manager = createConnectionManager(mockStorage); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should return a useQuery result", async () => { + vi.mocked(getUrlToken).mockReturnValue({}); + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + expect( + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet], + client: TEST_CLIENT, + }, + createWalletFn: (id: WalletId) => + createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => { + console.warn(id); + }, + switchChain: () => {}, + }), + manager, + }), + ).toBe(false); + }); + + it("should return `false` if there's no lastConnectedWalletIds", async () => { + vi.mocked(getUrlToken).mockReturnValue({}); + + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + expect( + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet], + client: TEST_CLIENT, + }, + createWalletFn: (id: WalletId) => + createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => { + console.warn(id); + }, + switchChain: () => {}, + }), + manager, + }), + ).toBe(false); + }); + + it("should call onTimeout on ... timeout", async () => { + vi.mocked(getUrlToken).mockReturnValue({}); + + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + mockStorage.setItem("thirdweb:active-wallet-id", wallet.id); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + // Purposefully mock the wallet.autoConnect method to test the timeout logic + wallet.autoConnect = () => + new Promise((resolve) => { + setTimeout(() => { + // @ts-ignore Mock purpose + resolve("Connection successful"); + }, 2100); + }); + + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet], + client: TEST_CLIENT, + onTimeout: () => console.info("TIMEOUTTED"), + timeout: 0, + }, + createWalletFn: (id: WalletId) => + createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => { + console.warn(id); + }, + switchChain: () => {}, + }), + manager, + }); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "AutoConnect timeout: 0ms limit exceeded.", + ); + expect(infoSpy).toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith("TIMEOUTTED"); + warnSpy.mockRestore(); + }); + + it("should handle auth cookie storage correctly", async () => { + const mockAuthCookie = "mock-auth-cookie"; + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + vi.mocked(getUrlToken).mockReturnValue({ + authCookie: mockAuthCookie, + walletId: wallet.id, + }); + + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet], + client: TEST_CLIENT, + }, + createWalletFn: () => wallet, + manager, + }); + + const storedCookie = await mockStorage.getItem( + AUTH_TOKEN_LOCAL_STORAGE_NAME(TEST_CLIENT.clientId), + ); + expect(storedCookie).toBe(mockAuthCookie); + }); + + it("should handle error when manager connection fails", async () => { + const wallet1 = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + mockStorage.setItem("thirdweb:active-wallet-id", wallet1.id); + mockStorage.setItem( + "thirdweb:connected-wallet-ids", + JSON.stringify([wallet1.id]), + ); + + const addConnectedWalletSpy = vi + .spyOn(manager, "connect") + .mockRejectedValueOnce(new Error("Connection failed")); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet1], + client: TEST_CLIENT, + }, + createWalletFn: () => wallet1, + manager, + }); + expect(addConnectedWalletSpy).toHaveBeenCalled(); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + "Error auto connecting wallet:", + "Connection failed", + ); + }); + + it("should connect multiple wallets correctly", async () => { + const wallet1 = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + const wallet2 = createWalletAdapter({ + adaptedAccount: { ...TEST_ACCOUNT_A, address: "0x123" }, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + wallet2.id = "io.metamask" as unknown as "adapter"; + + mockStorage.setItem("thirdweb:active-wallet-id", wallet1.id); + mockStorage.setItem( + "thirdweb:connected-wallet-ids", + JSON.stringify([wallet1.id, wallet2.id]), + ); + + const addConnectedWalletSpy = vi.spyOn(manager, "addConnectedWallet"); + + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet1, wallet2], + client: TEST_CLIENT, + }, + createWalletFn: () => wallet1, + manager, + }); + + expect(addConnectedWalletSpy).toHaveBeenCalledWith(wallet2); + }); + + it("should handle onConnect callback correctly", async () => { + const mockOnConnect = vi.fn(); + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + vi.mocked(getUrlToken).mockReturnValue({}); + mockStorage.setItem("thirdweb:active-wallet-id", wallet.id); + mockStorage.setItem( + "thirdweb:connected-wallet-ids", + JSON.stringify([wallet.id]), + ); + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet], + client: TEST_CLIENT, + onConnect: mockOnConnect, + }, + createWalletFn: () => wallet, + manager, + }); + + expect(mockOnConnect).toHaveBeenCalledWith(wallet); + }); + it("should continue even if onConnect callback throws", async () => { + const mockOnConnect = vi.fn(); + mockOnConnect.mockImplementation(() => { + throw new Error("onConnect error"); + }); + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + vi.mocked(getUrlToken).mockReturnValue({}); + mockStorage.setItem("thirdweb:active-wallet-id", wallet.id); + mockStorage.setItem( + "thirdweb:connected-wallet-ids", + JSON.stringify([wallet.id]), + ); + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet], + client: TEST_CLIENT, + onConnect: mockOnConnect, + }, + createWalletFn: () => wallet, + manager, + }); + + expect(mockOnConnect).toHaveBeenCalledWith(wallet); + }); + it("should call setLastAuthProvider if authProvider is present", async () => { + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + vi.mocked(getUrlToken).mockReturnValue({ + authProvider: "email", + walletId: wallet.id, + }); + const mockSetLastAuthProvider = vi.fn(); + + mockStorage.setItem("thirdweb:active-wallet-id", wallet.id); + mockStorage.setItem( + "thirdweb:connected-wallet-ids", + JSON.stringify([wallet.id]), + ); + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet], + client: TEST_CLIENT, + }, + createWalletFn: () => wallet, + manager, + setLastAuthProvider: mockSetLastAuthProvider, + }); + + expect(mockSetLastAuthProvider).toHaveBeenCalledWith("email", mockStorage); + }); + it("should set connection status to disconnect if no connectedWallet is returned", async () => { + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + + mockStorage.setItem("thirdweb:active-wallet-id", wallet.id); + mockStorage.setItem( + "thirdweb:connected-wallet-ids", + JSON.stringify([wallet.id]), + ); + + const addConnectedWalletSpy = vi + .spyOn(manager, "connect") + .mockResolvedValueOnce(null as unknown as Wallet); + + await autoConnectCore({ + storage: mockStorage, + props: { + wallets: [wallet], + client: TEST_CLIENT, + }, + createWalletFn: () => wallet, + manager, + }); + + expect(addConnectedWalletSpy).toHaveBeenCalled(); + expect(manager.activeWalletConnectionStatusStore.getValue()).toBe( + "disconnected", + ); + }); +}); + +describe("handleWalletConnection", () => { + const wallet = createWalletAdapter({ + adaptedAccount: TEST_ACCOUNT_A, + client: TEST_CLIENT, + chain: ethereum, + onDisconnect: () => {}, + switchChain: () => {}, + }); + it("should return the correct result", async () => { + const result = await handleWalletConnection({ + client: TEST_CLIENT, + lastConnectedChain: ethereum, + authResult: undefined, + wallet, + }); + + expect("address" in result).toBe(true); + expect(isAddress(result.address)).toBe(true); + expect("sendTransaction" in result).toBe(true); + expect(typeof result.sendTransaction).toBe("function"); + expect("signMessage" in result).toBe(true); + expect("signTypedData" in result).toBe(true); + expect("signTransaction" in result).toBe(true); + }); +}); diff --git a/packages/thirdweb/src/wallets/connection/autoConnectCore.ts b/packages/thirdweb/src/wallets/connection/autoConnectCore.ts new file mode 100644 index 00000000000..befc866c9b8 --- /dev/null +++ b/packages/thirdweb/src/wallets/connection/autoConnectCore.ts @@ -0,0 +1,184 @@ +import type { Chain } from "../../chains/types.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import type { AsyncStorage } from "../../utils/storage/AsyncStorage.js"; +import { timeoutPromise } from "../../utils/timeoutPromise.js"; +import { isEcosystemWallet } from "../ecosystem/is-ecosystem-wallet.js"; +import { ClientScopedStorage } from "../in-app/core/authentication/client-scoped-storage.js"; +import type { + AuthArgsType, + AuthStoredTokenWithCookieReturnType, +} from "../in-app/core/authentication/types.js"; +import { getUrlToken } from "../in-app/web/lib/get-url-token.js"; +import type { Wallet } from "../interfaces/wallet.js"; +import { + type ConnectionManager, + getLastConnectedChain, + getStoredActiveWalletId, + getStoredConnectedWalletIds, +} from "../manager/index.js"; +import type { WalletId } from "../wallet-types.js"; +import type { AutoConnectProps } from "./types.js"; + +/** + * @internal + */ +export const autoConnectCore = async ({ + storage, + props, + createWalletFn, + manager, + connectOverride, + getInstalledWallets, + setLastAuthProvider, +}: { + storage: AsyncStorage; + props: AutoConnectProps & { wallets: Wallet[] }; + createWalletFn: (id: WalletId) => Wallet; + manager: ConnectionManager; + connectOverride?: ( + walletOrFn: Wallet | (() => Promise), + ) => Promise; + getInstalledWallets?: () => Wallet[]; + setLastAuthProvider?: ( + authProvider: AuthArgsType["strategy"], + storage: AsyncStorage, + ) => Promise; +}): Promise => { + const { wallets, onConnect } = props; + const timeout = props.timeout ?? 15000; + + let autoConnected = false; + manager.isAutoConnecting.setValue(true); + + let [lastConnectedWalletIds, lastActiveWalletId] = await Promise.all([ + getStoredConnectedWalletIds(storage), + getStoredActiveWalletId(storage), + ]); + + const { authResult, walletId, authProvider, authCookie } = getUrlToken(); + const wallet = wallets.find((w) => w.id === walletId); + + // If an auth cookie is found and this site supports the wallet, we'll set the auth cookie in the client storage + if (authCookie && wallet) { + const clientStorage = new ClientScopedStorage({ + storage, + clientId: props.client.clientId, + ecosystem: isEcosystemWallet(wallet) + ? { + id: wallet.id, + partnerId: wallet.getConfig()?.partnerId, + } + : undefined, + }); + await clientStorage.saveAuthCookie(authCookie); + } + + if (walletId) { + lastActiveWalletId = walletId; + lastConnectedWalletIds = lastConnectedWalletIds?.includes(walletId) + ? lastConnectedWalletIds + : [walletId, ...(lastConnectedWalletIds || [])]; + } + if (authProvider) { + await setLastAuthProvider?.(authProvider, storage); + } + + // if no wallets were last connected or we didn't receive an auth token + if (!lastConnectedWalletIds) { + return autoConnected; + } + + // this flow can actually be used for a first connection in the case of a redirect + // in that case, we default to the passed chain to connect to + const lastConnectedChain = + (await getLastConnectedChain(storage)) || props.chain; + const availableWallets = [...wallets, ...(getInstalledWallets?.() ?? [])]; + const activeWallet = + lastActiveWalletId && + (availableWallets.find((w) => w.id === lastActiveWalletId) || + createWalletFn(lastActiveWalletId)); + + if (activeWallet) { + manager.activeWalletConnectionStatusStore.setValue("connecting"); // only set connecting status if we are connecting the last active EOA + await timeoutPromise( + handleWalletConnection({ + wallet: activeWallet, + client: props.client, + lastConnectedChain, + authResult, + }), + { + ms: timeout, + message: `AutoConnect timeout: ${timeout}ms limit exceeded.`, + }, + ).catch((err) => { + console.warn(err.message); + if (props.onTimeout) { + props.onTimeout(); + } + }); + + try { + // connected wallet could be activeWallet or smart wallet + const connectedWallet = await (connectOverride + ? connectOverride(activeWallet) + : manager.connect(activeWallet, { + client: props.client, + accountAbstraction: props.accountAbstraction, + })); + if (connectedWallet) { + autoConnected = true; + try { + onConnect?.(connectedWallet); + } catch { + // ignore + } + } else { + manager.activeWalletConnectionStatusStore.setValue("disconnected"); + } + } catch (e) { + if (e instanceof Error) { + console.warn("Error auto connecting wallet:", e.message); + } + manager.activeWalletConnectionStatusStore.setValue("disconnected"); + } + } else { + manager.activeWalletConnectionStatusStore.setValue("disconnected"); + } + + // then connect wallets that were last connected but were not set as active + const otherWallets = availableWallets.filter( + (w) => w.id !== lastActiveWalletId && lastConnectedWalletIds.includes(w.id), + ); + for (const wallet of otherWallets) { + try { + await handleWalletConnection({ + wallet, + client: props.client, + lastConnectedChain, + authResult, + }); + manager.addConnectedWallet(wallet); + } catch { + // no-op + } + } + manager.isAutoConnecting.setValue(false); + return autoConnected; // useQuery needs a return value +}; + +/** + * @internal + */ +export async function handleWalletConnection(props: { + wallet: Wallet; + client: ThirdwebClient; + authResult: AuthStoredTokenWithCookieReturnType | undefined; + lastConnectedChain: Chain | undefined; +}) { + return props.wallet.autoConnect({ + client: props.client, + chain: props.lastConnectedChain, + authResult: props.authResult, + }); +} diff --git a/packages/thirdweb/src/react/core/hooks/connection/types.ts b/packages/thirdweb/src/wallets/connection/types.ts similarity index 90% rename from packages/thirdweb/src/react/core/hooks/connection/types.ts rename to packages/thirdweb/src/wallets/connection/types.ts index b362a375bec..a08ef8bbd6c 100644 --- a/packages/thirdweb/src/react/core/hooks/connection/types.ts +++ b/packages/thirdweb/src/wallets/connection/types.ts @@ -1,8 +1,8 @@ -import type { Chain } from "../../../../chains/types.js"; -import type { ThirdwebClient } from "../../../../client/client.js"; -import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; -import type { SmartWalletOptions } from "../../../../wallets/smart/types.js"; -import type { AppMetadata } from "../../../../wallets/types.js"; +import type { Chain } from "../../chains/types.js"; +import type { ThirdwebClient } from "../../client/client.js"; +import type { Wallet } from "../../wallets/interfaces/wallet.js"; +import type { SmartWalletOptions } from "../../wallets/smart/types.js"; +import type { AppMetadata } from "../../wallets/types.js"; export type AutoConnectProps = { /** diff --git a/packages/thirdweb/src/react/web/wallets/defaultWallets.ts b/packages/thirdweb/src/wallets/defaultWallets.ts similarity index 53% rename from packages/thirdweb/src/react/web/wallets/defaultWallets.ts rename to packages/thirdweb/src/wallets/defaultWallets.ts index 0fa984ea165..b966a053957 100644 --- a/packages/thirdweb/src/react/web/wallets/defaultWallets.ts +++ b/packages/thirdweb/src/wallets/defaultWallets.ts @@ -1,13 +1,8 @@ -import type { Chain } from "../../../chains/types.js"; -import { - COINBASE, - METAMASK, - RAINBOW, - ZERION, -} from "../../../wallets/constants.js"; -import { createWallet } from "../../../wallets/create-wallet.js"; -import type { Wallet } from "../../../wallets/interfaces/wallet.js"; -import type { AppMetadata } from "../../../wallets/types.js"; +import type { Chain } from "../chains/types.js"; +import { COINBASE, METAMASK, RAINBOW, ZERION } from "./constants.js"; +import { createWallet } from "./create-wallet.js"; +import type { Wallet } from "./interfaces/wallet.js"; +import type { AppMetadata } from "./types.js"; /** * @internal diff --git a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts index a0a153a1a95..3a8d1ca954a 100644 --- a/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts +++ b/packages/thirdweb/src/wallets/in-app/web/lib/get-url-token.ts @@ -11,7 +11,7 @@ export function getUrlToken(): { authProvider?: AuthOption; authCookie?: string; } { - if (!window?.location) { + if (typeof window === "undefined") { // Not in web return {}; }