From f5666d6b347cc74c7ae6fabd50e84598bea94b30 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Thu, 25 Jun 2026 14:43:20 -0500 Subject: [PATCH] fix(nextjs): harden easy edits burner connect --- .../components/InstantBurnerSession.tsx | 68 +++++++++++++++---- .../services/web3/chainAwareBurnerWallet.ts | 45 +++++++++++- .../chainAwareBurnerWallet.test.ts | 44 +++++++++++- 3 files changed, 140 insertions(+), 17 deletions(-) diff --git a/packages/nextjs/components/InstantBurnerSession.tsx b/packages/nextjs/components/InstantBurnerSession.tsx index d67411a3..fb3f9907 100644 --- a/packages/nextjs/components/InstantBurnerSession.tsx +++ b/packages/nextjs/components/InstantBurnerSession.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { hardhat } from "viem/chains"; import { useAccount, useConnect, useConnectors, useDisconnect } from "wagmi"; import { useTargetNetwork } from "~~/hooks/scaffold-eth"; @@ -8,9 +8,11 @@ import { BURNER_WALLET_PK_STORAGE_KEY, FAUCET_CHAIN_ID, FAUCET_URL, + getParsedError, isBurnerConnector, isInstantBurnerSessionEnabled, normalizeStoredBurnerPrivateKey, + notification, requestInstantBurnerDrip, shouldAutoConnectInstantBurner, shouldClearInstantBurnerTrackingBeforeDisconnect, @@ -93,6 +95,7 @@ export const InstantBurnerSession = () => { const [dismissed, setDismissed] = useState(false); const [editingSessionRequested, setEditingSessionRequested] = useState(false); const [pauseUntil, setPauseUntil] = useState(undefined); + const [connectError, setConnectError] = useState(undefined); const connectingBurnerRef = useRef(false); const disconnectingBurnerRef = useRef(false); const burnerWasConnectedRef = useRef(false); @@ -173,6 +176,35 @@ export const InstantBurnerSession = () => { } }, [status]); + const connectBurner = useCallback(() => { + const burnerConnector = connectors.find(isBurnerConnector); + if (!burnerConnector || connectingBurnerRef.current) return false; + + connectingBurnerRef.current = true; + connect( + { connector: burnerConnector, chainId: FAUCET_CHAIN_ID }, + { + onSuccess: () => { + setConnectError(undefined); + }, + onError: error => { + const detail = getParsedError(error); + const message = detail + ? `Easy Edits could not connect: ${detail}` + : "Easy Edits could not connect the burner wallet."; + setConnectError(message); + setEditingSessionRequested(false); + setDismissed(false); + notification.error(`${message} Try Connect Wallet > Burner Wallet.`, { position: "bottom-center" }); + }, + onSettled: () => { + connectingBurnerRef.current = false; + }, + }, + ); + return true; + }, [connect, connectors]); + useEffect(() => { burnerWasConnectedRef.current = trackInstantBurnerWasConnected({ current: burnerWasConnectedRef.current, @@ -204,8 +236,7 @@ export const InstantBurnerSession = () => { return; } - const burnerConnector = connectors.find(isBurnerConnector); - if (!burnerConnector || connectingBurnerRef.current) return; + if (connectingBurnerRef.current) return; if ( !shouldAutoConnectInstantBurner({ enabled: INSTANT_BURNER_ENABLED, @@ -222,16 +253,8 @@ export const InstantBurnerSession = () => { 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]); + connectBurner(); + }, [connectBurner, connector?.id, dismissed, editingSessionRequested, pauseUntil, status, targetNetwork.id]); if (!INSTANT_BURNER_ENABLED || targetNetwork.id !== FAUCET_CHAIN_ID) { return null; @@ -241,8 +264,24 @@ export const InstantBurnerSession = () => { clearPublicHardhatBurnerKey(targetNetwork.id); setDismissed(false); setPauseUntil(undefined); + setConnectError(undefined); setEditingSessionRequested(true); requestInstantBurnerDrip(); + if ( + shouldAutoConnectInstantBurner({ + enabled: INSTANT_BURNER_ENABLED, + editingSessionRequested: true, + status, + targetChainId: targetNetwork.id, + faucetChainId: FAUCET_CHAIN_ID, + activeConnectorId: connector?.id, + pausedUntil: undefined, + realWalletFlowActive: false, + now: Date.now(), + }) + ) { + connectBurner(); + } }; const disableEditing = () => { @@ -257,6 +296,7 @@ export const InstantBurnerSession = () => { setEditingSessionRequested(false); setDismissed(true); setPauseUntil(undefined); + setConnectError(undefined); disconnect(); }; @@ -273,7 +313,7 @@ export const InstantBurnerSession = () => { ); } diff --git a/packages/nextjs/services/web3/chainAwareBurnerWallet.ts b/packages/nextjs/services/web3/chainAwareBurnerWallet.ts index 3b0292e6..db0d525c 100644 --- a/packages/nextjs/services/web3/chainAwareBurnerWallet.ts +++ b/packages/nextjs/services/web3/chainAwareBurnerWallet.ts @@ -5,6 +5,7 @@ import { SwitchChainError, createWalletClient, custom, + fallback, fromHex, getAddress, http, @@ -18,6 +19,7 @@ import { BURNER_WALLET_PK_STORAGE_KEY, selectBurnerChain, } from "~~/utils/scaffold-eth/instantBurner"; +import { getAlchemyHttpUrl } from "~~/utils/scaffold-eth/networks"; export class BurnerConnectorNotConnectedError extends BaseError { override name = "BurnerConnectorNotConnectedError"; @@ -91,8 +93,47 @@ function configuredChain(chains: readonly Chain[], chainId: number): Chain { return chain; } +function addUniqueRpcUrl(urls: string[], url: string | undefined) { + if (url && !urls.includes(url)) urls.push(url); +} + +function hasExplicitRpcOverride(chainRpcUrls: readonly string[]): boolean { + const explicitUrls = [ + process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL, + process.env.NEXT_PUBLIC_DEVNET_RPC_URL, + process.env.NEXT_PUBLIC_HARDHAT_RPC_URL, + ] + .map(url => url?.trim()) + .filter((url): url is string => !!url); + + return chainRpcUrls.some(url => explicitUrls.includes(url)); +} + +export function getBurnerRpcUrls(chain: Chain): readonly string[] { + const urls: string[] = []; + const chainRpcUrls = chain.rpcUrls.default.http; + const alchemyUrl = getAlchemyHttpUrl(chain.id); + + if (hasExplicitRpcOverride(chainRpcUrls)) { + for (const url of chainRpcUrls) addUniqueRpcUrl(urls, url); + addUniqueRpcUrl(urls, alchemyUrl); + } else { + addUniqueRpcUrl(urls, alchemyUrl); + for (const url of chainRpcUrls) addUniqueRpcUrl(urls, url); + } + + return urls; +} + +function burnerTransport(chain: Chain) { + const urls = getBurnerRpcUrls(chain); + if (urls.length === 0) throw new Error(`No RPC URL found for burner chain ${chain.id}.`); + const transports = urls.map(url => http(url)); + return transports.length === 1 ? transports[0] : fallback(transports); +} + function chainRpcUrl(chain: Chain): string { - const url = chain.rpcUrls.default.http[0]; + const url = getBurnerRpcUrls(chain)[0]; if (!url) throw new Error(`No RPC URL found for burner chain ${chain.id}.`); return url; } @@ -157,7 +198,7 @@ export const chainAwareBurner = ({ useSessionStorage = false }: { useSessionStor const client = createWalletClient({ chain, account: burnerAccount, - transport: http(), + transport: burnerTransport(chain), }); if (method === "eth_sendTransaction") { diff --git a/packages/nextjs/utils/scaffold-eth/chainAwareBurnerWallet.test.ts b/packages/nextjs/utils/scaffold-eth/chainAwareBurnerWallet.test.ts index a5565d3e..d0f64fff 100644 --- a/packages/nextjs/utils/scaffold-eth/chainAwareBurnerWallet.test.ts +++ b/packages/nextjs/utils/scaffold-eth/chainAwareBurnerWallet.test.ts @@ -28,7 +28,7 @@ registerHooks({ }, }); -const { chainAwareBurner } = await import("../../services/web3/chainAwareBurnerWallet.ts"); +const { chainAwareBurner, getBurnerRpcUrls } = await import("../../services/web3/chainAwareBurnerWallet.ts"); const { mainnet, sepolia } = await import("viem/chains"); const { numberToHex } = await import("viem/utils"); @@ -105,3 +105,45 @@ test("burner rejects mainnet even when wagmi appends it for ENS reads", async () await assert.rejects(() => connector.connect({ chainId: mainnet.id }), rejectsUnsupportedChain(mainnet.id)); await assert.rejects(() => connector.switchChain({ chainId: mainnet.id }), rejectsUnsupportedChain(mainnet.id)); }); + +test("burner Sepolia transport prefers the configured Alchemy fallback before public RPC", () => { + const previousSepoliaRpcUrl = process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL; + delete process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL; + + const sepoliaUrls = getBurnerRpcUrls(sepolia); + try { + assert.match(sepoliaUrls[0] ?? "", /^https:\/\/eth-sepolia\.g\.alchemy\.com\/v2\//); + assert.equal(new Set(sepoliaUrls).size, sepoliaUrls.length); + + assert.deepEqual(getBurnerRpcUrls(devnet), devnet.rpcUrls.default.http); + } finally { + if (previousSepoliaRpcUrl === undefined) { + delete process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL; + } else { + process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL = previousSepoliaRpcUrl; + } + } +}); + +test("burner honors an explicit Sepolia RPC override before the Alchemy fallback", () => { + const previousSepoliaRpcUrl = process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL; + const explicitRpcUrl = "https://custom-sepolia.example/rpc"; + const customSepolia = { + ...sepolia, + rpcUrls: { default: { http: [explicitRpcUrl] } }, + }; + + process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL = explicitRpcUrl; + try { + const sepoliaUrls = getBurnerRpcUrls(customSepolia); + assert.equal(sepoliaUrls[0], explicitRpcUrl); + assert.match(sepoliaUrls[1] ?? "", /^https:\/\/eth-sepolia\.g\.alchemy\.com\/v2\//); + assert.equal(new Set(sepoliaUrls).size, sepoliaUrls.length); + } finally { + if (previousSepoliaRpcUrl === undefined) { + delete process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL; + } else { + process.env.NEXT_PUBLIC_SEPOLIA_RPC_URL = previousSepoliaRpcUrl; + } + } +});