From 578fbd5cb7f5b5c4a05530b704241b1a1655cab6 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Tue, 23 Jun 2026 03:13:21 -0500 Subject: [PATCH 01/20] nextjs: add instant Sepolia burner session Give faucet-configured Sepolia builds a popup-free burner by default, while real wallets still take precedence. The session is gated by NEXT_PUBLIC_FAUCET_URL plus a kill switch and scoped to the faucet chain. It reuses the existing burner connector and faucet drip, surfaces funding, ready, and faucet-error state, and leaves the fork DevnetAutoFund path alone. Verified: - yarn workspace @se-2/nextjs test --test-reporter=spec - yarn next:check-types - yarn next:lint --max-warnings=0 - git diff --check - Browser smoke with NEXT_PUBLIC_TARGET_CHAIN=sepolia and a mock faucet: burner auto-connected, drip POSTed, real-wallet chooser opened, and cancel fell back to burner. Co-authored-by: Codex GPT-5 Tested-by: Codex GPT-5 Permanence-tier: Ephemeral --- packages/nextjs/.env.example | 15 +- packages/nextjs/components/FaucetStatus.tsx | 6 +- packages/nextjs/components/Header.tsx | 2 + .../components/InstantBurnerSession.tsx | 219 ++++++++++++++++++ .../scaffold-eth/GasFaucetButton.tsx | 13 +- .../hooks/scaffold-eth/useAutoFaucetDrip.ts | 13 +- .../nextjs/services/web3/wagmiConnectors.tsx | 14 +- packages/nextjs/utils/scaffold-eth/faucet.ts | 12 +- packages/nextjs/utils/scaffold-eth/index.ts | 1 + .../utils/scaffold-eth/instantBurner.test.ts | 174 ++++++++++++++ .../utils/scaffold-eth/instantBurner.ts | 114 +++++++++ 11 files changed, 566 insertions(+), 17 deletions(-) create mode 100644 packages/nextjs/components/InstantBurnerSession.tsx create mode 100644 packages/nextjs/utils/scaffold-eth/instantBurner.test.ts create mode 100644 packages/nextjs/utils/scaffold-eth/instantBurner.ts diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index 0ac77c8e..57c8bc25 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -84,6 +84,20 @@ NEXT_PUBLIC_FAUCET_URL= # to live Sepolia (11155111). Override only to point at another real testnet. NEXT_PUBLIC_FAUCET_CHAIN_ID= +# --- Instant burner session for live Sepolia demos --- +# When NEXT_PUBLIC_FAUCET_URL is set, the debug UI auto-connects the existing +# Scaffold-ETH burner wallet on the faucet chain so visitors land with a +# popup-free, disposable signer. The faucet drip then funds that burner. This is +# a demo stopgap only: writes are authored by the throwaway burner 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 while the app +# is on the faucet chain. +# +# Default: 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-first public demo, also set NEXT_PUBLIC_TARGET_CHAIN=sepolia. +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 +157,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/FaucetStatus.tsx b/packages/nextjs/components/FaucetStatus.tsx index 8c65b2bd..fafbcb01 100644 --- a/packages/nextjs/components/FaucetStatus.tsx +++ b/packages/nextjs/components/FaucetStatus.tsx @@ -14,7 +14,7 @@ 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 } }); @@ -41,9 +41,9 @@ export const FaucetStatus = () => { // Clear once the balance has increased past the baseline — the ETH is in. useEffect(() => { if (pendingHash && baseline.current !== undefined && data?.value !== undefined && data.value > baseline.current) { - setPending(undefined); + setReady(); } - }, [data?.value, pendingHash, setPending]); + }, [data?.value, pendingHash, setReady]); if (!pendingHash) return null; diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index af1d2cde..ff719389 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"; @@ -125,6 +126,7 @@ export const Header = () => {
+ diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx new file mode 100644 index 00000000..d8909960 --- /dev/null +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { Address, formatEther } from "viem"; +import { useAccount, useBalance, useConnect, useConnectors, useDisconnect } from "wagmi"; +import { WalletIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { useTargetNetwork } from "~~/hooks/scaffold-eth"; +import { + FAUCET_CHAIN_ID, + FAUCET_URL, + INSTANT_BURNER_PAUSE_MS, + getInstantBurnerMessage, + isBurnerConnector, + isInstantBurnerSessionEnabled, + shouldAutoConnectInstantBurner, + shouldDisconnectInstantBurner, + shouldResumeInstantBurnerAfterRealWalletModal, + useFaucetStatus, +} from "~~/utils/scaffold-eth"; + +const INSTANT_BURNER_ENABLED = isInstantBurnerSessionEnabled({ + faucetUrl: FAUCET_URL, + flag: process.env.NEXT_PUBLIC_INSTANT_BURNER_SESSION, +}); + +const shortAddress = (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`; + +const messageClass = { + funding: "text-info", + ready: "text-success", + waiting: "text-base-content/60", + error: "text-warning", +} as const; + +export const InstantBurnerSession = () => { + const { address, chainId, connector, status } = useAccount(); + const connectors = useConnectors(); + const { connect } = useConnect(); + const { disconnect } = useDisconnect(); + const { targetNetwork } = useTargetNetwork(); + const { connectModalOpen, openConnectModal } = useConnectModal(); + const faucetStatus = useFaucetStatus(); + const { data: balance } = useBalance({ + address: address as Address | undefined, + chainId: FAUCET_CHAIN_ID, + query: { enabled: !!address && chainId === FAUCET_CHAIN_ID }, + }); + + const [dismissed, setDismissed] = useState(false); + const [pauseUntil, setPauseUntil] = useState(undefined); + const [waitingForRealWallet, setWaitingForRealWallet] = useState(false); + const connectingBurnerRef = useRef(false); + const disconnectingBurnerRef = useRef(false); + const openedRealWalletModalRef = useRef(false); + const realWalletModalWasOpenRef = useRef(false); + + useEffect(() => { + setDismissed(false); + }, [address]); + + useEffect(() => { + if (!INSTANT_BURNER_ENABLED) return; + if ( + !shouldDisconnectInstantBurner({ + activeConnectorId: connector?.id, + chainId, + targetChainId: targetNetwork.id, + faucetChainId: FAUCET_CHAIN_ID, + }) + ) { + return; + } + if (disconnectingBurnerRef.current) return; + disconnectingBurnerRef.current = true; + disconnect(undefined, { + onSettled: () => { + disconnectingBurnerRef.current = false; + }, + }); + }, [chainId, connector?.id, disconnect, targetNetwork.id]); + + useEffect(() => { + if (!INSTANT_BURNER_ENABLED) return; + if (connector && !isBurnerConnector(connector)) { + setPauseUntil(undefined); + openedRealWalletModalRef.current = false; + } + }, [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(() => { + if (!INSTANT_BURNER_ENABLED) return; + const burnerConnector = connectors.find(isBurnerConnector); + if (!burnerConnector || connectingBurnerRef.current) return; + if ( + !shouldAutoConnectInstantBurner({ + enabled: INSTANT_BURNER_ENABLED, + status, + targetChainId: targetNetwork.id, + faucetChainId: FAUCET_CHAIN_ID, + activeConnectorId: connector?.id, + pausedUntil: pauseUntil, + now: Date.now(), + }) + ) { + return; + } + + connectingBurnerRef.current = true; + connect( + { connector: burnerConnector, chainId: FAUCET_CHAIN_ID }, + { + onSettled: () => { + connectingBurnerRef.current = false; + }, + }, + ); + }, [connect, connector?.id, connectors, pauseUntil, status, targetNetwork.id]); + + useEffect(() => { + if (!waitingForRealWallet) return; + if (status !== "disconnected" || !openConnectModal) return; + openedRealWalletModalRef.current = true; + realWalletModalWasOpenRef.current = false; + openConnectModal(); + setWaitingForRealWallet(false); + }, [openConnectModal, status, waitingForRealWallet]); + + useEffect(() => { + if (connectModalOpen) { + realWalletModalWasOpenRef.current = true; + return; + } + if ( + !shouldResumeInstantBurnerAfterRealWalletModal({ + requestedRealWallet: openedRealWalletModalRef.current, + modalWasOpen: realWalletModalWasOpenRef.current, + connectModalOpen, + status, + }) + ) { + return; + } + openedRealWalletModalRef.current = false; + realWalletModalWasOpenRef.current = false; + setPauseUntil(undefined); + }, [connectModalOpen, status]); + + if ( + !INSTANT_BURNER_ENABLED || + dismissed || + !address || + chainId !== FAUCET_CHAIN_ID || + targetNetwork.id !== FAUCET_CHAIN_ID || + !isBurnerConnector(connector) + ) { + return null; + } + + const message = getInstantBurnerMessage({ + pendingHash: faucetStatus.pendingHash, + balanceValue: balance?.value, + errorMessage: faucetStatus.errorMessage, + }); + const balanceLabel = balance ? `${Number(formatEther(balance.value)).toFixed(4)} ETH` : "balance..."; + + const connectRealWallet = () => { + setPauseUntil(Date.now() + INSTANT_BURNER_PAUSE_MS); + setWaitingForRealWallet(true); + disconnect(); + }; + + return ( +
+ +
+
+ temporary demo wallet +
+
+ {shortAddress(address)} + {message.label} + {balanceLabel} +
+
+ + +
+ ); +}; diff --git a/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx b/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx index 209a9be1..309c9368 100644 --- a/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx +++ b/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx @@ -3,8 +3,8 @@ /** * 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. */ @@ -26,8 +26,13 @@ export const GasFaucetButton = ({ hidden = false }: { hidden?: boolean } = {}) = 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..6d91ae3d 100644 --- a/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts +++ b/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts @@ -13,9 +13,9 @@ const dripped = new Set(); * (`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). + * "Adding gas…" indicator until the ETH lands; failures update the shared faucet + * status so the demo-wallet chip can tell users to retry or connect their own + * wallet. */ export function useAutoFaucetDrip() { const { address, chainId } = useAccount(); @@ -30,7 +30,12 @@ export function useAutoFaucetDrip() { inFlight.current = true; (async () => { const res = await requestDrip(address); - if (res.ok && res.txHash) useFaucetStatus.getState().setPending(res.txHash); + const faucetStatus = useFaucetStatus.getState(); + if (res.ok && res.txHash) { + faucetStatus.setPending(res.txHash); + } else if (!res.ok) { + faucetStatus.setError(res.message ?? "Faucet unreachable. Use Get test ETH or connect your own wallet."); + } inFlight.current = false; })(); }, [address, chainId]); diff --git a/packages/nextjs/services/web3/wagmiConnectors.tsx b/packages/nextjs/services/web3/wagmiConnectors.tsx index 47c191e7..3f09eaea 100644 --- a/packages/nextjs/services/web3/wagmiConnectors.tsx +++ b/packages/nextjs/services/web3/wagmiConnectors.tsx @@ -10,6 +10,7 @@ import { import { rainbowkitBurnerWallet } from "burner-connector"; import * as chains from "viem/chains"; import scaffoldConfig from "~~/scaffold.config"; +import { normalizeStoredBurnerPrivateKey, shouldSeedHardhatBurner } from "~~/utils/scaffold-eth"; import { HARDHAT_ACCOUNTS } from "~~/utils/scaffold-eth/hardhatAccounts"; // Polyfill indexedDB for server-side build/prerendering @@ -72,9 +73,16 @@ const { onlyLocalBurnerWallet, targetNetworks } = scaffoldConfig; // 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) { +if ( + typeof window !== "undefined" && + shouldSeedHardhatBurner({ + hasHardhatTarget: targetNetworks.some(n => n.id === (chains.hardhat as chains.Chain).id), + defaultChainId: targetNetworks[0].id, + hardhatChainId: (chains.hardhat as chains.Chain).id, + }) +) { + const existing = normalizeStoredBurnerPrivateKey(window.localStorage.getItem("burnerWallet.pk")); + if (!existing) { const pick = HARDHAT_ACCOUNTS[Math.floor(Math.random() * HARDHAT_ACCOUNTS.length)]; window.localStorage.setItem("burnerWallet.pk", pick.pk); } diff --git a/packages/nextjs/utils/scaffold-eth/faucet.ts b/packages/nextjs/utils/scaffold-eth/faucet.ts index 116bcb19..15b03082 100644 --- a/packages/nextjs/utils/scaffold-eth/faucet.ts +++ b/packages/nextjs/utils/scaffold-eth/faucet.ts @@ -16,7 +16,7 @@ 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. @@ -53,11 +53,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 { 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..b22de211 --- /dev/null +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts @@ -0,0 +1,174 @@ +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, + shouldAutoConnectInstantBurner, + shouldDisconnectInstantBurner, + shouldResumeInstantBurnerAfterRealWalletModal, + shouldSeedHardhatBurner, +} = await import("./instantBurner.ts"); + +test("instant burner only enables when a faucet URL is configured and the kill switch is not false", () => { + assert.equal(isInstantBurnerSessionEnabled({ faucetUrl: "", flag: undefined }), false); + assert.equal(isInstantBurnerSessionEnabled({ faucetUrl: " ", flag: "true" }), false); + assert.equal(isInstantBurnerSessionEnabled({ faucetUrl: "https://faucet.example", flag: undefined }), true); + assert.equal(isInstantBurnerSessionEnabled({ faucetUrl: "https://faucet.example", flag: "false" }), false); + assert.equal(isInstantBurnerSessionEnabled({ faucetUrl: "https://faucet.example", flag: "0" }), false); +}); + +test("auto-connect is limited to settled no-wallet state on the faucet target chain", () => { + const base = { + enabled: true, + status: "disconnected" as const, + targetChainId: 11155111, + faucetChainId: 11155111, + activeConnectorId: undefined, + pausedUntil: undefined, + 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, pausedUntil: base.now + INSTANT_BURNER_PAUSE_MS }), 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", + chainId: 26001993, + targetChainId: 26001993, + faucetChainId: 11155111, + }), + true, + ); + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "burnerWallet", + chainId: 11155111, + targetChainId: 26001993, + faucetChainId: 11155111, + }), + true, + ); + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "metaMask", + chainId: 26001993, + targetChainId: 26001993, + faucetChainId: 11155111, + }), + false, + ); +}); + +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("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("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("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..b4dd44a5 --- /dev/null +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.ts @@ -0,0 +1,114 @@ +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; + +type WalletStatus = "connected" | "connecting" | "disconnected" | "reconnecting"; + +export function isInstantBurnerSessionEnabled({ + faucetUrl, + flag, +}: { + faucetUrl: string | undefined; + flag: string | undefined; +}): boolean { + const normalizedFlag = (flag ?? "").trim().toLowerCase(); + return (faucetUrl ?? "").trim().length > 0 && normalizedFlag !== "false" && normalizedFlag !== "0"; +} + +export function isBurnerConnector(connector: Pick | undefined): boolean { + return connector?.id === BURNER_WALLET_CONNECTOR_ID; +} + +export function shouldAutoConnectInstantBurner({ + enabled, + status, + targetChainId, + faucetChainId, + activeConnectorId, + pausedUntil, + now, +}: { + enabled: boolean; + status: WalletStatus; + targetChainId: number; + faucetChainId: number; + activeConnectorId: string | undefined; + pausedUntil: number | undefined; + now: number; +}): boolean { + if (!enabled) 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 shouldDisconnectInstantBurner({ + activeConnectorId, + chainId, + targetChainId, + faucetChainId, +}: { + activeConnectorId: string | undefined; + chainId: number | undefined; + targetChainId: number; + faucetChainId: number; +}): boolean { + if (activeConnectorId !== BURNER_WALLET_CONNECTOR_ID) return false; + return chainId !== faucetChainId || targetChainId !== faucetChainId; +} + +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 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 type InstantBurnerMessage = { + tone: "funding" | "ready" | "waiting" | "error"; + label: string; +}; + +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" }; +} From af5b15c3b83c62bdd18a49a775b2e5685a07b4d5 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Tue, 23 Jun 2026 03:22:25 -0500 Subject: [PATCH 02/20] nextjs: require click before demo burner drip Avoid spending faucet ETH on page load. Faucet-configured Sepolia builds now show a one-click demo-wallet affordance; the burner connects and requests a drip only after that click. Persisted burner reconnects are disconnected unless the visitor has explicitly started the demo wallet in the current page session. Real wallets and the manual faucet button remain available. Verified: - yarn workspace @se-2/nextjs test --test-reporter=spec - yarn next:check-types - yarn next:lint --max-warnings=0 - git diff --check - Browser smoke with a mock faucet: reload did not increment faucet count; clicking Use demo wallet connected burner and incremented count once. Co-authored-by: Codex GPT-5 Tested-by: Codex GPT-5 Permanence-tier: Ephemeral --- packages/nextjs/.env.example | 19 +++---- .../components/InstantBurnerSession.tsx | 45 ++++++++++++---- .../hooks/scaffold-eth/useAutoFaucetDrip.ts | 30 +++++++---- .../utils/scaffold-eth/instantBurner.test.ts | 53 +++++++++++++++++++ .../utils/scaffold-eth/instantBurner.ts | 31 +++++++++++ 5 files changed, 149 insertions(+), 29 deletions(-) diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index 57c8bc25..b9220db4 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -85,17 +85,18 @@ NEXT_PUBLIC_FAUCET_URL= NEXT_PUBLIC_FAUCET_CHAIN_ID= # --- Instant burner session for live Sepolia demos --- -# When NEXT_PUBLIC_FAUCET_URL is set, the debug UI auto-connects the existing -# Scaffold-ETH burner wallet on the faucet chain so visitors land with a -# popup-free, disposable signer. The faucet drip then funds that burner. This is -# a demo stopgap only: writes are authored by the throwaway burner address, not by +# When NEXT_PUBLIC_FAUCET_URL is set, the debug UI shows a one-click demo-wallet +# affordance on the faucet chain. Clicking it connects the existing Scaffold-ETH +# burner wallet with no popup; the faucet drip then funds that burner. This is a +# demo stopgap only: writes are authored by the throwaway burner 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 while the app -# is on the faucet chain. +# precedence; disconnecting a real wallet falls back to the burner only after the +# visitor has explicitly started the demo wallet in this browser session. # -# Default: 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-first public demo, also set NEXT_PUBLIC_TARGET_CHAIN=sepolia. +# 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-first public demo, also set +# NEXT_PUBLIC_TARGET_CHAIN=sepolia. NEXT_PUBLIC_INSTANT_BURNER_SESSION= # --- Devnet / VPS deployment (same-origin reverse-proxy config) --- diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx index d8909960..d8c87126 100644 --- a/packages/nextjs/components/InstantBurnerSession.tsx +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -13,6 +13,7 @@ import { getInstantBurnerMessage, isBurnerConnector, isInstantBurnerSessionEnabled, + requestInstantBurnerDrip, shouldAutoConnectInstantBurner, shouldDisconnectInstantBurner, shouldResumeInstantBurnerAfterRealWalletModal, @@ -48,6 +49,7 @@ export const InstantBurnerSession = () => { }); const [dismissed, setDismissed] = useState(false); + const [demoSessionRequested, setDemoSessionRequested] = useState(false); const [pauseUntil, setPauseUntil] = useState(undefined); const [waitingForRealWallet, setWaitingForRealWallet] = useState(false); const connectingBurnerRef = useRef(false); @@ -64,6 +66,7 @@ export const InstantBurnerSession = () => { if ( !shouldDisconnectInstantBurner({ activeConnectorId: connector?.id, + demoSessionRequested, chainId, targetChainId: targetNetwork.id, faucetChainId: FAUCET_CHAIN_ID, @@ -78,7 +81,7 @@ export const InstantBurnerSession = () => { disconnectingBurnerRef.current = false; }, }); - }, [chainId, connector?.id, disconnect, targetNetwork.id]); + }, [chainId, connector?.id, demoSessionRequested, disconnect, targetNetwork.id]); useEffect(() => { if (!INSTANT_BURNER_ENABLED) return; @@ -108,6 +111,7 @@ export const InstantBurnerSession = () => { if ( !shouldAutoConnectInstantBurner({ enabled: INSTANT_BURNER_ENABLED, + demoSessionRequested, status, targetChainId: targetNetwork.id, faucetChainId: FAUCET_CHAIN_ID, @@ -128,7 +132,7 @@ export const InstantBurnerSession = () => { }, }, ); - }, [connect, connector?.id, connectors, pauseUntil, status, targetNetwork.id]); + }, [connect, connector?.id, connectors, demoSessionRequested, pauseUntil, status, targetNetwork.id]); useEffect(() => { if (!waitingForRealWallet) return; @@ -159,14 +163,35 @@ export const InstantBurnerSession = () => { setPauseUntil(undefined); }, [connectModalOpen, status]); - if ( - !INSTANT_BURNER_ENABLED || - dismissed || - !address || - chainId !== FAUCET_CHAIN_ID || - targetNetwork.id !== FAUCET_CHAIN_ID || - !isBurnerConnector(connector) - ) { + if (!INSTANT_BURNER_ENABLED || dismissed || targetNetwork.id !== FAUCET_CHAIN_ID) { + return null; + } + + if (!address && status === "disconnected") { + return ( +
    + + Demo wallet + +
    + ); + } + + if (!address || chainId !== FAUCET_CHAIN_ID || !isBurnerConnector(connector)) { return null; } diff --git a/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts b/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts index 6d91ae3d..9799f48e 100644 --- a/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts +++ b/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts @@ -2,27 +2,37 @@ import { useEffect, useRef } from "react"; import { useAccount } from "wagmi"; -import { isFaucetEnabled, requestDrip, useFaucetStatus } from "~~/utils/scaffold-eth"; +import { isFaucetEnabled, requestDrip, useFaucetStatus } from "~~/utils/scaffold-eth/faucet"; +import { consumeInstantBurnerDripRequest, shouldAutoDripInstantBurner } from "~~/utils/scaffold-eth/instantBurner"; // At most one auto-drip per (chain × address) across the session, tracked in a // module-level Set so it survives component remounts. const dripped = new Set(); /** - * 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 + * Fire one best-effort drip after the visitor explicitly clicks "Use demo + * wallet" 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 so the demo-wallet chip can tell users to retry or connect their own - * wallet. + * status so the demo-wallet chip 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 dripRequested = consumeInstantBurnerDripRequest(); + if ( + !shouldAutoDripInstantBurner({ + faucetEnabled: isFaucetEnabled(chainId), + activeConnectorId: connector?.id, + dripRequested, + }) + ) { + return; + } const key = `${chainId}:${address.toLowerCase()}`; if (dripped.has(key) || inFlight.current) return; @@ -38,5 +48,5 @@ export function useAutoFaucetDrip() { } inFlight.current = false; })(); - }, [address, chainId]); + }, [address, chainId, connector?.id]); } diff --git a/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts index b22de211..1e453607 100644 --- a/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts @@ -37,6 +37,7 @@ const { isInstantBurnerSessionEnabled, normalizeStoredBurnerPrivateKey, shouldAutoConnectInstantBurner, + shouldAutoDripInstantBurner, shouldDisconnectInstantBurner, shouldResumeInstantBurnerAfterRealWalletModal, shouldSeedHardhatBurner, @@ -53,6 +54,7 @@ test("instant burner only enables when a faucet URL is configured and the kill s test("auto-connect is limited to settled no-wallet state on the faucet target chain", () => { const base = { enabled: true, + demoSessionRequested: true, status: "disconnected" as const, targetChainId: 11155111, faucetChainId: 11155111, @@ -66,6 +68,7 @@ test("auto-connect is limited to settled no-wallet state on the faucet target ch 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, demoSessionRequested: false }), false); assert.equal(shouldAutoConnectInstantBurner({ ...base, pausedUntil: base.now + INSTANT_BURNER_PAUSE_MS }), false); }); @@ -81,6 +84,7 @@ test("burner disconnects when it drifts off the faucet chain", () => { assert.equal( shouldDisconnectInstantBurner({ activeConnectorId: "burnerWallet", + demoSessionRequested: true, chainId: 26001993, targetChainId: 26001993, faucetChainId: 11155111, @@ -90,6 +94,7 @@ test("burner disconnects when it drifts off the faucet chain", () => { assert.equal( shouldDisconnectInstantBurner({ activeConnectorId: "burnerWallet", + demoSessionRequested: true, chainId: 11155111, targetChainId: 26001993, faucetChainId: 11155111, @@ -99,12 +104,60 @@ test("burner disconnects when it drifts off the faucet chain", () => { assert.equal( shouldDisconnectInstantBurner({ activeConnectorId: "metaMask", + demoSessionRequested: false, chainId: 26001993, targetChainId: 26001993, faucetChainId: 11155111, }), false, ); + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "burnerWallet", + demoSessionRequested: false, + chainId: 11155111, + targetChainId: 11155111, + faucetChainId: 11155111, + }), + true, + ); + assert.equal( + shouldDisconnectInstantBurner({ + activeConnectorId: "burnerWallet", + demoSessionRequested: true, + chainId: 11155111, + targetChainId: 11155111, + faucetChainId: 11155111, + }), + false, + ); +}); + +test("auto-drip requires an explicit demo 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("real-wallet modal resume waits until the modal was actually open", () => { diff --git a/packages/nextjs/utils/scaffold-eth/instantBurner.ts b/packages/nextjs/utils/scaffold-eth/instantBurner.ts index b4dd44a5..178b45a5 100644 --- a/packages/nextjs/utils/scaffold-eth/instantBurner.ts +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.ts @@ -3,6 +3,7 @@ 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; +const INSTANT_BURNER_DRIP_REQUEST_KEY = "efs.instantBurnerDripRequested"; type WalletStatus = "connected" | "connecting" | "disconnected" | "reconnecting"; @@ -23,6 +24,7 @@ export function isBurnerConnector(connector: Pick | undefined): export function shouldAutoConnectInstantBurner({ enabled, + demoSessionRequested, status, targetChainId, faucetChainId, @@ -31,6 +33,7 @@ export function shouldAutoConnectInstantBurner({ now, }: { enabled: boolean; + demoSessionRequested: boolean; status: WalletStatus; targetChainId: number; faucetChainId: number; @@ -39,6 +42,7 @@ export function shouldAutoConnectInstantBurner({ now: number; }): boolean { if (!enabled) return false; + if (!demoSessionRequested) return false; if (status !== "disconnected") return false; if (targetChainId !== faucetChainId) return false; if (activeConnectorId && activeConnectorId !== BURNER_WALLET_CONNECTOR_ID) return false; @@ -48,19 +52,34 @@ export function shouldAutoConnectInstantBurner({ export function shouldDisconnectInstantBurner({ activeConnectorId, + demoSessionRequested, chainId, targetChainId, faucetChainId, }: { activeConnectorId: string | undefined; + demoSessionRequested: boolean; chainId: number | undefined; targetChainId: number; faucetChainId: number; }): boolean { if (activeConnectorId !== BURNER_WALLET_CONNECTOR_ID) return false; + if (!demoSessionRequested) return true; return chainId !== faucetChainId || targetChainId !== faucetChainId; } +export function shouldAutoDripInstantBurner({ + faucetEnabled, + activeConnectorId, + dripRequested, +}: { + faucetEnabled: boolean; + activeConnectorId: string | undefined; + dripRequested: boolean; +}): boolean { + return faucetEnabled && activeConnectorId === BURNER_WALLET_CONNECTOR_ID && dripRequested; +} + export function shouldResumeInstantBurnerAfterRealWalletModal({ requestedRealWallet, modalWasOpen, @@ -93,6 +112,18 @@ export function normalizeStoredBurnerPrivateKey(raw: string | null): `0x${string return normalized; } +export function requestInstantBurnerDrip(): void { + if (typeof window === "undefined") return; + window.sessionStorage.setItem(INSTANT_BURNER_DRIP_REQUEST_KEY, "1"); +} + +export function consumeInstantBurnerDripRequest(): boolean { + if (typeof window === "undefined") return false; + const requested = window.sessionStorage.getItem(INSTANT_BURNER_DRIP_REQUEST_KEY) === "1"; + if (requested) window.sessionStorage.removeItem(INSTANT_BURNER_DRIP_REQUEST_KEY); + return requested; +} + export type InstantBurnerMessage = { tone: "funding" | "ready" | "waiting" | "error"; label: string; From 8313e7bac8aee56fdd9c68f5e7c0a5421334e0f8 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Tue, 23 Jun 2026 03:31:27 -0500 Subject: [PATCH 03/20] nextjs: rename Sepolia wallet affordance --- packages/nextjs/.env.example | 18 +++++++------- .../components/InstantBurnerSession.tsx | 24 +++++++++---------- .../hooks/scaffold-eth/useAutoFaucetDrip.ts | 6 ++--- .../utils/scaffold-eth/instantBurner.test.ts | 16 ++++++------- .../utils/scaffold-eth/instantBurner.ts | 12 +++++----- 5 files changed, 38 insertions(+), 38 deletions(-) diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index b9220db4..9fdfbeb1 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -84,18 +84,18 @@ NEXT_PUBLIC_FAUCET_URL= # to live Sepolia (11155111). Override only to point at another real testnet. NEXT_PUBLIC_FAUCET_CHAIN_ID= -# --- Instant burner session for live Sepolia demos --- -# When NEXT_PUBLIC_FAUCET_URL is set, the debug UI shows a one-click demo-wallet -# affordance on the faucet chain. Clicking it connects the existing Scaffold-ETH -# burner wallet with no popup; the faucet drip then funds that burner. This is a -# demo stopgap only: writes are authored by the throwaway burner 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 started the demo wallet in this browser session. +# --- One-click editing wallet for live Sepolia --- +# When NEXT_PUBLIC_FAUCET_URL is set, the debug UI shows a one-click "Enable +# editing" affordance on the faucet chain. 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 in this browser session. # # 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-first public demo, also set +# from the manual wallet menu. For a Sepolia-first buildathon deployment, also set # NEXT_PUBLIC_TARGET_CHAIN=sepolia. NEXT_PUBLIC_INSTANT_BURNER_SESSION= diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx index d8c87126..e96df244 100644 --- a/packages/nextjs/components/InstantBurnerSession.tsx +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -49,7 +49,7 @@ export const InstantBurnerSession = () => { }); const [dismissed, setDismissed] = useState(false); - const [demoSessionRequested, setDemoSessionRequested] = useState(false); + const [editingSessionRequested, setEditingSessionRequested] = useState(false); const [pauseUntil, setPauseUntil] = useState(undefined); const [waitingForRealWallet, setWaitingForRealWallet] = useState(false); const connectingBurnerRef = useRef(false); @@ -66,7 +66,7 @@ export const InstantBurnerSession = () => { if ( !shouldDisconnectInstantBurner({ activeConnectorId: connector?.id, - demoSessionRequested, + editingSessionRequested, chainId, targetChainId: targetNetwork.id, faucetChainId: FAUCET_CHAIN_ID, @@ -81,7 +81,7 @@ export const InstantBurnerSession = () => { disconnectingBurnerRef.current = false; }, }); - }, [chainId, connector?.id, demoSessionRequested, disconnect, targetNetwork.id]); + }, [chainId, connector?.id, editingSessionRequested, disconnect, targetNetwork.id]); useEffect(() => { if (!INSTANT_BURNER_ENABLED) return; @@ -111,7 +111,7 @@ export const InstantBurnerSession = () => { if ( !shouldAutoConnectInstantBurner({ enabled: INSTANT_BURNER_ENABLED, - demoSessionRequested, + editingSessionRequested, status, targetChainId: targetNetwork.id, faucetChainId: FAUCET_CHAIN_ID, @@ -132,7 +132,7 @@ export const InstantBurnerSession = () => { }, }, ); - }, [connect, connector?.id, connectors, demoSessionRequested, pauseUntil, status, targetNetwork.id]); + }, [connect, connector?.id, connectors, editingSessionRequested, pauseUntil, status, targetNetwork.id]); useEffect(() => { if (!waitingForRealWallet) return; @@ -171,21 +171,21 @@ export const InstantBurnerSession = () => { return (
    - Demo wallet + Editing
    ); @@ -211,12 +211,12 @@ export const InstantBurnerSession = () => { return (
    - temporary demo wallet + free editing wallet
    {shortAddress(address)} @@ -234,7 +234,7 @@ export const InstantBurnerSession = () => {
    ); } + if (dismissed) { + return null; + } + if (!address || chainId !== FAUCET_CHAIN_ID || !isBurnerConnector(connector)) { return null; } diff --git a/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx b/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx index 309c9368..0d50ebdf 100644 --- a/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx +++ b/packages/nextjs/components/scaffold-eth/GasFaucetButton.tsx @@ -9,20 +9,65 @@ * `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. diff --git a/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts b/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts index b47191d6..645fe9dd 100644 --- a/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts +++ b/packages/nextjs/hooks/scaffold-eth/useAutoFaucetDrip.ts @@ -3,11 +3,18 @@ import { useEffect, useRef } from "react"; import { useAccount } from "wagmi"; import { isFaucetEnabled, requestDrip, useFaucetStatus } from "~~/utils/scaffold-eth/faucet"; -import { consumeInstantBurnerDripRequest, shouldAutoDripInstantBurner } from "~~/utils/scaffold-eth/instantBurner"; +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 one best-effort drip after the visitor explicitly clicks "Enable @@ -23,10 +30,13 @@ export function useAutoFaucetDrip() { useEffect(() => { if (!address) return; + const faucetEnabled = isFaucetEnabled(chainId); + if (!faucetEnabled || connector?.id !== BURNER_WALLET_CONNECTOR_ID) return; + const dripRequested = consumeInstantBurnerDripRequest(); if ( !shouldAutoDripInstantBurner({ - faucetEnabled: isFaucetEnabled(chainId), + faucetEnabled, activeConnectorId: connector?.id, dripRequested, }) @@ -35,6 +45,10 @@ export function useAutoFaucetDrip() { } const key = `${chainId}:${address.toLowerCase()}`; if (dripped.has(key) || inFlight.current) return; + if (shouldBlockFaucetDripRecipient({ recipientAddress: address, hardhatAddresses })) { + useFaucetStatus.getState().setError("This is a public local-dev address. Enable editing again to create a private wallet."); + return; + } dripped.add(key); inFlight.current = true; diff --git a/packages/nextjs/utils/scaffold-eth/faucet.ts b/packages/nextjs/utils/scaffold-eth/faucet.ts index 15b03082..9451b63a 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 editing" 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 @@ -19,7 +19,7 @@ import { create } from "zustand"; 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`. */ diff --git a/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts index b1d2f430..4cb6b906 100644 --- a/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts @@ -36,12 +36,19 @@ const { isBurnerConnector, isInstantBurnerSessionEnabled, normalizeStoredBurnerPrivateKey, + consumeInstantBurnerDripRequest, + requestInstantBurnerDrip, shouldAutoConnectInstantBurner, shouldAutoDripInstantBurner, + shouldBlockFaucetDripRecipient, + shouldBlockFaucetDripForBurner, shouldClearStoredHardhatBurner, shouldDisconnectInstantBurner, + shouldMarkInstantBurnerReady, + shouldResetInstantBurnerDismissalOnAddressChange, shouldResumeInstantBurnerAfterRealWalletModal, shouldSeedHardhatBurner, + shouldShowInstantBurnerEnable, } = await import("./instantBurner.ts"); test("instant burner only enables when a faucet URL is configured and the kill switch is not false", () => { @@ -171,6 +178,13 @@ test("auto-drip requires an explicit editing-wallet request", () => { ); }); +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("real-wallet modal resume waits until the modal was actually open", () => { assert.equal( shouldResumeInstantBurnerAfterRealWalletModal({ @@ -201,6 +215,43 @@ test("real-wallet modal resume waits until the modal was actually open", () => { ); }); +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("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( @@ -254,12 +305,118 @@ test("known public hardhat burner keys are cleared away from local hardhat", () ); }); +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", diff --git a/packages/nextjs/utils/scaffold-eth/instantBurner.ts b/packages/nextjs/utils/scaffold-eth/instantBurner.ts index 8c6eff09..a2aaa4d5 100644 --- a/packages/nextjs/utils/scaffold-eth/instantBurner.ts +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.ts @@ -3,7 +3,7 @@ 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; -const INSTANT_BURNER_DRIP_REQUEST_KEY = "efs.instantBurnerDripRequested"; +let instantBurnerDripRequested = false; type WalletStatus = "connected" | "connecting" | "disconnected" | "reconnecting"; @@ -62,6 +62,24 @@ export function shouldAutoConnectInstantBurner({ return true; } +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, @@ -92,6 +110,22 @@ export function shouldAutoDripInstantBurner({ 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, @@ -133,6 +167,34 @@ export function shouldClearStoredHardhatBurner({ 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; @@ -140,14 +202,12 @@ export function normalizeStoredBurnerPrivateKey(raw: string | null): `0x${string } export function requestInstantBurnerDrip(): void { - if (typeof window === "undefined") return; - window.sessionStorage.setItem(INSTANT_BURNER_DRIP_REQUEST_KEY, "1"); + instantBurnerDripRequested = true; } export function consumeInstantBurnerDripRequest(): boolean { - if (typeof window === "undefined") return false; - const requested = window.sessionStorage.getItem(INSTANT_BURNER_DRIP_REQUEST_KEY) === "1"; - if (requested) window.sessionStorage.removeItem(INSTANT_BURNER_DRIP_REQUEST_KEY); + const requested = instantBurnerDripRequested; + instantBurnerDripRequested = false; return requested; } @@ -156,6 +216,20 @@ export type InstantBurnerMessage = { 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, From a5777980d7cdac488994c27fda540af78cd75a72 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Tue, 23 Jun 2026 14:55:11 -0500 Subject: [PATCH 09/20] nextjs: simplify promptless edits header --- packages/nextjs/.env.example | 2 +- packages/nextjs/components/FaucetAutoDrip.tsx | 2 +- .../components/InstantBurnerSession.tsx | 60 ++++--------------- .../hooks/scaffold-eth/useAutoFaucetDrip.ts | 13 ++-- packages/nextjs/utils/scaffold-eth/faucet.ts | 2 +- 5 files changed, 25 insertions(+), 54 deletions(-) diff --git a/packages/nextjs/.env.example b/packages/nextjs/.env.example index 05e1f345..8c251b91 100644 --- a/packages/nextjs/.env.example +++ b/packages/nextjs/.env.example @@ -87,7 +87,7 @@ 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 editing" affordance. Clicking it connects +# 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 diff --git a/packages/nextjs/components/FaucetAutoDrip.tsx b/packages/nextjs/components/FaucetAutoDrip.tsx index aba78e59..dfc37d66 100644 --- a/packages/nextjs/components/FaucetAutoDrip.tsx +++ b/packages/nextjs/components/FaucetAutoDrip.tsx @@ -4,7 +4,7 @@ import { useAutoFaucetDrip } from "~~/hooks/scaffold-eth"; /** * Mounts the explicit editing-wallet faucet drip. Renders nothing; no-op unless - * the visitor clicked "Enable editing", `NEXT_PUBLIC_FAUCET_URL` is set, and the + * 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. diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx index 2e918994..24ec8e1d 100644 --- a/packages/nextjs/components/InstantBurnerSession.tsx +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -2,9 +2,8 @@ import { useEffect, useRef, useState } from "react"; import { useConnectModal } from "@rainbow-me/rainbowkit"; -import { Address, formatEther } from "viem"; import { hardhat } from "viem/chains"; -import { useAccount, useBalance, useConnect, useConnectors, useDisconnect } from "wagmi"; +import { useAccount, useConnect, useConnectors, useDisconnect } from "wagmi"; import { WalletIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useTargetNetwork } from "~~/hooks/scaffold-eth"; import scaffoldConfig from "~~/scaffold.config"; @@ -13,7 +12,6 @@ import { FAUCET_CHAIN_ID, FAUCET_URL, INSTANT_BURNER_PAUSE_MS, - getInstantBurnerMessage, isBurnerConnector, isInstantBurnerSessionEnabled, normalizeStoredBurnerPrivateKey, @@ -24,7 +22,6 @@ import { shouldResetInstantBurnerDismissalOnAddressChange, shouldResumeInstantBurnerAfterRealWalletModal, shouldShowInstantBurnerEnable, - useFaucetStatus, } from "~~/utils/scaffold-eth"; import { HARDHAT_ACCOUNTS } from "~~/utils/scaffold-eth/hardhatAccounts"; @@ -35,7 +32,6 @@ const INSTANT_BURNER_ENABLED = isInstantBurnerSessionEnabled({ faucetChainId: FAUCET_CHAIN_ID, }); -const shortAddress = (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`; const hardhatPrivateKeys = HARDHAT_ACCOUNTS.map(account => account.pk); const clearPublicHardhatBurnerKey = (targetChainId: number) => { @@ -53,13 +49,6 @@ const clearPublicHardhatBurnerKey = (targetChainId: number) => { } }; -const messageClass = { - funding: "text-info", - ready: "text-success", - waiting: "text-base-content/60", - error: "text-warning", -} as const; - export const InstantBurnerSession = () => { const { address, chainId, connector, status } = useAccount(); const connectors = useConnectors(); @@ -67,12 +56,6 @@ export const InstantBurnerSession = () => { const { disconnect } = useDisconnect(); const { targetNetwork } = useTargetNetwork(); const { connectModalOpen, openConnectModal } = useConnectModal(); - const faucetStatus = useFaucetStatus(); - const { data: balance } = useBalance({ - address: address as Address | undefined, - chainId: FAUCET_CHAIN_ID, - query: { enabled: !!address && chainId === FAUCET_CHAIN_ID }, - }); const [dismissed, setDismissed] = useState(false); const [editingSessionRequested, setEditingSessionRequested] = useState(false); @@ -239,16 +222,15 @@ export const InstantBurnerSession = () => { }) ) { return ( -
    - - Editing - -
    + + Enable promptless edits + ); } @@ -260,13 +242,6 @@ export const InstantBurnerSession = () => { return null; } - const message = getInstantBurnerMessage({ - pendingHash: faucetStatus.pendingHash, - balanceValue: balance?.value, - errorMessage: faucetStatus.errorMessage, - }); - const balanceLabel = balance ? `${Number(formatEther(balance.value)).toFixed(4)} ETH` : "balance..."; - const connectRealWallet = () => { setPauseUntil(Date.now() + INSTANT_BURNER_PAUSE_MS); setWaitingForRealWallet(true); @@ -275,26 +250,17 @@ export const InstantBurnerSession = () => { return (
    -
    -
    - free editing wallet -
    -
    - {shortAddress(address)} - {message.label} - {balanceLabel} -
    -
    + Promptless edits on
    - - + + diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx index 24ec8e1d..640b9727 100644 --- a/packages/nextjs/components/InstantBurnerSession.tsx +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { useConnectModal } from "@rainbow-me/rainbowkit"; import { hardhat } from "viem/chains"; import { useAccount, useConnect, useConnectors, useDisconnect } from "wagmi"; -import { WalletIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { WalletIcon } from "@heroicons/react/24/outline"; import { useTargetNetwork } from "~~/hooks/scaffold-eth"; import scaffoldConfig from "~~/scaffold.config"; import { @@ -34,6 +34,35 @@ const INSTANT_BURNER_ENABLED = isInstantBurnerSessionEnabled({ 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)); @@ -212,6 +241,13 @@ export const InstantBurnerSession = () => { requestInstantBurnerDrip(); }; + const disableEditing = () => { + setEditingSessionRequested(false); + setDismissed(true); + setPauseUntil(undefined); + disconnect(); + }; + if ( shouldShowInstantBurnerEnable({ enabled: INSTANT_BURNER_ENABLED, @@ -222,15 +258,11 @@ export const InstantBurnerSession = () => { }) ) { return ( - + /> ); } @@ -249,26 +281,20 @@ export const InstantBurnerSession = () => { }; return ( -
    - - Promptless edits on +
    + -
    ); 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)}
      Date: Tue, 23 Jun 2026 15:50:18 -0500 Subject: [PATCH 11/20] nextjs: anchor easy edits after wallet Keep the Easy Edits control to the right of the wallet chip so enabling the burner swaps Connect Wallet for the address chip without moving the toggle. Brighten the off state, use a larger sliding switch treatment, and remove the separate Own wallet button. Verified with nextjs lint, typecheck, unit tests, diff whitespace, and headless Chrome captures at 1280px and 2048px. Co-authored-by: Codex GPT-5 Tested-by: Codex GPT-5 Permanence-tier: Ephemeral --- packages/nextjs/components/Header.tsx | 2 +- .../components/InstantBurnerSession.tsx | 94 +++---------------- 2 files changed, 16 insertions(+), 80 deletions(-) diff --git a/packages/nextjs/components/Header.tsx b/packages/nextjs/components/Header.tsx index 3b347168..4a9902cc 100644 --- a/packages/nextjs/components/Header.tsx +++ b/packages/nextjs/components/Header.tsx @@ -128,8 +128,8 @@ export const Header = () => { - +
    diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx index 640b9727..96608e7e 100644 --- a/packages/nextjs/components/InstantBurnerSession.tsx +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -1,17 +1,14 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { useConnectModal } from "@rainbow-me/rainbowkit"; import { hardhat } from "viem/chains"; import { useAccount, useConnect, useConnectors, useDisconnect } from "wagmi"; -import { WalletIcon } from "@heroicons/react/24/outline"; import { useTargetNetwork } from "~~/hooks/scaffold-eth"; import scaffoldConfig from "~~/scaffold.config"; import { BURNER_WALLET_PK_STORAGE_KEY, FAUCET_CHAIN_ID, FAUCET_URL, - INSTANT_BURNER_PAUSE_MS, isBurnerConnector, isInstantBurnerSessionEnabled, normalizeStoredBurnerPrivateKey, @@ -20,7 +17,6 @@ import { shouldClearStoredHardhatBurner, shouldDisconnectInstantBurner, shouldResetInstantBurnerDismissalOnAddressChange, - shouldResumeInstantBurnerAfterRealWalletModal, shouldShowInstantBurnerEnable, } from "~~/utils/scaffold-eth"; import { HARDHAT_ACCOUNTS } from "~~/utils/scaffold-eth/hardhatAccounts"; @@ -36,10 +32,10 @@ const hardhatPrivateKeys = HARDHAT_ACCOUNTS.map(account => account.pk); const InstantBurnerToggle = ({ active, onClick, title }: { active: boolean; onClick: () => void; title: string }) => ( -
    + ); }; From e0fb248c75bf675d5b6fd0bda500969066674aaf Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Tue, 23 Jun 2026 17:54:23 -0500 Subject: [PATCH 12/20] nextjs: stop easy edits after wallet disconnect --- .../components/InstantBurnerSession.tsx | 21 +++++++++++++++++++ .../utils/scaffold-eth/instantBurner.test.ts | 15 +++++++++++++ .../utils/scaffold-eth/instantBurner.ts | 12 +++++++++++ 3 files changed, 48 insertions(+) diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx index 96608e7e..c7144ec6 100644 --- a/packages/nextjs/components/InstantBurnerSession.tsx +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -18,6 +18,7 @@ import { shouldDisconnectInstantBurner, shouldResetInstantBurnerDismissalOnAddressChange, shouldShowInstantBurnerEnable, + shouldStopInstantBurnerAfterExternalDisconnect, } from "~~/utils/scaffold-eth"; import { HARDHAT_ACCOUNTS } from "~~/utils/scaffold-eth/hardhatAccounts"; @@ -88,6 +89,7 @@ export const InstantBurnerSession = () => { const [pauseUntil, setPauseUntil] = useState(undefined); const connectingBurnerRef = useRef(false); const disconnectingBurnerRef = useRef(false); + const burnerWasConnectedRef = useRef(false); const previousAddressRef = useRef(undefined); useEffect(() => { @@ -151,8 +153,27 @@ export const InstantBurnerSession = () => { } }, [status]); + useEffect(() => { + if (status !== "connected") return; + burnerWasConnectedRef.current = chainId === FAUCET_CHAIN_ID && isBurnerConnector(connector); + }, [chainId, connector, 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 ( diff --git a/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts index 4cb6b906..4767c9c9 100644 --- a/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.test.ts @@ -49,6 +49,7 @@ const { shouldResumeInstantBurnerAfterRealWalletModal, shouldSeedHardhatBurner, shouldShowInstantBurnerEnable, + shouldStopInstantBurnerAfterExternalDisconnect, } = await import("./instantBurner.ts"); test("instant burner only enables when a faucet URL is configured and the kill switch is not false", () => { @@ -236,6 +237,20 @@ test("real-wallet connects do not undo a dismissed burner chip", () => { ); }); +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("enable-editing affordance remains available with no connected wallet", () => { const base = { enabled: true, diff --git a/packages/nextjs/utils/scaffold-eth/instantBurner.ts b/packages/nextjs/utils/scaffold-eth/instantBurner.ts index a2aaa4d5..84780183 100644 --- a/packages/nextjs/utils/scaffold-eth/instantBurner.ts +++ b/packages/nextjs/utils/scaffold-eth/instantBurner.ts @@ -62,6 +62,18 @@ export function shouldAutoConnectInstantBurner({ return true; } +export function shouldStopInstantBurnerAfterExternalDisconnect({ + wasBurnerConnected, + editingSessionRequested, + status, +}: { + wasBurnerConnected: boolean; + editingSessionRequested: boolean; + status: WalletStatus; +}): boolean { + return wasBurnerConnected && editingSessionRequested && status === "disconnected"; +} + export function shouldShowInstantBurnerEnable({ enabled, status, From 95ac59ef365ee9b634788cff763d9eff164fc8a6 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Tue, 23 Jun 2026 18:46:28 -0500 Subject: [PATCH 13/20] nextjs: polish easy edits toggle spacing --- .../nextjs/components/InstantBurnerSession.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx index c7144ec6..7e6db484 100644 --- a/packages/nextjs/components/InstantBurnerSession.tsx +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -33,7 +33,7 @@ const hardhatPrivateKeys = HARDHAT_ACCOUNTS.map(account => account.pk); const InstantBurnerToggle = ({ active, onClick, title }: { active: boolean; onClick: () => void; title: string }) => (