Loading...
Loading...
Frontend UX rules for Ethereum dApps that prevent the most common AI agent UI bugs. Mandatory patterns for onchain buttons, token approval flows, address display, USD values, RPC configuration, and pre-publish metadata. Built around Scaffold-ETH 2 but the patterns apply to any Ethereum frontend. Use when building any dApp frontend.
npx skill4agent add austintgriffith/ethskills frontend-uxuseTransactorwriteContractAsync<Address/>0x1234...5678⚠️ THIS IS THE #1 BUG AI AGENTS SHIP. The user clicks Approve, signs in their wallet, comes back to the app, and the Approve button is clickable again — so they click it again, send a duplicate transaction, and now two approvals are pending. The button MUST be disabled and show a spinner from the moment they click until the transaction confirms onchain. Not until the wallet closes. Not until the signature is sent. Until the BLOCK CONFIRMS.
// ✅ CORRECT: Separate loading state PER ACTION
const [isApproving, setIsApproving] = useState(false);
const [isStaking, setIsStaking] = useState(false);
<button
disabled={isApproving}
onClick={async () => {
setIsApproving(true);
try {
await writeContractAsync({ functionName: "approve", args: [...] });
} catch (e) {
console.error(e);
notification.error("Approval failed");
} finally {
setIsApproving(false);
}
}}
>
{isApproving ? "Approving..." : "Approve"}
</button>isLoading// ❌ WRONG: Raw wagmi — resolves after signing, not confirmation
const { writeContractAsync } = useWriteContract();
await writeContractAsync({...}); // Returns immediately after MetaMask signs!
// ✅ CORRECT: Scaffold hooks — waits for tx to be mined
const { writeContractAsync } = useScaffoldWriteContract("MyContract");
await writeContractAsync({...}); // Waits for actual onchain confirmationuseScaffoldWriteContractuseTransactor1. Not connected? → Big "Connect Wallet" button (NOT text saying "connect your wallet to play")
2. Wrong network? → Big "Switch to Base" button
3. Not enough approved? → "Approve" button (with loader per Rule 1)
4. Enough approved? → "Stake" / "Deposit" / action buttonNEVER show a text prompt like "Connect your wallet to play" or "Please connect to continue." Show a button. The user should always have exactly one thing to click.
const { data: allowance } = useScaffoldReadContract({
contractName: "Token",
functionName: "allowance",
args: [address, contractAddress],
});
const needsApproval = !allowance || allowance < amount;
const wrongNetwork = chain?.id !== targetChainId;
const notConnected = !address;
{notConnected ? (
<RainbowKitCustomConnectButton /> // Big connect button — NOT text
) : wrongNetwork ? (
<button onClick={switchNetwork} disabled={isSwitching}>
{isSwitching ? "Switching..." : "Switch to Base"}
</button>
) : needsApproval ? (
<button onClick={handleApprove} disabled={isApproving}>
{isApproving ? "Approving..." : "Approve $TOKEN"}
</button>
) : (
<button onClick={handleStake} disabled={isStaking}>
{isStaking ? "Staking..." : "Stake"}
</button>
)}<Address/><Address/>import { Address } from "~~/components/scaffold-eth";
// ✅ CORRECT
<Address address={userAddress} />
// ❌ WRONG — never render raw hex
<span>{userAddress}</span>
<p>0x1234...5678</p><Address/><AddressInput/><AddressInput/>import { AddressInput } from "~~/components/scaffold-eth";
// ✅ CORRECT
<AddressInput value={recipient} onChange={setRecipient} placeholder="Recipient address" />
// ❌ WRONG — never use a raw text input for addresses
<input type="text" value={recipient} onChange={e => setRecipient(e.target.value)} /><AddressInput/><Address/><AddressInput/><Address/><div className="text-center mt-8 text-sm opacity-70">
<p>Contract:</p>
<Address address={deployedContractAddress} />
</div>// ✅ CORRECT — Display with USD
<span>1,000 TOKEN (~$4.20)</span>
<span>0.5 ETH (~$1,250.00)</span>
// ✅ CORRECT — Input with live USD preview
<input value={amount} onChange={...} />
<span className="text-sm text-gray-500">
≈ ${(parseFloat(amount || "0") * tokenPrice).toFixed(2)} USD
</span>
// ❌ WRONG — Amount with no USD context
<span>1,000 TOKEN</span> // User has no idea what this is worthuseNativeCurrencyPrice()https://api.dexscreener.com/latest/dex/tokens/TOKEN_ADDRESS<h1>// ❌ WRONG — AI agents ALWAYS do this
<Header /> {/* Already shows "🦞 My dApp" */}
<main>
<h1>🦞 My dApp</h1> {/* DUPLICATE! Delete this. */}
<p>Description of the app</p>
...
</main>
// ✅ CORRECT — Jump straight into content
<Header /> {/* Shows the app name */}
<main>
<div className="grid grid-cols-2 gap-4">
{/* Stats, balances, actions — no redundant title */}
</div>
</main>mainnet.base.orgscaffold.config.tsrpcOverrides: {
[chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org",
},
pollingInterval: 3000, // 3 seconds, not the default 30000.env.local⚠️ SE2'sadds a barewagmiConfig.tsx(no URL) as a fallback transport. Viem resolves barehttp()to the chain's default public RPC (e.g.http()for Base). Even withmainnet.base.orgset in scaffold config, the public RPC will still get hit because viem'srpcOverridesfires transports in parallel. You must remove the barefallback()from the fallback array inhttp()so only your configured RPCs are used. If you don't, your app will spam the public RPC with every poll cycle and get 429 rate-limited in production.services/web3/wagmiConfig.tsx
watch: true// ❌ WRONG — hardcoded black, defeats the entire DaisyUI theme system
<div className="min-h-screen bg-[#0a0a0a] text-white">globals.csstailwind.config.ts<SwitchTheme/>data-themeprefers-color-scheme: light// ✅ CORRECT — responds to system preference AND the theme toggle
<div className="min-h-screen bg-base-200 text-base-content">
// DaisyUI semantic classes — use these everywhere:
// bg-base-100 (lightest surface — cards, inputs)
// bg-base-200 (default page background)
// bg-base-300 (borders, dividers)
// text-base-content (primary text)
// text-base-content/60 (secondary/muted text)// ✅ Acceptable dark-only — but ALSO remove <SwitchTheme/> from the header
// In app/layout.tsx:
<html data-theme="dark">
// AND delete <SwitchTheme /> from Header.tsx
// Don't leave a theme toggle that does nothinggrep -rn 'bg-\[#0\|bg-black\|bg-gray-9\|bg-zinc-9\|bg-neutral-9\|bg-slate-9' packages/nextjs/app/--radius-field: 9999rempackages/nextjs/styles/globals.css/* In BOTH @plugin "daisyui/theme" blocks (light AND dark): */
--radius-field: 9999rem; /* ❌ default — pill-shaped, clips textarea text */
--radius-field: 0.5rem; /* ✅ rounded-lg equivalent — works for all form elements */inputselecttextarearounded-mdgrep "radius-field" packages/nextjs/styles/globals.css9999rem// ❌ WRONG — user sees nothing
try { await writeTx(...) }
catch (e) { console.error(e) }
// ✅ RIGHT — user sees "Insufficient token balance"
try { await writeTx(...) }
catch (e) { setTxError(parseContractError(e)) }
// Below the button:
{txError && (
<div className="mt-3 alert alert-error text-sm">
<span>{txError}</span>
</div>
)}# List all custom errors in your contract's ABI
cat deployedContracts.ts | grep -o '"name":"[^"]*Error[^"]*"' | sort -u
# Or from the Solidity source
grep -rn 'error ' contracts/src/ | grep -v '//'ERC20InsufficientBalanceOwnableUnauthorizedAccount// In app/layout.tsx or getMetadata.ts
export const metadata: Metadata = {
title: "Your App Name",
description: "Description of the app",
openGraph: {
title: "Your App Name",
description: "Description of the app",
images: [{ url: "https://YOUR-LIVE-DOMAIN.com/thumbnail.png" }],
},
twitter: {
card: "summary_large_image",
title: "Your App Name",
description: "Description of the app",
images: ["https://YOUR-LIVE-DOMAIN.com/thumbnail.png"],
},
};https://localhostsummary_large_imagehttp()pollingInterval<Address/><AddressInput/>bg-base-200 text-base-contentpackages/nextjs/contracts/externalContracts.ts// packages/nextjs/contracts/externalContracts.ts
export default {
8453: { // Base chain ID
USDC: {
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
abi: [...], // ERC-20 ABI
},
},
} as const;useScaffoldReadContractuseScaffoldWriteContractdeployedContracts.tsexternalContracts.tsdeployedContracts.tsyarn deployexternalContracts.ts// Contract → Display
import { formatEther, formatUnits } from "viem";
formatEther(weiAmount); // 18 decimals (ETH, DAI, most tokens)
formatUnits(usdcAmount, 6); // 6 decimals (USDC, USDT)
// Display → Contract
import { parseEther, parseUnits } from "viem";
parseEther("1.5"); // → 1500000000000000000n
parseUnits("100", 6); // → 100000000n (USDC)15000000000000000001.5 ETH (~$3,750)