Loading...
Loading...
Compare original and translation side by side
useTransactorwriteContractAsync<Address/>0x1234...5678useTransactorwriteContractAsync<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⚠️ 这是AI Agent最常犯的错误。 用户点击授权,在钱包中签名后返回应用,发现授权按钮又能点击了——于是再次点击,发送了重复交易,导致两个授权请求同时待处理。按钮必须从用户点击的那一刻起就保持禁用并显示加载动画,直到交易在链上确认。 不是钱包关闭时,也不是签名发送时,而是区块确认后。
// ✅ 正确:为每个操作设置独立的加载状态
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("授权失败");
} finally {
setIsApproving(false);
}
}}
>
{isApproving ? "授权中..." : "授权"}
</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 confirmationuseScaffoldWriteContractuseTransactor// ❌ 错误:原生wagmi——签名后就返回,不等待确认
const { writeContractAsync } = useWriteContract();
await writeContractAsync({...}); // MetaMask签名后立即返回!
// ✅ 正确:Scaffold钩子——等待交易上链
const { writeContractAsync } = useScaffoldWriteContract("MyContract");
await writeContractAsync({...}); // 等待链上实际确认useScaffoldWriteContractuseTransactor1. 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>
)}1. 未连接钱包? → 显示“连接钱包”大按钮(不要用“请连接钱包以使用”这类文字提示)
2. 网络不正确? → 显示“切换至Base网络”大按钮
3. 授权额度不足? → 显示“授权”按钮(遵循规则1添加加载状态)
4. 授权额度足够? → 显示“质押”/“存入”/对应操作按钮绝不要显示“请连接钱包以使用”或“请连接以继续”这类文字提示。 直接显示按钮。用户每次都应该只有一个可点击的选项。
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 /> // 大的连接按钮——不要用文字
) : wrongNetwork ? (
<button onClick={switchNetwork} disabled={isSwitching}>
{isSwitching ? "切换中..." : "切换至Base网络"}
</button>
) : needsApproval ? (
<button onClick={handleApprove} disabled={isApproving}>
{isApproving ? "授权中..." : "授权 $TOKEN"}
</button>
) : (
<button onClick={handleStake} disabled={isStaking}>
{isStaking ? "质押中..." : "质押"}
</button>
)}<Address/><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/><Address/>import { Address } from "~~/components/scaffold-eth";
// ✅ 正确
<Address address={userAddress} />
// ❌ 错误——绝不要渲染原始十六进制地址
<span>{userAddress}</span>
<p>0x1234...5678</p><Address/><AddressInput/><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/><AddressInput/>import { AddressInput } from "~~/components/scaffold-eth";
// ✅ 正确
<AddressInput value={recipient} onChange={setRecipient} placeholder="接收方地址" />
// ❌ 错误——绝不要为地址输入使用原生文本输入框
<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><Address/><div className="text-center mt-8 text-sm opacity-70">
<p>合约地址:</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// ✅ 正确——显示时附带美元价值
<span>1,000 TOKEN (~$4.20)</span>
<span>0.5 ETH (~$1,250.00)</span>
// ✅ 正确——输入时显示实时美元预览
<input value={amount} onChange={...} />
<span className="text-sm text-gray-500">
≈ ${(parseFloat(amount || "0") * tokenPrice).toFixed(2)} USD
</span>
// ❌ 错误——仅显示数量,无美元参考
<span>1,000 TOKEN</span> // 用户完全不知道这值多少钱useNativeCurrencyPrice()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><h1>// ❌ 错误——AI Agent总是会犯这个错
<Header /> {/* 已经显示“🦞 My dApp” */}
<main>
<h1>🦞 My dApp</h1> {/* 重复了!删除这一行。 */}
<p>应用描述</p>
...
</main>
// ✅ 正确——直接进入内容
<Header /> {/* 显示应用名称 */}
<main>
<div className="grid grid-cols-2 gap-4">
{/* 统计数据、余额、操作按钮——不要冗余标题 */}
</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: truemainnet.base.orgscaffold.config.tsrpcOverrides: {
[chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org",
},
pollingInterval: 3000, // 3秒,不要使用默认的30000毫秒.env.local⚠️ SE2的添加了一个空的wagmiConfig.tsx(无URL)作为备用传输方式。 Viem会将空的http()解析为链的默认公共RPC(例如Base网络的http())。即使在scaffold配置中设置了mainnet.base.org,公共RPC仍会被调用,因为viem的rpcOverrides会并行触发所有传输方式。你必须从fallback()的备用数组中删除空的services/web3/wagmiConfig.tsx,这样就只会使用你配置的RPC。如果不这么做,你的应用会在每次轮询时请求公共RPC,导致生产环境中出现429请求受限错误。http()
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/// ❌ 错误——硬编码黑色背景,破坏了DaisyUI的主题系统
<div className="min-h-screen bg-[#0a0a0a] text-white">globals.csstailwind.config.ts<SwitchTheme/>data-themeprefers-color-scheme: light// ✅ 正确——响应系统偏好设置和主题切换按钮
<div className="min-h-screen bg-base-200 text-base-content">
// DaisyUI语义化类——全局使用这些:
// bg-base-100 (最浅的背景——卡片、输入框)
// bg-base-200 (默认页面背景)
// bg-base-300 (边框、分隔线)
// text-base-content (主要文本)
// text-base-content/60 (次要/弱化文本)// ✅ 可接受的纯深色模式——同时要从头部移除<SwitchTheme/>按钮
// 在app/layout.tsx中:
<html data-theme="dark">
// 并且从Header.tsx中删除<SwitchTheme />按钮
// 不要留下无效的主题切换按钮grep -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--radius-field: 9999rempackages/nextjs/styles/globals.css/* 在两个@plugin "daisyui/theme"块中(浅色和深色主题): */
--radius-field: 9999rem; /* ❌ 默认值——药丸形状,截断多行文本框文本 */
--radius-field: 0.5rem; /* ✅ 等效于rounded-lg——适用于所有表单元素 */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>
)}undefined// ❌ 错误——用户看不到任何提示
try { await writeTx(...) }
catch (e) { console.error(e) }
// ✅ 正确——用户会看到“代币余额不足”
try { await writeTx(...) }
catch (e) { setTxError(parseContractError(e)) }
// 按钮下方:
{txError && (
<div className="mt-3 alert alert-error text-sm">
<span>{txError}</span>
</div>
)}undefined
Every error in that list needs a human-readable string in your frontend. If you inherit OpenZeppelin, their errors (`ERC20InsufficientBalance`, `OwnableUnauthorizedAccount`, etc.) are in YOUR ABI too — don't forget them.
---
列表中的每个错误都需要在前端设置对应的通俗易懂的字符串。如果你继承了OpenZeppelin的合约,它们的错误(如`ERC20InsufficientBalance`、`OwnableUnauthorizedAccount`等)也会在你的ABI中——不要遗漏这些。
---// 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-content// 在app/layout.tsx或getMetadata.ts中
export const metadata: Metadata = {
title: "你的应用名称",
description: "应用描述",
openGraph: {
title: "你的应用名称",
description: "应用描述",
images: [{ url: "https://YOUR-LIVE-DOMAIN.com/thumbnail.png" }],
},
twitter: {
card: "summary_large_image",
title: "你的应用名称",
description: "应用描述",
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.tspackages/nextjs/contracts/externalContracts.ts// packages/nextjs/contracts/externalContracts.ts
export default {
8453: { // Base网络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)// 合约单位 → 显示单位
import { formatEther, formatUnits } from "viem";
formatEther(weiAmount); // 18位小数(ETH、DAI、大多数代币)
formatUnits(usdcAmount, 6); // 6位小数(USDC、USDT)
// 显示单位 → 合约单位
import { parseEther, parseUnits } from "viem";
parseEther("1.5"); // → 1500000000000000000n
parseUnits("100", 6); // → 100000000n (USDC)15000000000000000001.5 ETH (~$3,750)