frontend-ux

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Frontend UX Rules

前端UX规则

What You Probably Got Wrong

你可能忽略的问题

"The button works." Working is not the standard. Does it disable during the transaction? Does it show a spinner? Does it stay disabled until the chain confirms? Does it show an error if the user rejects? AI agents skip all of this, every time.
"I used wagmi hooks." Wrong hooks. Scaffold-ETH 2 wraps wagmi with
useTransactor
which waits for transaction confirmation — not just wallet signing. Raw wagmi's
writeContractAsync
resolves the moment the user clicks Confirm in MetaMask, BEFORE the tx is mined. Your button re-enables while the transaction is still pending.
"I showed the address." As raw hex? That's not showing it.
<Address/>
gives you ENS resolution, blockie avatars, copy-to-clipboard, and block explorer links. Raw
0x1234...5678
is unacceptable.

“按钮能用就行。” 能用远远不够标准。交易进行时它会禁用吗?会显示加载动画吗?会在链上确认前保持禁用状态吗?用户拒绝时会显示错误提示吗?AI Agent每次都会漏掉这些细节。
“我用了wagmi钩子。” 用错钩子了。Scaffold-ETH 2通过
useTransactor
封装wagmi,它会等待交易确认——而不只是钱包签名。原生wagmi的
writeContractAsync
在用户点击MetaMask中的确认按钮时就会返回,此时交易还未上链。这会导致按钮在交易仍处于待处理状态时就重新启用。
“我显示了地址。” 是显示原始十六进制吗?这不算正确展示。
<Address/>
组件支持ENS解析、区块头像、一键复制以及区块浏览器链接。直接显示
0x1234...5678
是不可接受的。

Rule 1: Every Onchain Button — Loader + Disable

规则1:所有链上按钮——加载动画+禁用状态

⚠️ 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.
ANY button that triggers a blockchain transaction MUST:
  1. Disable immediately on click
  2. Show a spinner ("Approving...", "Staking...", etc.)
  3. Stay disabled until the state update confirms the action completed
  4. Show success/error feedback when done
typescript
// ✅ 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>
❌ NEVER use a single shared
isLoading
for multiple buttons.
Each button gets its own loading state. A shared state causes the WRONG loading text to appear when UI conditionally switches between buttons.
⚠️ 这是AI Agent最常犯的错误。 用户点击授权,在钱包中签名后返回应用,发现授权按钮又能点击了——于是再次点击,发送了重复交易,导致两个授权请求同时待处理。按钮必须从用户点击的那一刻起就保持禁用并显示加载动画,直到交易在链上确认。 不是钱包关闭时,也不是签名发送时,而是区块确认后。
任何触发区块链交易的按钮都必须:
  1. 点击后立即禁用
  2. 显示加载动画(如“授权中...”、“质押中...”等)
  3. 保持禁用状态直到状态更新确认操作完成
  4. 操作完成后显示成功/错误反馈
typescript
// ✅ 正确:为每个操作设置独立的加载状态
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
状态。
每个按钮都要有自己的加载状态。共享状态会导致UI在切换按钮时显示错误的加载文本。

Scaffold Hooks Only — Never Raw Wagmi

仅使用Scaffold钩子——不要用原生wagmi

typescript
// ❌ 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 confirmation
Why:
useScaffoldWriteContract
uses
useTransactor
internally, which waits for block confirmation. Raw wagmi doesn't — your UI will show "success" while the transaction is still in the mempool.

typescript
// ❌ 错误:原生wagmi——签名后就返回,不等待确认
const { writeContractAsync } = useWriteContract();
await writeContractAsync({...}); // MetaMask签名后立即返回!

// ✅ 正确:Scaffold钩子——等待交易上链
const { writeContractAsync } = useScaffoldWriteContract("MyContract");
await writeContractAsync({...}); // 等待链上实际确认
原因:
useScaffoldWriteContract
内部使用
useTransactor
,它会等待区块确认。原生wagmi不会——你的UI会在交易仍处于内存池时就显示“成功”。

Rule 2: Four-State Flow — Connect → Network → Approve → Action

规则2:四状态流程——连接→网络→授权→操作

