Go智能合约实战:solc编译、abigen生成与链上调用
适合谁读:刚开始学区块链开发、会一点 Go 的同学。不需要提前了解 Solidity、ABI、字节码这些概念,文章会从零讲起。
读完你能做到:
- 说清楚
.sol、.abi、.bin、solc、abigen 分别是什么、彼此什么关系;- 自己写一个智能合约,编译它,用 abigen 生成 Go 代码,在 Sepolia 测试网上部署并调用;
- 理解为什么"继承 ERC20 就是代币、继承 ERC721 就是 NFT";
- 用真实的链上数据,看懂一笔合约调用在底层到底发生了什么。
文中所有链上数据都来自 Sepolia 测试网上真实部署的合约,并附有查询命令,可以自行复现。
目录
- 第 1 章 先搞懂几个概念
- 第 2 章 准备开发环境
- 第 3 章 写第一个合约:最简计数器
- 第 4 章 用 solc 编译:看懂 .abi 和 .bin
- 第 5 章 用 abigen 生成 Go 代码并调用合约
- 第 6 章 进阶:ERC20 代币与 ERC721 NFT
- 第 7 章 原理深挖:用 Sepolia 链上数据逐层验证
- 第 8 章 踩坑记录
- 第 9 章 常见误区与总结
第 1 章 先搞懂几个概念
1.1 智能合约是什么
可以先把智能合约理解成:部署在区块链上的一段程序。
和你熟悉的 Go 后端服务对比一下:
| Go 后端服务 | 智能合约 | |
|---|---|---|
| 运行在哪 | 你自己的服务器 | 以太坊网络里成千上万个节点,每个节点都执行一遍 |
| 数据存在哪 | MySQL、Redis | 区块链上(合约自己的存储空间) |
| 谁能改代码 | 你随时可以重新发布 | 部署后代码不可修改 |
| 怎么调用 | HTTP / gRPC 请求 | 发送一笔"交易",或做一次只读查询 |
| 调用要花钱吗 | 不用 | 修改数据要付手续费(gas) |
所以合约开发分为 4 步:写代码 → 编译 → 部署上链 → 调用。下一节逐步展开,看看每一步做什么、由谁来做。
1.2 合约的一生:从写代码到被调用的 4 步
本节会提到交易、gas、nonce、RPC 节点等术语,遇到不认识的可以先查 1.10 其他常用术语速查。
先看全景:

