学习目标: 完成测试网部署、源码验证、ABI 导出和前端接入。
上一课
本课目录
- 配置环境变量
- 🧪 先把测试补上
- 🚀 写部署脚本
- 🌐 部署到 Sepolia
- ✅ 在 Etherscan 上验证源码
- 🧾 导出 ABI 给前端用
- ⚛️ React 前端接入:用 wagmi + viem
- 🔌 配置 wagmi
- 🔗 连接钱包
- 📖 读取链上数据
- ✍️ 写入链上:调用 increment
- 🧰 用 cast 做链上排查
- 🐛 常见坑位,逐个填
- 🧼 测试网阶段的几个最佳实践
- 📦 一套更顺手的毕业目录结构
- 🧭 这节的验收标准
配置环境变量
项目根目录创建 .env:
PRIVATE_KEY=你的测试钱包私钥
SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/你的key
ETHERSCAN_API_KEY=你的etherscan_api_key
别把 `.env` 提交到 Git。
检查 .gitignore 里有没有:
.env
cache
out
broadcast
在 foundry.toml 里启用环境变量和验证器
打开 foundry.toml,改成这样:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }
这里几件事:
- rpc_endpoints 让你后面可以直接写 --rpc-url sepolia
- etherscan 让 forge verify-contract 和 --verify 能直接用
- solc_version 最好跟你合约 pragma 对齐,少踩坑
🧪 先把测试补上
你可以不写测试就部署。 但真到链上了,再改就是多花 gas。测试网虽然不要真钱,也别养成裸奔习惯。
新建 test/Counter.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Counter} from "../src/Counter.sol";
contract CounterTest is Test {
Counter counter;
function setUp() public {
counter = new Counter(10);
}
function test_InitialNumber() public {
assertEq(counter.number(), 10);
}
function test_SetNumber() public {
counter.setNumber(42);
assertEq(counter.number(), 42);
}
function test_Increment() public {
counter.increment();
assertEq(counter.number(), 11);
}
}
跑测试:
forge test
看到全绿再继续。 测试不是装样子,这一步能提前拦掉很多低级错误。
🚀 写部署脚本
Foundry 的部署脚本一般放在 script/ 目录。
新建 script/DeployCounter.s.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Script} from "forge-std/Script.sol";
import {Counter} from "../src/Counter.sol";
contract DeployCounter is Script {
function run() external returns (Counter) {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
Counter counter = new Counter(0);
vm.stopBroadcast();
return counter;
}
}
这里的核心就两步:
- vm.envUint("PRIVATE_KEY") 从 .env 读私钥
- vm.startBroadcast(...) 之后的操作,会真的发交易到链上
🌐 部署到 Sepolia
先跑一遍模拟执行,看看脚本有没有问题:
forge script script/DeployCounter.s.sol:DeployCounter --rpc-url sepolia
这一步通常不会真正广播,只是本地模拟。 输出正常,再真发。
真正广播部署
forge script script/DeployCounter.s.sol:DeployCounter \
--rpc-url sepolia \
--broadcast
如果一切正常,你会在输出里看到:
- 部署交易 hash
- 新合约地址
- broadcast 目录下的执行记录
广播记录在哪里看
Foundry 会生成类似这样的目录:
broadcast/DeployCounter.s.sol/11155111/run-latest.json
11155111 就是 Sepolia 的 chain id。
这个文件很有用,里面通常能看到:
- 实际部署的合约地址
- 交易信息
- 发送者地址
- nonce
别每次都手抄地址,容易抄错。 后面前端对接,直接从这里拿。
✅ 在 Etherscan 上验证源码
部署成功后,下一步就是验证。 不验证也能用,但别人打开合约地址只能看到字节码,体验很差。前端调试时你自己也难受。
方式一:部署时顺手验证
Foundry 支持一把梭:
forge script script/DeployCounter.s.sol:DeployCounter \
--rpc-url sepolia \
--broadcast \
--verify
如果你是第一次跑,建议先只部署,再手动验证。 因为验证失败时,日志比较乱,新手容易看懵。
方式二:手动验证
假设你的合约地址是:
0xYourContractAddress
执行:
forge verify-contract \
0xYourContractAddress \
src/Counter.sol:Counter \
--chain sepolia
如果构造函数有参数,得把参数编码后带上。
我们的构造函数是:
constructor(uint256 _initialNumber)
部署时传的是 0,所以验证时要补上构造参数。
先编码:
cast abi-encode "constructor(uint256)" 0
你会得到一串十六进制结果,比如:
0x0000000000000000000000000000000000000000000000000000000000000000
然后验证:
forge verify-contract \
0xYourContractAddress \
src/Counter.sol:Counter \
--chain sepolia \
--constructor-args 0x0000000000000000000000000000000000000000000000000000000000000000
等待验证结果
提交之后,可以查状态:
forge verify-check <GUID> --chain sepolia
有时候 Foundry 会直接输出验证结果。 有时候只给你一个 GUID,那就拿这个查。
🧾 导出 ABI 给前端用
前端跟合约交互,最少要两样:
- 合约地址
- ABI
ABI 在哪里
编译后,ABI 会在:
out/Counter.sol/Counter.json
打开这个文件,里面有很多内容。前端常用的是 abi 字段。
你也可以直接用命令提取:
cat out/Counter.sol/Counter.json
或者手动复制 abi 那一段。
建一个前端配置文件
假设你有个 React 项目,在前端里新建:
src/contracts/counter.ts
写入:
export const COUNTER_ADDRESS = "0x你的合约地址";
export const COUNTER_ABI = [
{
"type": "constructor",
"inputs": [
{
"name": "_initialNumber",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "NumberChanged",
"inputs": [
{
"name": "newNumber",
"type": "uint256",
"indexed": false,
"internalType": "uint256"
}
],
"anonymous": false
},
{
"type": "function",
"name": "increment",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
},
{
"type": "function",
"name": "number",
"inputs": [],
"outputs": [
{
"name": "",
"type": "uint256",
"internalType": "uint256"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "owner",
"inputs": [],
"outputs": [
{
"name": "",
"type": "address",
"internalType": "address"
}
],
"stateMutability": "view"
},
{
"type": "function",
"name": "setNumber",
"inputs": [
{
"name": "_number",
"type": "uint256",
"internalType": "uint256"
}
],
"outputs": [],
"stateMutability": "nonpayable"
}
] as const;
⚛️ React 前端接入:用 wagmi + viem
Web3 前端现在这套组合很常见:
- wagmi:React hooks 封装
- viem:底层链交互库
- MetaMask:钱包入口
比老一套 ethers + 一堆自写状态管理更顺手。
安装依赖
在前端项目里执行:
npm install wagmi viem @tanstack/react-query
如果你要接钱包连接器,再装:
npm install @wagmi/core
很多脚手架项目里 React Query 已经有了,没有就补上。
🔌 配置 wagmi
新建 src/web3/config.ts:
import { http, createConfig } from "wagmi";
import { sepolia } from "wagmi/chains";
export const config = createConfig({
chains: [sepolia],
transports: {
[sepolia.id]: http(),
},
});
然后在入口文件里包一层 Provider。
src/main.tsx 或 src/index.tsx:
import React from "react";
import ReactDOM from "react-dom/client";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./web3/config";
import App from "./App";
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</WagmiProvider>
</React.StrictMode>
);
🔗 连接钱包
先做最基础的连接按钮。
在 src/App.tsx 里先写一版:
import { useAccount, useConnect, useDisconnect } from "wagmi";
import { injected } from "wagmi/connectors";
function App() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
return (
<div style={{ padding: 24 }}>
<h1>Counter DApp</h1>
{isConnected ? (
<>
<p>已连接地址:{address}</p>
<button onClick={() => disconnect()}>断开钱包</button>
</>
) : (
<button onClick={() => connect({ connector: injected() })}>
连接 MetaMask
</button>
)}
</div>
);
}
export default App;
这一步容易踩的坑
- 浏览器没装 MetaMask
- MetaMask 没切到 Sepolia
- 钱包没开测试网显示
- 某些 wagmi 版本里 connector 写法有差异
如果 injected() 报类型问题,先看你装的 wagmi 版本文档,别硬抄旧代码。
📖 读取链上数据
接下来读 number。
改造 src/App.tsx:
import { useAccount, useConnect, useDisconnect, useReadContract } from "wagmi";
import { injected } from "wagmi/connectors";
import { COUNTER_ABI, COUNTER_ADDRESS } from "./contracts/counter";
function App() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const { data, isLoading, refetch } = useReadContract({
address: COUNTER_ADDRESS,
abi: COUNTER_ABI,
functionName: "number",
});
return (
<div style={{ padding: 24 }}>
<h1>Counter DApp</h1>
{isConnected ? (
<>
<p>已连接地址:{address}</p>
<button onClick={() => disconnect()}>断开钱包</button>
</>
) : (
<button onClick={() => connect({ connector: injected() })}>
连接 MetaMask
</button>
)}
<hr />
<h2>当前 number</h2>
{isLoading ? <p>读取中...</p> : <p>{String(data)}</p>}
<button onClick={() => refetch()}>手动刷新</button>
</div>
);
}
export default App;
如果这里读不到值,优先检查:
- 地址是不是部署出来的那个
- ABI 对不对
- 当前链是不是 Sepolia
- 合约确实部署成功了没
✍️ 写入链上:调用 increment
读能通,再写。
在 src/App.tsx 继续加:
import {
useAccount,
useConnect,
useDisconnect,
useReadContract,
useWriteContract,
useWaitForTransactionReceipt,
} from "wagmi";
import { injected } from "wagmi/connectors";
import { COUNTER_ABI, COUNTER_ADDRESS } from "./contracts/counter";
function App() {
const { address, isConnected } = useAccount();
const { connect } = useConnect();
const { disconnect } = useDisconnect();
const { data, isLoading, refetch } = useReadContract({
address: COUNTER_ADDRESS,
abi: COUNTER_ABI,
functionName: "number",
});
const { data: hash, writeContract, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
});
return (
<div style={{ padding: 24 }}>
<h1>Counter DApp</h1>
{isConnected ? (
<>
<p>已连接地址:{address}</p>
<button onClick={() => disconnect()}>断开钱包</button>
</>
) : (
<button onClick={() => connect({ connector: injected() })}>
连接 MetaMask
</button>
)}
<hr />
<h2>当前 number</h2>
{isLoading ? <p>读取中...</p> : <p>{String(data)}</p>}
<button onClick={() => refetch()}>手动刷新</button>
<hr />
<button
disabled={!isConnected || isPending || isConfirming}
onClick={() =>
writeContract({
address: COUNTER_ADDRESS,
abi: COUNTER_ABI,
functionName: "increment",
})
}
>
{isPending ? "等待钱包确认..." : isConfirming ? "链上确认中..." : "加 1"}
</button>
{hash && <p>交易哈希:{hash}</p>}
{isSuccess && <p>交易已上链</p>}
</div>
);
}
export default App;
这段代码干了什么
- useWriteContract 发交易
- 用户会在 MetaMask 里确认
- useWaitForTransactionReceipt 等交易上链
- 成功后你可以 refetch() 一下,把最新值拉回来
更顺手一点的话,可以在交易确认成功后自动刷新:
import { useEffect } from "react";
然后加:
useEffect(() => {
if (isSuccess) {
refetch();
}
}, [isSuccess, refetch]);
🧰 用 cast 做链上排查
前端一红,别只会盯着浏览器控制台。 cast 是个很好用的命令行排查工具,很多时候比你开页面点半天更快。
查当前 number
cast call 0x你的合约地址 "number()(uint256)" --rpc-url sepolia
查 owner
cast call 0x你的合约地址 "owner()(address)" --rpc-url sepolia
查某笔交易详情
cast tx 0x交易哈希 --rpc-url sepolia
查部署地址有没有代码
cast code 0x你的合约地址 --rpc-url sepolia
如果返回 0x,那这个地址上没合约。 不是你地址抄错了,就是部署根本没成功。
🐛 常见坑位,逐个填
这部分很重要。 很多人不是不会写,是被这些小坑反复绊。
1. insufficient funds for gas * price + value
钱包里没测试币。
处理:
- 去 faucet 领 Sepolia ETH
- 确认领到的是当前部署钱包地址
- 确认钱包切的是 Sepolia,不是别的网
2. EvmError: Revert
说明链上执行回滚了。
排查顺序:
- 构造函数是不是传错参数
- 合约函数里有没有 require
- 当前调用账户有没有权限
- 前端传参类型是不是不对
如果函数签名是 uint256,你前端传字符串、空值、负数,都可能出事。
3. Etherscan 验证失败
最常见的几个原因:
- 编译器版本不一致
- optimizer 配置不一致
- 构造函数参数漏了
- 合约路径写错了
- 你改过代码但没重新部署
你部署用的是啥配置,验证就得一模一样。 这地方最烦人的点就在于:不是“差不多就行”,而是必须完全一致。
4. 前端读不到合约
重点查四项:
- 合约地址
- ABI
- 链 ID
- RPC 网络状态
尤其 ABI。 你一改合约函数,ABI 不更新,前端就开始抽风。
5. 钱包连上了,但按钮点了没反应
常见原因:
- 没触发 writeContract
- 被按钮的 disabled 卡住了
- 钱包弹窗被浏览器拦了
- 当前链不是 Sepolia
- MetaMask 账户没切对
这种问题最笨但最有效的查法:
- 打开浏览器控制台
- 在点击前后打 log
- 看 hash 有没有生成
- 看 MetaMask 有没有弹确认
6. Private key must be 32 bytes
.env 里的私钥格式不对。
一般要注意:
- 有的工具要求带 0x
- 有的读取时不接受奇怪空格
- 不要把引号也复制进去
Foundry 常见做法是直接放十六进制私钥,格式干净一点。
🧼 测试网阶段的几个最佳实践
这部分不是必须,但能帮你少返工。
专门分离测试钱包
别图省事。 测试钱包就是测试钱包,跟主钱包隔离。
合约地址别硬编码到到处都是
前端里统一放配置文件。 多环境时尤其重要,不然后面地址一改,全项目搜半天。
可以做成这样:
export const CONTRACTS = {
sepolia: {
counter: "0x你的合约地址",
},
};
部署参数留痕
你今天部署传的是 0,下周可能传的是 100。 别靠脑子记,直接把参数写进脚本,或者写进部署记录文件。
每次部署后立刻做三件事
- 记下合约地址
- 验证源码
- 用 cast call 读一遍关键状态
这三个动作做完,后面前端联调会省很多事。
📦 一套更顺手的毕业目录结构
如果你准备把这个项目继续往下做,目录可以整理成这样:
foundry-counter/
├── src/
│ └── Counter.sol
├── script/
│ └── DeployCounter.s.sol
├── test/
│ └── Counter.t.sol
├── out/
├── broadcast/
├── .env
├── foundry.toml
└── frontend/
└── src/
├── contracts/
│ └── counter.ts
├── web3/
│ └── config.ts
└── App.tsx
这样找东西不费劲。
🧭 这节的验收标准
做到下面这些,才算毕业,不是“差不多”。
链上部分
- forge build 成功
- forge test 成功
- forge script ... --broadcast 成功
- 能拿到明确的合约地址
- Etherscan 能看到已验证源码
前端部分
- MetaMask 能连接
- 页面能读到 number
- 点击按钮能发起 increment
- 交易确认后页面值能刷新
- 区块浏览器能查到这笔交易
排查能力
至少会用这几个命令:
forge build
forge test
forge script
forge verify-contract
cast call
cast tx
cast code
这套东西一旦打通,你就不是“会写一个本地玩的 Solidity demo”了,你已经能把一个最小 DApp 真扔到测试网上跑起来。
下一步如果继续做,通常就是:
- 加权限控制
- 接事件监听
- 做多合约部署
- 上 ERC-20 / NFT
- 接签名登录
- 把前端钱包状态、交易状态做完整
先把这节打满。测试网部署和验证这关过了,后面的活才算进正题。