When a user needs to interact with the app, there are FOUR states. Show exactly ONE big, obvious button at a time:
1. 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 button
NEVER 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.
typescript
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>
)}
Critical details:
  • Always read allowance via a hook so the UI updates automatically when the approval tx confirms
  • Never rely on local state alone for allowance tracking
  • Wrong network check comes FIRST — if the user clicks Approve while on the wrong network, everything breaks
  • Never show Approve and Action simultaneously — one button at a time

当用户需要与应用交互时,存在四种状态。每次只显示一个醒目的大按钮:
1. 未连接钱包?       → 显示“连接钱包”大按钮(不要用“请连接钱包以使用”这类文字提示)
2. 网络不正确?       → 显示“切换至Base网络”大按钮
3. 授权额度不足? → 显示“授权”按钮(遵循规则1添加加载状态)
4. 授权额度足够?     → 显示“质押”/“存入”/对应操作按钮
绝不要显示“请连接钱包以使用”或“请连接以继续”这类文字提示。 直接显示按钮。用户每次都应该只有一个可点击的选项。
typescript
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>
)}
关键细节:
  • 始终通过钩子读取授权额度,这样UI会在授权交易确认后自动更新
  • 绝不要仅依赖本地状态跟踪授权额度
  • 先检查网络是否正确——如果用户在错误网络上点击授权,所有操作都会失败
  • 绝不要同时显示授权和操作按钮——每次只显示一个

Rule 3: Address Display — Always
<Address/>

规则3:地址显示——始终使用
<Address/>
组件

EVERY time you display an Ethereum address, use scaffold-eth's
<Address/>
component:
typescript
import { Address } from "~~/components/scaffold-eth";

// ✅ CORRECT
<Address address={userAddress} />

// ❌ WRONG — never render raw hex
<span>{userAddress}</span>
<p>0x1234...5678</p>
<Address/>
handles ENS resolution, blockie avatars, copy-to-clipboard, truncation, and block explorer links. Raw hex is unacceptable.
每次显示以太坊地址时,都要使用scaffold-eth的
<Address/>
组件:
typescript
import { Address } from "~~/components/scaffold-eth";

// ✅ 正确
<Address address={userAddress} />

// ❌ 错误——绝不要渲染原始十六进制地址
<span>{userAddress}</span>
<p>0x1234...5678</p>
<Address/>
组件支持ENS解析、区块头像、一键复制、地址截断以及区块浏览器链接。直接显示十六进制地址是不可接受的。

Address Input — Always
<AddressInput/>

地址输入——始终使用
<AddressInput/>
组件

EVERY time the user needs to enter an Ethereum address, use
<AddressInput/>
:
typescript
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/>
provides ENS resolution (type "vitalik.eth" → resolves to address), blockie avatar preview, validation, and paste handling.
The pair:
<Address/>
for DISPLAY,
<AddressInput/>
for INPUT. Always.
每次让用户输入以太坊地址时,都要使用
<AddressInput/>
组件:
typescript
import { AddressInput } from "~~/components/scaffold-eth";

// ✅ 正确
<AddressInput value={recipient} onChange={setRecipient} placeholder="接收方地址" />

// ❌ 错误——绝不要为地址输入使用原生文本输入框
<input type="text" value={recipient} onChange={e => setRecipient(e.target.value)} />
<AddressInput/>
组件支持ENS解析(输入“vitalik.eth”会解析为对应地址)、区块头像预览、地址验证以及粘贴处理。
搭配使用: 显示地址用
<Address/>
,输入地址用
<AddressInput/>
。必须遵循这一规则。

Show Your Contract Address

显示合约地址

Every dApp should display its deployed contract address at the bottom of the main page using
<Address/>
. Users want to verify the contract on a block explorer. This builds trust and is standard practice.
typescript
<div className="text-center mt-8 text-sm opacity-70">
  <p>Contract:</p>
  <Address address={deployedContractAddress} />
</div>

每个dApp都应在主页面底部使用
<Address/>
组件显示其部署的合约地址
。用户需要在区块浏览器中验证合约,这能建立信任,是行业标准做法。
typescript
<div className="text-center mt-8 text-sm opacity-70">
  <p>合约地址:</p>
  <Address address={deployedContractAddress} />
</div>

Rule 4: USD Values Everywhere

规则4:处处显示美元价值

