跳到文章正文
07 / 应用

测试网部署与验证

部署到 Sepolia、验证源码、导出 ABI,完成前端与合约的连接。

约 25 分钟阅读
第 7 课:测试网部署与验证
本文为作者已发布教程的站内版本。在 X 查看原文与讨论

学习目标: 完成测试网部署、源码验证、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。 别靠脑子记,直接把参数写进脚本,或者写进部署记录文件。


每次部署后立刻做三件事

  1. 记下合约地址
  2. 验证源码
  3. 用 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
  • 接签名登录
  • 把前端钱包状态、交易状态做完整

先把这节打满。测试网部署和验证这关过了,后面的活才算进正题。

关于作者

,现居湖北武汉,拥有 10 多年经验的全栈开发工程师,分享 AI 编程、Web3 与独立开发实践。

X 原文