diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index 0ac77c8e..8c251b91 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -69,8 +69,9 @@ NEXT_PUBLIC_SEPOLIA_RPC_URL= # --- Gas-drip faucet (OFF by default) --- # Base URL of an HTTP faucet service (efs-project/devnet → faucet/). When set AND # the wallet is on the faucet's chain (NEXT_PUBLIC_FAUCET_CHAIN_ID below), the app -# auto-drips a little gas on connect and shows a "Get test ETH" button in the -# wallet menu. Unset = disabled. +# can request gas after an explicit user action: the wallet-menu "Get test ETH" +# button, or the one-click editing wallet flow below. Loading the page or merely +# connecting a wallet does not drip. Unset = disabled. # # This is for the one network with NO unlocked account — live Sepolia (11155111). # The fork chains (Local 31337 and the EFS Devnet 26001993) are funded instead by @@ -80,10 +81,27 @@ NEXT_PUBLIC_SEPOLIA_RPC_URL= # NEXT_PUBLIC_FAUCET_URL=https:///faucet NEXT_PUBLIC_FAUCET_URL= -# Chain the faucet funds — the drip only fires when the wallet is on it. Defaults -# to live Sepolia (11155111). Override only to point at another real testnet. +# Chain the faucet funds — faucet requests are allowed only when the wallet is on +# it. Defaults to live Sepolia (11155111). Override only to point at another real testnet. NEXT_PUBLIC_FAUCET_CHAIN_ID= +# --- One-click editing wallet for live Sepolia --- +# When NEXT_PUBLIC_FAUCET_URL is set and the build defaults to that faucet chain, +# the debug UI shows a one-click "Enable promptless edits" affordance. Clicking it connects +# the existing Scaffold-ETH burner wallet with no popup; the faucet drip then funds +# that wallet. This is only a signer convenience: writes are authored by this wallet +# address, not by any durable EFS identity/account model. Real wallets still work +# and take precedence; disconnecting a real wallet falls back to the burner only +# after the visitor has explicitly enabled editing on the current page. +# +# Default: the one-click affordance is enabled whenever NEXT_PUBLIC_FAUCET_URL is +# configured. Set to false as a kill switch without removing the faucet service +# from the manual wallet menu. For a Sepolia buildathon deployment, set +# NEXT_PUBLIC_TARGET_CHAIN=sepolia; devnet/local-first builds intentionally do not +# show the one-click burner, even if a user later switches the header to Sepolia, +# because the bundled burner connector follows the configured default chain. +NEXT_PUBLIC_INSTANT_BURNER_SESSION= + # --- Devnet / VPS deployment (same-origin reverse-proxy config) --- # When this app is served from a public host that reverse-proxies its own chain + # IPFS + Arweave daemons (e.g. the EFS devnet at https://178.104.79.94.nip.io), @@ -143,4 +161,3 @@ NEXT_PUBLIC_DEVNET_BANNER= # a tarball has no .git dir; set NEXT_PUBLIC_GIT_SHA= explicitly). NEXT_PUBLIC_GIT_SHA= NEXT_PUBLIC_FORK_BLOCK= - diff --git a/packages/nextjs/components/FaucetAutoDrip.tsx b/packages/nextjs/components/FaucetAutoDrip.tsx index f712fdad..dfc37d66 100644 --- a/packages/nextjs/components/FaucetAutoDrip.tsx +++ b/packages/nextjs/components/FaucetAutoDrip.tsx @@ -3,10 +3,11 @@ import { useAutoFaucetDrip } from "~~/hooks/scaffold-eth"; /** - * Mounts the HTTP-faucet auto-drip. Renders nothing; no-op unless - * `NEXT_PUBLIC_FAUCET_URL` is set and the wallet is on the faucet's chain - * (`NEXT_PUBLIC_FAUCET_CHAIN_ID`). Sibling to `DevnetAutoFund`, which covers the - * local hardhat fork via the unlocked node account. + * Mounts the explicit editing-wallet faucet drip. Renders nothing; no-op unless + * the visitor clicked "Enable promptless edits", `NEXT_PUBLIC_FAUCET_URL` is set, and the + * burner wallet is on the faucet's chain (`NEXT_PUBLIC_FAUCET_CHAIN_ID`). + * Sibling to `DevnetAutoFund`, which covers the fork chains through the unlocked + * node account. */ export const FaucetAutoDrip = () => { useAutoFaucetDrip(); diff --git a/packages/nextjs/components/FaucetStatus.tsx b/packages/nextjs/components/FaucetStatus.tsx index 8c65b2bd..4f2cfd11 100644 --- a/packages/nextjs/components/FaucetStatus.tsx +++ b/packages/nextjs/components/FaucetStatus.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAccount, useBalance } from "wagmi"; -import { useFaucetStatus } from "~~/utils/scaffold-eth"; +import { FAUCET_CHAIN_ID, shouldMarkInstantBurnerReady, useFaucetStatus } from "~~/utils/scaffold-eth"; /** * Persistent "Adding gas…" indicator next to the wallet while a faucet drip is in @@ -14,10 +14,14 @@ import { useFaucetStatus } from "~~/utils/scaffold-eth"; * the balance has visibly increased — i.e. the ETH actually landed. */ export const FaucetStatus = () => { - const { pendingHash, setPending } = useFaucetStatus(); + const { pendingHash, setPending, setReady } = useFaucetStatus(); const { address } = useAccount(); const queryClient = useQueryClient(); - const { data, queryKey } = useBalance({ address, query: { enabled: !!address } }); + const { data, queryKey } = useBalance({ + address, + chainId: FAUCET_CHAIN_ID, + query: { enabled: !!address }, + }); const baseline = useRef(undefined); // On a fresh drip: snapshot the pre-drip balance (the cached value, before any @@ -38,12 +42,19 @@ export const FaucetStatus = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [pendingHash]); - // Clear once the balance has increased past the baseline — the ETH is in. + // Clear once the dripped ETH is visibly in the wallet. If no baseline was + // cached before the drip, any positive balance is enough to avoid a stuck chip. useEffect(() => { - if (pendingHash && baseline.current !== undefined && data?.value !== undefined && data.value > baseline.current) { - setPending(undefined); + if ( + shouldMarkInstantBurnerReady({ + pendingHash, + baselineValue: baseline.current, + balanceValue: data?.value, + }) + ) { + setReady(); } - }, [data?.value, pendingHash, setPending]); + }, [data?.value, pendingHash, setReady]); if (!pendingHash) return null; diff --git a/packages/nextjs/components/FeedbackButton.tsx b/packages/nextjs/components/FeedbackButton.tsx index 2f6a20e5..dd468ace 100644 --- a/packages/nextjs/components/FeedbackButton.tsx +++ b/packages/nextjs/components/FeedbackButton.tsx @@ -46,7 +46,7 @@ export const FeedbackButton = ({ variant = "desktop" }: FeedbackButtonProps) => className="hidden xl:inline-flex btn btn-ghost btn-sm rounded-full font-normal gap-1.5 px-2" > {icon} - Feedback + Feedback ); }; diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index af1d2cde..4a9902cc 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -8,6 +8,7 @@ import { Bars3Icon, BugAntIcon, FolderIcon } from "@heroicons/react/24/outline"; import { DevWalletSwitcher } from "~~/components/DevWalletSwitcher"; import { FaucetStatus } from "~~/components/FaucetStatus"; import { FeedbackButton } from "~~/components/FeedbackButton"; +import { InstantBurnerSession } from "~~/components/InstantBurnerSession"; import { NetworkSwitcher } from "~~/components/NetworkSwitcher"; import { SwitchTheme } from "~~/components/SwitchTheme"; import { RainbowKitCustomConnectButton } from "~~/components/scaffold-eth"; @@ -124,10 +125,11 @@ export const Header = () => {
- + +
diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx new file mode 100644 index 00000000..c74de744 --- /dev/null +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { hardhat } from "viem/chains"; +import { useAccount, useConnect, useConnectors, useDisconnect } from "wagmi"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth"; +import scaffoldConfig from "~~/scaffold.config"; +import { + BURNER_WALLET_PK_STORAGE_KEY, + FAUCET_CHAIN_ID, + FAUCET_URL, + isBurnerConnector, + isInstantBurnerSessionEnabled, + normalizeStoredBurnerPrivateKey, + requestInstantBurnerDrip, + shouldAutoConnectInstantBurner, + shouldClearInstantBurnerTrackingBeforeDisconnect, + shouldClearStoredHardhatBurner, + shouldDisconnectInstantBurner, + shouldResetInstantBurnerDismissalOnAddressChange, + shouldShowInstantBurnerEnable, + shouldStopInstantBurnerAfterExternalDisconnect, + shouldSuppressInstantBurnerTracking, + trackInstantBurnerWasConnected, +} from "~~/utils/scaffold-eth"; +import { HARDHAT_ACCOUNTS } from "~~/utils/scaffold-eth/hardhatAccounts"; + +const INSTANT_BURNER_ENABLED = isInstantBurnerSessionEnabled({ + faucetUrl: FAUCET_URL, + flag: process.env.NEXT_PUBLIC_INSTANT_BURNER_SESSION, + defaultChainId: scaffoldConfig.targetNetworks[0].id, + faucetChainId: FAUCET_CHAIN_ID, +}); + +const hardhatPrivateKeys = HARDHAT_ACCOUNTS.map(account => account.pk); + +const InstantBurnerToggle = ({ active, onClick, title }: { active: boolean; onClick: () => void; title: string }) => ( + +); + +const clearPublicHardhatBurnerKey = (targetChainId: number) => { + if (typeof window === "undefined") return; + const storedPrivateKey = normalizeStoredBurnerPrivateKey(window.localStorage.getItem(BURNER_WALLET_PK_STORAGE_KEY)); + if ( + shouldClearStoredHardhatBurner({ + targetChainId, + hardhatChainId: hardhat.id, + storedPrivateKey, + hardhatPrivateKeys, + }) + ) { + window.localStorage.removeItem(BURNER_WALLET_PK_STORAGE_KEY); + } +}; + +export const InstantBurnerSession = () => { + const { address, chainId, connector, status } = useAccount(); + const connectors = useConnectors(); + const { connect } = useConnect(); + const { disconnect } = useDisconnect(); + const { targetNetwork } = useTargetNetwork(); + + const [dismissed, setDismissed] = useState(false); + const [editingSessionRequested, setEditingSessionRequested] = useState(false); + const [pauseUntil, setPauseUntil] = useState(undefined); + const connectingBurnerRef = useRef(false); + const disconnectingBurnerRef = useRef(false); + const burnerWasConnectedRef = useRef(false); + const previousAddressRef = useRef(undefined); + + useEffect(() => { + if ( + shouldResetInstantBurnerDismissalOnAddressChange({ + dismissed, + previousAddress: previousAddressRef.current, + nextAddress: address, + activeConnectorId: connector?.id, + }) + ) { + setDismissed(false); + } + previousAddressRef.current = address; + }, [address, connector?.id, dismissed]); + + useEffect(() => { + if (!INSTANT_BURNER_ENABLED || targetNetwork.id !== FAUCET_CHAIN_ID) return; + clearPublicHardhatBurnerKey(targetNetwork.id); + }, [targetNetwork.id]); + + useEffect(() => { + if (!INSTANT_BURNER_ENABLED) return; + const shouldDisconnect = shouldDisconnectInstantBurner({ + activeConnectorId: connector?.id, + editingSessionRequested, + chainId, + targetChainId: targetNetwork.id, + faucetChainId: FAUCET_CHAIN_ID, + }); + if (!shouldDisconnect) { + return; + } + if (disconnectingBurnerRef.current) return; + if ( + shouldClearInstantBurnerTrackingBeforeDisconnect({ + activeConnectorId: connector?.id, + shouldDisconnect, + }) + ) { + burnerWasConnectedRef.current = trackInstantBurnerWasConnected({ + current: burnerWasConnectedRef.current, + status, + chainId, + faucetChainId: FAUCET_CHAIN_ID, + activeConnectorId: connector?.id, + disablingInstantBurner: true, + }); + } + disconnectingBurnerRef.current = true; + disconnect(undefined, { + onSettled: () => { + disconnectingBurnerRef.current = false; + }, + }); + }, [chainId, connector?.id, editingSessionRequested, disconnect, status, targetNetwork.id]); + + useEffect(() => { + if (!INSTANT_BURNER_ENABLED) return; + if (connector && !isBurnerConnector(connector)) { + setPauseUntil(undefined); + } + }, [connector]); + + useEffect(() => { + if (pauseUntil === undefined) return; + const delay = Math.max(0, pauseUntil - Date.now()); + const timer = window.setTimeout(() => setPauseUntil(undefined), delay); + return () => window.clearTimeout(timer); + }, [pauseUntil]); + + useEffect(() => { + if (status === "connected" || status === "disconnected") { + connectingBurnerRef.current = false; + } + }, [status]); + + useEffect(() => { + burnerWasConnectedRef.current = trackInstantBurnerWasConnected({ + current: burnerWasConnectedRef.current, + status, + chainId, + faucetChainId: FAUCET_CHAIN_ID, + activeConnectorId: connector?.id, + disablingInstantBurner: shouldSuppressInstantBurnerTracking({ + activeConnectorId: connector?.id, + editingSessionRequested, + intentionalDisconnectInProgress: disconnectingBurnerRef.current, + }), + }); + }, [chainId, connector?.id, editingSessionRequested, status]); + + useEffect(() => { + if (!INSTANT_BURNER_ENABLED) return; + if ( + shouldStopInstantBurnerAfterExternalDisconnect({ + wasBurnerConnected: burnerWasConnectedRef.current, + editingSessionRequested, + status, + }) + ) { + burnerWasConnectedRef.current = false; + setEditingSessionRequested(false); + setDismissed(true); + setPauseUntil(undefined); + return; + } + + const burnerConnector = connectors.find(isBurnerConnector); + if (!burnerConnector || connectingBurnerRef.current) return; + if ( + !shouldAutoConnectInstantBurner({ + enabled: INSTANT_BURNER_ENABLED, + editingSessionRequested: editingSessionRequested && !dismissed, + status, + targetChainId: targetNetwork.id, + faucetChainId: FAUCET_CHAIN_ID, + activeConnectorId: connector?.id, + pausedUntil: pauseUntil, + realWalletFlowActive: false, + now: Date.now(), + }) + ) { + return; + } + + connectingBurnerRef.current = true; + connect( + { connector: burnerConnector, chainId: FAUCET_CHAIN_ID }, + { + onSettled: () => { + connectingBurnerRef.current = false; + }, + }, + ); + }, [connect, connector?.id, connectors, dismissed, editingSessionRequested, pauseUntil, status, targetNetwork.id]); + + if (!INSTANT_BURNER_ENABLED || targetNetwork.id !== FAUCET_CHAIN_ID) { + return null; + } + + const enableEditing = () => { + clearPublicHardhatBurnerKey(targetNetwork.id); + setDismissed(false); + setPauseUntil(undefined); + setEditingSessionRequested(true); + requestInstantBurnerDrip(); + }; + + const disableEditing = () => { + burnerWasConnectedRef.current = trackInstantBurnerWasConnected({ + current: burnerWasConnectedRef.current, + status, + chainId, + faucetChainId: FAUCET_CHAIN_ID, + activeConnectorId: connector?.id, + disablingInstantBurner: true, + }); + setEditingSessionRequested(false); + setDismissed(true); + setPauseUntil(undefined); + disconnect(); + }; + + if ( + shouldShowInstantBurnerEnable({ + enabled: INSTANT_BURNER_ENABLED, + status, + targetChainId: targetNetwork.id, + faucetChainId: FAUCET_CHAIN_ID, + address, + }) + ) { + return ( + + ); + } + + if (dismissed) { + return null; + } + + if (!address || chainId !== FAUCET_CHAIN_ID || !isBurnerConnector(connector)) { + return null; + } + + return ( + + ); +}; diff --git a/packages/nextjs/components/NetworkSwitcher.tsx b/packages/nextjs/components/NetworkSwitcher.tsx index 589e9dac..3d69aac8 100644 --- a/packages/nextjs/components/NetworkSwitcher.tsx +++ b/packages/nextjs/components/NetworkSwitcher.tsx @@ -100,7 +100,7 @@ export const NetworkSwitcher = () => { title="Switch the network the debug UI reads from" > - {networkLabel(targetNetwork)} + {networkLabel(targetNetwork)}
    { useInitializeNativeCurrencyPrice(); @@ -56,7 +74,7 @@ export const ScaffoldEthAppWithProviders = ({ children }: { children: React.Reac }, []); return ( - + +
    {fileItems.length > 1 && ( diff --git a/packages/nextjs/components/explorer/ListPreviewPane.tsx b/packages/nextjs/components/explorer/ListPreviewPane.tsx index f2ac9021..bf9324cf 100644 --- a/packages/nextjs/components/explorer/ListPreviewPane.tsx +++ b/packages/nextjs/components/explorer/ListPreviewPane.tsx @@ -1273,7 +1273,7 @@ export const ListPreviewPane = ({ uid, name, attester: listAttester, onClose, co ); return ( -
    +
    {/* Header */}
    diff --git a/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx b/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx index 209a9be1..0d50ebdf 100644 --- a/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx +++ b/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx @@ -3,31 +3,81 @@ /** * GasFaucetButton — manual "Get test ETH", backed by the HTTP faucet service. * Renders null unless `NEXT_PUBLIC_FAUCET_URL` is set and the wallet is on the - * faucet's chain (`NEXT_PUBLIC_FAUCET_CHAIN_ID` — the devnet 26001993 by default, - * or live Sepolia). Sibling to `FaucetButton` (hardhat-only, which funds the + * faucet's chain (`NEXT_PUBLIC_FAUCET_CHAIN_ID` — live Sepolia by default). + * Sibling to `FaucetButton` (hardhat-only, which funds the * burner from the unlocked node account). Rendered as an `
  • ` in * `AddressInfoDropdown`, matching the other wallet-menu rows. */ import { useState } from "react"; -import { useAccount } from "wagmi"; +import { hardhat } from "viem/chains"; +import { useAccount, useDisconnect } from "wagmi"; import { BanknotesIcon } from "@heroicons/react/24/outline"; -import { isFaucetEnabled, notification, requestDrip, useFaucetStatus } from "~~/utils/scaffold-eth"; +import { + BURNER_WALLET_PK_STORAGE_KEY, + isFaucetEnabled, + normalizeStoredBurnerPrivateKey, + notification, + requestDrip, + shouldBlockFaucetDripForBurner, + shouldBlockFaucetDripRecipient, + useFaucetStatus, +} from "~~/utils/scaffold-eth"; +import { HARDHAT_ACCOUNTS } from "~~/utils/scaffold-eth/hardhatAccounts"; + +const hardhatAddresses = HARDHAT_ACCOUNTS.map(account => account.address); +const hardhatPrivateKeys = HARDHAT_ACCOUNTS.map(account => account.pk); export const GasFaucetButton = ({ hidden = false }: { hidden?: boolean } = {}) => { - const { address, chainId } = useAccount(); + const { address, chainId, connector } = useAccount(); + const { disconnect } = useDisconnect(); const [loading, setLoading] = useState(false); // Only on the faucet's chain with a configured faucet; otherwise the button is gone. if (!isFaucetEnabled(chainId)) return null; + const clearPublicHardhatBurnerBeforeFaucet = () => { + if (typeof window === "undefined" || chainId === undefined) return false; + const storedPrivateKey = normalizeStoredBurnerPrivateKey(window.localStorage.getItem(BURNER_WALLET_PK_STORAGE_KEY)); + const blocked = shouldBlockFaucetDripForBurner({ + activeConnectorId: connector?.id, + targetChainId: chainId, + hardhatChainId: hardhat.id, + storedPrivateKey, + hardhatPrivateKeys, + }); + if (!blocked) return false; + window.localStorage.removeItem(BURNER_WALLET_PK_STORAGE_KEY); + return true; + }; + const getETH = async () => { if (!address) return; setLoading(true); + const clearedPublicBurnerKey = clearPublicHardhatBurnerBeforeFaucet(); + const blockedPublicRecipient = shouldBlockFaucetDripRecipient({ + recipientAddress: address, + hardhatAddresses, + }); + if (clearedPublicBurnerKey || blockedPublicRecipient) { + const message = clearedPublicBurnerKey + ? "Cleared a public local-dev burner key. Reconnect the burner wallet, then request test ETH." + : "This is a public local-dev address. Switch to a private wallet before requesting Sepolia ETH."; + useFaucetStatus.getState().setError(message); + notification.warning(message); + if (clearedPublicBurnerKey) disconnect(); + setLoading(false); + return; + } const res = await requestDrip(address); // On a real drip, hand off to the header's "Adding gas…" indicator (persists // until the ETH lands); only surface a toast for non-drip outcomes. - if (res.ok && res.txHash) useFaucetStatus.getState().setPending(res.txHash); - else if (!res.ok) notification.error(res.message ?? "Faucet request failed. Try again shortly."); + if (res.ok && res.txHash) { + useFaucetStatus.getState().setPending(res.txHash); + } else if (!res.ok) { + const message = res.message ?? "Faucet request failed. Try again shortly."; + useFaucetStatus.getState().setError(message); + notification.error(message); + } setLoading(false); }; diff --git a/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts b/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts index 9f0da97f..45db5f16 100644 --- a/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts +++ b/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts @@ -2,36 +2,73 @@ import { useEffect, useRef } from "react"; import { useAccount } from "wagmi"; -import { isFaucetEnabled, requestDrip, useFaucetStatus } from "~~/utils/scaffold-eth"; +import { notification } from "~~/utils/scaffold-eth"; +import { isFaucetEnabled, requestDrip, useFaucetStatus } from "~~/utils/scaffold-eth/faucet"; +import { + BURNER_WALLET_CONNECTOR_ID, + consumeInstantBurnerDripRequest, + shouldAutoDripInstantBurner, + shouldBlockFaucetDripRecipient, +} from "~~/utils/scaffold-eth/instantBurner"; +import { HARDHAT_ACCOUNTS } from "~~/utils/scaffold-eth/hardhatAccounts"; -// At most one auto-drip per (chain × address) across the session, tracked in a -// module-level Set so it survives component remounts. +// At most one editing-wallet drip per (chain × address) across the current page, +// tracked in a module-level Set so it survives component remounts but not reloads. const dripped = new Set(); +const hardhatAddresses = HARDHAT_ACCOUNTS.map(account => account.address); /** - * Fire a best-effort drip when a wallet connects on the faucet's chain - * (`NEXT_PUBLIC_FAUCET_CHAIN_ID`) and a faucet URL is configured. No-op otherwise - * — on the local hardhat fork `DevnetAutoFund` handles funding instead. On a real - * drip it sets the shared faucet status so the header shows a persistent - * "Adding gas…" indicator until the ETH lands; benign outcomes (already-funded / - * cooldown) and transient errors stay quiet (the "Get test ETH" button is the - * manual retry path). + * Fire one best-effort drip after the visitor explicitly clicks "Enable + * promptless edits" on the faucet's chain. No-op for page-load reconnects and real-wallet + * connects — the manual "Get test ETH" menu item remains available there. On a + * real drip it sets the shared faucet status so the header shows a persistent + * "Adding gas…" indicator until the ETH lands; failures update the shared faucet + * status and a toast can tell users to retry or connect their own wallet. */ export function useAutoFaucetDrip() { - const { address, chainId } = useAccount(); + const { address, chainId, connector } = useAccount(); const inFlight = useRef(false); useEffect(() => { - if (!address || !isFaucetEnabled(chainId)) return; + if (!address) return; + const faucetEnabled = isFaucetEnabled(chainId); + if (!faucetEnabled || connector?.id !== BURNER_WALLET_CONNECTOR_ID) return; + + const dripRequested = consumeInstantBurnerDripRequest(); + if ( + !shouldAutoDripInstantBurner({ + faucetEnabled, + activeConnectorId: connector?.id, + dripRequested, + }) + ) { + return; + } const key = `${chainId}:${address.toLowerCase()}`; if (dripped.has(key) || inFlight.current) return; + if (shouldBlockFaucetDripRecipient({ recipientAddress: address, hardhatAddresses })) { + const message = "Public local-dev address. Enable promptless edits again to create a private wallet."; + useFaucetStatus.getState().setError(message); + notification.warning(message, { position: "bottom-center" }); + return; + } - dripped.add(key); inFlight.current = true; (async () => { - const res = await requestDrip(address); - if (res.ok && res.txHash) useFaucetStatus.getState().setPending(res.txHash); - inFlight.current = false; + try { + const res = await requestDrip(address); + const faucetStatus = useFaucetStatus.getState(); + if (res.ok && res.txHash) { + dripped.add(key); + faucetStatus.setPending(res.txHash); + } else if (!res.ok) { + const message = res.message ?? "Faucet unreachable. Try Get test ETH or use your wallet."; + faucetStatus.setError(message); + notification.error(message, { position: "bottom-center" }); + } + } finally { + inFlight.current = false; + } })(); - }, [address, chainId]); + }, [address, chainId, connector?.id]); } diff --git a/packages/nextjs/services/web3/wagmiConnectors.tsx b/packages/nextjs/services/web3/wagmiConnectors.tsx index 47c191e7..b3b95cd8 100644 --- a/packages/nextjs/services/web3/wagmiConnectors.tsx +++ b/packages/nextjs/services/web3/wagmiConnectors.tsx @@ -10,6 +10,12 @@ import { import { rainbowkitBurnerWallet } from "burner-connector"; import * as chains from "viem/chains"; import scaffoldConfig from "~~/scaffold.config"; +import { + BURNER_WALLET_PK_STORAGE_KEY, + normalizeStoredBurnerPrivateKey, + shouldClearStoredHardhatBurner, + shouldSeedHardhatBurner, +} from "~~/utils/scaffold-eth"; import { HARDHAT_ACCOUNTS } from "~~/utils/scaffold-eth/hardhatAccounts"; // Polyfill indexedDB for server-side build/prerendering @@ -67,16 +73,29 @@ if (typeof window === "undefined" && !global.indexedDB) { } const { onlyLocalBurnerWallet, targetNetworks } = scaffoldConfig; +const hardhatChainId = (chains.hardhat as chains.Chain).id; +const defaultChainId = targetNetworks[0].id; +const hasHardhatTarget = targetNetworks.some(n => n.id === hardhatChainId); -// Seed the burner wallet with a pre-funded hardhat account on first visit, so the -// dev UI is usable immediately without clicking the faucet. Only runs when hardhat -// is a target network and no PK has been stored yet — subsequent visits keep the -// account the user last switched to via DevWalletSwitcher. -if (typeof window !== "undefined" && targetNetworks.some(n => n.id === (chains.hardhat as chains.Chain).id)) { - const existing = window.localStorage.getItem("burnerWallet.pk")?.replaceAll('"', ""); - if (!existing || existing === "0x" || existing.length < 66) { +// Seed the burner wallet with a pre-funded hardhat account on local hardhat, and +// clear those public keys before any non-hardhat target can faucet-fund a burner. +// Subsequent local visits keep the account the user last switched to via +// DevWalletSwitcher. +if (typeof window !== "undefined") { + const existing = normalizeStoredBurnerPrivateKey(window.localStorage.getItem(BURNER_WALLET_PK_STORAGE_KEY)); + const hardhatPrivateKeys = HARDHAT_ACCOUNTS.map(account => account.pk); + if ( + shouldClearStoredHardhatBurner({ + targetChainId: defaultChainId, + hardhatChainId, + storedPrivateKey: existing, + hardhatPrivateKeys, + }) + ) { + window.localStorage.removeItem(BURNER_WALLET_PK_STORAGE_KEY); + } else if (shouldSeedHardhatBurner({ hasHardhatTarget, defaultChainId, hardhatChainId }) && !existing) { const pick = HARDHAT_ACCOUNTS[Math.floor(Math.random() * HARDHAT_ACCOUNTS.length)]; - window.localStorage.setItem("burnerWallet.pk", pick.pk); + window.localStorage.setItem(BURNER_WALLET_PK_STORAGE_KEY, pick.pk); } } @@ -88,13 +107,13 @@ const wallets = [ rainbowWallet, safeWallet, // AGENT-NOTE: With Sepolia now in targetNetworks and `onlyLocalBurnerWallet: false`, - // the burner is reachable on Sepolia by design. This fails safe: the faucet is - // hardhat-only and a burner has no Sepolia funds, so it can't accidentally spend. + // the burner is reachable on Sepolia by design. The instant editing wallet flow + // clears public hardhat keys before Sepolia can faucet-fund a burner. // Do NOT "fix" this by setting `onlyLocalBurnerWallet: true` — the gate's first // clause (`!targetNetworks.some(id !== hardhat)`) is false the moment a non-hardhat // network is present, so flipping the flag would REMOVE the burner entirely and // break the local dev flow. - ...(!targetNetworks.some(network => network.id !== (chains.hardhat as chains.Chain).id) || !onlyLocalBurnerWallet + ...(!targetNetworks.some(network => network.id !== hardhatChainId) || !onlyLocalBurnerWallet ? typeof window !== "undefined" ? [rainbowkitBurnerWallet] : [] diff --git a/packages/nextjs/utils/scaffold-eth/faucet.ts b/packages/nextjs/utils/scaffold-eth/faucet.ts index 116bcb19..2e435937 100644 --- a/packages/nextjs/utils/scaffold-eth/faucet.ts +++ b/packages/nextjs/utils/scaffold-eth/faucet.ts @@ -2,8 +2,8 @@ import { sepolia } from "viem/chains"; import { create } from "zustand"; /** - * HTTP drip-faucet client — gives a connecting wallet a little gas so users - * don't have to hunt for a faucet. + * HTTP drip-faucet client — gives a wallet a little gas after the visitor asks + * for it through "Enable promptless edits" or "Get test ETH". * * Serves the chain where no account is unlocked — **live Sepolia (11155111)** by * default. The fork chains (local 31337 and the EFS Devnet 26001993) are funded @@ -16,10 +16,10 @@ import { create } from "zustand"; * The client holds no key; it only POSTs an address. The faucet service decides * eligibility (already-funded / cooldown / cap). */ -const FAUCET_URL = (process.env.NEXT_PUBLIC_FAUCET_URL ?? "").trim().replace(/\/$/, ""); +export const FAUCET_URL = (process.env.NEXT_PUBLIC_FAUCET_URL ?? "").trim().replace(/\/$/, ""); /** - * Chain the HTTP faucet funds; the drip fires only when the wallet is on it. + * Chain the HTTP faucet funds; callers may drip only when the wallet is on it. * Defaults to live Sepolia — the one network with no unlocked account * (`DevnetAutoFund` covers the forks). Override with `NEXT_PUBLIC_FAUCET_CHAIN_ID`. */ @@ -40,6 +40,20 @@ export function isFaucetEnabled(chainId: number | undefined): boolean { return FAUCET_URL.length > 0 && chainId === FAUCET_CHAIN_ID; } +export function normalizeDripResponse({ httpOk, body }: { httpOk: boolean; body: DripResult }): DripResult { + if (!httpOk || body.ok === false) { + return { ok: false, reason: body.reason, message: body.message }; + } + if (body.txHash) { + return { ok: true, txHash: body.txHash }; + } + return { + ok: false, + reason: body.reason ?? "missing_tx_hash", + message: body.message ?? "Faucet response did not include a transaction hash.", + }; +} + /** * Request a drip for `address`. Never throws — network/parse failures resolve to * `{ ok: false }` so callers (especially the fire-and-forget connect path) don't @@ -53,11 +67,19 @@ export function isFaucetEnabled(chainId: number | undefined): boolean { */ type FaucetStatusStore = { pendingHash?: string; + errorMessage?: string; + readyAt?: number; setPending: (hash?: string) => void; + setReady: () => void; + setError: (message: string) => void; }; export const useFaucetStatus = create(set => ({ pendingHash: undefined, - setPending: hash => set({ pendingHash: hash }), + errorMessage: undefined, + readyAt: undefined, + setPending: hash => set({ pendingHash: hash, errorMessage: undefined }), + setReady: () => set({ pendingHash: undefined, errorMessage: undefined, readyAt: Date.now() }), + setError: message => set({ pendingHash: undefined, errorMessage: message }), })); export async function requestDrip(address: string): Promise { @@ -69,7 +91,7 @@ export async function requestDrip(address: string): Promise { body: JSON.stringify({ address }), }); const body = (await res.json().catch(() => ({}))) as DripResult; - return res.ok ? { ok: true, txHash: body.txHash } : { ok: false, reason: body.reason, message: body.message }; + return normalizeDripResponse({ httpOk: res.ok, body }); } catch { return { ok: false, reason: "network_error" }; } diff --git a/packages/nextjs/utils/scaffold-eth/index.ts b/packages/nextjs/utils/scaffold-eth/index.ts index c2e3be34..670c3f01 100644 --- a/packages/nextjs/utils/scaffold-eth/index.ts +++ b/packages/nextjs/utils/scaffold-eth/index.ts @@ -6,4 +6,5 @@ export * from "./decodeTxData"; export * from "./ensureWalletChain"; export * from "./faucet"; export * from "./getParsedError"; +export * from "./instantBurner"; export * from "./networkLabel"; diff --git a/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts new file mode 100644 index 00000000..b49c607b --- /dev/null +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts @@ -0,0 +1,582 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +type ResolveHookContext = { parentURL?: string }; +type ResolveHookResult = { url: string; shortCircuit?: boolean }; +type NextResolve = (specifier: string, context: ResolveHookContext) => ResolveHookResult; +type ModuleWithRegisterHooks = { + registerHooks(hooks: { + resolve: (specifier: string, context: ResolveHookContext, nextResolve: NextResolve) => ResolveHookResult; + }): void; +}; + +const { registerHooks } = (await import("node:module")) as unknown as ModuleWithRegisterHooks; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith("~~/")) { + return { url: new URL(`../../${specifier.slice(3)}.ts`, import.meta.url).href, shortCircuit: true }; + } + try { + return nextResolve(specifier, context); + } catch (e) { + if (specifier.startsWith(".") && !/\.[cm]?[jt]sx?$/.test(specifier)) { + return { url: new URL(`${specifier}.ts`, context.parentURL).href, shortCircuit: true }; + } + throw e; + } + }, +}); + +const { + BURNER_WALLET_CONNECTOR_ID, + BURNER_WALLET_PK_STORAGE_KEY, + INSTANT_BURNER_PAUSE_MS, + getInstantBurnerMessage, + isBurnerConnector, + isInstantBurnerSessionEnabled, + normalizeStoredBurnerPrivateKey, + consumeInstantBurnerDripRequest, + requestInstantBurnerDrip, + shouldAutoConnectInstantBurner, + shouldAutoDripInstantBurner, + shouldBlockFaucetDripRecipient, + shouldBlockFaucetDripForBurner, + shouldClearStoredHardhatBurner, + shouldClearInstantBurnerTrackingBeforeDisconnect, + shouldDisconnectInstantBurner, + shouldMarkInstantBurnerReady, + shouldResetInstantBurnerDismissalOnAddressChange, + shouldReconnectWagmiOnMount, + shouldResumeInstantBurnerAfterRealWalletModal, + shouldSeedHardhatBurner, + shouldShowInstantBurnerEnable, + shouldSuppressInstantBurnerTracking, + shouldStopInstantBurnerAfterExternalDisconnect, + trackInstantBurnerWasConnected, +} = await import("./instantBurner.ts"); + +const { normalizeDripResponse } = await import("./faucet.ts"); + +test("instant burner only enables when a faucet URL is configured and the kill switch is not false", () => { + const base = { + faucetUrl: "https://faucet.example", + flag: undefined, + defaultChainId: 11155111, + faucetChainId: 11155111, + }; + + assert.equal(isInstantBurnerSessionEnabled({ ...base, faucetUrl: "" }), false); + assert.equal(isInstantBurnerSessionEnabled({ ...base, faucetUrl: " ", flag: "true" }), false); + assert.equal(isInstantBurnerSessionEnabled(base), true); + assert.equal(isInstantBurnerSessionEnabled({ ...base, flag: "false" }), false); + assert.equal(isInstantBurnerSessionEnabled({ ...base, flag: "0" }), false); + assert.equal(isInstantBurnerSessionEnabled({ ...base, defaultChainId: 26001993 }), false); +}); + +test("wagmi reconnect-on-mount is disabled while instant burner can be offered", () => { + assert.equal(shouldReconnectWagmiOnMount({ instantBurnerEnabled: true }), false); + assert.equal(shouldReconnectWagmiOnMount({ instantBurnerEnabled: false }), true); +}); + +test("auto-connect is limited to settled no-wallet state on the faucet target chain", () => { + const base = { + enabled: true, + editingSessionRequested: true, + status: "disconnected" as const, + targetChainId: 11155111, + faucetChainId: 11155111, + activeConnectorId: undefined, + pausedUntil: undefined, + realWalletFlowActive: false, + now: 1_000, + }; + + assert.equal(shouldAutoConnectInstantBurner(base), true); + assert.equal(shouldAutoConnectInstantBurner({ ...base, status: "connecting" }), false); + assert.equal(shouldAutoConnectInstantBurner({ ...base, targetChainId: 26001993 }), false); + assert.equal(shouldAutoConnectInstantBurner({ ...base, activeConnectorId: "metaMask" }), false); + assert.equal(shouldAutoConnectInstantBurner({ ...base, enabled: false }), false); + assert.equal(shouldAutoConnectInstantBurner({ ...base, editingSessionRequested: false }), false); + assert.equal(shouldAutoConnectInstantBurner({ ...base, pausedUntil: base.now + INSTANT_BURNER_PAUSE_MS }), false); + assert.equal(shouldAutoConnectInstantBurner({ ...base, realWalletFlowActive: true }), false); +}); + +test("burner connector detection is explicit", () => { + assert.equal(BURNER_WALLET_CONNECTOR_ID, "burnerWallet"); + assert.equal(BURNER_WALLET_PK_STORAGE_KEY, "burnerWallet.pk"); + assert.equal(isBurnerConnector({ id: "burnerWallet" }), true); + assert.equal(isBurnerConnector({ id: "metaMask" }), false); + assert.equal(isBurnerConnector(undefined), false); +}); + +test("burner disconnects when it drifts off the faucet chain", () => { + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "burnerWallet", + editingSessionRequested: true, + chainId: 26001993, + targetChainId: 26001993, + faucetChainId: 11155111, + }), + true, + ); + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "burnerWallet", + editingSessionRequested: true, + chainId: 11155111, + targetChainId: 26001993, + faucetChainId: 11155111, + }), + true, + ); + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "metaMask", + editingSessionRequested: false, + chainId: 26001993, + targetChainId: 26001993, + faucetChainId: 11155111, + }), + false, + ); + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "burnerWallet", + editingSessionRequested: false, + chainId: 11155111, + targetChainId: 11155111, + faucetChainId: 11155111, + }), + true, + ); + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "burnerWallet", + editingSessionRequested: true, + chainId: 11155111, + targetChainId: 11155111, + faucetChainId: 11155111, + }), + false, + ); +}); + +test("auto-drip requires an explicit editing-wallet request", () => { + assert.equal( + shouldAutoDripInstantBurner({ + faucetEnabled: true, + activeConnectorId: "burnerWallet", + dripRequested: true, + }), + true, + ); + assert.equal( + shouldAutoDripInstantBurner({ + faucetEnabled: true, + activeConnectorId: "burnerWallet", + dripRequested: false, + }), + false, + ); + assert.equal( + shouldAutoDripInstantBurner({ + faucetEnabled: true, + activeConnectorId: "metaMask", + dripRequested: true, + }), + false, + ); +}); + +test("one-click drip request is same-page only and single-use", () => { + assert.equal(consumeInstantBurnerDripRequest(), false); + requestInstantBurnerDrip(); + assert.equal(consumeInstantBurnerDripRequest(), true); + assert.equal(consumeInstantBurnerDripRequest(), false); +}); + +test("faucet response requires a tx hash for a successful drip", () => { + assert.deepEqual( + normalizeDripResponse({ httpOk: true, body: { ok: true, txHash: "0xabc" } }), + { ok: true, txHash: "0xabc" }, + ); + assert.deepEqual( + normalizeDripResponse({ httpOk: true, body: { ok: true } }), + { + ok: false, + reason: "missing_tx_hash", + message: "Faucet response did not include a transaction hash.", + }, + ); + assert.deepEqual( + normalizeDripResponse({ httpOk: true, body: { ok: false, reason: "cooldown", message: "Try later." } }), + { ok: false, reason: "cooldown", message: "Try later." }, + ); + assert.deepEqual( + normalizeDripResponse({ httpOk: false, body: { ok: false, reason: "disabled", message: "Disabled." } }), + { ok: false, reason: "disabled", message: "Disabled." }, + ); +}); + +test("real-wallet modal resume waits until the modal was actually open", () => { + assert.equal( + shouldResumeInstantBurnerAfterRealWalletModal({ + requestedRealWallet: true, + modalWasOpen: false, + connectModalOpen: false, + status: "disconnected", + }), + false, + ); + assert.equal( + shouldResumeInstantBurnerAfterRealWalletModal({ + requestedRealWallet: true, + modalWasOpen: true, + connectModalOpen: false, + status: "disconnected", + }), + true, + ); + assert.equal( + shouldResumeInstantBurnerAfterRealWalletModal({ + requestedRealWallet: true, + modalWasOpen: true, + connectModalOpen: true, + status: "disconnected", + }), + false, + ); +}); + +test("real-wallet connects do not undo a dismissed burner chip", () => { + assert.equal( + shouldResetInstantBurnerDismissalOnAddressChange({ + dismissed: true, + previousAddress: "0x1111111111111111111111111111111111111111", + nextAddress: "0x2222222222222222222222222222222222222222", + activeConnectorId: "metaMask", + }), + false, + ); + assert.equal( + shouldResetInstantBurnerDismissalOnAddressChange({ + dismissed: true, + previousAddress: "0x1111111111111111111111111111111111111111", + nextAddress: "0x3333333333333333333333333333333333333333", + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + }), + true, + ); +}); + +test("wallet-menu burner disconnect stops promptless reconnect", () => { + const base = { + wasBurnerConnected: true, + editingSessionRequested: true, + status: "disconnected" as const, + }; + + assert.equal(shouldStopInstantBurnerAfterExternalDisconnect(base), true); + assert.equal(shouldStopInstantBurnerAfterExternalDisconnect({ ...base, wasBurnerConnected: false }), false); + assert.equal(shouldStopInstantBurnerAfterExternalDisconnect({ ...base, editingSessionRequested: false }), false); + assert.equal(shouldStopInstantBurnerAfterExternalDisconnect({ ...base, status: "connecting" }), false); + assert.equal(shouldStopInstantBurnerAfterExternalDisconnect({ ...base, status: "connected" }), false); +}); + +test("turning Easy Edits off clears stale burner disconnect tracking before re-enable", () => { + let wasBurnerConnected = trackInstantBurnerWasConnected({ + current: false, + status: "connected" as const, + chainId: 11155111, + faucetChainId: 11155111, + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + }); + + assert.equal(wasBurnerConnected, true); + + wasBurnerConnected = trackInstantBurnerWasConnected({ + current: wasBurnerConnected, + status: "disconnected" as const, + chainId: undefined, + faucetChainId: 11155111, + activeConnectorId: undefined, + disablingInstantBurner: true, + }); + + assert.equal(wasBurnerConnected, false); + assert.equal( + shouldStopInstantBurnerAfterExternalDisconnect({ + wasBurnerConnected, + editingSessionRequested: true, + status: "disconnected", + }), + false, + ); +}); + +test("forced burner disconnect clears stale tracking before re-enable", () => { + assert.equal( + shouldClearInstantBurnerTrackingBeforeDisconnect({ + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + shouldDisconnect: true, + }), + true, + ); + assert.equal( + shouldClearInstantBurnerTrackingBeforeDisconnect({ + activeConnectorId: "metaMask", + shouldDisconnect: true, + }), + false, + ); + assert.equal( + shouldClearInstantBurnerTrackingBeforeDisconnect({ + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + shouldDisconnect: false, + }), + false, + ); +}); + +test("intentional burner disconnect suppresses stale tracking re-arm", () => { + let wasBurnerConnected = trackInstantBurnerWasConnected({ + current: false, + status: "connected" as const, + chainId: 11155111, + faucetChainId: 11155111, + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + }); + + assert.equal(wasBurnerConnected, true); + + wasBurnerConnected = trackInstantBurnerWasConnected({ + current: wasBurnerConnected, + status: "connected" as const, + chainId: 11155111, + faucetChainId: 11155111, + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + disablingInstantBurner: true, + }); + + assert.equal(wasBurnerConnected, false); + + const suppressTracking = shouldSuppressInstantBurnerTracking({ + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + editingSessionRequested: true, + intentionalDisconnectInProgress: true, + }); + + assert.equal(suppressTracking, true); + + wasBurnerConnected = trackInstantBurnerWasConnected({ + current: wasBurnerConnected, + status: "connected" as const, + chainId: 11155111, + faucetChainId: 11155111, + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + disablingInstantBurner: suppressTracking, + }); + + assert.equal(wasBurnerConnected, false); +}); + +test("enable-editing affordance remains available with no connected wallet", () => { + const base = { + enabled: true, + status: "disconnected" as const, + targetChainId: 11155111, + faucetChainId: 11155111, + address: undefined, + }; + + assert.equal(shouldShowInstantBurnerEnable(base), true); + assert.equal(shouldShowInstantBurnerEnable({ ...base, address: "0x1111111111111111111111111111111111111111" }), false); + assert.equal(shouldShowInstantBurnerEnable({ ...base, status: "connecting" }), false); + assert.equal(shouldShowInstantBurnerEnable({ ...base, targetChainId: 26001993 }), false); + assert.equal(shouldShowInstantBurnerEnable({ ...base, enabled: false }), false); +}); + +test("hardhat seed keys only apply when local hardhat is the default target", () => { + assert.equal(shouldSeedHardhatBurner({ hasHardhatTarget: true, defaultChainId: 31337, hardhatChainId: 31337 }), true); + assert.equal( + shouldSeedHardhatBurner({ hasHardhatTarget: true, defaultChainId: 11155111, hardhatChainId: 31337 }), + false, + ); + assert.equal( + shouldSeedHardhatBurner({ hasHardhatTarget: false, defaultChainId: 11155111, hardhatChainId: 31337 }), + false, + ); +}); + +test("known public hardhat burner keys are cleared away from local hardhat", () => { + const hardhatPrivateKeys = [`0x${"a".repeat(64)}`, `0x${"b".repeat(64)}`]; + + assert.equal( + shouldClearStoredHardhatBurner({ + targetChainId: 11155111, + hardhatChainId: 31337, + storedPrivateKey: `0x${"a".repeat(64)}`, + hardhatPrivateKeys, + }), + true, + ); + assert.equal( + shouldClearStoredHardhatBurner({ + targetChainId: 31337, + hardhatChainId: 31337, + storedPrivateKey: `0x${"a".repeat(64)}`, + hardhatPrivateKeys, + }), + false, + ); + assert.equal( + shouldClearStoredHardhatBurner({ + targetChainId: 11155111, + hardhatChainId: 31337, + storedPrivateKey: `0x${"c".repeat(64)}`, + hardhatPrivateKeys, + }), + false, + ); + assert.equal( + shouldClearStoredHardhatBurner({ + targetChainId: 11155111, + hardhatChainId: 31337, + storedPrivateKey: undefined, + hardhatPrivateKeys, + }), + false, + ); +}); + +test("manual faucet blocks public local-dev burner keys on live faucet chains", () => { + const hardhatPrivateKeys = [`0x${"a".repeat(64)}`, `0x${"b".repeat(64)}`]; + + assert.equal( + shouldBlockFaucetDripForBurner({ + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + targetChainId: 11155111, + hardhatChainId: 31337, + storedPrivateKey: `0x${"a".repeat(64)}`, + hardhatPrivateKeys, + }), + true, + ); + assert.equal( + shouldBlockFaucetDripForBurner({ + activeConnectorId: "metaMask", + targetChainId: 11155111, + hardhatChainId: 31337, + storedPrivateKey: `0x${"a".repeat(64)}`, + hardhatPrivateKeys, + }), + false, + ); + assert.equal( + shouldBlockFaucetDripForBurner({ + activeConnectorId: BURNER_WALLET_CONNECTOR_ID, + targetChainId: 11155111, + hardhatChainId: 31337, + storedPrivateKey: `0x${"c".repeat(64)}`, + hardhatPrivateKeys, + }), + false, + ); +}); + +test("http faucet blocks known public hardhat recipient addresses for every connector", () => { + const hardhatAddresses = [ + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + ]; + + assert.equal( + shouldBlockFaucetDripRecipient({ + recipientAddress: "0x1111111111111111111111111111111111111111", + hardhatAddresses, + }), + true, + ); + assert.equal( + shouldBlockFaucetDripRecipient({ + recipientAddress: "0x2222222222222222222222222222222222222222".toUpperCase(), + hardhatAddresses, + }), + true, + ); + assert.equal( + shouldBlockFaucetDripRecipient({ + recipientAddress: "0x3333333333333333333333333333333333333333", + hardhatAddresses, + }), + false, + ); + assert.equal( + shouldBlockFaucetDripRecipient({ + recipientAddress: undefined, + hardhatAddresses, + }), + false, + ); +}); + +test("private key normalization keeps valid stored keys and rejects placeholders", () => { + assert.equal(normalizeStoredBurnerPrivateKey(null), undefined); + assert.equal(normalizeStoredBurnerPrivateKey('"0x"'), undefined); + assert.equal(normalizeStoredBurnerPrivateKey(`"0x${"a".repeat(64)}"`), `0x${"a".repeat(64)}`); +}); + +test("faucet status clears once funded balance is visible", () => { + assert.equal( + shouldMarkInstantBurnerReady({ + pendingHash: "0xabc", + baselineValue: undefined, + balanceValue: 1n, + }), + true, + ); + assert.equal( + shouldMarkInstantBurnerReady({ + pendingHash: "0xabc", + baselineValue: 4n, + balanceValue: 4n, + }), + false, + ); + assert.equal( + shouldMarkInstantBurnerReady({ + pendingHash: "0xabc", + baselineValue: 4n, + balanceValue: 5n, + }), + true, + ); + assert.equal( + shouldMarkInstantBurnerReady({ + pendingHash: undefined, + baselineValue: undefined, + balanceValue: 1n, + }), + false, + ); +}); + +test("session chip message surfaces funding, ready, and faucet errors", () => { + assert.deepEqual(getInstantBurnerMessage({ pendingHash: "0xabc", balanceValue: 0n, errorMessage: undefined }), { + tone: "funding", + label: "funding...", + }); + assert.deepEqual(getInstantBurnerMessage({ pendingHash: undefined, balanceValue: 1n, errorMessage: undefined }), { + tone: "ready", + label: "ready", + }); + assert.deepEqual( + getInstantBurnerMessage({ pendingHash: undefined, balanceValue: 0n, errorMessage: "Faucet offline" }), + { + tone: "error", + label: "Faucet offline", + }, + ); +}); diff --git a/packages/nextjs/utils/scaffold-eth/instantBurner.ts b/packages/nextjs/utils/scaffold-eth/instantBurner.ts new file mode 100644 index 00000000..e129cf58 --- /dev/null +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.ts @@ -0,0 +1,309 @@ +import type { Connector } from "wagmi"; + +export const BURNER_WALLET_CONNECTOR_ID = "burnerWallet"; +export const BURNER_WALLET_PK_STORAGE_KEY = "burnerWallet.pk"; +export const INSTANT_BURNER_PAUSE_MS = 30_000; +let instantBurnerDripRequested = false; + +type WalletStatus = "connected" | "connecting" | "disconnected" | "reconnecting"; + +export function isInstantBurnerSessionEnabled({ + faucetUrl, + flag, + defaultChainId, + faucetChainId, +}: { + faucetUrl: string | undefined; + flag: string | undefined; + defaultChainId: number; + faucetChainId: number; +}): boolean { + const normalizedFlag = (flag ?? "").trim().toLowerCase(); + return ( + (faucetUrl ?? "").trim().length > 0 && + normalizedFlag !== "false" && + normalizedFlag !== "0" && + defaultChainId === faucetChainId + ); +} + +export function isBurnerConnector(connector: Pick | undefined): boolean { + return connector?.id === BURNER_WALLET_CONNECTOR_ID; +} + +export function shouldAutoConnectInstantBurner({ + enabled, + editingSessionRequested, + status, + targetChainId, + faucetChainId, + activeConnectorId, + pausedUntil, + realWalletFlowActive, + now, +}: { + enabled: boolean; + editingSessionRequested: boolean; + status: WalletStatus; + targetChainId: number; + faucetChainId: number; + activeConnectorId: string | undefined; + pausedUntil: number | undefined; + realWalletFlowActive: boolean; + now: number; +}): boolean { + if (!enabled) return false; + if (!editingSessionRequested) return false; + if (realWalletFlowActive) return false; + if (status !== "disconnected") return false; + if (targetChainId !== faucetChainId) return false; + if (activeConnectorId && activeConnectorId !== BURNER_WALLET_CONNECTOR_ID) return false; + if (pausedUntil !== undefined && pausedUntil > now) return false; + return true; +} + +export function shouldReconnectWagmiOnMount({ + instantBurnerEnabled, +}: { + instantBurnerEnabled: boolean; +}): boolean { + return !instantBurnerEnabled; +} + +export function shouldStopInstantBurnerAfterExternalDisconnect({ + wasBurnerConnected, + editingSessionRequested, + status, +}: { + wasBurnerConnected: boolean; + editingSessionRequested: boolean; + status: WalletStatus; +}): boolean { + return wasBurnerConnected && editingSessionRequested && status === "disconnected"; +} + +export function trackInstantBurnerWasConnected({ + current, + status, + chainId, + faucetChainId, + activeConnectorId, + disablingInstantBurner = false, +}: { + current: boolean; + status: WalletStatus; + chainId: number | undefined; + faucetChainId: number; + activeConnectorId: string | undefined; + disablingInstantBurner?: boolean; +}): boolean { + if (disablingInstantBurner) return false; + if (status !== "connected") return current; + return chainId === faucetChainId && activeConnectorId === BURNER_WALLET_CONNECTOR_ID; +} + +export function shouldShowInstantBurnerEnable({ + enabled, + status, + targetChainId, + faucetChainId, + address, +}: { + enabled: boolean; + status: WalletStatus; + targetChainId: number; + faucetChainId: number; + address: string | undefined; +}): boolean { + if (!enabled) return false; + if (targetChainId !== faucetChainId) return false; + return !address && status === "disconnected"; +} + +export function shouldDisconnectInstantBurner({ + activeConnectorId, + editingSessionRequested, + chainId, + targetChainId, + faucetChainId, +}: { + activeConnectorId: string | undefined; + editingSessionRequested: boolean; + chainId: number | undefined; + targetChainId: number; + faucetChainId: number; +}): boolean { + if (activeConnectorId !== BURNER_WALLET_CONNECTOR_ID) return false; + if (!editingSessionRequested) return true; + return chainId !== faucetChainId || targetChainId !== faucetChainId; +} + +export function shouldClearInstantBurnerTrackingBeforeDisconnect({ + activeConnectorId, + shouldDisconnect, +}: { + activeConnectorId: string | undefined; + shouldDisconnect: boolean; +}): boolean { + return shouldDisconnect && activeConnectorId === BURNER_WALLET_CONNECTOR_ID; +} + +export function shouldSuppressInstantBurnerTracking({ + activeConnectorId, + editingSessionRequested, + intentionalDisconnectInProgress, +}: { + activeConnectorId: string | undefined; + editingSessionRequested: boolean; + intentionalDisconnectInProgress: boolean; +}): boolean { + if (activeConnectorId !== BURNER_WALLET_CONNECTOR_ID) return false; + return intentionalDisconnectInProgress || !editingSessionRequested; +} + +export function shouldAutoDripInstantBurner({ + faucetEnabled, + activeConnectorId, + dripRequested, +}: { + faucetEnabled: boolean; + activeConnectorId: string | undefined; + dripRequested: boolean; +}): boolean { + return faucetEnabled && activeConnectorId === BURNER_WALLET_CONNECTOR_ID && dripRequested; +} + +export function shouldResetInstantBurnerDismissalOnAddressChange({ + dismissed, + previousAddress, + nextAddress, + activeConnectorId, +}: { + dismissed: boolean; + previousAddress: string | undefined; + nextAddress: string | undefined; + activeConnectorId: string | undefined; +}): boolean { + if (!dismissed) return false; + if (!nextAddress || nextAddress === previousAddress) return false; + return activeConnectorId === BURNER_WALLET_CONNECTOR_ID; +} + +export function shouldResumeInstantBurnerAfterRealWalletModal({ + requestedRealWallet, + modalWasOpen, + connectModalOpen, + status, +}: { + requestedRealWallet: boolean; + modalWasOpen: boolean; + connectModalOpen: boolean; + status: WalletStatus; +}): boolean { + return requestedRealWallet && modalWasOpen && !connectModalOpen && status === "disconnected"; +} + +export function shouldSeedHardhatBurner({ + hasHardhatTarget, + defaultChainId, + hardhatChainId, +}: { + hasHardhatTarget: boolean; + defaultChainId: number; + hardhatChainId: number; +}): boolean { + return hasHardhatTarget && defaultChainId === hardhatChainId; +} + +export function shouldClearStoredHardhatBurner({ + targetChainId, + hardhatChainId, + storedPrivateKey, + hardhatPrivateKeys, +}: { + targetChainId: number; + hardhatChainId: number; + storedPrivateKey: `0x${string}` | undefined; + hardhatPrivateKeys: readonly string[]; +}): boolean { + if (targetChainId === hardhatChainId || !storedPrivateKey) return false; + return hardhatPrivateKeys.some(pk => pk.toLowerCase() === storedPrivateKey.toLowerCase()); +} + +export function shouldBlockFaucetDripForBurner({ + activeConnectorId, + targetChainId, + hardhatChainId, + storedPrivateKey, + hardhatPrivateKeys, +}: { + activeConnectorId: string | undefined; + targetChainId: number; + hardhatChainId: number; + storedPrivateKey: `0x${string}` | undefined; + hardhatPrivateKeys: readonly string[]; +}): boolean { + if (activeConnectorId !== BURNER_WALLET_CONNECTOR_ID) return false; + return shouldClearStoredHardhatBurner({ targetChainId, hardhatChainId, storedPrivateKey, hardhatPrivateKeys }); +} + +export function shouldBlockFaucetDripRecipient({ + recipientAddress, + hardhatAddresses, +}: { + recipientAddress: string | undefined; + hardhatAddresses: readonly string[]; +}): boolean { + if (!recipientAddress) return false; + return hardhatAddresses.some(address => address.toLowerCase() === recipientAddress.toLowerCase()); +} + +export function normalizeStoredBurnerPrivateKey(raw: string | null): `0x${string}` | undefined { + const normalized = raw?.replaceAll('"', "") as `0x${string}` | undefined; + if (!normalized || normalized === "0x" || normalized.length < 66) return undefined; + return normalized; +} + +export function requestInstantBurnerDrip(): void { + instantBurnerDripRequested = true; +} + +export function consumeInstantBurnerDripRequest(): boolean { + const requested = instantBurnerDripRequested; + instantBurnerDripRequested = false; + return requested; +} + +export type InstantBurnerMessage = { + tone: "funding" | "ready" | "waiting" | "error"; + label: string; +}; + +export function shouldMarkInstantBurnerReady({ + pendingHash, + baselineValue, + balanceValue, +}: { + pendingHash: string | undefined; + baselineValue: bigint | undefined; + balanceValue: bigint | undefined; +}): boolean { + if (!pendingHash || balanceValue === undefined) return false; + if (baselineValue === undefined) return balanceValue > 0n; + return balanceValue > baselineValue; +} + +export function getInstantBurnerMessage({ + pendingHash, + balanceValue, + errorMessage, +}: { + pendingHash: string | undefined; + balanceValue: bigint | undefined; + errorMessage: string | undefined; +}): InstantBurnerMessage { + if (pendingHash) return { tone: "funding", label: "funding..." }; + if (balanceValue !== undefined && balanceValue > 0n) return { tone: "ready", label: "ready" }; + if (errorMessage) return { tone: "error", label: errorMessage }; + return { tone: "waiting", label: "waiting for gas" }; +}