Go智能合约实战:solc编译、abigen生成与链上调用

适合谁读:刚开始学区块链开发、会一点 Go 的同学。不需要提前了解 Solidity、ABI、字节码这些概念,文章会从零讲起。

读完你能做到:

  • 说清楚 .sol、.abi、.bin、solc、abigen 分别是什么、彼此什么关系;
  • 自己写一个智能合约,编译它,用 abigen 生成 Go 代码,在 Sepolia 测试网上部署并调用;
  • 理解为什么"继承 ERC20 就是代币、继承 ERC721 就是 NFT";
  • 用真实的链上数据,看懂一笔合约调用在底层到底发生了什么。

文中所有链上数据都来自 Sepolia 测试网上真实部署的合约,并附有查询命令,可以自行复现。


目录


第 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 接口定义
abigenprotoc-gen-go(根据 .proto 生成 Go 客户端代码)
生成的 counter.go生成的 xxx.pb.go 客户端
以太坊节点 RPCgRPC 服务端地址

整条链路:

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)现金、积分每一份都一模一样,可以互换、可以拆分
ERC721NFT、非同质化代币(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 表示

三者对比

对比项ERC20ERC721ERC1155
常见叫法代币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写调用程序https://go.dev/dl/
solc编译合约brew install solidity,或安装 Foundry 后使用其自带的 solc
abigen生成 Go 代码go install github.com/ethereum/go-ethereum/cmd/abigen@v1.17.8
Foundry(可选)本地测试链 anvil、命令行工具 castcurl -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 测试网

  1. RPC 地址:注册 Infura(或 Alchemy),创建项目,得到形如 https://sepolia.infura.io/v3/<API_KEY> 的地址;
  2. 测试账户:在 MetaMask 里新建一个专门用于测试的账户,导出私钥;
  3. 测试币:在 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 byindexed 表示这个字段可以被检索过滤
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() externalnonpayable(会改状态,但不接收 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 标准。从代码的角度看,一个标准就是一组必须实现的函数和事件:

标准必须实现的核心函数必须发出的事件
ERC20totalSupply、balanceOf、transfer、approve、transferFrom、allowanceTransfer、Approval
ERC721balanceOf、ownerOf、safeTransferFrom、transferFrom、approve、setApprovalForAll;元数据扩展 name、symbol、tokenURITransfer、Approval、ApprovalForAll
ERC1155balanceOf(地址, id)、balanceOfBatch、safeTransferFrom、safeBatchTransferFrom、setApprovalForAll;元数据扩展 uriTransferSingle、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

生成的方法比简单版多了很多,因为标准函数也都生成了:

包主要方法
tokenDeployCounterToken、Name()、Symbol()、TotalSupply()、BalanceOf()、Transfer()、Count()、Increment()
nftDeployCounterNFT、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: abigengo install 装到 ~/go/bin,但它不在 PATH 里export PATH="$HOME/go/bin:$PATH" 写进 ~/.zshrc
zsh: command not found: solcFoundry 管理的 solc 在 ~/Library/Application Support/svm/<版本>/,文件名带版本号,不在 PATH 里写完整路径、设 alias,或 brew install solidity
Source "@openzeppelin/..." not foundsolc 不知道去哪找依赖加 --include-path node_modules
abigen: open counter/counter.go: no such file or directoryabigen 不会自动创建目录先 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 常见误区

  1. "ABI 存在链上。" 不在。链上只有字节码,ABI 由开发者保管或在 Etherscan 公开。
  2. "生成的 Go 代码包含合约逻辑。" 不包含。Go 代码只负责"怎么调用",逻辑在链上的字节码里执行。
  3. "把 logo 写进 ERC20 合约,钱包就会显示。" 不会。ERC20 标准没有图标字段,钱包从链下渠道获取。
  4. "必须继承 OpenZeppelin 才能做代币。" 不是。实现了标准接口就行,OpenZeppelin 只是最安全省事的实现。
  5. "读合约也要花钱。" 不用。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 如何完成编码、签名与发送。