EVERY token or ETH amount displayed should include its USD value. EVERY token or ETH input should show a live USD preview.
typescript
// ✅ 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 worth
Where to get prices:
  • ETH price: SE2 built-in hook —
    useNativeCurrencyPrice()
  • Custom tokens: DexScreener API (
    https://api.dexscreener.com/latest/dex/tokens/TOKEN_ADDRESS
    ), onchain Uniswap quoter, or Chainlink oracle
This applies to both display AND input:
  • Displaying a balance? Show USD next to it.
  • User entering an amount to send/stake/swap? Show live USD preview below the input.
  • Transaction confirmation? Show USD value of what they're about to do.

所有显示的代币或ETH数量都应附带美元价值。 所有代币或ETH输入框都应显示实时美元预览。
typescript
// ✅ 正确——显示时附带美元价值
<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>  // 用户完全不知道这值多少钱
价格获取渠道:
  • ETH价格: SE2内置钩子——
    useNativeCurrencyPrice()
  • 自定义代币: DexScreener API(
    https://api.dexscreener.com/latest/dex/tokens/TOKEN_ADDRESS
    )、链上Uniswap报价器或Chainlink预言机
这适用于显示和输入场景:
  • 显示余额?在旁边附上美元价值。
  • 用户输入转账/质押/兑换的数量?在输入框下方显示实时美元预览。
  • 交易确认?显示用户即将操作的金额对应的美元价值。

Rule 5: No Duplicate Titles

规则5:不要重复标题

DO NOT put the app name as an
<h1>
at the top of the page body.
The SE2 header already displays the app name. Repeating it wastes space and looks amateur.
typescript
// ❌ 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>
标签。
SE2的头部已经显示了应用名称。重复显示会浪费空间,看起来很不专业。
typescript
// ❌ 错误——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>

Rule 6: RPC Configuration

规则6:RPC配置

NEVER use public RPCs (
mainnet.base.org
, etc.) — they rate-limit and cause random failures in production.
In
scaffold.config.ts
, ALWAYS set:
typescript
rpcOverrides: {
  [chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org",
},
pollingInterval: 3000,  // 3 seconds, not the default 30000
Keep the API key in
.env.local
— never hardcode it in config files that get committed to Git.
⚠️ SE2's
wagmiConfig.tsx
adds a bare
http()
(no URL) as a fallback transport.
Viem resolves bare
http()
to the chain's default public RPC (e.g.
mainnet.base.org
for Base). Even with
rpcOverrides
set in scaffold config, the public RPC will still get hit because viem's
fallback()
fires transports in parallel. You must remove the bare
http()
from the fallback array in
services/web3/wagmiConfig.tsx
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.
Monitor RPC usage: Sensible = 1 request every 3 seconds. If you see 15+ requests/second, you have a bug:
  • Hooks re-rendering in loops
  • Duplicate hook calls
  • Missing dependency arrays
  • watch: true
    on hooks that don't need it

绝不要使用公共RPC(如
mainnet.base.org
等)——它们会限制请求频率,导致生产环境中出现随机故障。
scaffold.config.ts
中,必须设置:
typescript
rpcOverrides: {
  [chains.base.id]: process.env.NEXT_PUBLIC_BASE_RPC || "https://mainnet.base.org",
},
pollingInterval: 3000,  // 3秒,不要使用默认的30000毫秒
将API密钥保存在
.env.local
——绝不要硬编码到会提交到Git的配置文件中。
⚠️ SE2的
wagmiConfig.tsx
添加了一个空的
http()
(无URL)作为备用传输方式。
Viem会将空的
http()
解析为链的默认公共RPC(例如Base网络的
mainnet.base.org
)。即使在scaffold配置中设置了
rpcOverrides
,公共RPC仍会被调用,因为viem的
fallback()
会并行触发所有传输方式。你必须从
services/web3/wagmiConfig.tsx
的备用数组中删除空的
http()
,这样就只会使用你配置的RPC。如果不这么做,你的应用会在每次轮询时请求公共RPC,导致生产环境中出现429请求受限错误。
监控RPC使用情况: 合理的频率是每3秒1次请求。如果看到每秒15+次请求,说明存在bug:
  • 钩子循环重渲染
  • 重复调用钩子
  • 缺少依赖数组
  • 不需要的钩子设置了
    watch: true

Rule 7: Use DaisyUI Semantic Colors — Never Hardcode Dark Backgrounds

