Initial commit with 🏗️ Scaffold-ETH 2 @ 1.0.5

This commit is contained in:
han
2026-01-10 18:17:37 +07:00
commit 98751c5b87
165 changed files with 29073 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import React from "react";
import Link from "next/link";
import { hardhat } from "viem/chains";
import { CurrencyDollarIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { HeartIcon } from "@heroicons/react/24/outline";
import { SwitchTheme } from "~~/components/SwitchTheme";
import { BuidlGuidlLogo } from "~~/components/assets/BuidlGuidlLogo";
import { Faucet } from "~~/components/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { useGlobalState } from "~~/services/store/store";
/**
* Site footer
*/
export const Footer = () => {
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
const { targetNetwork } = useTargetNetwork();
const isLocalNetwork = targetNetwork.id === hardhat.id;
return (
<div className="min-h-0 py-5 px-1 mb-11 lg:mb-0">
<div>
<div className="fixed flex justify-between items-center w-full z-10 p-4 bottom-0 left-0 pointer-events-none">
<div className="flex flex-col md:flex-row gap-2 pointer-events-auto">
{nativeCurrencyPrice > 0 && (
<div>
<div className="btn btn-primary btn-sm font-normal gap-1 cursor-auto">
<CurrencyDollarIcon className="h-4 w-4" />
<span>{nativeCurrencyPrice.toFixed(2)}</span>
</div>
</div>
)}
{isLocalNetwork && (
<>
<Faucet />
<Link href="/blockexplorer" passHref className="btn btn-primary btn-sm font-normal gap-1">
<MagnifyingGlassIcon className="h-4 w-4" />
<span>Block Explorer</span>
</Link>
</>
)}
</div>
<SwitchTheme className={`pointer-events-auto ${isLocalNetwork ? "self-end md:self-auto" : ""}`} />
</div>
</div>
<div className="w-full">
<ul className="menu menu-horizontal w-full">
<div className="flex justify-center items-center gap-2 text-sm w-full">
<div className="text-center">
<a href="https://github.com/scaffold-eth/se-2" target="_blank" rel="noreferrer" className="link">
Fork me
</a>
</div>
<span>·</span>
<div className="flex justify-center items-center gap-2">
<p className="m-0 text-center">
Built with <HeartIcon className="inline-block h-4 w-4" /> at
</p>
<a
className="flex justify-center items-center gap-1"
href="https://buidlguidl.com/"
target="_blank"
rel="noreferrer"
>
<BuidlGuidlLogo className="w-3 h-5 pb-1" />
<span className="link">BuidlGuidl</span>
</a>
</div>
<span>·</span>
<div className="text-center">
<a href="https://t.me/joinchat/KByvmRe5wkR-8F_zz6AjpA" target="_blank" rel="noreferrer" className="link">
Support
</a>
</div>
</div>
</ul>
</div>
</div>
);
};

View File

@@ -0,0 +1,124 @@
"use client";
import React, { useRef } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { hardhat } from "viem/chains";
import { Bars3Icon, BugAntIcon } from "@heroicons/react/24/outline";
import { ArrowDownTrayIcon, ArrowPathIcon, ArrowUpTrayIcon, PhotoIcon } from "@heroicons/react/24/outline";
import { FaucetButton, RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
import { useOutsideClick, useTargetNetwork } from "~~/hooks/scaffold-eth";
type HeaderMenuLink = {
label: string;
href: string;
icon?: React.ReactNode;
};
export const menuLinks: HeaderMenuLink[] = [
{
label: "Home",
href: "/",
},
{
label: "My NFTs",
href: "/myNFTs",
icon: <PhotoIcon className="h-4 w-4" />,
},
{
label: "Transfers",
href: "/transfers",
icon: <ArrowPathIcon className="h-4 w-4" />,
},
{
label: "IPFS Upload",
href: "/ipfsUpload",
icon: <ArrowUpTrayIcon className="h-4 w-4" />,
},
{
label: "IPFS Download",
href: "/ipfsDownload",
icon: <ArrowDownTrayIcon className="h-4 w-4" />,
},
{
label: "Debug Contracts",
href: "/debug",
icon: <BugAntIcon className="h-4 w-4" />,
},
];
export const HeaderMenuLinks = () => {
const pathname = usePathname();
return (
<>
{menuLinks.map(({ label, href, icon }) => {
const isActive = pathname === href;
return (
<li key={href}>
<Link
href={href}
passHref
className={`${
isActive ? "bg-secondary shadow-md" : ""
} hover:bg-secondary hover:shadow-md focus:!bg-secondary active:!text-neutral py-1.5 px-3 text-sm rounded-full gap-2 grid grid-flow-col`}
>
{icon}
<span>{label}</span>
</Link>
</li>
);
})}
</>
);
};
/**
* Site header
*/
export const Header = () => {
const { targetNetwork } = useTargetNetwork();
const isLocalNetwork = targetNetwork.id === hardhat.id;
const burgerMenuRef = useRef<HTMLDetailsElement>(null);
useOutsideClick(burgerMenuRef, () => {
burgerMenuRef?.current?.removeAttribute("open");
});
return (
<div className="sticky lg:static top-0 navbar bg-base-100 min-h-0 shrink-0 justify-between z-20 shadow-md shadow-secondary px-0 sm:px-2">
<div className="navbar-start w-auto lg:w-1/2">
<details className="dropdown" ref={burgerMenuRef}>
<summary className="ml-1 btn btn-ghost lg:hidden hover:bg-transparent">
<Bars3Icon className="h-1/2" />
</summary>
<ul
className="menu menu-compact dropdown-content mt-3 p-2 shadow-sm bg-base-100 rounded-box w-52"
onClick={() => {
burgerMenuRef?.current?.removeAttribute("open");
}}
>
<HeaderMenuLinks />
</ul>
</details>
<Link href="/" passHref className="hidden lg:flex items-center gap-2 ml-4 mr-6 shrink-0">
<div className="flex relative w-10 h-10">
<Image alt="SE2 logo" className="cursor-pointer" fill src="/logo.svg" />
</div>
<div className="flex flex-col">
<span className="font-bold leading-tight">SRE Challenges</span>
<span className="text-xs">Tokenization</span>
</div>
</Link>
<ul className="hidden lg:flex lg:flex-nowrap menu menu-horizontal px-1 gap-2">
<HeaderMenuLinks />
</ul>
</div>
<div className="navbar-end grow mr-4">
<RainbowKitCustomConnectButton />
{isLocalNetwork && <FaucetButton />}
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { RainbowKitProvider, darkTheme, lightTheme } from "@rainbow-me/rainbowkit";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppProgressBar as ProgressBar } from "next-nprogress-bar";
import { useTheme } from "next-themes";
import { Toaster } from "react-hot-toast";
import { WagmiProvider } from "wagmi";
import { Footer } from "~~/components/Footer";
import { Header } from "~~/components/Header";
import { BlockieAvatar } from "~~/components/scaffold-eth";
import { useInitializeNativeCurrencyPrice } from "~~/hooks/scaffold-eth";
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
useInitializeNativeCurrencyPrice();
return (
<>
<div className={`flex flex-col min-h-screen `}>
<Header />
<main className="relative flex flex-col flex-1">{children}</main>
<Footer />
</div>
<Toaster />
</>
);
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
export const ScaffoldEthAppWithProviders = ({ children }: { children: React.ReactNode }) => {
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark";
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
avatar={BlockieAvatar}
theme={mounted ? (isDarkMode ? darkTheme() : lightTheme()) : lightTheme()}
>
<ProgressBar height="3px" color="#2299dd" />
<ScaffoldEthApp>{children}</ScaffoldEthApp>
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
};

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
export const SwitchTheme = ({ className }: { className?: string }) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const isDarkMode = resolvedTheme === "dark";
const handleToggle = () => {
if (isDarkMode) {
setTheme("light");
return;
}
setTheme("dark");
};
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<div className={`flex space-x-2 h-8 items-center justify-center text-sm ${className}`}>
<input
id="theme-toggle"
type="checkbox"
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all"
onChange={handleToggle}
checked={isDarkMode}
/>
<label htmlFor="theme-toggle" className={`swap swap-rotate ${!isDarkMode ? "swap-active" : ""}`}>
<SunIcon className="swap-on h-5 w-5" />
<MoonIcon className="swap-off h-5 w-5" />
</label>
</div>
);
};

View File

@@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};

