App background

Wallet Connect

Wallet Connect

Integra wallets de criptomonedas en tu app de forma rápida y segura.

Capturas

Captura del botón cuando hay una cuenta conectada. Captura del botón cuando hay una cuenta conectada.

Que utilizamos aquí?

Instalación

Un componente listo para integrar: copia el código, crea el archivo y úsalo en tu proyecto. Puedes personalizarlo y reutilizar sus partes internas según tus necesidades.
⚠️ Recuerda cambiar las importaciones en el caso de no tenerlas configuradas.
ConnectWallet.tsx
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type * as AvatarPrimitive from "@radix-ui/react-avatar";
import {
	ChevronDown,
	Copy,
	CopyCheck,
	Droplet,
	Loader,
	LogOut,
	Wallet,
	X,
} from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import type { Address } from "viem";
import {
	type Connector,
	useAccount,
	useBalance,
	useChainId,
	useConnectors,
	useDisconnect,
	useEnsAvatar,
	useEnsName,
	useSwitchChain,
} from "wagmi";
import {
	AlertDialog,
	AlertDialogCancel,
	AlertDialogContent,
	AlertDialogDescription,
	AlertDialogFooter,
	AlertDialogHeader,
	AlertDialogTitle,
	AlertDialogTrigger,
} from "@/components/AlertDialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/Avatar";
import { Button } from "@/components/Button";

function useAccountActions() {
	const [isCopied, setIsCopied] = useState(false);
	const { address } = useAccount();
	const { disconnectAsync } = useDisconnect();

	const handleCopyToClipboard = async () => {
		if (address === undefined) {
			toast.error("No account connected", { dismissible: true });

			return;
		}

		try {
			await navigator.clipboard.writeText(address);
			setIsCopied(true);
		} catch (err) {
			toast.error("Failed address copy to clipboard", { dismissible: true });

			console.error("Copying address failed: ", err);
		} finally {
			setTimeout(() => setIsCopied(false), 2000);
		}
	};

	const handleDisconnect = async () => {
		try {
			await disconnectAsync();
		} catch (err) {
			toast.error("Disconnect account failed", { dismissible: true });

			console.error("Disconnect account failed:", err);
		}
	};

	return {
		address,
		isCopied,
		handleCopyToClipboard,
		handleDisconnect,
	};
}

export function ConnectWallet() {
	const { address } = useAccount();

	if (address) {
		return (
			<div className="flex gap-2">
				<WagmiAccount address={address}>
					<Button className="w-max px-0.5 py-0.5" variant="outline">
						<AccountBalance className="leading-relaxed ml-1.5 mr-1" />

						<div className="flex gap-2 items-center bg-accent px-1.5 size-full rounded-md">
							<Avatar className="size-5">
								<AccountAvatarImage address={address} />

								<AvatarFallback className="bg-transparent">
									<Wallet />
								</AvatarFallback>
							</Avatar>

							<span className="leading-relaxed">
								{truncateAddress(address)}
							</span>
						</div>
					</Button>
				</WagmiAccount>

				<ChainSwitcherDialog>
					{(currentChainName) => (
						<Button variant="outline" className="px-2">
							<div className="flex gap-2 items-center size-full">
								<span className="leading-relaxed">{currentChainName}</span>
							</div>

							<ChevronDown />
						</Button>
					)}
				</ChainSwitcherDialog>
			</div>
		);
	}

	return (
		<ConnectWalletDialog>
			<Button>
				<Wallet />

				<span className="leading-relaxed">Connect Wallet</span>
			</Button>
		</ConnectWalletDialog>
	);
}

function ConnectWalletDialog({ children }: { children: React.ReactNode }) {
	const connectors = useConnectors();

	return (
		<AlertDialog>
			<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>

			<AlertDialogContent>
				<AlertDialogHeader>
					<AlertDialogTitle>Pick Wallet</AlertDialogTitle>

					<AlertDialogDescription>
						Select your preferred wallet provider from the available wallet
						list.
					</AlertDialogDescription>
				</AlertDialogHeader>

				<div className="p-0.5 grid grid-cols-1 md:grid-cols-2 max-h-40 md:max-h-72 gap-4 overflow-y-auto">
					{connectors.map((connector) => (
						<WalletConnectorCard key={connector.id} connector={connector} />
					))}
				</div>

				<AlertDialogFooter>
					<AlertDialogCancel>Cancel</AlertDialogCancel>
				</AlertDialogFooter>
			</AlertDialogContent>
		</AlertDialog>
	);
}

