Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 54 additions & 14 deletions packages/nextjs/components/InstantBurnerSession.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
"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";
import {
BURNER_WALLET_PK_STORAGE_KEY,
FAUCET_CHAIN_ID,
FAUCET_URL,
getParsedError,
isBurnerConnector,
isInstantBurnerSessionEnabled,
normalizeStoredBurnerPrivateKey,
notification,
requestInstantBurnerDrip,
shouldAutoConnectInstantBurner,
shouldClearInstantBurnerTrackingBeforeDisconnect,
Expand Down Expand Up @@ -93,6 +95,7 @@ export const InstantBurnerSession = () => {
const [dismissed, setDismissed] = useState(false);
const [editingSessionRequested, setEditingSessionRequested] = useState(false);
const [pauseUntil, setPauseUntil] = useState<number | undefined>(undefined);
const [connectError, setConnectError] = useState<string | undefined>(undefined);
const connectingBurnerRef = useRef(false);
const disconnectingBurnerRef = useRef(false);
const burnerWasConnectedRef = useRef(false);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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 = () => {
Expand All @@ -257,6 +296,7 @@ export const InstantBurnerSession = () => {
setEditingSessionRequested(false);
setDismissed(true);
setPauseUntil(undefined);
setConnectError(undefined);
disconnect();
};

Expand All @@ -273,7 +313,7 @@ export const InstantBurnerSession = () => {
<InstantBurnerToggle
active={false}
onClick={enableEditing}
title="Use a free Sepolia wallet for promptless edits"
title={connectError ?? "Use a free Sepolia wallet for promptless edits"}
/>
);
}
Expand Down
45 changes: 43 additions & 2 deletions packages/nextjs/services/web3/chainAwareBurnerWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SwitchChainError,
createWalletClient,
custom,
fallback,
fromHex,
getAddress,
http,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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;
}
}
});
Loading