| 步骤 | 做什么 | 谁来做 | 用什么工具 | 产物 | 在哪发生 | 花钱吗 | 本文章节 |
|---|---|---|---|---|---|---|---|
| ① 写代码 | 用 Solidity 定义数据和规则 | 开发者 | VS Code、Remix 等编辑器 | .sol 源码 | 本地 | 否 | 第 3、6 章 |
| ② 编译 | 把源码翻译成字节码和接口说明 | solc 编译器 | solc | .bin + .abi | 本地 | 否 | 第 4 章 |
| ③ 部署上链 | 把字节码发到链上,换来一个合约地址 | 开发者发起,节点、验证者、EVM 共同完成 | abigen 生成的 DeployXxx 函数 | 合约地址 + 链上代码 | 链上 | 是 | 第 5 章、7.2~7.4 |
| ④ 调用 | 读取数据,或执行函数修改数据 | 任何人:用户、程序、其他合约 | abigen 生成的方法、钱包、前端 | 返回值,或状态变化 + 事件 | 链上 | 读免费,写要付 gas | 第 5、6 章、7.5~7.7 |
下面逐步展开。
① 写代码:开发者
开发者用 Solidity 描述三件事:
- 存什么:状态变量,比如
uint256 public count;,数据会永久存在链上; - 能做什么:函数,比如
increment(); - 发生了什么:事件,比如
Incremented,用来通知链下程序。
写合约和写 Go 后端最大的思维差别:
- 部署后不能改:有 bug 也没法热修复,所以上链前要在本地充分测试;
- 每次写存储都花钱:能少存就少存;
- 代码对所有人公开:任何人都能调用任何
public/external函数,权限要靠代码自己控制。
② 编译:solc 编译器
这一步完全在本地完成,不和区块链打交道。solc 读入 .sol,输出两个文件:
.bin:字节码,要发到链上;.abi:接口说明书,留在链下,给调用方使用。
随后 abigen 根据 .abi 生成 Go 代码,也在本地完成,可以看作编译的延伸。
③ 部署上链:开发者发起,全网协作完成
部署本质上是发送一笔特殊的交易。整个过程中每一步由不同的角色负责:
| 顺序 | 发生了什么 | 谁负责 |
|---|---|---|
| 1 | 构造一笔交易:to(接收方)留空,data 填入 .bin 字节码,用私钥签名 | 你的 Go 程序(abigen 生成的 DeployCounter 替你完成) |
| 2 | 把签好名的交易发给 RPC 节点,节点把它广播到全网的交易池 | RPC 节点(如 Infura) |
| 3 | 从交易池中挑出这笔交易,打包进新区块 | 验证者(validator:质押了 ETH、负责出块的节点) |
| 4 | 执行字节码中的部署代码:运行构造函数(比如铸造代币),最后返回运行时代码 | 每个节点里的 EVM |
| 5 | 为合约分配地址(由部署者地址 + nonce 计算得出),永久保存运行时代码,并向部署者收取 gas 费 | 以太坊协议 |
完成之后:
- 合约有了地址,从此任何人都能调用它;
- 链上保存的只是运行时代码,构造函数只执行这一次;
- 代码再也不能修改。要改,只能重新部署一个新合约(新地址)。
④ 调用:任何人
调用分为"读"和"写"两种,二者的差别非常大:
读(view / pure 函数) | 写(修改状态的函数) | |
|---|---|---|
| 例子 | count()、balanceOf() | increment()、transfer() |
| 底层方式 | eth_call 请求 | 发送交易(eth_sendRawTransaction) |
| 需要私钥签名吗 | 不需要 | 需要 |
| 谁来执行 | 你连接的那一个节点,本地执行后直接返回 | 打包进区块后,全网每个节点都执行一遍 |
| 花钱吗 | 免费 | 付 gas |
| 多久有结果 | 立即 | 要等打包,Sepolia 约 12 秒出一个块 |
| 得到什么 | 函数返回值 | 交易回执:状态变化 + 事件日志,拿不到函数返回值 |
最后一行是初学者常踩的坑:写操作没法直接拿到函数的返回值。想知道结果,要么等交易打包后再读一次,要么从回执的事件里取。这也是第 5 章的代码在 increment 之后要再调用一次 Count() 的原因。
本文的重点是 ② 编译和 ④ 用 Go 调用,③ 部署也会借助 abigen 生成的 DeployXxx 函数顺带完成,① 则在第 3 章和第 6 章动手实践。
1.3 Solidity 与 .sol 文件
Solidity 是编写以太坊智能合约最常用的编程语言,语法有点像 JavaScript 和 Go 的混合。用 Solidity 写的源码文件后缀是 .sol,比如 Counter.sol。
.sol 是写给人看的,以太坊不能直接运行它——就像 CPU 不能直接运行 .go 文件一样,需要先编译。
1.4 EVM 与字节码(.bin 文件)
EVM(Ethereum Virtual Machine,以太坊虚拟机)是以太坊上执行合约的"虚拟 CPU"。每个以太坊节点里都有一个 EVM。
EVM 只认识一种东西:字节码(bytecode)——一串由 EVM 指令组成的二进制数据。编译器把它以十六进制文本的形式保存在 .bin 文件里,长这样:
6080604052348015600e575f5ffd5b50...
- 每两个十六进制字符是一个字节,大多对应一条 EVM 指令;
- 部署合约,本质上就是把这串字节码发到链上;
- 链上只存字节码,不存源码。
你可以把 .bin 类比成 go build 产出的可执行文件。
1.5 ABI(.abi 文件)
问题来了:字节码是一串机器码,里面没有函数名、没有参数名。外部程序(比如你的 Go 程序)怎么知道这个合约有哪些函数、该怎么调用?
答案就是 ABI(Application Binary Interface,应用程序二进制接口)。它是一个 JSON 文件(.abi),相当于合约的接口说明书:
{
"name": "increment",
"type": "function",
"inputs": [],
"outputs": [],
"stateMutability": "nonpayable"
}
这一项告诉调用方:"这个合约有一个叫 increment 的函数,没有参数,没有返回值,会修改数据(nonpayable)。"
.bin 和 .abi 缺一不可:
.bin决定合约能做什么(部署到链上执行);.abi决定外界怎么调用它(链下程序使用)。
1.6 solc:Solidity 编译器
solc 就是 Solidity 的官方编译器。它读入 .sol,输出 .bin 和 .abi:
Counter.sol ──solc──▶ Counter.bin(字节码) + Counter.abi(接口说明书)
它的角色等同于 Go 的 go build。
1.7 Geth、go-ethereum 与 abigen
- go-ethereum:以太坊官方的 Go 语言实现,是一个 Go 库,也是一组工具的集合;
- Geth:go-ethereum 里最出名的程序——以太坊节点客户端。人们也常用"Geth"代指整个 go-ethereum 项目;
- abigen:go-ethereum 自带的一个代码生成工具(ABI + gen)。它读入
.abi(和.bin),自动生成一个.go文件,里面是调用这个合约的 Go 函数。
有了 abigen,你在 Go 里调用合约就像调用普通函数一样:
instance.Increment(auth) // 调用合约的 increment()
instance.Count(nil) // 读取合约的 count
而不用自己去拼装底层的字节数据。
1.8 用 Go 的视角串起来
如果你用过 gRPC,会发现这套流程几乎一模一样:
| 合约开发 | Go / gRPC 世界里的类比 |
|---|---|
.sol 源码 | .go 源码 |
| solc 编译器 | go build |
.bin 字节码 | 编译出来的可执行文件 |
| EVM | 运行可执行文件的 CPU |
.abi 接口说明 | .proto 接口定义 |
| abigen | protoc-gen-go(根据 .proto 生成 Go 客户端代码) |
生成的 counter.go | 生成的 xxx.pb.go 客户端 |
| 以太坊节点 RPC | gRPC 服务端地址 |
整条链路:
Counter.sol ──solc──▶ Counter.abi + Counter.bin ──abigen──▶ counter.go ──你的 main.go 调用──▶ 以太坊节点 ──▶ 链上合约
(源码) (说明书) (机器码) (Go 客户端)
1.9 代币标准:ERC20、ERC721、ERC1155
什么是 ERC 标准
ERC(Ethereum Request for Comments)是以太坊社区制定的应用层标准,以 EIP(以太坊改进提案)的形式发布,后面的数字是提案编号。
它规定了一类合约必须提供哪些函数和事件。打个比方:ERC 标准就像 USB 接口规范——不管哪个厂家生产的 U 盘,只要符合 USB 规范,插到任何电脑上都能用。同样,只要合约符合 ERC20 规范,任何钱包、交易所、区块浏览器都能直接识别和操作它,不需要为它单独适配。
最常用的三个标准:
| 标准 | 常见叫法 | 生活中的类比 | 一句话理解 |
|---|---|---|---|
| ERC20 | 代币、同质化代币(FT,Fungible Token) | 现金、积分 | 每一份都一模一样,可以互换、可以拆分 |
| ERC721 | NFT、非同质化代币(Non-Fungible Token) | 房产证、带座位号的门票 | 每一个都独一无二,不能互换、不能拆分 |
| ERC1155 | 多代币标准(Multi-Token)、半同质化代币(SFT,Semi-Fungible Token) | 游戏背包 | 一个合约里同时管理多种物品,每种都可以有数量 |
ERC20:同质化代币
"同质化"的意思是:你手里的 1 个代币和我手里的 1 个代币完全一样,可以等价交换。就像你的 100 元和我的 100 元没有区别。
特点:
- 合约只记录"每个地址有多少个",核心数据是
地址 → 余额; - 可以拆分:通过
decimals(小数位数,通常为 18)支持 0.5 个、0.001 个这样的数量; - 一个合约就代表一种代币。
应用场景:
| 场景 | 例子 |
|---|---|
| 稳定币:与法币 1:1 锚定 | USDT、USDC |
| 治理代币:持有者可以投票决定协议的发展方向 | UNI(Uniswap)、AAVE |
| 包装资产:把 ETH 等原生资产包装成 ERC20,方便在 DeFi 中使用 | WETH |
| 项目积分、平台币、游戏金币 | 各类项目自发的代币 |
ERC721:非同质化代币(NFT)
"非同质化"的意思是:每一个都是独一无二的。合约里的每个 NFT 都有一个唯一编号 tokenId,以及属于它自己的描述信息(元数据,比如名称、图片)。就像两张同一场演唱会的门票,座位号不同就不能互换。
特点:
- 合约记录"每个编号属于谁",核心数据是
tokenId → 持有者; - 不可拆分:你不能拥有半个 NFT;
- 每个 tokenId 可以有独立的元数据(通过
tokenURI返回),钱包和 NFT 市场据此显示图片; - 一个合约通常是一个"系列",里面有很多个不同编号的 NFT。
应用场景:
| 场景 | 例子 |
|---|---|
| 数字藏品、头像 | BAYC(无聊猿) |
| 链上域名 | ENS(如 vitalik.eth) |
| 门票、会员资格、证书 | 活动门票、课程结业证书 |
| 代表一份独特的资产 | Uniswap V3 的流动性头寸就是一个 NFT |
ERC1155:多代币标准
ERC20 和 ERC721 各管一类:一个合约要么是一种代币,要么是一个 NFT 系列。如果一个游戏里既有金币(同质化,有很多个),又有限量版武器(每把独一无二),按传统做法就得部署很多个合约。
ERC1155 把它们统一了起来:一个合约里可以有多种 token,每种用一个 id 区分,每种都可以有数量。
- 金币:id = 1,你有 1000 个(数量多,相当于同质化代币);
- 传奇剑:id = 2,全服只有 1 把(数量为 1,相当于 NFT)。
特点:
- 核心数据是
(地址, id) → 数量; - 支持批量转账:一笔交易就能同时转出多种物品,比逐个转账省 gas;
- 一个合约管理全部资产,部署和维护成本更低。
应用场景:
| 场景 | 例子 |
|---|---|
| 链游道具:金币、药水、装备放在同一个合约里 | 该标准最初由区块链游戏平台 Enjin 提出 |
| 同一作品发行多份的数字藏品 | 一幅作品发行 100 份 |
| 分档门票 | VIP 票 100 张、普通票 1000 张,用两个 id 表示 |
三者对比
| 对比项 | ERC20 | ERC721 | ERC1155 |
|---|---|---|---|
| 常见叫法 | 代币 | NFT | 多代币 |
| 每个 token 是否相同 | 相同 | 各不相同 | 同一 id 内相同,不同 id 之间不同 |
| 能否拆分 | 能(有小数) | 不能 | 一般不能 |
| 一个合约管理 | 一种代币 | 一个 NFT 系列 | 多种代币和 NFT |
| 查余额的函数 | balanceOf(地址) | ownerOf(tokenId) 查持有者 | balanceOf(地址, id) |
| 批量转账 | 不支持 | 不支持 | 支持 |
| 典型应用 | 稳定币、治理代币 | 数字藏品、域名、门票 | 链游道具、多版本藏品 |
按赛道看:三种标准分别用在哪
区块链应用通常按"赛道"(行业方向)划分。下表列出主流赛道里三种标准各自承担的角色:
| 赛道 | ERC20(同质化代币) | ERC721(NFT) | ERC1155(多代币 / 半同质化) |
|---|---|---|---|
| DeFi 去中心化金融:借贷、稳定币、理财 | • 稳定币:USDC、DAI(超额抵押) • 借贷抵押品:WETH、WBTC • 存款凭证:在 Aave 存入 USDC 会收到 aUSDC | • 锁仓仓位凭证:如 Velodrome 等协议的 veNFT,锁仓数量和期限各不相同 • 借贷 / 理财仓位凭证 | • 预测市场的结果份额:Polymarket 底层使用基于 ERC1155 的 Conditional Tokens,"是 / 否"各为一个 id • 按期限区分的债权:同一期限为一个 id |
| DEX 去中心化交易所 | • 交易对里的代币本身 • Uniswap V2 的流动性凭证(LP Token) • 流动性挖矿的奖励代币 | • Uniswap V3 的流动性头寸:每个头寸的价格区间不同,所以用 NFT 表示 | • 较少直接使用。Uniswap V4 采用了思路相近的多代币标准 ERC-6909 来记录用户在协议内的余额 |
| GameFi 链游 | • 游戏内通用货币 • 游戏治理代币:AXS(Axie Infinity) | • 独一无二的角色、宠物:Axie 宠物 • 土地、传奇装备 | • 主场:药水、箭矢等消耗品 • 同款多件的皮肤、装备 • 一笔交易批量发放奖励 |
| NFT 与数字藏品 | • 交易市场的平台代币 | • 头像(PFP):BAYC • 艺术品、限量收藏 | • 同一作品发行多份(Editions):Zora 等平台的创作者合约 • 盲盒:开出不同稀有度 |
| MEME 模因币 | • 绝对主力:PEPE、SHIB(以太坊上的 MEME 币) | • 社区头像系列 • 社区早期成员身份凭证 | • 社区周边、系列纪念品 • 批量空投 |
| SocialFi 社交金融 | • 创作者代币、粉丝代币 • 打赏、付费订阅的结算 | • 社交身份:Lens Protocol 的用户主页就是 NFT • 链上域名:ENS(如 vitalik.eth) | • 会员分级徽章:普通 / 高级 / 至尊各为一个 id • 批量发行的活动门票、贴纸 • ENS 的子域名包装器(NameWrapper)也使用 ERC1155 |
| DePIN 去中心化物理基础设施:存储、算力、网络 | • 节点激励和网络服务结算:如 Livepeer 的 LPT | • 物理设备的链上身份:一台设备对应一个 NFT • 节点运营许可证 | • 同型号设备批量登记 • 分级权益卡(应用较少) |
| RWA 现实资产代币化 | • 法币稳定币:USDT、USDC • 黄金代币:PAXG • 代币化基金、国债:BlackRock 的 BUIDL | • 唯一的资产凭证:一套房产的产权、一件艺术品 • 票据、合同等单一凭证 | • 分时度假的使用权:每个时段一个 id • 同一批次的应收账款、债券 |
从这张表可以看出几个规律:
- ERC20 覆盖面最广:凡是"可以计数、可以拆分"的东西都用它,包括货币、积分、份额、凭证。DeFi 和 MEME 几乎全是 ERC20 的天下。
- ERC721 用于"每一份都不一样"的东西:除了头像和艺术品,仓位(价格区间、锁仓期限各不相同)和身份(域名、社交主页、设备 ID)也是它的重要用途。
- ERC1155 的主场是链游和批量发行:在 DeFi 中较少见,但预测市场是一个典型例外。
- 同一个项目往往混用多个标准:比如一款链游可能同时有 ERC20 的金币、ERC721 的角色和 ERC1155 的道具。
- "份额化"常常回到 ERC20:一套房产本可以是一个 NFT,但如果要卖给很多人共同持有,通常会拆成 ERC20 份额。RWA 项目出于合规要求,还会在 ERC20 的基础上增加转账白名单(如 ERC-3643 标准)。
选型的简单原则:发币用 ERC20;每个都要独一无二用 ERC721;一个项目里有多种资产(尤其是游戏)用 ERC1155。
本文第 6 章会动手实现 ERC20 和 ERC721,ERC1155 的写法与它们类似,不再展开。
1.10 其他常用术语速查
| 术语 | 一句话解释 |
|---|---|
| 地址 | 账户或合约的唯一标识,形如 0x83Da…d88C,20 字节 |
| 私钥 | 控制账户的密钥,用来给交易签名。泄露 = 资产被盗 |
| 交易 | 一次修改链上状态的请求:转账、部署合约、调用会改数据的函数 |
| 只读调用(eth_call) | 读取合约数据,不改状态,不需要签名,不花钱 |
| gas | 执行交易需要的"燃料",按 gas 数量 × gas 单价付手续费 |
| nonce | 账户已发送的交易序号,防止交易被重放 |
| 区块 | 一批交易打包在一起,约每 12 秒产生一个 |
| RPC 节点 | 你的程序连接以太坊网络的入口,如 Infura 提供的 URL |
| 验证者(validator) | 质押了 ETH、负责把交易打包成区块的节点,打包后获得手续费奖励 |
| Sepolia | 以太坊官方测试网,币免费领取,适合练习 |
| Etherscan | 区块链浏览器,可以查看任意交易、地址、合约 |
第 2 章 准备开发环境
2.1 需要安装的工具
| 工具 | 用途 | 安装方式 |
|---|---|---|
| Go | 写调用程序 | |
| solc | 编译合约 | brew install solidity,或安装 Foundry 后使用其自带的 solc |
| abigen | 生成 Go 代码 | go install github.com/ethereum/go-ethereum/cmd/abigen@v1.17.8 |
| Foundry(可选) | 本地测试链 anvil、命令行工具 cast | curl -L https://foundry.paradigm.xyz | bash && foundryup |
go install 会把 abigen 装到 ~/go/bin,记得把它加进 PATH:
echo 'export PATH="$HOME/go/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
abigen --version # 能输出版本号即成功
solc --version
2.2 准备 Sepolia 测试网
- RPC 地址:注册
Infura(或 Alchemy),创建项目,得到形如
https://sepolia.infura.io/v3/<API_KEY>的地址; - 测试账户:在 MetaMask 里新建一个专门用于测试的账户,导出私钥;
- 测试币:在 Sepolia 水龙头(如 Google Cloud Web3 Faucet)领一点 SepoliaETH。
把 RPC 地址和私钥放进环境变量,程序通过 os.Getenv 读取,不要把私钥写进代码:
export ETH_RPC_URL=https://sepolia.infura.io/v3/<你的 API Key>
export SENDER_PRIVATE_KEY=<测试账户私钥>
echo ${#SENDER_PRIVATE_KEY} # 应为 64(不带 0x)或 66(带 0x)
2.3 初始化 Go 项目
mkdir counter-demo && cd counter-demo
go mod init counter-demo
go get github.com/ethereum/go-ethereum@v1.17.8
mkdir contracts
第 3 章 写第一个合约:最简计数器
新建 contracts/Counter.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title Counter - 最简计数器合约
contract Counter {
uint256 public count;
event Incremented(address indexed by, uint256 newCount);
/// @notice 计数器加 1
function increment() external {
count += 1;
emit Incremented(msg.sender, count);
}
}
逐行解释:
| 代码 | 含义 |
|---|---|
// SPDX-License-Identifier: MIT | 开源协议声明,编译器要求写上 |
pragma solidity ^0.8.20; | 要求编译器版本 ≥ 0.8.20 且 < 0.9.0 |
contract Counter { ... } | 定义一个合约,类似 Go 里的 type Counter struct 加上它的方法 |
uint256 public count; | 一个存在链上的无符号整数(状态变量)。public 会让编译器自动生成一个同名的只读函数 count() |
event Incremented(...) | 定义一个事件,相当于写日志,链下程序可以查询和订阅 |
address indexed by | indexed 表示这个字段可以被检索过滤 |
function increment() external | 一个可以从外部调用的函数 |
count += 1; | 修改链上状态,所以调用它需要发交易、付 gas |
msg.sender | 调用者的地址,由链自动填入,无法伪造 |
emit Incremented(...) | 发出事件 |
这个合约不继承任何标准,就是一个普通合约——第 6 章会看到,加上 ERC20 / ERC721 后它会变成什么。
第 4 章 用 solc 编译:看懂 .abi 和 .bin
4.1 编译
solc --abi --bin -o build contracts/Counter.sol
--abi:输出接口说明书;--bin:输出字节码;-o build:输出到build/目录(不存在会自动创建)。
Compiler run successful. Artifact(s) can be found in directory "build".
得到两个文件:build/Counter.abi、build/Counter.bin。
4.2 看懂 Counter.abi
格式化后的内容:
[
{
"type": "event",
"name": "Incremented",
"anonymous": false,
"inputs": [
{ "name": "by", "type": "address", "indexed": true },
{ "name": "newCount", "type": "uint256", "indexed": false }
]
},
{
"type": "function",
"name": "count",
"stateMutability": "view",
"inputs": [],
"outputs": [{ "type": "uint256" }]
},
{
"type": "function",
"name": "increment",
"stateMutability": "nonpayable",
"inputs": [],
"outputs": []
}
]
和源码一一对应:
| 源码 | ABI 中的描述 |
|---|---|
event Incremented(address indexed by, uint256 newCount) | type: event,两个参数,by 是 indexed |
uint256 public count | 自动生成的 count() 函数,view(只读),返回 uint256 |
function increment() external | nonpayable(会改状态,但不接收 ETH) |
stateMutability 的几种取值:
| 值 | 含义 | 调用方式 |
|---|---|---|
view | 只读状态 | eth_call,免费 |
pure | 连状态都不读,纯计算 | eth_call,免费 |
nonpayable | 修改状态 | 发交易,付 gas |
payable | 修改状态,且可以同时接收 ETH | 发交易,付 gas |
4.3 看懂 Counter.bin
6080604052348015600e575f5ffd5b50...
- 开头的
60 80 60 40 52翻译成指令是PUSH1 0x80; PUSH1 0x40; MSTORE,几乎所有 Solidity 合约都以它开头,用来初始化内存; - 整个
.bin包含两部分:- 部署代码:只在部署时执行一次,负责运行构造函数,然后把下面的运行时代码交给链保存;
- 运行时代码:真正存在链上、每次调用都执行的代码(可以用
solc --bin-runtime单独输出)。
现在只需记住:.bin 是要发到链上的东西,.abi 是留在链下给调用方用的东西。 第 7 章会用真实的链上数据验证这一点。
第 5 章 用 abigen 生成 Go 代码并调用合约
5.1 生成 Go 绑定
mkdir -p counter # abigen 不会自动创建目录,必须先建好
abigen --abi build/Counter.abi --bin build/Counter.bin \
--pkg counter --type Counter --out counter/counter.go
| 参数 | 含义 |
|---|---|
--abi | 输入的 ABI 文件 |
--bin | 输入的字节码。传了才会生成 DeployCounter 部署函数 |
--pkg | 生成代码的 Go 包名,要和所在目录名一致 |
--type | 生成的 Go 结构体名 |
--out | 输出文件路径 |
5.2 生成的代码里有什么
打开 counter/counter.go(约 400 行,不要手改,改合约后重新生成即可),主要内容:
| 生成内容 | 作用 |
|---|---|
CounterMetaData{ABI: "...", Bin: "0x6080..."} | 把 ABI 和字节码原样嵌进 Go 代码 |
DeployCounter(auth, client) | 部署合约,返回合约地址、交易、合约实例 |
NewCounter(address, client) | 绑定一个已经部署好的合约 |
Count(opts) | 对应合约的 count(),只读调用 |
Increment(opts) | 对应合约的 increment(),发交易 |
FilterIncremented(...) / WatchIncremented(...) | 查询历史事件 / 实时订阅事件 |
看一个生成的方法,会发现它非常简单:
func (_Counter *CounterTransactor) Increment(opts *bind.TransactOpts) (*types.Transaction, error) {
return _Counter.contract.Transact(opts, "increment")
}
真正干活的是 go-ethereum 里的通用代码 bind.BoundContract:根据 ABI 把函数调用编码成字节、自动获取 nonce、估算 gas、用私钥签名、通过 RPC 发出去。
abigen 的价值在于"类型安全":方法名写错、参数类型不对,Go 编译器直接报错,而不是等到链上执行失败。
5.3 编写调用程序
新建 main.go:
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"counter-demo/counter" // abigen 生成的包
)
func main() {
deploy := flag.Bool("deploy", false, "部署一个新合约")
addrHex := flag.String("addr", "", "已部署的合约地址")
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
// 1. 连接节点
client, err := ethclient.DialContext(ctx, os.Getenv("ETH_RPC_URL"))
if err != nil {
log.Fatal(err)
}
// 2. 准备签名者:私钥 + 链 ID
privKey, err := crypto.HexToECDSA(strings.TrimPrefix(os.Getenv("SENDER_PRIVATE_KEY"), "0x"))
if err != nil {
log.Fatal(err)
}
chainID, err := client.ChainID(ctx)
if err != nil {
log.Fatal(err)
}
auth, err := bind.NewKeyedTransactorWithChainID(privKey, chainID)
if err != nil {
log.Fatal(err)
}
// 3. 部署新合约,或绑定已部署的合约
var instance *counter.Counter
if *deploy {
addr, tx, inst, err := counter.DeployCounter(auth, client)
if err != nil {
log.Fatal(err)
}
if _, err := bind.WaitDeployed(ctx, client, tx); err != nil { // 等部署上链
log.Fatal(err)
}
fmt.Println("合约地址:", addr.Hex())
instance = inst
} else {
instance, err = counter.NewCounter(common.HexToAddress(*addrHex), client)
if err != nil {
log.Fatal(err)
}
}
// 4. 读:调用 view 函数,不花 gas
before, err := instance.Count(&bind.CallOpts{Context: ctx})
if err != nil {
log.Fatal(err)
}
fmt.Println("调用前 count:", before)
// 5. 写:发交易,要签名、付 gas,并等待打包
tx, err := instance.Increment(auth)
if err != nil {
log.Fatal(err)
}
receipt, err := bind.WaitMined(ctx, client, tx)
if err != nil {
log.Fatal(err)
}
after, err := instance.Count(&bind.CallOpts{Context: ctx})
if err != nil {
log.Fatal(err)
}
fmt.Printf("交易 %s 已打包,状态 %d,调用后 count: %s\n", tx.Hash().Hex(), receipt.Status, after)
}
几个要点:
auth(TransactOpts)是"签名者":所有写操作都要它;nonce、gas 留空时由绑定代码自动填写;- 部署后必须等上链(
WaitDeployed),否则立刻读合约会因为"地址上还没有代码"而失败; - 写操作后要等打包(
WaitMined),再读才能看到新值; - 读操作不需要
auth,只要CallOpts。
5.4 运行
go run . -deploy # 部署 + 调用一次 increment
go run . -addr <上一步的合约地址> # 对已有合约再 +1
笔者在 Sepolia 上的实际运行结果(这个版本的合约多了一个 logo 常量,其余与上文相同):
合约地址: 0xaacC3D125053D969dfF25732205C1765DF6a7557
调用前 count: 0
increment 交易: 0x68271c05bfe05be4a63cd7265ce884cbbb206e7ac2c7935d801e242b61710424
区块号 : 11975558, 状态: 1 (1=成功)
调用后 count: 1
再运行一次 -addr,调用前 count 变成了 1、调用后变成 2。第二次运行是一个全新的进程,却读到了上次的值——因为数据存在链上,而不是程序内存里。
在 Etherscan 上可以查到这个合约:https://sepolia.etherscan.io/address/0xaacC3D125053D969dfF25732205C1765DF6a7557
不想花测试币?用 Foundry 的
anvil启动一个本地链:anvil会打印 10 个带余额的测试私钥,设置ETH_RPC_URL=http://127.0.0.1:8545即可在本地跑通全流程。
到这里,最核心的流程已经走完:写合约 → solc 编译 → abigen 生成 → Go 部署调用。后面的章节是进阶和原理。
第 6 章 进阶:ERC20 代币与 ERC721 NFT
6.1 链上没有"代币"这种类型
一个容易误解的地方:EVM 眼里所有合约都一样,就是"一段字节码 + 一块存储空间",并没有一个叫"代币"的特殊类型。
"代币"是社区约定出来的,也就是 1.9 节介绍的 ERC 标准。从代码的角度看,一个标准就是一组必须实现的函数和事件:
| 标准 | 必须实现的核心函数 | 必须发出的事件 |
|---|---|---|
| ERC20 | totalSupply、balanceOf、transfer、approve、transferFrom、allowance | Transfer、Approval |
| ERC721 | balanceOf、ownerOf、safeTransferFrom、transferFrom、approve、setApprovalForAll;元数据扩展 name、symbol、tokenURI | Transfer、Approval、ApprovalForAll |
| ERC1155 | balanceOf(地址, id)、balanceOfBatch、safeTransferFrom、safeBatchTransferFrom、setApprovalForAll;元数据扩展 uri | TransferSingle、TransferBatch、ApprovalForAll |
钱包、交易所、区块浏览器都是照着这份约定写的:
- MetaMask 调
balanceOf显示你的余额; - Uniswap 调
transferFrom完成兑换; - Etherscan 看到符合格式的
Transfer事件,就把合约收录为代币; - NFT 市场调
tokenURI获取图片和描述。
只要实现了这组接口,整个生态就能"即插即用"地对接你的合约。 这就是标准的价值。
6.2 不继承标准,又是什么?
就是第 3 章那样的普通合约。它照样能存数据、跑逻辑、被调用,只是钱包和浏览器不知道该怎么展示它。链上大部分合约都是这一类:DEX 交易对、借贷协议、DAO 投票、多签钱包……
另外,标准只规定"至少要有什么",不限制你加别的功能。
6.3 用 OpenZeppelin 实现标准
自己从头实现 ERC20 需要几百行代码,还容易写出安全漏洞。业界通用做法是继承 OpenZeppelin 提供的、经过审计的实现:
npm install @openzeppelin/contracts # 安装到 node_modules/
ERC20 版:CounterToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract CounterToken is ERC20 { // is ERC20:继承标准实现
uint256 public count;
event Incremented(address indexed by, uint256 newCount);
/// 部署时给部署者铸造 1000 枚代币(decimals 默认 18)
constructor() ERC20("Counter Token", "CNT") {
_mint(msg.sender, 1000 * 10 ** decimals());
}
function increment() external {
count += 1;
emit Incremented(msg.sender, count);
}
}
只加了 is ERC20 和一个构造函数,合约就拥有了 balanceOf、transfer 等全部标准函数,同时保留了我们自己的 count 和 increment。
不是"继承"让它变成了代币,而是"实现了标准接口"。 不用 OpenZeppelin、自己手写也完全可以,只要函数签名和行为符合规范,外界一样认。
ERC721 版:CounterNFT.sol(带链上 logo)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Base64} from "@openzeppelin/contracts/utils/Base64.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
contract CounterNFT is ERC721 {
// 用 constant:编译进字节码,只在部署时付一次费用
// SVG 属性用单引号,避免和 Solidity 字符串、JSON 的双引号冲突
string public constant logo =
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><circle cx='32' cy='32' r='30' fill='#6366f1'/><text x='32' y='42' font-size='28' font-family='monospace' text-anchor='middle' fill='#fff'>C6</text></svg>";
uint256 public count;
event Incremented(address indexed by, uint256 newCount);
constructor() ERC721("Counter NFT", "CNFT") {
_mint(msg.sender, 1); // 给部署者铸造 #1 号 NFT
}
function increment() external {
count += 1;
emit Incremented(msg.sender, count);
}
/// NFT 元数据:JSON 里的 image 字段就是上面的 SVG,全部存在链上
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
string memory json = string.concat(
'{"name":"Counter #', Strings.toString(tokenId),
'","description":"Counter contract logo NFT","image":"data:image/svg+xml;base64,',
Base64.encode(bytes(logo)),
'","attributes":[{"trait_type":"count","value":', Strings.toString(count), "}]}"
);
return string.concat("data:application/json;base64,", Base64.encode(bytes(json)));
}
}
用 base64 编码而不是直接拼原文,是因为 SVG 里的 #、< 等字符放进 URI 需要转义,容易出错。
6.4 编译带依赖的合约
合约里 import "@openzeppelin/...",需要用 --include-path 告诉 solc 去哪里找:
solc --base-path . --include-path node_modules \
--abi --bin --overwrite -o build \
contracts/CounterToken.sol contracts/CounterNFT.sol
build/ 下除了 CounterToken.* 和 CounterNFT.*,还会输出 ERC20.abi、Strings.bin 等依赖合约的文件,忽略即可。
6.5 生成两套 Go 绑定
mkdir -p token nft
abigen --abi build/CounterToken.abi --bin build/CounterToken.bin --pkg token --type CounterToken --out token/token.go
abigen --abi build/CounterNFT.abi --bin build/CounterNFT.bin --pkg nft --type CounterNFT --out nft/nft.go
生成的方法比简单版多了很多,因为标准函数也都生成了:
| 包 | 主要方法 |
|---|---|
token | DeployCounterToken、Name()、Symbol()、TotalSupply()、BalanceOf()、Transfer()、Count()、Increment() |
nft | DeployCounterNFT、OwnerOf()、TokenURI()、Logo()、Count()、Increment() |
6.6 一套流程调用两个合约:Go 接口的妙用
两个合约都有 count() 和 increment(),abigen 为它们生成的方法签名完全一致。利用 Go 接口的隐式实现,可以只写一份通用逻辑:
// abigen 生成的 *token.CounterToken 和 *nft.CounterNFT 都自动满足这个接口
type counter interface {
Count(opts *bind.CallOpts) (*big.Int, error)
Increment(opts *bind.TransactOpts) (*types.Transaction, error)
}
var c counter
if *contractType == "erc20" {
c = setupToken(...) // 部署/绑定 CounterToken,打印名称、总供应量、余额
} else {
c = setupNFT(...) // 部署/绑定 CounterNFT,打印 NFT 持有者,保存链上 logo
}
runIncrement(ctx, client, auth, callOpts, c) // 读 count → increment → 等待上链 → 再读 count
完整代码如下(用它替换第 5 章的 main.go;token/、nft/ 两个包由 6.5 节生成):
点击展开完整 main.go(约 210 行)
package main
import (
"context"
"flag"
"fmt"
"log"
"math/big"
"os"
"strings"
"time"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"counter-demo/nft"
"counter-demo/token"
)
// 任务 2:使用 abigen 生成的绑定代码与计数器合约交互
//
// 环境变量:ETH_RPC_URL、SENDER_PRIVATE_KEY
//
// 使用示例:
//
// go run . -type erc20 -deploy # 部署 CounterToken(ERC20)并调用一次 increment
// go run . -type erc721 -deploy # 部署 CounterNFT(ERC721,带 logo)并调用一次 increment
// go run . -type erc20 -addr 0xabc... # 对已部署的合约调用 increment
func main() {
contractType := flag.String("type", "", "合约类型:erc20 或 erc721")
deploy := flag.Bool("deploy", false, "先部署一个新合约")
addrHex := flag.String("addr", "", "已部署的合约地址")
flag.Parse()
if *contractType != "erc20" && *contractType != "erc721" {
log.Fatal("-type must be erc20 or erc721")
}
if !*deploy && !common.IsHexAddress(*addrHex) {
log.Fatal("use -deploy or -addr <contract address>")
}
rpcURL := os.Getenv("ETH_RPC_URL")
// 兼容复制私钥时带上的空白和大小写 0x 前缀
privKeyHex := strings.TrimSpace(os.Getenv("SENDER_PRIVATE_KEY"))
privKeyHex = strings.TrimPrefix(strings.TrimPrefix(privKeyHex, "0x"), "0X")
if rpcURL == "" || privKeyHex == "" {
log.Fatal("ETH_RPC_URL and SENDER_PRIVATE_KEY must be set")
}
// 部署和等待上链比较慢,超时给宽一点
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
client, err := ethclient.DialContext(ctx, rpcURL)
if err != nil {
log.Fatalf("failed to connect: %v", err)
}
defer client.Close()
privKey, err := crypto.HexToECDSA(privKeyHex)
if err != nil {
log.Fatalf("invalid private key: %v", err)
}
chainID, err := client.ChainID(ctx)
if err != nil {
log.Fatalf("failed to get chain id: %v", err)
}
// TransactOpts 负责签名,nonce / gas 留空时由绑定代码自动估算
auth, err := bind.NewKeyedTransactorWithChainID(privKey, chainID)
if err != nil {
log.Fatalf("failed to create transactor: %v", err)
}
auth.Context = ctx
callOpts := &bind.CallOpts{Context: ctx}
var c counter
if *contractType == "erc20" {
c = setupToken(ctx, client, auth, callOpts, *deploy, *addrHex)
} else {
c = setupNFT(ctx, client, auth, callOpts, *deploy, *addrHex)
}
runIncrement(ctx, client, auth, callOpts, c)
}
// counter 是两个合约共有的计数器方法。
// abigen 为 CounterToken 和 CounterNFT 生成的这两个方法签名一致,因此可以用同一套流程调用。
type counter interface {
Count(opts *bind.CallOpts) (*big.Int, error)
Increment(opts *bind.TransactOpts) (*types.Transaction, error)
}
// setupToken 部署或绑定 CounterToken,并打印 ERC20 基本信息。
func setupToken(ctx context.Context, client *ethclient.Client, auth *bind.TransactOpts, callOpts *bind.CallOpts, deploy bool, addrHex string) *token.CounterToken {
var (
addr common.Address
instance *token.CounterToken
err error
)
if deploy {
var tx *types.Transaction
addr, tx, instance, err = token.DeployCounterToken(auth, client)
if err != nil {
log.Fatalf("failed to deploy: %v", err)
}
waitDeployed(ctx, client, tx, addr)
fmt.Printf("代币页面: https://sepolia.etherscan.io/token/%s\n", addr.Hex())
} else {
addr = common.HexToAddress(addrHex)
if instance, err = token.NewCounterToken(addr, client); err != nil {
log.Fatalf("failed to bind contract: %v", err)
}
}
name, err := instance.Name(callOpts)
if err != nil {
log.Fatalf("failed to read name: %v", err)
}
symbol, err := instance.Symbol(callOpts)
if err != nil {
log.Fatalf("failed to read symbol: %v", err)
}
supply, err := instance.TotalSupply(callOpts)
if err != nil {
log.Fatalf("failed to read totalSupply: %v", err)
}
balance, err := instance.BalanceOf(callOpts, auth.From)
if err != nil {
log.Fatalf("failed to read balance: %v", err)
}
fmt.Printf("代币 : %s (%s)\n", name, symbol)
fmt.Printf("总供应量 : %s(最小单位,18 位小数)\n", supply)
fmt.Printf("我的余额 : %s\n", balance)
return instance
}
// setupNFT 部署或绑定 CounterNFT,打印 #1 号 NFT 信息并把链上 logo 保存为文件。
func setupNFT(ctx context.Context, client *ethclient.Client, auth *bind.TransactOpts, callOpts *bind.CallOpts, deploy bool, addrHex string) *nft.CounterNFT {
var (
addr common.Address
instance *nft.CounterNFT
err error
)
if deploy {
var tx *types.Transaction
addr, tx, instance, err = nft.DeployCounterNFT(auth, client)
if err != nil {
log.Fatalf("failed to deploy: %v", err)
}
waitDeployed(ctx, client, tx, addr)
// 构造函数给部署者铸造了 #1 号 NFT,Etherscan 的 NFT 页面会通过 tokenURI 渲染 logo
fmt.Printf("NFT 页面: https://sepolia.etherscan.io/nft/%s/1\n", addr.Hex())
} else {
addr = common.HexToAddress(addrHex)
if instance, err = nft.NewCounterNFT(addr, client); err != nil {
log.Fatalf("failed to bind contract: %v", err)
}
}
owner, err := instance.OwnerOf(callOpts, big.NewInt(1))
if err != nil {
log.Fatalf("failed to read owner: %v", err)
}
logo, err := instance.Logo(callOpts)
if err != nil {
log.Fatalf("failed to read logo: %v", err)
}
// 把链上读回的 SVG 落盘,用浏览器打开即可看到 logo
if err := os.WriteFile("logo_from_chain.svg", []byte(logo), 0o644); err != nil {
log.Fatalf("failed to save logo: %v", err)
}
fmt.Printf("NFT #1 持有者: %s\n", owner.Hex())
fmt.Printf("Logo : 已保存到 logo_from_chain.svg(%d 字节)\n", len(logo))
return instance
}
// waitDeployed 等待部署交易上链。
// 必须等部署完成,否则紧接着读合约会因为地址上还没有代码而失败。
func waitDeployed(ctx context.Context, client *ethclient.Client, tx *types.Transaction, addr common.Address) {
fmt.Printf("部署交易: %s\n等待上链...\n", tx.Hash().Hex())
if _, err := bind.WaitDeployed(ctx, client, tx); err != nil {
log.Fatalf("deploy not mined: %v", err)
}
fmt.Printf("合约地址: %s\n", addr.Hex())
}
// runIncrement 读取 count,调用 increment 并等待上链,再读取新的 count。
func runIncrement(ctx context.Context, client *ethclient.Client, auth *bind.TransactOpts, callOpts *bind.CallOpts, c counter) {
before, err := c.Count(callOpts)
if err != nil {
log.Fatalf("failed to read count: %v", err)
}
fmt.Printf("调用前 count: %s\n", before)
tx, err := c.Increment(auth)
if err != nil {
log.Fatalf("failed to call increment: %v", err)
}
fmt.Printf("increment 交易: %s\n等待上链...\n", tx.Hash().Hex())
receipt, err := bind.WaitMined(ctx, client, tx)
if err != nil {
log.Fatalf("increment not mined: %v", err)
}
after, err := c.Count(callOpts)
if err != nil {
log.Fatalf("failed to read count: %v", err)
}
fmt.Printf("区块号 : %d, 状态: %d (1=成功)\n", receipt.BlockNumber.Uint64(), receipt.Status)
fmt.Printf("调用后 count: %s\n", after)
}
运行时用 -type 参数选择合约:
go run . -type erc20 -deploy
go run . -type erc721 -deploy
注意区分:程序的
-type只是选择操作哪个合约;abigen 的--type是生成的 Go 结构体名,两者无关。
Sepolia 上的运行结果:
# -type erc20 -deploy
合约地址: 0xC9fB6CA5265DacB5400215498F1D8086A1F0Aa6E
代币 : Counter Token (CNT)
总供应量 : 1000000000000000000000(最小单位,18 位小数)
我的余额 : 1000000000000000000000
调用前 count: 0
区块号 : 11975695, 状态: 1 (1=成功)
调用后 count: 1
# -type erc721 -deploy
合约地址: 0x2d603Ff01E88F5dcF4f0e8F23750010700a33c01
NFT 页面: https://sepolia.etherscan.io/nft/0x2d603Ff01E88F5dcF4f0e8F23750010700a33c01/1
NFT #1 持有者: 0x83Da6626Ef0721dA9D452a7eeDc4F291A3f9d88C
Logo : 已保存到 logo_from_chain.svg(214 字节)
调用前 count: 0
区块号 : 11975702, 状态: 1 (1=成功)
调用后 count: 1
为什么总供应量是 1000000000000000000000?因为 链上只存整数。ERC20 的 decimals = 18 表示"最小单位是 10⁻¹⁸ 枚",1000 枚 = 1000 × 10¹⁸ 个最小单位。钱包显示"1000 CNT"是前端换算后的结果——和"1 元 = 100 分,数据库里存 100"是一个道理。
6.7 代币图标与 NFT 图片的区别
| ERC20 代币图标 | ERC721 NFT 图片 | |
|---|---|---|
| 标准里有图片字段吗 | 没有 | 有:tokenURI 返回的 JSON 中的 image |
| 钱包从哪获取 | 链下登记:Etherscan Token Info、Trust Wallet assets、CoinGecko、Token List | 调用合约的 tokenURI |
| 推荐格式 | 正方形 PNG(如 256×256,透明背景),SVG 作源文件 | 简单图形可用链上 SVG;复杂图片用 PNG/JPG 存 IPFS |
所以把 logo 写进 ERC20 合约,钱包也不会显示;而 NFT 的图片确实由合约提供。
第 7 章 原理深挖:用 Sepolia 链上数据逐层验证
前面讲的都是结论。这一章用 Foundry 的 cast 命令直接查询 Sepolia 上的真实数据,把每个结论和源码一一对上。
export R=https://ethereum-sepolia-rpc.publicnode.com # 任一 Sepolia RPC 均可
| 名称 | 地址 / 哈希 |
|---|---|
| CounterToken(ERC20) | 0xC9fB6CA5265DacB5400215498F1D8086A1F0Aa6E |
| CounterNFT(ERC721) | 0x2d603Ff01E88F5dcF4f0e8F23750010700a33c01 |
| 部署者 | 0x83Da6626Ef0721dA9D452a7eeDc4F291A3f9d88C |
| CounterToken 部署交易 | 0xbdd4783c…0cff20 |
| 第一次 increment 交易 | 0x5e8e7cc2…5f5442 |
7.1 函数选择器:链上怎么"找到"函数
字节码里没有函数名,那调用 increment() 时,链上怎么知道要执行哪段代码?靠的是函数选择器:
选择器 =
keccak256("函数签名")的前 4 个字节
cast sig "increment()" # 0xd09de08a
cast sig "count()" # 0x06661abd
cast sig "name()" # 0x06fdde03
cast sig "balanceOf(address)" # 0x70a08231
cast sig "transfer(address,uint256)" # 0xa9059cbb
合约字节码开头有一段"分发器":读取交易数据的前 4 个字节,和自己支持的选择器逐个比较,匹配上就跳到对应函数。ABI 告诉调用方选择器怎么算、参数怎么编码,这就是 ABI 存在的意义。
7.2 部署交易:input 就是 .bin
cast rpc eth_getTransactionByHash 0xbdd4783c71fcdd5505038778646403a6e9828ffc3fefe11cb1ae9e564a0cff20 --rpc-url $R
关键字段:
"to": null ← 部署交易没有接收方
"type": "0x2" ← EIP-1559 类型交易
"nonce": "0x27" ← 39
"input": "0x608060405234801561000f575f5ffd5b50604051806040016040528060 0d 81526020017f 436f756e74657220546f6b656e..."
把链上 input 和本地 build/CounterToken.bin 逐字节对比:
| 对比项 | 结果 |
|---|---|
链上 input 长度 | 6165 字节 |
本地 CounterToken.bin 长度 | 6165 字节 |
| 内容是否完全相同 | 是 |
这证明 abigen 生成的 DeployCounterToken 做的事情就是:把 .bin 原样放进交易的 input,to 留空。
input 中间那段 436f756e74657220546f6b656e 是什么?
cast to-ascii 436f756e74657220546f6b656e # Counter Token
正是构造函数里的 ERC20("Counter Token", "CNT")——字符串被直接编进了字节码。
7.3 部署回执:构造函数真的执行了
cast receipt 0xbdd4783c71fcdd5505038778646403a6e9828ffc3fefe11cb1ae9e564a0cff20 --rpc-url $R
contractAddress 0xc9fb6ca5265dacb5400215498f1d8086a1f0aa6e
blockNumber 11975694
logs[0].topics [0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, ← Transfer 事件
0x0000000000000000000000000000000000000000000000000000000000000000, ← from = 零地址
0x00000000000000000000000083da6626ef0721da9d452a7eedc4f291a3f9d88c] ← to = 部署者
logs[0].data 0x00000000000000000000000000000000000000000000003635c9adc5dea00000 ← 数量
- 合约地址不是随机的,由 部署者地址 + nonce 计算得出;
- 构造函数里的
_mint发出了一条 ERC20 标准的Transfer事件,from为零地址表示"凭空铸造"; cast to-dec 0x3635c9adc5dea00000得到1000000000000000000000,即 1000 × 10¹⁸;- Etherscan 正是看到这条符合 ERC20 格式的
Transfer事件,才把这个合约识别为代币——印证了第 6 章"标准是约定"。
7.4 链上代码:只剩运行时代码
cast code 0xC9fB6CA5265DacB5400215498F1D8086A1F0Aa6E --rpc-url $R
| 对比项 | 字节数 |
|---|---|
部署交易 input(部署代码 + 运行时代码) | 6165 |
链上合约代码(eth_getCode) | 3729 |
本地 solc --bin-runtime 输出的运行时代码 | 3729 |
| CounterNFT 链上合约代码(含 SVG 和 Base64 逻辑) | 8823 |
部署代码跑完构造函数就被丢弃,链上只保存运行时代码,且与 solc --bin-runtime 的输出完全一致。
以太坊限制单个合约的运行时代码不超过 24 KB(EIP-170),把大图片写进合约很容易超限。
7.5 写操作:increment() 交易只有 4 个字节
cast rpc eth_getTransactionByHash 0x5e8e7cc223d3c462d0c2c0f844330e8d114124247cfd8d4474262a928d5f5442 --rpc-url $R
"to": "0xc9fb6ca5265dacb5400215498f1d8086a1f0aa6e" ← 合约地址
"input": "0xd09de08a" ← increment() 的选择器
"value": "0x0"
"nonce": "0x28" ← 40,紧接部署交易的 39
"gas": "0x1f581" ← abigen 自动估算的 gas 上限
"maxFeePerGas": "0x22"
"maxPriorityFeePerGas": "0x2"
Go 里只写了一行 c.Increment(auth),最终链上留下的就是 0xd09de08a 这 4 个字节。nonce、gas、手续费全由绑定代码自动填好。
再看回执中的事件:
cast receipt 0x5e8e7cc223d3c462d0c2c0f844330e8d114124247cfd8d4474262a928d5f5442 --rpc-url $R
status 1
logs[0].topics [0x38ac789ed44572701765277c4d0970f2db1c1a571ed39e84358095ae4eaa5420, ← keccak256("Incremented(address,uint256)")
0x00000000000000000000000083da6626ef0721da9d452a7eedc4f291a3f9d88c] ← by(indexed)
logs[0].data 0x0000000000000000000000000000000000000000000000000000000000000001 ← newCount = 1
对照源码 event Incremented(address indexed by, uint256 newCount);:
- 第一个 topic 是事件签名的哈希,用来识别"这是哪个事件";
indexed的参数放进topics,可以被过滤检索;非indexed的参数放进data;- abigen 生成的
FilterIncremented(opts, by []common.Address)就是利用topics[1]按调用者过滤。
7.6 读操作:eth_call 的原始返回值与 ABI 编码
Count()、BalanceOf()、Name() 底层都是 eth_call。绕过 Go,直接发原始请求看看:
返回一个整数:count()
cast rpc eth_call '{"to":"0xC9fB6CA5265DacB5400215498F1D8086A1F0Aa6E","data":"0x06661abd"}' latest --rpc-url $R
"0x0000000000000000000000000000000000000000000000000000000000000002"
32 字节的 uint256,值为 2(两次 increment 之后)。
带参数:balanceOf(address)
参数按 ABI 规则补齐到 32 字节,拼在选择器后面:
0x70a08231 ← 选择器
00000000000000000000000083da6626ef0721da9d452a7eedc4f291a3f9d88c ← 地址左侧补零到 32 字节
cast rpc eth_call '{"to":"0xC9fB6CA5265DacB5400215498F1D8086A1F0Aa6E","data":"0x70a0823100000000000000000000000083da6626ef0721da9d452a7eedc4f291a3f9d88c"}' latest --rpc-url $R
"0x00000000000000000000000000000000000000000000003635c9adc5dea00000"
和部署回执里 Transfer 事件的数量完全相同——1000 × 10¹⁸。
返回字符串:name()
cast rpc eth_call '{"to":"0xC9fB6CA5265DacB5400215498F1D8086A1F0Aa6E","data":"0x06fdde03"}' latest --rpc-url $R
0x0000000000000000000000000000000000000000000000000000000000000020 ← 偏移量:字符串从第 32 字节开始
000000000000000000000000000000000000000000000000000000000000000d ← 长度:13 字节
436f756e74657220546f6b656e00000000000000000000000000000000000000 ← "Counter Token",右侧补零
这就是 ABI 编码规则:
- 静态类型(
uint256、address、bool)直接占 32 字节; - 动态类型(
string、bytes、数组)用"偏移量 + 长度 + 数据"表示。
abigen 生成的 Name() 内部调用 ABI 解码器,把这 96 字节还原成 Go 的 string——这正是"类型安全绑定"替你做掉的脏活。
7.7 NFT:tokenURI 返回的链上元数据
cast call 0x2d603Ff01E88F5dcF4f0e8F23750010700a33c01 "ownerOf(uint256)(address)" 1 --rpc-url $R
0x83Da6626Ef0721dA9D452a7eeDc4F291A3f9d88C
cast call 0x2d603Ff01E88F5dcF4f0e8F23750010700a33c01 "tokenURI(uint256)(string)" 1 --rpc-url $R
"data:application/json;base64,eyJuYW1lIjoiQ291bnRlciAjMSIsImRlc2NyaXB0aW9uIjoiQ291bnRlciBj..."
base64 解码后:
{
"name": "Counter #1",
"description": "Counter contract logo NFT",
"image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcn...",
"attributes": [{ "trait_type": "count", "value": 2 }]
}
image再解码就是合约里的logo常量,图片完全存在链上,不依赖 IPFS 或任何服务器;count属性实时读取链上状态,两次 increment 后为 2——元数据是"活的"。
7.8 一次调用的完整链路
main.go c.Increment(auth)
↓ abigen 生成的方法
token.go _CounterToken.contract.Transact(opts, "increment")
↓ bind.BoundContract:查 ABI 得到选择器 0xd09de08a → 获取 nonce(40) → 估算 gas → 私钥签名
JSON-RPC eth_sendRawTransaction
↓
Sepolia 交易 0x5e8e7cc2… { to: 0xC9fB…, input: 0xd09de08a }
↓ EVM 执行链上运行时代码(3729 字节),分发器匹配 0xd09de08a → increment()
链上状态 count 0 → 1,发出事件 Incremented(by=0x83Da…, newCount=1)
↓
main.go bind.WaitMined 拿到回执 status=1 → 再次 eth_call count() → 1
第 8 章 踩坑记录
| 现象 | 原因 | 解决 |
|---|---|---|
zsh: command not found: abigen | go install 装到 ~/go/bin,但它不在 PATH 里 | export PATH="$HOME/go/bin:$PATH" 写进 ~/.zshrc |
zsh: command not found: solc | Foundry 管理的 solc 在 ~/Library/Application Support/svm/<版本>/,文件名带版本号,不在 PATH 里 | 写完整路径、设 alias,或 brew install solidity |
Source "@openzeppelin/..." not found | solc 不知道去哪找依赖 | 加 --include-path node_modules |
abigen: open counter/counter.go: no such file or directory | abigen 不会自动创建目录 | 先 mkdir -p counter |
invalid hex character 'x' in private key | 私钥变量里混入了多余字符 | echo ${#SENDER_PRIVATE_KEY} 检查长度(应为 64 或 66) |
| 部署后立刻调用报错 | 部署交易还没上链,地址上没有代码 | 用 bind.WaitDeployed 等待 |
调用 Increment 后读到的还是旧值 | 交易还没被打包 | 用 bind.WaitMined 等待回执 |
改了 .sol,Go 代码没变化 | 生成代码不会自动更新,链上字节码也不可修改 | 重新 solc → abigen → 部署 |
| Etherscan 合约页只有一串字节码 | 合约没有验证源码 | 用 Verify and Publish 上传源码,编译参数需与部署时一致 |
| Etherscan NFT 页显示占位图 | 测试网的 NFT 元数据索引慢且不稳定 | 链上数据可用 cast call ... tokenURI 自行验证 |
| 转账 gas 不是 21000 | 接收地址上有代码(合约或委托账户) | 用 EstimateGas 估算,别写死 |
第 9 章 常见误区与总结
9.1 常见误区
- "ABI 存在链上。" 不在。链上只有字节码,ABI 由开发者保管或在 Etherscan 公开。
- "生成的 Go 代码包含合约逻辑。" 不包含。Go 代码只负责"怎么调用",逻辑在链上的字节码里执行。
- "把 logo 写进 ERC20 合约,钱包就会显示。" 不会。ERC20 标准没有图标字段,钱包从链下渠道获取。
- "必须继承 OpenZeppelin 才能做代币。" 不是。实现了标准接口就行,OpenZeppelin 只是最安全省事的实现。
- "读合约也要花钱。" 不用。
view/pure函数走eth_call,免费;只有修改状态才需要发交易付 gas。
9.2 总结
写合约(Solidity,.sol)
│ solc
▼
.abi(接口说明书,留在链下) + .bin(EVM 字节码,发到链上)
│ abigen
▼
Go 绑定代码(Deploy / Caller / Transactor / Filterer)
│ ethclient + 私钥签名
▼
JSON-RPC ──▶ 以太坊节点 ──▶ EVM 执行字节码
- solc 把人读的 Solidity 编译成机器执行的字节码,并输出描述接口的 ABI;
- abigen 把 ABI 翻译成类型安全的 Go 代码,底层统一由
bind.BoundContract负责编码、签名和发送; - ERC 标准是生态的"通用插座",实现了它,钱包、浏览器、交易所就能直接接入你的合约。
9.3 下一步可以学什么
- 用 Foundry 写合约测试:
forge test,比每次部署到测试网快得多; - 监听事件:用 abigen 生成的
WatchIncremented实时订阅链上事件,做一个简单的链上数据索引; - 验证合约源码:在 Etherscan 上 Verify,让任何人都能看到并调用你的合约函数;
- 读 go-ethereum 源码:从
accounts/abi/bind包开始,看BoundContract.Transact如何完成编码、签名与发送。