function WalletConnectorCard({ connector }: { connector: Connector }) {
	const [ready, setReady] = useState(false);

	useEffect(() => {
		(async () => {
			const provider = await connector.getProvider();

			setReady(!!provider);
		})();
	}, [connector]);

	return (
		<button
			type="button"
			className="bg-muted rounded-md dark:bg-card h-30 border hover:bg-muted-foreground/15 dark:hover:bg-foreground/10 transition-colors focus:outline-2 disabled:opacity-50 dark:hover:disabled:bg-card hover:disabled:bg-muted disabled:cursor-not-allowed"
			disabled={!ready}
			onClick={() => connector.connect()}
			title={
				!ready
					? `${connector.name} not available`
					: `Connect with ${connector.name}`
			}
		>
			<div className="flex flex-col gap-2 items-center justify-center">
				<Avatar className="size-8">
					<AvatarImage src={connector.icon} alt={`Logo of ${connector.name}`} />

					<AvatarFallback className="bg-transparent">
						<Wallet />
					</AvatarFallback>
				</Avatar>

				<span>{connector.name}</span>
			</div>
		</button>
	);
}

export function ChainSwitcherDialog({
	children,
}: {
	children: (currentChainName: string) => React.ReactNode;
}) {
	const { switchChain, chains } = useSwitchChain();
	const chainId = useChainId();

	const currentChain = useMemo(
		() => chains.find(({ id }) => id === chainId),
		[chainId, chains],
	);

	return (
		<AlertDialog>
			<AlertDialogTrigger asChild>
				{children(currentChain?.name ?? "Unknown")}
			</AlertDialogTrigger>

			<AlertDialogContent className="sm:max-w-xs gap-6">
				<AlertDialogHeader className="flex relative gap-0">
					<AlertDialogTitle>Switch Chain</AlertDialogTitle>

					<AlertDialogDescription>
						Switch to preferred chain
					</AlertDialogDescription>

					<AlertDialogPrimitive.AlertDialogCancel asChild>
						<Button
							size="icon"
							variant="ghost"
							className="absolute right-0 bottom-1/2 translate-y-1/2"
						>
							<X />
						</Button>
					</AlertDialogPrimitive.AlertDialogCancel>
				</AlertDialogHeader>

				<div className="flex flex-col gap-2 mt-1">
					{chains.length === 0 ? (
						<div className="flex flex-col gap-2 items-center justify-center">
							<Droplet className="size-14" />

							<div className="text-center">No chains available</div>
						</div>
					) : (
						chains.map(({ id, name }) => (
							<Button
								key={id}
								aria-label={`Switch to ${name}`}
								aria-selected={id === chainId}
								variant={id === chainId ? "default" : "outline"}
								className="justify-between focus-visible:ring-0 focus-visible:dark:ring-0 aria-[selected=false]:focus-visible:bg-accent aria-[selected=true]:focus-visible:bg-primary/90"
								onClick={() => {
									switchChain(
										{ chainId: id },
										{
											onError: (error) => {
												console.error("Failed to switch chain", error);

												toast.error("Failed to switch chain", {
													dismissible: true,
												});
											},
										},
									);
								}}
							>
								<span>{name}</span>

								{id === chainId && <span className="text-xs">Connected</span>}
							</Button>
						))
					)}
				</div>
			</AlertDialogContent>
		</AlertDialog>
	);
}

export function WagmiAccount({
	address,
	children,
}: {
	address: Address;
	children: React.ReactNode;
}) {
	const { handleCopyToClipboard, handleDisconnect, isCopied } =
		useAccountActions();

	return (
		<AlertDialog>
			<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>

			<AlertDialogContent className="sm:max-w-xs gap-0">
				<AlertDialogHeader>
					<AlertDialogTitle hidden>Account Details</AlertDialogTitle>

					<AlertDialogDescription hidden>
						Details of account {address}
					</AlertDialogDescription>

					<AlertDialogPrimitive.AlertDialogCancel asChild>
						<Button size="icon" variant="ghost" className="ml-auto">
							<X />
						</Button>
					</AlertDialogPrimitive.AlertDialogCancel>
				</AlertDialogHeader>

				<div className="flex flex-col items-center gap-3 text-foreground">
					<Avatar className="size-16">
						<AccountAvatarImage address={address} />

						<AvatarFallback>
							<Wallet />
						</AvatarFallback>
					</Avatar>

					<div className="flex flex-col items-center">
						<span className="opacity-80">{truncateAddress(address)}</span>

						<AccountBalance as="h1" />
					</div>
				</div>

				<AlertDialogFooter className="w-full flex gap-4 justify-between mt-4">
					<WagmiAccountActionCard onClick={handleCopyToClipboard}>
						{isCopied ? <CopyCheck /> : <Copy />}
						Copy Address
					</WagmiAccountActionCard>

					<WagmiAccountActionCard onClick={handleDisconnect}>
						<LogOut />
						Disconnect
					</WagmiAccountActionCard>
				</AlertDialogFooter>
			</AlertDialogContent>
		</AlertDialog>
	);
}