规则7:使用DaisyUI语义化颜色——不要硬编码深色背景

AI agents love dark UIs and will hardcode them. This is always wrong:
tsx
// ❌ WRONG — hardcoded black, defeats the entire DaisyUI theme system
<div className="min-h-screen bg-[#0a0a0a] text-white">
Why this is a problem: SE2 ships with DaisyUI configured for both light and dark themes (usually via
globals.css
or
tailwind.config.ts
). It also includes a
<SwitchTheme/>
toggle in the header. When you hardcode a dark background, you break all of this:
  • Light-mode users (macOS/iOS/Windows system setting) get a black page
  • The theme toggle does nothing — the page ignores
    data-theme
  • prefers-color-scheme: light
    is silently ignored
Always use DaisyUI semantic color variables:
tsx
// ✅ 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)
If you genuinely want dark-only, you must commit to it fully — don't half-do it:
tsx
// ✅ 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 nothing
Quick scan for the mistake:
bash
grep -rn 'bg-\[#0\|bg-black\|bg-gray-9\|bg-zinc-9\|bg-neutral-9\|bg-slate-9' packages/nextjs/app/
Any match on a root page wrapper → fix it.

AI Agent偏爱深色UI,并且会硬编码深色背景。这总是错误的:
tsx
// ❌ 错误——硬编码黑色背景,破坏了DaisyUI的主题系统
<div className="min-h-screen bg-[#0a0a0a] text-white">
问题原因: SE2默认配置了DaisyUI的浅色和深色主题(通常在
globals.css
tailwind.config.ts
中)。头部还包含了
<SwitchTheme/>
主题切换按钮。当你硬编码深色背景时,会破坏所有这些功能:
  • 浅色模式用户(macOS/iOS/Windows系统设置为浅色)会看到黑色页面
  • 主题切换按钮失效——页面忽略
    data-theme
    属性
  • prefers-color-scheme: light
    设置被静默忽略
始终使用DaisyUI语义化颜色变量:
tsx
// ✅ 正确——响应系统偏好设置和主题切换按钮
<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  (次要/弱化文本)
如果你确实只想使用深色模式,必须完全贯彻——不要半途而废:
tsx
// ✅ 可接受的纯深色模式——同时要从头部移除<SwitchTheme/>按钮
// 在app/layout.tsx中:
<html data-theme="dark">
// 并且从Header.tsx中删除<SwitchTheme />按钮
// 不要留下无效的主题切换按钮
快速检查错误:
bash
grep -rn 'bg-\[#0\|bg-black\|bg-gray-9\|bg-zinc-9\|bg-neutral-9\|bg-slate-9' packages/nextjs/app/
如果根页面容器有匹配结果,就需要修复。

Rule 8: Fix SE2's Pill-Shaped Form Inputs

规则8:修复SE2的药丸形状表单输入框

SE2's DaisyUI theme sets
--radius-field: 9999rem
— fully pill-shaped inputs. Single-line inputs look fine, but textareas, multi-line inputs, and selects look broken — text clips against the extreme border radius.
AI agents never fix this. They see the DaisyUI class, assume it's correct, and ship pill-shaped textareas.
Fix it in the theme, not per-element. In
packages/nextjs/styles/globals.css
, change both themes:
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 */
That's it — one line per theme, every
input
,
select
, and
textarea
inherits it globally. Do NOT add
rounded-md
to individual elements — that fights the theme system and breaks when the theme changes.
Quick check:
bash
grep "radius-field" packages/nextjs/styles/globals.css
If it says
9999rem
, fix it.

SE2的DaisyUI主题设置了
--radius-field: 9999rem
——输入框是完全的药丸形状。单行输入框看起来没问题,但多行文本框、多行输入框和下拉选择框会显示异常——文本会被极端的边框半径截断。
AI Agent永远不会修复这个问题。它们看到DaisyUI类,就认为是正确的,然后发布药丸形状的多行文本框。
在主题中修复,不要逐个元素修改。
packages/nextjs/styles/globals.css
中,修改两个主题:
css
/* 在两个@plugin "daisyui/theme"块中(浅色和深色主题): */

