mcpField
Q
qa

qa

github.com/austintgriffith测试与质量30236 次安装2 天前更新
代码仓库

关于 qa

面向 Scaffold-ETH 2 dApp 的发布前 QA 审查指南,供独立审查者检查代码与实际用户流程。覆盖钱包交互、合约地址、RPC、移动端和信任假设,并要求报告通过或失败而非修复。

主要功能

  1. 01逐项检查用户交互与钱包流程
  2. 02验证合约地址展示及地址输入
  3. 03检查 RPC 配置和外部合约登记
  4. 04审查品牌、元数据及主题行为
  5. 05测试移动端钱包深度链接
  6. 06按信任假设编写 CROPS Review

适用场景

  1. 01在发布前审查 SE2 dApp 的完整用户流程
  2. 02检查审批、切换网络等交易操作状态
  3. 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。审核者应当:

  1. 阅读源代码(app/、components/、contracts/)
  2. 在浏览器中打开应用,并逐一点击体验所有流程
  3. 检查以下每一项并报告 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]);

关键规则:

  1. 先触发 TX,再进行深度链接。 永远不要在写入调用之前设置 window.location.href
  2. 如果存在 window.ethereum,则跳过深度链接 — 这意味着用户已经在钱包的内置浏览器中
  3. 检查 localStorage 中的 WalletConnect 会话数据 — 仅凭 connector.id 无法判断用户使用的是哪个钱包
  4. 使用 rainbow:// 这类简单的 scheme URL — 不要使用 rainbow://dapp/...,否则会重新加载页面
  5. 包装每一个写入调用 — 包括 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 参考资料


审计摘要

将每项标记为通过或失败:

阻止发布的问题
  • 钱包连接显示的是一个按钮,而不是文本
  • 网络不匹配时,主 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 存在时,不进行深度链接(应用内浏览器)

评论

支持 Markdown