function WagmiAccountActionCard({
	onClick,
	children,
}: {
	onClick: () => void;
	children: React.ReactNode;
}) {
	return (
		<button
			type="button"
			onClick={onClick}
			className="select-none disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer w-full h-16 gap-1 hover:bg-accent/90 bg-accent/50 rounded-md flex items-center justify-center flex-col text-xs [&_svg]:size-5"
		>
			{children}
		</button>
	);
}

type AccountAvatarImageProps = Omit<
	React.ComponentProps<typeof AvatarPrimitive.Image>,
	"children" | "asChild"
> & {
	address: Address;
	defaultAvatarUrl?: string | undefined;
};

export function AccountAvatarImage({
	className = "size-5",
	address,
	defaultAvatarUrl = `https://effigy.im/a/${address}.svg`,
	...props
}: AccountAvatarImageProps) {
	const { data: ensName } = useEnsName({
		address,
		query: { select: (data) => data ?? undefined },
	});

	const { data: ensAvatar } = useEnsAvatar({
		name: ensName,
		query: {
			enabled: ensName !== undefined,
			select: (data) => data ?? undefined,
		},
	});

	return <AvatarImage {...props} src={ensAvatar ?? defaultAvatarUrl} />;
}

type AccountBalanceProps<T extends React.ElementType> = {
	as?: T;
	className?: string;
} & React.ComponentPropsWithoutRef<T>;

export function AccountBalance<T extends React.ElementType = "span">({
	className = "font-medium text-lg",
	as: Component,
	...props
}: AccountBalanceProps<T>) {
	const { address } = useAccount();

	const { data, isLoading, error, isError } = useBalance({
		address,
		query: { enabled: address !== undefined },
	});

	const Comp = Component ?? "span";

	useEffect(() => {
		if (!isError) {
			return;
		}

		toast.error("Error obtaining balance, reload the page", {
			dismissible: true,
		});

		console.error("Error obtaining balance", error);
	}, [isError, error]);

	return (
		<Comp className={className} {...props}>
			{isLoading ? (
				<Loader className="animate-spin" />
			) : isError || data === undefined ? (
				"Err"
			) : (
				`${data.formatted} ${data.symbol}`
			)}
		</Comp>
	);
}

export function truncateAddress(address: Address): string {
	const start = address.slice(0, 4);
	const end = address.slice(-10, -1);

	return `${start}...${end}`;
}

Ejemplo Básico

En un entorno donde el proyecto ya tiene shadcn y los respectivos componentes necesarios, además que cuente ya con wagmi & viem instalados el proceso de integrar el componente a su proyecto puede ser de la forma en la que se le explicará a continuación.

En lugar de usar el ejemplo que se muestra a continuación, usted puedeseguir los pasos que dicta la documentación oficial de wagmi. Aquí solo tratamos de simplificar el proceso dedicado a el componente en cuestión.

https://wagmi.sh/react/getting-started

Configuración

El primer paso es configurar wagmi. Si planeas utilizar WalletConnect, necesitarás un projectId que puedes obtener en WalletConnect Cloud.
providers.tsx
import { type Config, createConfig, http } from "wagmi";
import { base, mainnet } from "wagmi/chains";
import { injected, metaMask, safe, walletConnect } from "wagmi/connectors";

export const WAGMI_CONFIG: Config = createConfig({
	chains: [mainnet, base],
	connectors: [
		injected(),
		walletConnect({ projectId: "YOUR_PROJECT_ID" }),
		metaMask(),
		safe(),
	],
	transports: {
		[mainnet.id]: http(),
		[base.id]: http(),
	},
});
Luego, configura QueryClient y crea un componente Providers que envuelva tu aplicación. Esto proporcionará el contexto necesario para wagmi y react-query.
providers.tsx
import { QueryClient } from "@tanstack/react-query"

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
	return (
		<WagmiProvider config={WAGMI_CONFIG}>
          <QueryClientProvider client={queryClient}>
            {children}
          </QueryClientProvider>
		</WagmiProvider>
	);
}
Envuelve tu aplicación de React con el componente Providers que creaste. Si utilizas Storybook, recuerda hacer lo mismo en tu archivo de configuración, como .storybook/preview.tsx, para asegurar que los componentes tengan acceso al contexto de wagmi.
index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { Providers } from "./providers";
import App from "./App";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <Providers>
      <App />
    </Providers>
  </React.StrictMode>
);
Una vez completada la configuración, puedes integrar el componente ConnectWallet en cualquier parte de tu aplicación para gestionar la conexión de la billetera.
header.tsx
<header className="flex items-center justify-between p-4">
  <h1 className="text-2xl font-bold">My DApp</h1>
  <ConnectWallet />
</header>