--radius-field: 9999rem;   /* ❌ 默认值——药丸形状,截断多行文本框文本 */
--radius-field: 0.5rem;    /* ✅ 等效于rounded-lg——适用于所有表单元素 */
这样就完成了——每个主题修改一行代码,所有
input
select
textarea
都会全局继承这个设置。不要为单个元素添加
rounded-md
——这会与主题系统冲突,并且在切换主题时失效。
快速检查:
bash
grep "radius-field" packages/nextjs/styles/globals.css
如果值是
9999rem
,就需要修复。

Rule 9: Contract Error Translation

规则9:合约错误翻译

When a contract reverts, the user must see a human-readable explanation. Not a hex selector. Not a silent button reset. Not a console.error.
The principle: Read your contract's ABI. Find every custom error. Map each one to plain English. Display it inline below the button that triggered it.
Steps:
  1. Extract all errors from your ABI — your contract's custom errors AND inherited ones (OpenZeppelin, etc.)
  2. Write a mapping function that takes a caught error and returns a user-facing string
  3. Include wallet-level errors — user rejected, insufficient gas
  4. Add a fallback — if you can't parse it, still show something ("Transaction failed")
  5. Display inline — a persistent alert below the button, not a toast. Clear it when the user edits an input.
tsx
// ❌ 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>
)}
How to find your errors:
bash
undefined
当合约回滚时,用户必须看到易于理解的解释。不要显示十六进制选择器,不要静默重置按钮,不要只在控制台打印错误。
原则: 读取合约的ABI,找到所有自定义错误,将每个错误映射为通俗易懂的英文,显示在触发错误的按钮下方。
步骤:
  1. 从ABI中提取所有错误——合约的自定义错误以及继承的错误(如OpenZeppelin等)
  2. 编写映射函数,将捕获的错误转换为面向用户的字符串
  3. 包含钱包级错误——用户拒绝、gas不足
  4. 添加回退方案——如果无法解析错误,仍要显示提示(如“交易失败”)
  5. 内联显示——在按钮下方显示持久的提示框,不要用弹窗。用户编辑输入时清除提示。
tsx
// ❌ 错误——用户看不到任何提示
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>
)}
如何查找错误:
bash
undefined

List all custom errors in your contract's ABI

列出合约ABI中的所有自定义错误

cat deployedContracts.ts | grep -o '"name":"[^"]Error[^"]"' | sort -u
cat deployedContracts.ts | grep -o '"name":"[^"]Error[^"]"' | sort -u

Or from the Solidity source

或者从Solidity源码中查找

grep -rn 'error ' contracts/src/ | grep -v '//'

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.

---
grep -rn 'error ' contracts/src/ | grep -v '//'

列表中的每个错误都需要在前端设置对应的通俗易懂的字符串。如果你继承了OpenZeppelin的合约,它们的错误(如`ERC20InsufficientBalance`、`OwnableUnauthorizedAccount`等)也会在你的ABI中——不要遗漏这些。

---

Rule 10: Pre-Publish Checklist

规则10:发布前检查清单

BEFORE deploying frontend to production, EVERY item must pass:
Open Graph / Twitter Cards (REQUIRED):
typescript
// 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"],
  },
};
⚠️ The OG image URL MUST be:
  • Absolute URL starting with
    https://
  • The LIVE production domain (NOT
    localhost
    , NOT relative path)
  • NOT an environment variable that could be unset
  • Actually reachable (test by visiting the URL in a browser)
Remove ALL Scaffold-ETH 2 default identity:
  • README rewritten — not the SE2 template README
  • Footer cleaned — remove BuidlGuidl links, "Fork me" link, support links, any SE2 branding. Replace with your project's repo link
  • Favicon updated — not the SE2 default
  • Tab title is your app name — not "Scaffold-ETH 2"
Full checklist:
  • OG image URL is absolute, live production domain
  • OG title and description set (not default SE2 text)
  • Twitter card type set (
    summary_large_image
    )
  • All SE2 default branding removed (README, footer, favicon, tab title)
  • Browser tab title is correct
  • RPC overrides set (not public RPCs)
  • Bare
    http()
    removed from wagmiConfig.tsx fallback array (no silent public RPC fallback)
  • pollingInterval
    is 3000
  • All contract addresses match what's deployed
  • No hardcoded testnet/localhost values in production code
  • Every address display uses
    <Address/>
  • Every address input uses
    <AddressInput/>
  • Every onchain button has its own loader + disabled state
  • Approve flow has network check → approve → action pattern
  • No duplicate h1 title matching header
  • No hardcoded dark backgrounds — page uses
    bg-base-200 text-base-content
    (or dark forced + toggle removed)

