关于 qa
面向 Scaffold-ETH 2 dApp 的发布前 QA 审查指南,供独立审查者检查代码与实际用户流程。覆盖钱包交互、合约地址、RPC、移动端和信任假设,并要求报告通过或失败而非修复。
主要功能
- 01逐项检查用户交互与钱包流程
- 02验证合约地址展示及地址输入
- 03检查 RPC 配置和外部合约登记
- 04审查品牌、元数据及主题行为
- 05测试移动端钱包深度链接
- 06按信任假设编写 CROPS Review
适用场景
- 01在发布前审查 SE2 dApp 的完整用户流程
- 02检查审批、切换网络等交易操作状态
- 03验证生产配置和移动端钱包连接
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ austintgriffith/ ethskills --skill qa安装后在 Claude Code 中输入/qa确认已加载。
详细介绍
dApp QA — Scaffold-ETH 2 构建的上线前审查
你可能做错了什么
“应用已部署,所以我们完成了。” 对于 SE2 构建,上线还包括确保 UX 正确、元数据完善、RPC 可靠、合约已验证,并清理品牌元素。
“操作流程很直观。” 如果连接、网络、授权和操作没有严格地一次只进行一项,并且没有正确显示待处理状态,用户就可能发起重复或失败的交易。
“SE2 默认设置适合生产环境。” 默认 README、页脚、标题、favicon 和默认 RPC 回退设置都是模板脚手架,不是生产环境决策。
“没有控制台错误就算通过。” 此处的 QA 通过/失败取决于行为和用户体验:必须验证真实钱包流程、移动端深层链接行为、错误信息是否易懂,以及信任信号是否到位。
在 dApp 构建完成后,将此内容交给一个全新的 Agent。审核者应当:
- 阅读源代码(
app/、components/、contracts/) - 在浏览器中打开应用,并逐一点击体验所有流程
- 检查以下每一项并报告 PASS/FAIL,不要修复问题
先获取 crops/SKILL.md,并在报告中加入 CROPS Review。简单应用可以进行简洁的基线审查;涉及资金、授权、托管、钱包权限、L2/跨桥流程、用户隐私数据、身份、稳定币、管理员权限或托管的 RPC/索引器/中继器/代付器/前端基础设施的应用,则需要完整的四大支柱审查。
🚨 严重:钱包流程——使用按钮,而非文本
在没有连接钱包的情况下打开应用。
- ❌ 失败: 显示“Connect your wallet to play”“Please connect to continue”或任何提示用户连接钱包的段落文本
- ✅ 通过: 一个醒目、显眼的 Connect Wallet 按钮是主要 UI 元素
这是 AI Agent 最常犯的错误。 每个普通 LLM 都会写出 <p>Please connect your wallet</p>,而不是渲染 <RainbowKitCustomConnectButton />。
🚨 严重:四状态按钮流程
应用必须始终只显示一个主按钮,并按以下流程推进:
1. Not connected → Connect Wallet button
2. Wrong network → Switch to [Chain] button
3. Needs approval → Approve button
4. Ready → Action button (Stake/Deposit/Swap)
请特别检查:
- ❌ FAIL:“批准”和“操作”按钮同时可见
- ❌ **FAIL:**没有网络检查——应用尝试在错误的链上运行,并静默失败
- ❌ **FAIL:**连接的钱包处于错误网络时,没有将主链上 CTA 渲染为“切换到 [Chain]”按钮。SE-2 的页眉
WrongNetworkDropdown并不足够——操作按钮本身必须变为切换 CTA,否则用户会在错误的链上点击“签名”/“质押”/“存款”,并遭遇静默的 wagmi 错误。 - ❌ **FAIL:**用户可以点击“批准”,在钱包中签名,返回后又在交易待处理时再次点击“批准”
- ✅ **PASS:**一次只显示一个按钮。“批准”按钮显示加载动画,并保持禁用状态,直到区块链上确认区块。之后切换为操作按钮。
- ✅ PASS:操作按钮的渲染逻辑会根据
useChainId() === targetNetwork.id(或等效条件)分支;不匹配时,在主 CTA 的同一位置渲染由useSwitchChain驱动的“切换到 [Chain]”按钮。
**代码检查:**按钮的 disabled 属性必须绑定到 useScaffoldWriteContract 的 isPending。确认代码使用了 useScaffoldWriteContract(等待区块确认),而不是原始 wagmi useWriteContract(钱包签名后即返回):
grep -rn "useWriteContract" packages/nextjs/
在 scaffold-eth 内部以外找到任何匹配项 → 存在缺陷。
注意:有两个漏洞,都会导致重复批准。
wagmi 的 isPending 会在钱包返回交易哈希时变为 false,而不是等到交易确认。writeContractAsync 仍在等待确认。在此期间,isPending = false 且 approveCooldown = false → 按钮会在交易进行中重新启用。
修复需要两种状态:
approvalSubmitting— 在处理函数开头设置,并在finally {}中清除(覆盖点击到哈希之间的间隔)approveCooldown— 在await返回后设置,在 4 秒后并完成重新获取后清除(覆盖确认到缓存更新之间的间隔)
const [approvalSubmitting, setApprovalSubmitting] = useState(false);
const [approveCooldown, setApproveCooldown] = useState(false);
const handleApprove = async () => {
if (approvalSubmitting || approveCooldown) return;
setApprovalSubmitting(true);
try {
await approveWrite({ functionName: "approve", args: [spender, amount] });
setApproveCooldown(true);
setTimeout(() => { setApproveCooldown(false); refetchAllowance(); }, 4000);
} catch (e) {
notifyError("Approval failed");
} finally {
setApprovalSubmitting(false); // must be finally — releases on rejection too
}
};
<button disabled={isPending || approvalSubmitting || approveCooldown}>
- ❌ **FAIL:**按钮的
disabled只读取isPending或只读取approveCooldown - ❌ **FAIL:**没有
approvalSubmitting状态,或没有在finally {}中清除 - ✅ PASS:
disabled={isPending || approvalSubmitting || approveCooldown},且两种状态均得到正确管理
🚨 严重:移除 SE2 品牌标识
AI Agent 会把脚手架视为不可更改的内容,并保留所有默认品牌标识。
- **页脚:**移除 BuidlGuidl 链接、“Built with 🏗️ SE2”、“Fork me”链接和支持链接。替换为项目自己的仓库链接,或直接清除
- **标签页标题:**必须是应用名称,不能是“Scaffold-ETH 2”、“SE-2 App”或“App Name | Scaffold-ETH 2”
- **README:**必须介绍本项目,而不是 SE2 模板 README。移除“Built with Scaffold-ETH 2”章节和 SE2 文档链接
- **网站图标:**不能使用 SE2 默认图标
重要:针对信任假设进行 CROPS 审查
- ❌ **FAIL:**QA 报告中没有
CROPS Review块。 - ❌ **FAIL:**CROPS Review 只列出泛泛的内容,没有说明谁可以阻止用户、哪些数据会泄露、谁控制资金/升级/恢复,以及用户如何退出。
- ❌ **FAIL:**应用存在与生产环境相关的信任假设(资金、授权、托管、钱包权限、L2/桥接、私有数据、身份、稳定币、管理员权限、托管 RPC/索引器/中继器/支付方/前端),但 CROPS Review 仅是一项简短的检查,且没有使用
crops/SKILL.md中的完整模板。 - ✅ **PASS:**存在具体的
CROPS Review块,说明选定的默认方案、接受的妥协以及用户的退出路径。审查深度与应用的信任假设相匹配。
重要:合约地址显示
- ❌ **FAIL:**页面上没有任何位置显示已部署合约的地址
- ✅ **PASS:**使用
<Address/>组件显示合约地址(blockie、ENS、复制、区块浏览器链接)
Agent 会显示已连接的钱包地址,却忘记显示用户正在交互的合约地址。
重要:地址输入 — 始终使用 <AddressInput/>
所有接受以太坊地址的输入框都必须使用 <AddressInput/>,而不是普通的 <input type="text">。
- ❌ 失败:
<input type="text" placeholder="0x..." value={addr} onChange={e => setAddr(e.target.value)} /> - ✅ 通过:
<AddressInput value={addr} onChange={setAddr} placeholder="0x... or ENS name" />
<AddressInput/> 提供 ENS 解析(输入 "vitalik.eth" → 解析为地址)、blockie 头像预览、验证和粘贴处理。原始文本输入框不适用于收集地址。
在 SE2 中,它位于 @scaffold-ui/components:
import { AddressInput } from "@scaffold-ui/components";
// or
import { AddressInput } from "~~/components/scaffold-eth"; // if re-exported
快速检查:
grep -rn 'type="text"' packages/nextjs/app/ | grep -i "addr\|owner\|recip\|0x"
grep -rn 'placeholder="0x' packages/nextjs/app/
任何匹配项都表示失败。请替换为 <AddressInput/>。
配对使用:<Address/> 用于显示,<AddressInput/> 用于输入。务必遵循。
重要:美元价值
- ❌ 失败: 显示代币数量,如 "1,000 TOKEN" 或 "0.5 ETH",但没有美元价值
- ✅ 通过: "0.5 ETH (~$1,250)",并显示美元换算值
Agent 不会主动添加美元价值。检查所有显示代币或 ETH 数量的位置,包括输入框。
重要:OG 图片必须使用绝对 URL
- ❌ 失败:
images: ["/thumbnail.jpg"]— 相对路径,会导致所有地方的链接预览都失效 - ✅ 通过:
images: ["https://yourdomain.com/thumbnail.jpg"]— 生产环境的绝对 URL
快速检查:
grep -n "og:image\|images:" packages/nextjs/app/layout.tsx
重要:RPC 和轮询配置
打开 packages/nextjs/scaffold.config.ts:
- ❌ 失败:
pollingInterval: 30000(默认值 — 会导致 UI 使用体验不佳,更新延迟 30 秒) - ✅ 通过:
pollingInterval: 3000 - ❌ 失败: 使用 SE2 自带的默认 Alchemy API 密钥
- ❌ 失败: 代码引用
process.env.NEXT_PUBLIC_*,但部署环境(Vercel/托管平台)中并未实际设置该变量。系统会回退到mainnet.base.org等公共 RPC,导致请求受到速率限制 - ✅ 通过:
rpcOverrides使用process.env.NEXT_PUBLIC_*变量,且已确认在托管平台上设置了环境变量 - ❌ 失败:
services/web3/wagmiConfig.tsx仍包含裸http()回退传输(会静默并行访问公共 RPC,导致请求受到速率限制) - ✅ 通过: 已移除裸
http()回退;仅保留预期配置的传输
请确认环境变量已设置,而不只是被引用。 AI Agent 会修改代码以使用 process.env,看到模式匹配结果为通过后就继续操作,却从未在 Vercel/托管平台上设置实际变量。请检查:
vercel env ls | grep RPC
重要:注册 SE2 externalContracts.ts
Scaffold 钩子仅适用于在 deployedContracts.ts(自动生成)或 externalContracts.ts(手动维护)中注册的合约。如果未注册外部合约,前端读写操作会静默失败。
- ❌ 失败: 前端代码引用了
packages/nextjs/contracts/externalContracts.ts中未包含的代币/协议合约 - ❌ 失败: 手动编辑
deployedContracts.ts以添加外部合约 - ✅ 通过: 所有外部合约都在
externalContracts.ts中定义,且链、地址和 ABI 均正确
示例:
export default {
8453: {
USDC: {
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
abi: [...],
},
},
} as const;
切勿直接编辑 deployedContracts.ts。该文件会在部署时重新生成。
重要:深色模式 — 不要硬编码深色背景
AI Agent 喜欢深色 UI 的风格,并会直接在页面包装器上硬编码:
// ❌ FAIL — hardcoded black background, ignores system preference AND DaisyUI theme
<div className="min-h-screen bg-[#0a0a0a] text-white">
这会绕过整个 DaisyUI 主题系统。浅色模式用户会看到黑色页面。SE2 页眉中的 SwitchTheme 切换按钮会失效。prefers-color-scheme 会被忽略。
检查是否存在以下模式:
grep -rn 'bg-\[#0\|bg-black\|bg-gray-9\|bg-zinc-9\|bg-neutral-9\|bg-slate-9' packages/nextjs/app/
根布局 div 或页面包装器中任何匹配项 → 失败。
- ❌ 失败: 根页面包装器使用硬编码的十六进制颜色或 Tailwind 深色背景类(
bg-[#0a0a0a]、bg-black、bg-zinc-900等) - ❌ 失败: 页眉中有
SwitchTheme切换按钮,但页面完全忽略data-theme - ✅ 通过: 所有背景都使用 DaisyUI 语义变量 —
bg-base-100、bg-base-200、text-base-content - ✅ 通过(仅深色模式例外): 在
<html>上通过data-theme="dark"明确强制使用主题,并且从页眉中移除<SwitchTheme/>组件
修复方法:
// ✅ CORRECT — responds to light/dark toggle and prefers-color-scheme
<div className="min-h-screen bg-base-200 text-base-content">
重要:RainbowKit 中的 Phantom 钱包
Phantom 不在 SE2 默认钱包列表中。许多用户使用 Phantom — 如果列表中没有它,他们就无法连接。
- ❌ 失败: RainbowKit 钱包列表中没有 Phantom 钱包
- ✅ 通过:
wagmiConnectors.tsx中包含phantomWallet
重要:移动端深度链接
RainbowKit v2 / WalletConnect v2 不会自动深度链接到钱包应用。 它依赖推送通知,而推送通知速度慢且不可靠。你必须自行实现深度链接。
在移动设备上,用户点击需要签名的按钮时,必须打开其钱包应用。请进行以下测试:在手机上打开应用,通过 WalletConnect 连接钱包,点击操作按钮 — 钱包应用是否会打开并显示待签名的交易?
- ❌ 失败: 没有任何反应,用户必须手动切换到钱包应用
- ❌ 失败: 深度链接在交易之前触发 — 用户打开钱包后没有待签名的内容
- ❌ 失败: 在
writeContractAsync()之前调用window.location.href = "rainbow://"— 页面跳转,TX 永远不会触发 - ❌ 失败: 打开了错误的钱包(例如,用户通过 Rainbow 连接,却打开了 MetaMask)
- ❌ 失败: 在钱包的内置浏览器中使用深度链接(没有必要 — 用户已经在钱包中)
- ✅ 通过: 每个交易按钮都先触发 TX,再延迟深度链接到正确的钱包应用
如何实现
模式:writeAndOpen 辅助函数。 先触发写入调用(通过 WalletConnect 发送 TX 请求),再延迟深度链接,将用户切换到钱包:
const writeAndOpen = useCallback(
<T,>(writeFn: () => Promise<T>): Promise<T> => {
const promise = writeFn(); // Fire TX — does gas estimation + WC relay
setTimeout(openWallet, 2000); // Switch to wallet AFTER request is relayed
return promise;
},
[openWallet],
);
// Usage — wraps every write call:
await writeAndOpen(() => gameWrite({ functionName: "click", args: [...] }));
为什么要延迟 2 秒? writeContractAsync 必须估算 Gas、编码 calldata,并通过 WalletConnect 服务器中继签名请求。300 毫秒太快了 — 钱包还来不及收到请求。
检测钱包: wagmi 中的 connector.id 会返回 "walletConnect",而不是 "rainbow" 或 "metamask"。你必须检查多个来源:
const openWallet = useCallback(() => {
if (typeof window === "undefined") return;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (!isMobile || window.ethereum) return; // Skip if desktop or in-app browser
// Check connector, wagmi storage, AND WalletConnect session data
const allIds = [connector?.id, connector?.name,
localStorage.getItem("wagmi.recentConnectorId")]
.filter(Boolean).join(" ").toLowerCase();
let wcWallet = "";
try {
const wcKey = Object.keys(localStorage).find(k => k.startsWith("wc@2:client"));
if (wcKey) wcWallet = (localStorage.getItem(wcKey) || "").toLowerCase();
} catch {}
const search = `${allIds} ${wcWallet}`;
const schemes: [string[], string][] = [
[["rainbow"], "rainbow://"],
[["metamask"], "metamask://"],
[["coinbase", "cbwallet"], "cbwallet://"],
[["trust"], "trust://"],
[["phantom"], "phantom://"],
];
for (const [keywords, scheme] of schemes) {
if (keywords.some(k => search.includes(k))) {
window.location.href = scheme;
return;
}
}
}, [connector]);
关键规则:
- 先触发 TX,再进行深度链接。 永远不要在写入调用之前设置
window.location.href - 如果存在
window.ethereum,则跳过深度链接 — 这意味着用户已经在钱包的内置浏览器中 - 检查 localStorage 中的 WalletConnect 会话数据 — 仅凭
connector.id无法判断用户使用的是哪个钱包 - 使用
rainbow://这类简单的 scheme URL — 不要使用rainbow://dapp/...,否则会重新加载页面 - 包装每一个写入调用 — 包括 approve、action、claim 和 batch,而不只是主要调用
🚨 关键:在区块浏览器上验证合约
部署后,必须在区块浏览器上验证每个合约。未经验证的合约会引发信任问题——用户无法查看源代码,看起来就像你在隐瞒什么。
- ❌ 失败: 区块浏览器对任何已部署合约显示“Contract source code not verified”
- ✅ 通过: 区块浏览器上的所有已部署合约都显示已验证的源代码,并带有绿色勾号
如何检查: 从 deployedContracts.ts 获取每个合约地址,在区块浏览器(Etherscan、Basescan、Arbiscan 等)中打开,并查找带有 ✅ 勾号的“Contract”选项卡。如果只显示字节码,就表示尚未验证。
如何修复(SE2):
yarn verify --network mainnet # or base, arbitrum, optimism, etc.
如何修复(Foundry):
forge verify-contract <ADDRESS> <CONTRACT> --chain <CHAIN_ID> --etherscan-api-key $ETHERSCAN_API_KEY
AI Agent 经常会跳过验证,因为 yarn deploy 执行成功后就继续下一步了。只有验证通过后,部署才算完成。
重要:按钮加载状态——DaisyUI 的 loading 类不正确
使用 DaisyUI + SE2 时,AI Agent 几乎总会错误地实现按钮加载状态。
错误做法: 直接在 btn 上添加 loading 类:
// ❌ FAIL — DaisyUI's `loading` class on a `btn` replaces the entire button content
// with a spinner that fills the full button. No text, misaligned, looks broken.
<button className={`btn btn-primary ${isPending ? "loading" : ""}`}>
{isPending ? "Approving..." : "Approve"}
</button>
正确做法: 从按钮类中移除 loading,并在按钮内与文本并列添加一个内联的 loading-spinner span:
// ✅ PASS — small spinner inside the button, text visible next to it
<button className="btn btn-primary" disabled={isPending}>
{isPending && <span className="loading loading-spinner loading-sm mr-2" />}
{isPending ? "Approving..." : "Approve"}
</button>
检查代码中是否存在此问题:
grep -rn '"loading"' packages/nextjs/app/
按钮的 className 中出现任何 "loading" 字符串 → 失败。
- ❌ 失败: 按钮上使用
className={... isPending ? "loading" : ""} - ✅ 通过: 按钮内包含
<span className="loading loading-spinner loading-sm" />
重要:SE2 胶囊形输入框(--radius-field)
SE2 DaisyUI 主题默认将 --radius-field 设为 9999rem,这会让文本区域/选择框变成胶囊形,并且经常导致内容被裁切。
- ❌ 失败:
packages/nextjs/styles/globals.css中仍保留--radius-field: 9999rem - ✅ 通过: 在浅色和深色主题块中,都将
--radius-field改为0.5rem(或类似值)
在主题中修复(而非逐个组件修复):
/* In BOTH @plugin "daisyui/theme" blocks */
--radius-field: 0.5rem;
不要通过在每个输入框上零散添加 rounded-* 工具类来修补;应在主题层面一次性修复。
SE2 参考资料
- 文档:https://docs.scaffoldeth.io/
- UI 组件:https://ui.scaffoldeth.io/
- SpeedRun Ethereum:https://speedrunethereum.com/
审计摘要
将每项标记为通过或失败:
阻止发布的问题
- 钱包连接显示的是一个按钮,而不是文本
- 网络不匹配时,主 CTA 位置会显示切换按钮(不只是标题栏下拉菜单中)
- 一次只显示一个按钮(连接 → 网络 → 授权 → 操作)
- 授权按钮在整个周期中保持禁用:
approvalSubmitting(点击→哈希)、approveCooldown(确认→缓存刷新)——两种状态都必须存在,且都必须绑定到disabled属性 - 已在区块浏览器(Etherscan/Basescan/Arbiscan)上验证合约——任何人都能阅读源代码
- QA 报告中包含 CROPS 审查:说明选定的默认方案、接受的妥协、谁能阻止用户、哪些数据会泄露、谁控制资金/升级/恢复,以及用户的退出路径
- 已移除 SE2 页脚品牌标识
- 已移除 SE2 标签页标题
- 已替换 SE2 README
应修复
- 使用
<Address/>显示合约地址 - 所有地址输入框都使用
<AddressInput/>——地址不能使用原生<input type="text"> - 所有代币/ETH 数量旁都显示美元金额
- OG 图片使用绝对生产环境 URL
- pollingInterval 为 3000
- 已设置 RPC 覆盖项(不是默认的 SE2 密钥),并确认托管平台已设置环境变量
- Favicon 已从 SE2 默认图标更新
- 在
globals.css中将--radius-field从9999rem改为0.5rem(或类似值)——文本区域不能呈药丸形 - 所有合约错误都映射为易读的消息——不能使用静默捕获块,也不能显示原始十六进制选择器
- 没有硬编码的深色背景——页面外层使用
bg-base-200 text-base-content(或强制设置data-theme="dark"并移除<SwitchTheme/>) - 按钮加载动画使用内联
<span className="loading loading-spinner loading-sm" />——不能在按钮本身使用className="... loading" - RainbowKit 钱包列表中包含 Phantom 钱包
- 移动端:所有交易按钮都能深度链接到钱包(先发起交易,再调用
setTimeout(openWallet, 2000)) - 移动端:钱包检测会检查 WC 会话数据,而不仅仅是
connector.id - 移动端:当
window.ethereum存在时,不进行深度链接(应用内浏览器)