View File

@@ -0,0 +1,18 @@
export const BuidlGuidlLogo = ({ className }: { className: string }) => {
return (
<svg
className={className}
width="53"
height="72"
viewBox="0 0 53 72"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M25.9 17.434v15.638h3.927v9.04h9.718v-9.04h6.745v18.08l-10.607 19.88-12.11-.182-12.11.183L.856 51.152v-18.08h6.713v9.04h9.75v-9.04h4.329V2.46a2.126 2.126 0 0 1 4.047-.914c1.074.412 2.157 1.5 3.276 2.626 1.33 1.337 2.711 2.726 4.193 3.095 1.496.373 2.605-.026 3.855-.475 1.31-.47 2.776-.997 5.005-.747 1.67.197 2.557 1.289 3.548 2.509 1.317 1.623 2.82 3.473 6.599 3.752l-.024.017c-2.42 1.709-5.726 4.043-10.86 3.587-1.605-.139-2.736-.656-3.82-1.153-1.546-.707-2.997-1.37-5.59-.832-2.809.563-4.227 1.892-5.306 2.903-.236.221-.456.427-.67.606Z"
clipRule="evenodd"
/>
</svg>
);
};

View File

@@ -0,0 +1,187 @@
"use client";
import { AddressCopyIcon } from "./AddressCopyIcon";
import { AddressLinkWrapper } from "./AddressLinkWrapper";
import { Address as AddressType, getAddress, isAddress } from "viem";
import { normalize } from "viem/ens";
import { useEnsAvatar, useEnsName } from "wagmi";
import { BlockieAvatar } from "~~/components/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
const textSizeMap = {
"3xs": "text-[10px]",
"2xs": "text-[11px]",
xs: "text-xs",
sm: "text-sm",
base: "text-base",
lg: "text-lg",
xl: "text-xl",
"2xl": "text-2xl",
"3xl": "text-3xl",
"4xl": "text-4xl",
} as const;
const blockieSizeMap = {
"3xs": 4,
"2xs": 5,
xs: 6,
sm: 7,
base: 8,
lg: 9,
xl: 10,
"2xl": 12,
"3xl": 15,
"4xl": 17,
"5xl": 19,
"6xl": 21,
"7xl": 23,
} as const;
const copyIconSizeMap = {
"3xs": "h-2.5 w-2.5",
"2xs": "h-3 w-3",
xs: "h-3.5 w-3.5",
sm: "h-4 w-4",
base: "h-[18px] w-[18px]",
lg: "h-5 w-5",
xl: "h-[22px] w-[22px]",
"2xl": "h-6 w-6",
"3xl": "h-[26px] w-[26px]",
"4xl": "h-7 w-7",
} as const;
type SizeMap = typeof textSizeMap | typeof blockieSizeMap;
const getNextSize = <T extends SizeMap>(sizeMap: T, currentSize: keyof T, step = 1): keyof T => {
const sizes = Object.keys(sizeMap) as Array<keyof T>;
const currentIndex = sizes.indexOf(currentSize);
const nextIndex = Math.min(currentIndex + step, sizes.length - 1);
return sizes[nextIndex];
};
const getPrevSize = <T extends SizeMap>(sizeMap: T, currentSize: keyof T, step = 1): keyof T => {
const sizes = Object.keys(sizeMap) as Array<keyof T>;
const currentIndex = sizes.indexOf(currentSize);
const prevIndex = Math.max(currentIndex - step, 0);
return sizes[prevIndex];
};
type AddressProps = {
address?: AddressType;
disableAddressLink?: boolean;
format?: "short" | "long";
size?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
onlyEnsOrAddress?: boolean;
};
export const Address = ({
address,
disableAddressLink,
format,
size = "base",
onlyEnsOrAddress = false,
}: AddressProps) => {
const checkSumAddress = address ? getAddress(address) : undefined;
const { targetNetwork } = useTargetNetwork();
const { data: ens, isLoading: isEnsNameLoading } = useEnsName({
address: checkSumAddress,
chainId: 1,
query: {
enabled: isAddress(checkSumAddress ?? ""),
},
});
const { data: ensAvatar } = useEnsAvatar({
name: ens ? normalize(ens) : undefined,
chainId: 1,
query: {
enabled: Boolean(ens),
gcTime: 30_000,
},
});
const shortAddress = checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4);
const displayAddress = format === "long" ? checkSumAddress : shortAddress;
const displayEnsOrAddress = ens || displayAddress;
const showSkeleton = !checkSumAddress || (!onlyEnsOrAddress && (ens || isEnsNameLoading));
const addressSize = showSkeleton && !onlyEnsOrAddress ? getPrevSize(textSizeMap, size, 2) : size;
const ensSize = getNextSize(textSizeMap, addressSize);
const blockieSize = showSkeleton && !onlyEnsOrAddress ? getNextSize(blockieSizeMap, addressSize, 4) : addressSize;
if (!checkSumAddress) {
return (
<div className="flex items-center">
<div
className="shrink-0 skeleton rounded-full"
style={{
width: (blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"],
height: (blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"],
}}
></div>
<div className="flex flex-col space-y-1">
{!onlyEnsOrAddress && (
<div className={`ml-1.5 skeleton rounded-lg font-bold ${textSizeMap[ensSize]}`}>
<span className="invisible">0x1234...56789</span>
</div>
)}
<div className={`ml-1.5 skeleton rounded-lg ${textSizeMap[addressSize]}`}>
<span className="invisible">0x1234...56789</span>
</div>
</div>
</div>
);
}
if (!isAddress(checkSumAddress)) {
return <span className="text-error">Wrong address</span>;
}
const blockExplorerAddressLink = getBlockExplorerAddressLink(targetNetwork, checkSumAddress);
return (
<div className="flex items-center shrink-0">
<div className="shrink-0">
<BlockieAvatar
address={checkSumAddress}
ensImage={ensAvatar}
size={(blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"]}
/>
</div>
<div className="flex flex-col">
{showSkeleton &&
(isEnsNameLoading ? (
<div className={`ml-1.5 skeleton rounded-lg font-bold ${textSizeMap[ensSize]}`}>
<span className="invisible">{shortAddress}</span>
</div>
) : (
<span className={`ml-1.5 ${textSizeMap[ensSize]} font-bold`}>
<AddressLinkWrapper
disableAddressLink={disableAddressLink}
blockExplorerAddressLink={blockExplorerAddressLink}
>
{ens}
</AddressLinkWrapper>
</span>
))}
<div className="flex">
<span className={`ml-1.5 ${textSizeMap[addressSize]} font-normal`}>
<AddressLinkWrapper
disableAddressLink={disableAddressLink}
blockExplorerAddressLink={blockExplorerAddressLink}
>
{onlyEnsOrAddress ? displayEnsOrAddress : displayAddress}
</AddressLinkWrapper>
</span>
<AddressCopyIcon
className={`ml-1 ${copyIconSizeMap[addressSize]} cursor-pointer`}
address={checkSumAddress}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
export const AddressCopyIcon = ({ className, address }: { className?: string; address: string }) => {
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
useCopyToClipboard();
return (
<button
onClick={e => {
e.stopPropagation();
copyAddressToClipboard(address);
}}
type="button"
>
{isAddressCopiedToClipboard ? (
<CheckCircleIcon className={className} aria-hidden="true" />
) : (
<DocumentDuplicateIcon className={className} aria-hidden="true" />
)}
</button>
);
};

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
import { hardhat } from "viem/chains";
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
type AddressLinkWrapperProps = {
children: React.ReactNode;
disableAddressLink?: boolean;
blockExplorerAddressLink: string;
};
export const AddressLinkWrapper = ({
children,
disableAddressLink,
blockExplorerAddressLink,
}: AddressLinkWrapperProps) => {
const { targetNetwork } = useTargetNetwork();
return disableAddressLink ? (
<>{children}</>
) : (
<Link
href={blockExplorerAddressLink}
target={targetNetwork.id === hardhat.id ? undefined : "_blank"}
rel={targetNetwork.id === hardhat.id ? undefined : "noopener noreferrer"}
>
{children}
</Link>
);
};

View File

@@ -0,0 +1,75 @@
"use client";
import { Address, formatEther } from "viem";
import { useDisplayUsdMode } from "~~/hooks/scaffold-eth/useDisplayUsdMode";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
import { useGlobalState } from "~~/services/store/store";
type BalanceProps = {
address?: Address;
className?: string;
usdMode?: boolean;
};
/**
* Display (ETH & USD) balance of an ETH address.
*/
export const Balance = ({ address, className = "", usdMode }: BalanceProps) => {
const { targetNetwork } = useTargetNetwork();
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
const isNativeCurrencyPriceFetching = useGlobalState(state => state.nativeCurrency.isFetching);
const {
data: balance,
isError,
isLoading,
} = useWatchBalance({
address,
});
const { displayUsdMode, toggleDisplayUsdMode } = useDisplayUsdMode({ defaultUsdMode: usdMode });
if (!address || isLoading || balance === null || (isNativeCurrencyPriceFetching && nativeCurrencyPrice === 0)) {
return (
<div className="animate-pulse flex space-x-4">
<div className="rounded-md bg-slate-300 h-6 w-6"></div>
<div className="flex items-center space-y-6">
<div className="h-2 w-28 bg-slate-300 rounded-sm"></div>
</div>
</div>
);
}
if (isError) {
return (
<div className="border-2 border-base-content/30 rounded-md px-2 flex flex-col items-center max-w-fit cursor-pointer">
<div className="text-warning">Error</div>
</div>
);
}
const formattedBalance = balance ? Number(formatEther(balance.value)) : 0;
return (
<button
className={`btn btn-sm btn-ghost flex flex-col font-normal items-center hover:bg-transparent ${className}`}
onClick={toggleDisplayUsdMode}
type="button"
>
<div className="w-full flex items-center justify-center">
{displayUsdMode ? (
<>
<span className="text-[0.8em] font-bold mr-1">$</span>
<span>{(formattedBalance * nativeCurrencyPrice).toFixed(2)}</span>
</>
) : (
<>
<span>{formattedBalance.toFixed(4)}</span>
<span className="text-[0.8em] font-bold ml-1">{targetNetwork.nativeCurrency.symbol}</span>
</>
)}
</div>
</button>
);
};

View File

@@ -0,0 +1,17 @@
"use client";
import { AvatarComponent } from "@rainbow-me/rainbowkit";
import { blo } from "blo";
// Custom Avatar for RainbowKit
export const BlockieAvatar: AvatarComponent = ({ address, ensImage, size }) => (
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
// eslint-disable-next-line @next/next/no-img-element
<img
className="rounded-full"
src={ensImage || blo(address as `0x${string}`)}
width={size}
height={size}
alt={`${address} avatar`}
/>
);

View File

@@ -0,0 +1,129 @@
"use client";
import { useEffect, useState } from "react";
import { Address as AddressType, createWalletClient, http, parseEther } from "viem";
import { hardhat } from "viem/chains";
import { useAccount } from "wagmi";
import { BanknotesIcon } from "@heroicons/react/24/outline";
import { Address, AddressInput, Balance, EtherInput } from "~~/components/scaffold-eth";
import { useTransactor } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
// Account index to use from generated hardhat accounts.
const FAUCET_ACCOUNT_INDEX = 0;
const localWalletClient = createWalletClient({
chain: hardhat,
transport: http(),
});
/**
* Faucet modal which lets you send ETH to any address.
*/
export const Faucet = () => {
const [loading, setLoading] = useState(false);
const [inputAddress, setInputAddress] = useState<AddressType>();
const [faucetAddress, setFaucetAddress] = useState<AddressType>();
const [sendValue, setSendValue] = useState("");
const { chain: ConnectedChain } = useAccount();
const faucetTxn = useTransactor(localWalletClient);
useEffect(() => {
const getFaucetAddress = async () => {
try {
const accounts = await localWalletClient.getAddresses();
setFaucetAddress(accounts[FAUCET_ACCOUNT_INDEX]);
} catch (error) {
notification.error(
<>
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
<p className="m-0">
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
</p>
<p className="mt-1 break-normal">
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
</p>
</>,
);
console.error("⚡️ ~ file: Faucet.tsx:getFaucetAddress ~ error", error);
}
};
getFaucetAddress();
}, []);
const sendETH = async () => {
if (!faucetAddress || !inputAddress) {
return;
}
try {
setLoading(true);
await faucetTxn({
to: inputAddress,
value: parseEther(sendValue as `${number}`),
account: faucetAddress,
});
setLoading(false);
setInputAddress(undefined);
setSendValue("");
} catch (error) {
console.error("⚡️ ~ file: Faucet.tsx:sendETH ~ error", error);
setLoading(false);
}
};
// Render only on local chain
if (ConnectedChain?.id !== hardhat.id) {
return null;
}
return (
<div>
<label htmlFor="faucet-modal" className="btn btn-primary btn-sm font-normal gap-1">
<BanknotesIcon className="h-4 w-4" />
<span>Faucet</span>
</label>
<input type="checkbox" id="faucet-modal" className="modal-toggle" />
<label htmlFor="faucet-modal" className="modal cursor-pointer">
<label className="modal-box relative">
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
<h3 className="text-xl font-bold mb-3">Local Faucet</h3>
<label htmlFor="faucet-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</label>
<div className="space-y-3">
<div className="flex space-x-4">
<div>
<span className="text-sm font-bold">From:</span>
<Address address={faucetAddress} onlyEnsOrAddress />
</div>
<div>
<span className="text-sm font-bold pl-3">Available:</span>
<Balance address={faucetAddress} />
</div>
</div>
<div className="flex flex-col space-y-3">
<AddressInput
placeholder="Destination Address"
value={inputAddress ?? ""}
onChange={value => setInputAddress(value as AddressType)}
/>
<EtherInput placeholder="Amount to send" value={sendValue} onChange={value => setSendValue(value)} />
<button className="h-10 btn btn-primary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
{!loading ? (
<BanknotesIcon className="h-6 w-6" />
) : (
<span className="loading loading-spinner loading-sm"></span>
)}
<span>Send</span>
</button>
</div>
</div>
</label>
</label>
</div>
);
};

View File

@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { createWalletClient, http, parseEther } from "viem";
import { hardhat } from "viem/chains";
import { useAccount } from "wagmi";
import { BanknotesIcon } from "@heroicons/react/24/outline";
import { useTransactor } from "~~/hooks/scaffold-eth";
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
// Number of ETH faucet sends to an address
const NUM_OF_ETH = "1";
const FAUCET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const localWalletClient = createWalletClient({
chain: hardhat,
transport: http(),
});
/**
* FaucetButton button which lets you grab eth.
*/
export const FaucetButton = () => {
const { address, chain: ConnectedChain } = useAccount();
const { data: balance } = useWatchBalance({ address });
const [loading, setLoading] = useState(false);
const faucetTxn = useTransactor(localWalletClient);
const sendETH = async () => {
if (!address) return;
try {
setLoading(true);
await faucetTxn({
account: FAUCET_ADDRESS,
to: address,
value: parseEther(NUM_OF_ETH),
});
setLoading(false);
} catch (error) {
console.error("⚡️ ~ file: FaucetButton.tsx:sendETH ~ error", error);
setLoading(false);
}
};
// Render only on local chain
if (ConnectedChain?.id !== hardhat.id) {
return null;
}
const isBalanceZero = balance && balance.value === 0n;
return (
<div
className={
!isBalanceZero
? "ml-1"
: "ml-1 tooltip tooltip-bottom tooltip-primary tooltip-open font-bold before:left-auto before:transform-none before:content-[attr(data-tip)] before:-translate-x-2/5"
}
data-tip="Grab funds from faucet"
>
<button className="btn btn-secondary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
{!loading ? (
<BanknotesIcon className="h-4 w-4" />
) : (
<span className="loading loading-spinner loading-xs"></span>
)}
</button>
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { blo } from "blo";
import { useDebounceValue } from "usehooks-ts";
import { Address, isAddress } from "viem";
import { normalize } from "viem/ens";
import { useEnsAddress, useEnsAvatar, useEnsName } from "wagmi";
import { CommonInputProps, InputBase, isENS } from "~~/components/scaffold-eth";
/**
* Address input with ENS name resolution
*/
export const AddressInput = ({ value, name, placeholder, onChange, disabled }: CommonInputProps<Address | string>) => {
// Debounce the input to keep clean RPC calls when resolving ENS names
// If the input is an address, we don't need to debounce it
const [_debouncedValue] = useDebounceValue(value, 500);
const debouncedValue = isAddress(value) ? value : _debouncedValue;
const isDebouncedValueLive = debouncedValue === value;
// If the user changes the input after an ENS name is already resolved, we want to remove the stale result
const settledValue = isDebouncedValueLive ? debouncedValue : undefined;
const {
data: ensAddress,
isLoading: isEnsAddressLoading,
isError: isEnsAddressError,
} = useEnsAddress({
name: settledValue,
chainId: 1,
query: {
gcTime: 30_000,
enabled: isDebouncedValueLive && isENS(debouncedValue),
},
});
const [enteredEnsName, setEnteredEnsName] = useState<string>();
const {
data: ensName,
isLoading: isEnsNameLoading,
isError: isEnsNameError,
} = useEnsName({
address: settledValue as Address,
chainId: 1,
query: {
enabled: isAddress(debouncedValue),
gcTime: 30_000,
},
});
const { data: ensAvatar, isLoading: isEnsAvatarLoading } = useEnsAvatar({
name: ensName ? normalize(ensName) : undefined,
chainId: 1,
query: {
enabled: Boolean(ensName),
gcTime: 30_000,
},
});
// ens => address
useEffect(() => {
if (!ensAddress) return;
// ENS resolved successfully
setEnteredEnsName(debouncedValue);
onChange(ensAddress);
}, [ensAddress, onChange, debouncedValue]);
useEffect(() => {
setEnteredEnsName(undefined);
}, [value]);
const reFocus = isEnsAddressError || isEnsNameError || ensName === null || ensAddress === null;
return (
<InputBase<Address>
name={name}
placeholder={placeholder}
error={ensAddress === null}
value={value as Address}
onChange={onChange}
disabled={isEnsAddressLoading || isEnsNameLoading || disabled}
reFocus={reFocus}
prefix={
ensName ? (
<div className="flex bg-base-300 rounded-l-full items-center">
{isEnsAvatarLoading && <div className="skeleton bg-base-200 w-[35px] h-[35px] rounded-full shrink-0"></div>}
{ensAvatar ? (
<span className="w-[35px]">
{
// eslint-disable-next-line
<img className="w-full rounded-full" src={ensAvatar} alt={`${ensAddress} avatar`} />
}
</span>
) : null}
<span className="text-accent px-2">{enteredEnsName ?? ensName}</span>
</div>
) : (
(isEnsNameLoading || isEnsAddressLoading) && (
<div className="flex bg-base-300 rounded-l-full items-center gap-2 pr-2">
<div className="skeleton bg-base-200 w-[35px] h-[35px] rounded-full shrink-0"></div>
<div className="skeleton bg-base-200 h-3 w-20"></div>
</div>
)
)
}
suffix={
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
// eslint-disable-next-line @next/next/no-img-element
value && <img alt="" className="rounded-full!" src={blo(value as `0x${string}`)} width="35" height="35" />
}
/>
);
};

View File

@@ -0,0 +1,31 @@
import { useCallback } from "react";
import { hexToString, isHex, stringToHex } from "viem";
import { CommonInputProps, InputBase } from "~~/components/scaffold-eth";
export const Bytes32Input = ({ value, onChange, name, placeholder, disabled }: CommonInputProps) => {
const convertStringToBytes32 = useCallback(() => {
if (!value) {
return;
}
onChange(isHex(value) ? hexToString(value, { size: 32 }) : stringToHex(value, { size: 32 }));
}, [onChange, value]);
return (
<InputBase
name={name}
value={value}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
suffix={
<button
className="self-center cursor-pointer text-xl font-semibold px-4 text-accent"
onClick={convertStringToBytes32}
type="button"
>
#
</button>
}
/>
);
};

View File

@@ -0,0 +1,28 @@
import { useCallback } from "react";
import { bytesToString, isHex, toBytes, toHex } from "viem";
import { CommonInputProps, InputBase } from "~~/components/scaffold-eth";
export const BytesInput = ({ value, onChange, name, placeholder, disabled }: CommonInputProps) => {
const convertStringToBytes = useCallback(() => {
onChange(isHex(value) ? bytesToString(toBytes(value)) : toHex(toBytes(value)));
}, [onChange, value]);
return (
<InputBase
name={name}
value={value}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
suffix={
<button
className="self-center cursor-pointer text-xl font-semibold px-4 text-accent"
onClick={convertStringToBytes}
type="button"
>
#
</button>
}
/>
);
};

View File

@@ -0,0 +1,128 @@
import { useMemo, useState } from "react";
import { ArrowsRightLeftIcon } from "@heroicons/react/24/outline";
import { CommonInputProps, InputBase, SIGNED_NUMBER_REGEX } from "~~/components/scaffold-eth";
import { useDisplayUsdMode } from "~~/hooks/scaffold-eth/useDisplayUsdMode";
import { useGlobalState } from "~~/services/store/store";
const MAX_DECIMALS_USD = 2;
function etherValueToDisplayValue(usdMode: boolean, etherValue: string, nativeCurrencyPrice: number) {
if (usdMode && nativeCurrencyPrice) {
const parsedEthValue = parseFloat(etherValue);
if (Number.isNaN(parsedEthValue)) {
return etherValue;
} else {
// We need to round the value rather than use toFixed,
// since otherwise a user would not be able to modify the decimal value
return (
Math.round(parsedEthValue * nativeCurrencyPrice * 10 ** MAX_DECIMALS_USD) /
10 ** MAX_DECIMALS_USD
).toString();
}
} else {
return etherValue;
}
}
function displayValueToEtherValue(usdMode: boolean, displayValue: string, nativeCurrencyPrice: number) {
if (usdMode && nativeCurrencyPrice) {
const parsedDisplayValue = parseFloat(displayValue);
if (Number.isNaN(parsedDisplayValue)) {
// Invalid number.
return displayValue;
} else {
// Compute the ETH value if a valid number.
return (parsedDisplayValue / nativeCurrencyPrice).toString();
}
} else {
return displayValue;
}
}
/**
* Input for ETH amount with USD conversion.
*
* onChange will always be called with the value in ETH
*/
export const EtherInput = ({
value,
name,
placeholder,
onChange,
disabled,
usdMode,
}: CommonInputProps & { usdMode?: boolean }) => {
const [transitoryDisplayValue, setTransitoryDisplayValue] = useState<string>();
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
const isNativeCurrencyPriceFetching = useGlobalState(state => state.nativeCurrency.isFetching);
const { displayUsdMode, toggleDisplayUsdMode } = useDisplayUsdMode({ defaultUsdMode: usdMode });
// The displayValue is derived from the ether value that is controlled outside of the component
// In usdMode, it is converted to its usd value, in regular mode it is unaltered
const displayValue = useMemo(() => {
const newDisplayValue = etherValueToDisplayValue(displayUsdMode, value, nativeCurrencyPrice || 0);
if (transitoryDisplayValue && parseFloat(newDisplayValue) === parseFloat(transitoryDisplayValue)) {
return transitoryDisplayValue;
}
// Clear any transitory display values that might be set
setTransitoryDisplayValue(undefined);
return newDisplayValue;
}, [nativeCurrencyPrice, transitoryDisplayValue, displayUsdMode, value]);
const handleChangeNumber = (newValue: string) => {
if (newValue && !SIGNED_NUMBER_REGEX.test(newValue)) {
return;
}
// Following condition is a fix to prevent usdMode from experiencing different display values
// than what the user entered. This can happen due to floating point rounding errors that are introduced in the back and forth conversion
if (displayUsdMode) {
const decimals = newValue.split(".")[1];
if (decimals && decimals.length > MAX_DECIMALS_USD) {
return;
}
}
// Since the display value is a derived state (calculated from the ether value), usdMode would not allow introducing a decimal point.
// This condition handles a transitory state for a display value with a trailing decimal sign
if (newValue.endsWith(".") || newValue.endsWith(".0")) {
setTransitoryDisplayValue(newValue);
} else {
setTransitoryDisplayValue(undefined);
}
const newEthValue = displayValueToEtherValue(displayUsdMode, newValue, nativeCurrencyPrice || 0);
onChange(newEthValue);
};
return (
<InputBase
name={name}
value={displayValue}
placeholder={placeholder}
onChange={handleChangeNumber}
disabled={disabled}
prefix={<span className="pl-4 -mr-2 text-accent self-center">{displayUsdMode ? "$" : "Ξ"}</span>}
suffix={
<div
className={`${
nativeCurrencyPrice > 0
? ""
: "tooltip tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none"
}`}
data-tip={isNativeCurrencyPriceFetching ? "Fetching price" : "Unable to fetch price"}
>
<button
className="btn btn-primary h-[2.2rem] min-h-[2.2rem]"
onClick={toggleDisplayUsdMode}
disabled={!displayUsdMode && !nativeCurrencyPrice}
type="button"
>
<ArrowsRightLeftIcon className="h-3 w-3 cursor-pointer" aria-hidden="true" />
</button>
</div>
}
/>
);
};

View File

@@ -0,0 +1,66 @@
import { ChangeEvent, FocusEvent, ReactNode, useCallback, useEffect, useRef } from "react";
import { CommonInputProps } from "~~/components/scaffold-eth";
type InputBaseProps<T> = CommonInputProps<T> & {
error?: boolean;
prefix?: ReactNode;
suffix?: ReactNode;
reFocus?: boolean;
};
export const InputBase = <T extends { toString: () => string } | undefined = string>({
name,
value,
onChange,
placeholder,
error,
disabled,
prefix,
suffix,
reFocus,
}: InputBaseProps<T>) => {
const inputReft = useRef<HTMLInputElement>(null);
let modifier = "";
if (error) {
modifier = "border-error";
} else if (disabled) {
modifier = "border-disabled bg-base-300";
}
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value as unknown as T);
},
[onChange],
);
// Runs only when reFocus prop is passed, useful for setting the cursor
// at the end of the input. Example AddressInput
const onFocus = (e: FocusEvent<HTMLInputElement, Element>) => {
if (reFocus !== undefined) {
e.currentTarget.setSelectionRange(e.currentTarget.value.length, e.currentTarget.value.length);
}
};
useEffect(() => {
if (reFocus !== undefined && reFocus === true) inputReft.current?.focus();
}, [reFocus]);
return (
<div className={`flex border-2 border-base-300 bg-base-200 rounded-full text-accent ${modifier}`}>
{prefix}
<input
className="input input-ghost focus-within:border-transparent focus:outline-hidden focus:bg-transparent h-[2.2rem] min-h-[2.2rem] px-4 border w-full font-medium placeholder:text-accent/70 text-base-content/70 focus:text-base-content/70"
placeholder={placeholder}
name={name}
value={value?.toString()}
onChange={handleChange}
disabled={disabled}
autoComplete="off"
ref={inputReft}
onFocus={onFocus}
/>
{suffix}
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from "react";
import { parseEther } from "viem";
import { CommonInputProps, InputBase, IntegerVariant, isValidInteger } from "~~/components/scaffold-eth";
type IntegerInputProps = CommonInputProps<string> & {
variant?: IntegerVariant;
disableMultiplyBy1e18?: boolean;
};
export const IntegerInput = ({
value,
onChange,
name,
placeholder,
disabled,
variant = IntegerVariant.UINT256,
disableMultiplyBy1e18 = false,
}: IntegerInputProps) => {
const [inputError, setInputError] = useState(false);
const multiplyBy1e18 = useCallback(() => {
if (!value) {
return;
}
return onChange(parseEther(value).toString());
}, [onChange, value]);
useEffect(() => {
if (isValidInteger(variant, value)) {
setInputError(false);
} else {
setInputError(true);
}
}, [value, variant]);
return (
<InputBase
name={name}
value={value}
placeholder={placeholder}
error={inputError}
onChange={onChange}
disabled={disabled}
suffix={
!inputError &&
!disableMultiplyBy1e18 && (
<div
className="space-x-4 flex tooltip tooltip-top tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none"
data-tip="Multiply by 1e18 (wei)"
>
<button
className={`${disabled ? "cursor-not-allowed" : "cursor-pointer"} font-semibold px-4 text-accent`}
onClick={multiplyBy1e18}
disabled={disabled}
type="button"
>
</button>
</div>
)
}
/>
);
};

View File

@@ -0,0 +1,9 @@
"use client";
export * from "./AddressInput";
export * from "./Bytes32Input";
export * from "./BytesInput";
export * from "./EtherInput";
export * from "./InputBase";
export * from "./IntegerInput";
export * from "./utils";

View File

@@ -0,0 +1,109 @@
export type CommonInputProps<T = string> = {
value: T;
onChange: (newValue: T) => void;
name?: string;
placeholder?: string;
disabled?: boolean;
};
export enum IntegerVariant {
UINT8 = "uint8",
UINT16 = "uint16",
UINT24 = "uint24",
UINT32 = "uint32",
UINT40 = "uint40",
UINT48 = "uint48",
UINT56 = "uint56",
UINT64 = "uint64",
UINT72 = "uint72",
UINT80 = "uint80",
UINT88 = "uint88",
UINT96 = "uint96",
UINT104 = "uint104",
UINT112 = "uint112",
UINT120 = "uint120",
UINT128 = "uint128",
UINT136 = "uint136",
UINT144 = "uint144",
UINT152 = "uint152",
UINT160 = "uint160",
UINT168 = "uint168",
UINT176 = "uint176",
UINT184 = "uint184",
UINT192 = "uint192",
UINT200 = "uint200",
UINT208 = "uint208",
UINT216 = "uint216",
UINT224 = "uint224",
UINT232 = "uint232",
UINT240 = "uint240",
UINT248 = "uint248",
UINT256 = "uint256",
INT8 = "int8",
INT16 = "int16",
INT24 = "int24",
INT32 = "int32",
INT40 = "int40",
INT48 = "int48",
INT56 = "int56",
INT64 = "int64",
INT72 = "int72",
INT80 = "int80",
INT88 = "int88",
INT96 = "int96",
INT104 = "int104",
INT112 = "int112",
INT120 = "int120",
INT128 = "int128",
INT136 = "int136",
INT144 = "int144",
INT152 = "int152",
INT160 = "int160",
INT168 = "int168",
INT176 = "int176",
INT184 = "int184",
INT192 = "int192",
INT200 = "int200",
INT208 = "int208",
INT216 = "int216",
INT224 = "int224",
INT232 = "int232",
INT240 = "int240",
INT248 = "int248",
INT256 = "int256",
}
export const SIGNED_NUMBER_REGEX = /^-?\d+\.?\d*$/;
export const UNSIGNED_NUMBER_REGEX = /^\.?\d+\.?\d*$/;
export const isValidInteger = (dataType: IntegerVariant, value: string) => {
const isSigned = dataType.startsWith("i");
const bitcount = Number(dataType.substring(isSigned ? 3 : 4));
let valueAsBigInt;
try {
valueAsBigInt = BigInt(value);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {}
if (typeof valueAsBigInt !== "bigint") {
if (!value || typeof value !== "string") {
return true;
}
return isSigned ? SIGNED_NUMBER_REGEX.test(value) || value === "-" : UNSIGNED_NUMBER_REGEX.test(value);
} else if (!isSigned && valueAsBigInt < 0) {
return false;
}
const hexString = valueAsBigInt.toString(16);
const significantHexDigits = hexString.match(/.*x0*(.*)$/)?.[1] ?? "";
if (
significantHexDigits.length * 4 > bitcount ||
(isSigned && significantHexDigits.length * 4 === bitcount && parseInt(significantHexDigits.slice(-1)?.[0], 16) < 8)
) {
return false;
}
return true;
};
// Treat any dot-separated string as a potential ENS name
const ensRegex = /.+\..+/;
export const isENS = (address = "") => ensRegex.test(address);

View File

@@ -0,0 +1,136 @@
import { useRef, useState } from "react";
import { NetworkOptions } from "./NetworkOptions";
import { getAddress } from "viem";
import { Address } from "viem";
import { useAccount, useDisconnect } from "wagmi";
import {
ArrowLeftOnRectangleIcon,
ArrowTopRightOnSquareIcon,
ArrowsRightLeftIcon,
CheckCircleIcon,
ChevronDownIcon,
DocumentDuplicateIcon,
EyeIcon,
QrCodeIcon,
} from "@heroicons/react/24/outline";
import { BlockieAvatar, isENS } from "~~/components/scaffold-eth";
import { useCopyToClipboard, useOutsideClick } from "~~/hooks/scaffold-eth";
import { getTargetNetworks } from "~~/utils/scaffold-eth";
const BURNER_WALLET_ID = "burnerWallet";
const allowedNetworks = getTargetNetworks();
type AddressInfoDropdownProps = {
address: Address;
blockExplorerAddressLink: string | undefined;
displayName: string;
ensAvatar?: string;
};
export const AddressInfoDropdown = ({
address,
ensAvatar,
displayName,
blockExplorerAddressLink,
}: AddressInfoDropdownProps) => {
const { disconnect } = useDisconnect();
const { connector } = useAccount();
const checkSumAddress = getAddress(address);
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
useCopyToClipboard();
const [selectingNetwork, setSelectingNetwork] = useState(false);
const dropdownRef = useRef<HTMLDetailsElement>(null);
const closeDropdown = () => {
setSelectingNetwork(false);
dropdownRef.current?.removeAttribute("open");
};
useOutsideClick(dropdownRef, closeDropdown);
return (
<>
<details ref={dropdownRef} className="dropdown dropdown-end leading-3">
<summary className="btn btn-secondary btn-sm pl-0 pr-2 shadow-md dropdown-toggle gap-0 h-auto!">
<BlockieAvatar address={checkSumAddress} size={30} ensImage={ensAvatar} />
<span className="ml-2 mr-1">
{isENS(displayName) ? displayName : checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4)}
</span>
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
</summary>
<ul className="dropdown-content menu z-2 p-2 mt-2 shadow-center shadow-accent bg-base-200 rounded-box gap-1">
<NetworkOptions hidden={!selectingNetwork} />
<li className={selectingNetwork ? "hidden" : ""}>
<div
className="h-8 btn-sm rounded-xl! flex gap-3 py-3 cursor-pointer"
onClick={() => copyAddressToClipboard(checkSumAddress)}
>
{isAddressCopiedToClipboard ? (
<>
<CheckCircleIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
<span className="whitespace-nowrap">Copied!</span>
</>
) : (
<>
<DocumentDuplicateIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
<span className="whitespace-nowrap">Copy address</span>
</>
)}
</div>
</li>
<li className={selectingNetwork ? "hidden" : ""}>
<label htmlFor="qrcode-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3">
<QrCodeIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span className="whitespace-nowrap">View QR Code</span>
</label>
</li>
<li className={selectingNetwork ? "hidden" : ""}>
<button className="h-8 btn-sm rounded-xl! flex gap-3 py-3" type="button">
<ArrowTopRightOnSquareIcon className="h-6 w-4 ml-2 sm:ml-0" />
<a
target="_blank"
href={blockExplorerAddressLink}
rel="noopener noreferrer"
className="whitespace-nowrap"
>
View on Block Explorer
</a>
</button>
</li>
{allowedNetworks.length > 1 ? (
<li className={selectingNetwork ? "hidden" : ""}>
<button
className="h-8 btn-sm rounded-xl! flex gap-3 py-3"
type="button"
onClick={() => {
setSelectingNetwork(true);
}}
>
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Switch Network</span>
</button>
</li>
) : null}
{connector?.id === BURNER_WALLET_ID ? (
<li>
<label htmlFor="reveal-burner-pk-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3 text-error">
<EyeIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span>Reveal Private Key</span>
</label>
</li>
) : null}
<li className={selectingNetwork ? "hidden" : ""}>
<button
className="menu-item text-error h-8 btn-sm rounded-xl! flex gap-3 py-3"
type="button"
onClick={() => disconnect()}
>
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Disconnect</span>
</button>
</li>
</ul>
</details>
</>
);
};

View File

@@ -0,0 +1,33 @@
import { QRCodeSVG } from "qrcode.react";
import { Address as AddressType } from "viem";
import { Address } from "~~/components/scaffold-eth";
type AddressQRCodeModalProps = {
address: AddressType;
modalId: string;
};
export const AddressQRCodeModal = ({ address, modalId }: AddressQRCodeModalProps) => {
return (
<>
<div>
<input type="checkbox" id={`${modalId}`} className="modal-toggle" />
<label htmlFor={`${modalId}`} className="modal cursor-pointer">
<label className="modal-box relative">
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
<label htmlFor={`${modalId}`} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</label>
<div className="space-y-3 py-6">
<div className="flex flex-col items-center gap-6">
<QRCodeSVG value={address} size={256} />
<Address address={address} format="long" disableAddressLink onlyEnsOrAddress />
</div>
</div>
</label>
</label>
</div>
</>
);
};

View File

@@ -0,0 +1,48 @@
import { useTheme } from "next-themes";
import { useAccount, useSwitchChain } from "wagmi";
import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
import { getNetworkColor } from "~~/hooks/scaffold-eth";
import { getTargetNetworks } from "~~/utils/scaffold-eth";
const allowedNetworks = getTargetNetworks();
type NetworkOptionsProps = {
hidden?: boolean;
};
export const NetworkOptions = ({ hidden = false }: NetworkOptionsProps) => {
const { switchChain } = useSwitchChain();
const { chain } = useAccount();
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark";
return (
<>
{allowedNetworks
.filter(allowedNetwork => allowedNetwork.id !== chain?.id)
.map(allowedNetwork => (
<li key={allowedNetwork.id} className={hidden ? "hidden" : ""}>
<button
className="menu-item btn-sm rounded-xl! flex gap-3 py-3 whitespace-nowrap"
type="button"
onClick={() => {
switchChain?.({ chainId: allowedNetwork.id });
}}
>
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span>
Switch to{" "}
<span
style={{
color: getNetworkColor(allowedNetwork, isDarkMode),
}}
>
{allowedNetwork.name}
</span>
</span>
</button>
</li>
))}
</>
);
};

View File

@@ -0,0 +1,59 @@
import { useRef } from "react";
import { rainbowkitBurnerWallet } from "burner-connector";
import { ShieldExclamationIcon } from "@heroicons/react/24/outline";
import { useCopyToClipboard } from "~~/hooks/scaffold-eth";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
const BURNER_WALLET_PK_KEY = "burnerWallet.pk";
export const RevealBurnerPKModal = () => {
const { copyToClipboard, isCopiedToClipboard } = useCopyToClipboard();
const modalCheckboxRef = useRef<HTMLInputElement>(null);
const handleCopyPK = async () => {
try {
const storage = rainbowkitBurnerWallet.useSessionStorage ? sessionStorage : localStorage;
const burnerPK = storage?.getItem(BURNER_WALLET_PK_KEY);
if (!burnerPK) throw new Error("Burner wallet private key not found");
await copyToClipboard(burnerPK);
notification.success("Burner wallet private key copied to clipboard");
} catch (e) {
const parsedError = getParsedError(e);
notification.error(parsedError);
if (modalCheckboxRef.current) modalCheckboxRef.current.checked = false;
}
};
return (
<>
<div>
<input type="checkbox" id="reveal-burner-pk-modal" className="modal-toggle" ref={modalCheckboxRef} />
<label htmlFor="reveal-burner-pk-modal" className="modal cursor-pointer">
<label className="modal-box relative">
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
<label htmlFor="reveal-burner-pk-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</label>
<div>
<p className="text-lg font-semibold m-0 p-0">Copy Burner Wallet Private Key</p>
<div role="alert" className="alert alert-warning mt-4">
<ShieldExclamationIcon className="h-6 w-6" />
<span className="font-semibold">
Burner wallets are intended for local development only and are not safe for storing real funds.
</span>
</div>
<p>
Your Private Key provides <strong>full access</strong> to your entire wallet and funds. This is
currently stored <strong>temporarily</strong> in your browser.
</p>
<button className="btn btn-outline btn-error" onClick={handleCopyPK} disabled={isCopiedToClipboard}>
Copy Private Key To Clipboard
</button>
</div>
</label>
</label>
</div>
</>
);
};

View File

@@ -0,0 +1,32 @@
import { NetworkOptions } from "./NetworkOptions";
import { useDisconnect } from "wagmi";
import { ArrowLeftOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
export const WrongNetworkDropdown = () => {
const { disconnect } = useDisconnect();
return (
<div className="dropdown dropdown-end mr-2">
<label tabIndex={0} className="btn btn-error btn-sm dropdown-toggle gap-1">
<span>Wrong network</span>
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
</label>
<ul
tabIndex={0}
className="dropdown-content menu p-2 mt-1 shadow-center shadow-accent bg-base-200 rounded-box gap-1"
>
<NetworkOptions />
<li>
<button
className="menu-item text-error btn-sm rounded-xl! flex gap-3 py-3"
type="button"
onClick={() => disconnect()}
>
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span>Disconnect</span>
</button>
</li>
</ul>
</div>
);
};

View File

@@ -0,0 +1,69 @@
"use client";
// @refresh reset
import { Balance } from "../Balance";
import { AddressInfoDropdown } from "./AddressInfoDropdown";
import { AddressQRCodeModal } from "./AddressQRCodeModal";
import { RevealBurnerPKModal } from "./RevealBurnerPKModal";
import { WrongNetworkDropdown } from "./WrongNetworkDropdown";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { Address } from "viem";
import { useNetworkColor } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
/**
* Custom Wagmi Connect Button (watch balance + custom design)
*/
export const RainbowKitCustomConnectButton = () => {
const networkColor = useNetworkColor();
const { targetNetwork } = useTargetNetwork();
return (
<ConnectButton.Custom>
{({ account, chain, openConnectModal, mounted }) => {
const connected = mounted && account && chain;
const blockExplorerAddressLink = account
? getBlockExplorerAddressLink(targetNetwork, account.address)
: undefined;
return (
<>
{(() => {
if (!connected) {
return (
<button className="btn btn-primary btn-sm" onClick={openConnectModal} type="button">
Connect Wallet
</button>
);
}
if (chain.unsupported || chain.id !== targetNetwork.id) {
return <WrongNetworkDropdown />;
}
return (
<>
<div className="flex flex-col items-center mr-1">
<Balance address={account.address as Address} className="min-h-0 h-auto" />
<span className="text-xs" style={{ color: networkColor }}>
{chain.name}
</span>
</div>
<AddressInfoDropdown
address={account.address as Address}
displayName={account.displayName}
ensAvatar={account.ensAvatar}
blockExplorerAddressLink={blockExplorerAddressLink}
/>
<AddressQRCodeModal address={account.address as Address} modalId="qrcode-modal" />
<RevealBurnerPKModal />
</>
);
})()}
</>
);
}}
</ConnectButton.Custom>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./Address/Address";
export * from "./Balance";
export * from "./BlockieAvatar";
export * from "./Faucet";
export * from "./FaucetButton";
export * from "./Input";
export * from "./RainbowKitCustomConnectButton";