在将前端部署到生产环境之前,必须完成以下所有检查项:
Open Graph / Twitter卡片(必填):
typescript
// 在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"],
  },
};
⚠️ OG图片URL必须满足:
  • https://
    开头的绝对URL
  • 使用生产环境的真实域名(不要用
    localhost
    ,不要用相对路径)
  • 不要使用可能未设置的环境变量
  • 可以正常访问(在浏览器中访问该URL测试)
移除所有Scaffold-ETH 2默认标识:
  • 重写README——不要使用SE2模板的README
  • 清理页脚——移除BuidlGuidl链接、“Fork me”链接、支持链接以及所有SE2品牌标识,替换为你的项目仓库链接
  • 更新网站图标——不要使用SE2默认图标
  • 浏览器标签标题为你的应用名称——不要用“Scaffold-ETH 2”
完整检查清单:
  • OG图片URL是绝对路径,使用生产环境真实域名
  • 设置了OG标题和描述(不要用SE2默认文本)
  • 设置了Twitter卡片类型(
    summary_large_image
  • 移除了所有SE2默认品牌标识(README、页脚、网站图标、标签标题)
  • 浏览器标签标题正确
  • 设置了RPC覆盖(不要用公共RPC)
  • 从wagmiConfig.tsx的备用数组中移除了空的
    http()
    (无静默公共RPC备用)
  • pollingInterval
    设置为3000
  • 所有合约地址与部署的地址一致
  • 生产代码中没有硬编码的测试网/localhost值
  • 所有地址显示都使用
    <Address/>
    组件
  • 所有地址输入都使用
    <AddressInput/>
    组件
  • 所有链上按钮都有独立的加载状态+禁用状态
  • 授权流程遵循网络检查→授权→操作的规范
  • 没有与头部重复的h1标题
  • 没有硬编码的深色背景——页面使用
    bg-base-200 text-base-content
    (或强制深色模式+移除切换按钮)

externalContracts.ts — Before You Build

externalContracts.ts — 构建前准备

ALL external contracts (tokens, protocols, anything you didn't deploy) MUST be added to
packages/nextjs/contracts/externalContracts.ts
with address and ABI BEFORE building the frontend.
typescript
// packages/nextjs/contracts/externalContracts.ts
export default {
  8453: {  // Base chain ID
    USDC: {
      address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      abi: [...],  // ERC-20 ABI
    },
  },
} as const;
Why BEFORE: Scaffold hooks (
useScaffoldReadContract
,
useScaffoldWriteContract
) only work with contracts registered in
deployedContracts.ts
(auto-generated) or
externalContracts.ts
(manual). If you write frontend code referencing a contract that isn't registered, it silently fails.
Never edit
deployedContracts.ts
— it's auto-generated by
yarn deploy
. Put your external contracts in
externalContracts.ts
.

所有外部合约(代币、协议、任何你未部署的合约)都必须在构建前端之前添加到
packages/nextjs/contracts/externalContracts.ts
中,并包含地址和ABI。
typescript
// packages/nextjs/contracts/externalContracts.ts
export default {
  8453: {  // Base网络ID
    USDC: {
      address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      abi: [...],  // ERC-20 ABI
    },
  },
} as const;
为什么要提前添加: Scaffold钩子(
useScaffoldReadContract
useScaffoldWriteContract
)仅适用于在
deployedContracts.ts
(自动生成)或
externalContracts.ts
(手动添加)中注册的合约。如果你编写的前端代码引用了未注册的合约,会静默失败。
永远不要编辑
deployedContracts.ts
——它是由
yarn deploy
自动生成的。将外部合约添加到
externalContracts.ts
中。

Human-Readable Amounts

易于理解的金额展示

Always convert between contract units and display units:
typescript
// 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)
Never show raw wei/units to users.
1500000000000000000
means nothing.
1.5 ETH (~$3,750)
means everything.

始终在合约单位和显示单位之间进行转换:
typescript
// 合约单位 → 显示单位
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)
绝不要向用户显示原始wei/单位。
1500000000000000000
毫无意义,
1.5 ETH (~$3,750)
才清晰明了。

Resources

资源