Web3系统学习系列04-Solidity 基础

Web3系统学习系列04-Solidity 基础

Posted by 十渊 on 2026-05-10

📖 模块 04 — Solidity 基础语法 + ERC-20 合约部署(Day 4-5)

学习时长:2 天 | 产出:Sepolia 上的合约地址(0x...
对应路线:Day 4-5,学 Solidity 基础 + Remix 写 ERC-20,部署到 Sepolia

存量代码 DeepSeek - Into the Unknown
Layout of a Solidity Source File — Solidity 0.8.35-develop documentation
Remix IDE - Smart Contract Development
智能合约简介 — Solidity 0.8.35-开发文档
合约永久存储与修改


🎯 任务目标

  • [ ] 理解 Solidity 基本语法:数据类型、函数修饰符、事件、mapping
  • [ ] 理解 ERC-20 标准:接口定义、6 个必须实现的函数
  • [ ] 在 Remix IDE 中编写并编译 ERC-20 合约
  • [ ] 连接 MetaMask(Sepolia)部署合约,获得合约地址
  • [ ] 在 Etherscan 验证合约(Verify & Publish)
  • [ ] 调用合约函数:transfer、approve、allowance

📚 学习材料(序号编码)

M-01 — Solidity 官方文档(中文)

链接https://docs.soliditylang.org/zh/latest/
重点章节(按顺序阅读):

序号 章节 重点内容
M-01-A /introduction-to-smart-contracts 合约基本结构、Storage vs Memory
M-01-B /types.html uint、address、bool、string、bytes、mapping
M-01-C /contracts.html 函数、修饰符(modifier)、事件(event)、constructor
M-01-D /units-and-global-variables.html msg.sender、msg.value、block.timestamp

预计时间:Day 4 上午,2-3 小时


M-02 — Remix IDE(在线合约编辑器)

链接https://remix.ethereum.org
说明:无需安装,浏览器直接使用。提供编辑器 + 编译器 + 部署界面。
操作:打开后在左侧 File Explorer → 新建 .sol 文件 → 开始写代码


M-03 — ERC-20 标准文档

链接https://eips.ethereum.org/EIPS/eip-20
说明:ERC-20 是以太坊最重要的代币标准,定义了 6 个函数 + 2 个事件。
重点阅读:Specification 部分(函数签名列表),5 分钟可读完。


M-04 — OpenZeppelin ERC-20 实现(参考)

链接https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol
说明:业界标准实现,读懂后面试能说"生产环境用 OpenZeppelin,不自己实现"。
建议:先自己手写一遍,再对照 OZ 实现理解差距。


M-05 — Sepolia Etherscan 合约验证教程

链接https://docs.etherscan.io/tutorials/verifying-contracts-programmatically
简版操作(不需要命令行):

  1. 部署后复制合约地址
  2. 打开 https://sepolia.etherscan.io/verifyContract
  3. 粘贴合约地址,选择编译器版本,粘贴源码,提交

M-06 — 视频:Solidity 速成(可选)

链接https://www.youtube.com/watch?v=ipwxYa-F1uY(Patrick Collins,32小时版可只看前2小时)
说明:如果文档阅读感觉枯燥,用这个视频引导。
推荐段落:0:00 - 2:00:00(变量、函数、合约部署)


🧠 概念精讲

0. Solidity 快速开始简单应用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
// SPDX-License-Identifier: MIT
// SPDX 用于声明开源协议
// MIT 是最常见的 Solidity 开源许可证

pragma solidity ^0.8.20;
// 指定 Solidity 编译器版本
// ^0.8.20 表示:
// >=0.8.20 且 <0.9.0 都可以编译

contract MyContract {
// contract = 智能合约
// 类似 Java 里的 class
// 部署后会生成一个链上地址

// =========================================================
// 1. State Variable(状态变量)
// =========================================================

uint public count;
// uint = 无符号整数
// public = 自动生成 getter 读取函数
// count 会永久存储在区块链 storage 中

// =========================================================
// 2. Event(事件)
// =========================================================

event Increment(address user, uint value);
// event = 链上日志系统
// 用于记录重要行为
// 前端 / 区块浏览器可以监听

// 参数:
// user = 谁触发了 increment
// value = increment 后的新值

// =========================================================
// 3. Modifier(修饰器)
// =========================================================

modifier onlyOwner() {
// modifier = 函数执行前的统一规则

// "_" 表示:
// 真正函数逻辑插入这里

_;
}

// =========================================================
// 4. Constructor(构造函数)
// =========================================================

constructor() {
// constructor 会在部署时执行一次

// 常用于:
// 1. 初始化 owner
// 2. 初始化状态
// 3. 初始化配置
}

// =========================================================
// 5. Function(函数)
// =========================================================

function increment() public {
// public = 任何地址都能调用

count++;
// 等价于:
// count = count + 1

// 修改 storage
// 会消耗 Gas

emit Increment(msg.sender, count);
// emit = 触发 event

// msg.sender =
// 当前调用这个函数的钱包地址
}

// =========================================================
// 6. Struct(结构体)
// =========================================================

struct User {
// struct = 自定义数据结构

uint age;
}

// 类似 Java:

/*
class User {
int age;
}
*/

// =========================================================
// 7. Enum(枚举)
// =========================================================

enum State {
Open,
Closed
}

// enum = 有限状态集合

// Open = 0
// Closed = 1

// 常用于:
// 订单状态
// NFT销售状态
// DAO投票状态

// =========================================================
// 8. Custom Error(自定义错误)
// =========================================================

error NotEnoughBalance(uint current, uint required);

// error 比 require("string")
// 更省 Gas

// current = 当前余额
// required = 需要余额
}

image-20260512173654034

部署

image-20260512173722894

选择账户, 方法进行调用

image-20260512173812979

调用成功

image-20260512173819712

查询count方法,交易次数出来

image-20260512173850082

1. Solidity 核心语法速览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20; // 指定编译器版本

contract MyToken {
// 状态变量(存储在 Storage,永久保存,写入贵!)
string public name = "MyToken";
uint256 public totalSupply;

// mapping:类似 Java 的 HashMap<address, uint256>
mapping(address => uint256) public balanceOf;

// 事件:写入日志,比写 Storage 便宜 10 倍
event Transfer(address indexed from, address indexed to, uint256 value);

// constructor:只在部署时执行一次
constructor(uint256 _initialSupply) {
totalSupply = _initialSupply;
balanceOf[msg.sender] = _initialSupply; // msg.sender = 部署者地址
}

// 函数修饰符
// public = 外部和内部都能调用
// private = 只有合约内部
// internal = 合约内部 + 继承合约
// external = 只有外部调用
// view = 不修改状态(免费 call,不消耗 Gas)
// pure = 不读不写状态

function transfer(address _to, uint256 _amount) public returns (bool) {
require(balanceOf[msg.sender] >= _amount, "Insufficient balance");
balanceOf[msg.sender] -= _amount;
balanceOf[_to] += _amount;
emit Transfer(msg.sender, _to, _amount); // 发出事件,记录日志
return true;
}
}

2. 关键概念对比(金融科技视角)

Web3 概念 金融科技类比 关键差异
mapping(address => uint256) 数据库表(地址→余额) 在链上,所有人可读,写入费 Gas
event Transfer(...) 数据库 Change Log / Kafka 消息 存在区块日志,不可修改,比 SSTORE 便宜
msg.sender 请求中的 JWT Token 里的 userId 由交易签名密码学保证,无法伪造
require(condition, msg) 业务校验 + 抛异常 失败则 revert,Gas 退还(已用的不退)
constructor Spring Bean 初始化 / 数据库初始脚本 只执行一次,部署时运行
view 函数 GET 接口(只读) 本地节点执行,完全免费,不上链
view 函数 POST 接口(写操作) 需要发交易,消耗 Gas,上链

3. ERC-20 标准接口

ERC-20 是一套约定,任何实现以下接口的合约都是"合法"ERC-20 代币:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface IERC20 {
// 查询类(view,免费)
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);

// 写操作(需要发交易,消耗 Gas)
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);

// 事件(转账和授权时必须 emit)
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}

approve + transferFrom 的用途(类比:银行代扣授权):

  • approve(DeFi协议地址, 1000) = 授权某协议最多扣 1000 个代币
  • transferFrom(你的地址, 协议地址, 500) = 协议用你的授权扣 500 个代币
  • 这是 Uniswap、Compound 等 DeFi 协议运作的基础

4. Storage vs Memory vs Stack

存储位置 生命周期 费用 用途
Storage 永久(区块链上) 最贵(SSTORE 20000 Gas) 状态变量(余额、名称等)
Memory 函数调用期间 较便宜 函数内临时变量、字符串参数
Stack 极短(当前操作) 几乎免费 EVM 内部计算
Calldata 只读,函数调用期间 比 Memory 便宜 external 函数的输入参数

🔧 动手实操步骤

Step 1:打开 Remix 并创建文件

  1. 打开 https://remix.ethereum.org
  2. 左侧 File Explorer → 点击「+」新建文件
  3. 文件名:MyERC20.sol

Step 2:编写 ERC-20 合约

将以下完整合约复制到 MyERC20.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/**
* @title MyERC20Token
* @dev 手动实现 ERC-20(学习用途,生产环境请用 OpenZeppelin)
*/
contract MyERC20Token {
// ===== 基本信息 =====
string public name;
string public symbol;
uint8 public decimals = 18; // 和 ETH 一样,18位小数
uint256 public totalSupply;

// ===== 核心状态 =====
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

// ===== 事件 =====
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

// ===== 构造函数 =====
constructor(string memory _name, string memory _symbol, uint256 _initialSupply) {
name = _name;
symbol = _symbol;
// _initialSupply 是"人类可读"数量,乘以 10^18 转换为最小单位
totalSupply = _initialSupply * 10 ** uint256(decimals);
balanceOf[msg.sender] = totalSupply;
emit Transfer(address(0), msg.sender, totalSupply); // 铸造事件
}

// ===== 转账 =====
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(balanceOf[msg.sender] >= amount, "Insufficient balance");

balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}

// ===== 授权 =====
function approve(address spender, uint256 amount) public returns (bool) {
require(spender != address(0), "Approve to zero address");
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}

// ===== 代扣转账(需要先授权)=====
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(balanceOf[from] >= amount, "Insufficient balance");
require(allowance[from][msg.sender] >= amount, "Insufficient allowance");

allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
emit Transfer(from, to, amount);
return true;
}
}

image-20260517220345622


Step 3:编译合约

  1. 左侧点击「Solidity Compiler」图标(第2个)
  2. 编译器版本选择 0.8.20
  3. 点击「Compile MyERC20.sol」
  4. 左侧出现绿色勾 = 编译成功

常见错误排查

  • pragma solidity ^0.8.20 与编译器版本不匹配 → 调整编译器版本
  • 缺少分号 → 检查每行结尾

image-20260517223413874

image-20260517223428251

测试授权10

image-20260517223723358

选择account2转账

image-20260517223826773

image-20260517223850395

转账成功

image-20260517224102028

account1账户减少了5

image-20260517224114768

account2账户增加5

image-20260517224152281


Step 4:连接 MetaMask 并部署到 Sepolia

  1. 左侧点击「Deploy & Run Transactions」图标(第3个)
  2. Environment 选择 Injected Provider - MetaMask

image-20260517224230519

  1. MetaMask 弹窗 → 选择 Sepolia 网络 → 连接

选择之前创建的maskwallet

image-20260517224527745

image-20260517224547950

一定要在设置->高级设置里面开启测试网络

image-20260517224716115

  1. 确认 Account 显示你的 Sepolia 地址

image-20260517224738586

  1. Contract 选择 MyERC20Token
  1. 展开 Deploy 下方的参数输入框:
    1
    2
    3
    _name: "ZMERC20"
    _symbol: "^"
    _initialSupply: 1000
  2. 点击「Deploy」→ MetaMask 确认 → 等待交易上链
  3. 左下角「Deployed Contracts」出现合约地址 🎉

image-20260517224947268

这里因为采用的测试忘了gas不够演示到这里就足够

📸 截图要求:Remix 底部显示合约地址的界面


Step 5:与合约交互

在 Remix 的 Deployed Contracts 区域,展开你的合约,可以:

读取数据(免费)

  • 点击 name → 显示 “TestToken”
  • 点击 totalSupply → 显示 1000000000000000000000000(加了 18 位 0)
  • balanceOf 输入你的地址 → 显示全部余额

写入操作(消耗 Gas)

  • transfer:输入某个地址 + 金额,点击执行,MetaMask 确认
  • 执行后在 Etherscan 查看 Transfer 事件日志

Step 6:在 Etherscan 验证合约(可选但推荐)

  1. 复制部署后的合约地址
  2. 打开 https://sepolia.etherscan.io,搜索该地址
  3. 点击「Contract」→「Verify and Publish」
  4. 选择 Solidity (Single file),编译器版本 v0.8.20,MIT 协议
  5. 粘贴源码,提交
  6. 验证成功后合约显示绿色勾,任何人可以读取源码

📸 截图要求:Etherscan 上合约页面(显示合约地址 + Contract 标签)


✅ 产出检查清单

  • [ ] 在 Remix 中成功编译合约(无错误)
  • [ ] 部署到 Sepolia,获得合约地址(记录:0x_______________
  • [ ] 在 Etherscan 上能查看到该合约
  • [ ] 成功调用 transfer 转账,在 Etherscan 看到 Transfer 事件日志
  • [ ] 截图存档:① Remix 编译成功界面 ② 部署成功界面(显示合约地址)③ Etherscan 合约页面

📝 测试题


选择题

T01. 在 Solidity 合约中,mapping(address => uint256) public balanceOf 存储在哪里?调用 balanceOf(myAddr) 需要花费 Gas 吗?

A. Memory,每次函数调用结束后清空,调用免费
B. Storage(区块链上),读取需要发交易,消耗 Gas
C. Storage(区块链上),public 自动生成 view getter,链下调用免费
D. Calldata,只在交易执行期间存在,调用免费

参考答案

Cpublic 状态变量自动生成对应的 view 函数(getter)。view 函数在链下(通过 eth_call RPC)执行时完全免费,节点本地计算即可,不需要广播交易。但如果是在合约内部写 mapping 值(SSTORE),则消耗大量 Gas。


T02. ERC-20 合约的 emit Transfer(address(0), msg.sender, totalSupply) 在 constructor 中的含义是?

A. 把所有代币从零地址销毁
B. 表示代币从"无"(零地址代表铸造源头)转到部署者,是代币"铸造"的约定表示
C. 这是错误的,不应该 emit Transfer 到零地址
D. 向零地址发送代币,实际上没有任何效果

参考答案

B。ERC-20 标准中约定:Transfer 事件 from = address(0) 表示铸造(mint),to = address(0) 表示销毁(burn)。这是链上事件的语义约定,Etherscan 等工具按此规则显示"铸造"而非普通转账。


T03. 以下哪个 Solidity 函数调用不需要支付 Gas(在链下调用时)?

1
2
3
4
function A() public { balanceOf[msg.sender] += 1; }
function B() public view returns (uint256) { return balanceOf[msg.sender]; }
function C() public pure returns (uint256) { return 1 + 1; }
function D() external payable { /* 接收ETH */ }

A. 只有 A
B. B 和 C
C. 只有 C
D. A、B、C、D 都需要 Gas

参考答案

Bview(读取状态,不修改)和 pure(不读不写状态)函数在链下执行时免费。A 修改了状态变量,必须发交易消耗 Gas。Dpayable,也需要发交易。


T04. approve(spenderAddr, 1000) 之后,spenderAddr 可以调用 transferFrom 转多少次?

A. 只能转一次,每次授权只能用一次
B. 可以转多次,只要累计金额不超过 1000
C. 可以无限转账,1000 只是最低限额
D. 取决于合约实现,ERC-20 标准未规定

参考答案

Ballowance 是累计额度,每次 transferFrom 会减少 allowance 余额。直到 allowance 归零前,spender 可以多次调用 transferFrom,但累计转出金额不能超过授权额度。


判断题

T05. Solidity 中 uint256 不会溢出,因为 pragma solidity ^0.8.0 以上版本默认启用溢出保护。(对 / 错)

参考答案

。Solidity 0.8.0+ 默认对整数加减乘法启用溢出/下溢检查,溢出时自动 revert。如果确实需要不检查的算术(通常在确定不会溢出的场景,节省 Gas),可以使用 unchecked { ... } 块。


T06. 部署 ERC-20 合约到 Sepolia 后,合约代码可以被修改,只需要重新调用 Remix 的 Deploy 按钮即可更新。(对 / 错)

参考答案

。区块链上的合约代码一旦部署不可更改(immutable)。重新 Deploy 会生成一个全新的合约地址,旧合约依然存在于链上。如果需要可升级合约,必须使用代理模式(Proxy Pattern),这是进阶话题。


简答题

T07. 解释 ERC-20 中 approve + transferFrom 机制的作用场景。
类比你金融科技背景,这与哪种业务模式类似?存在什么安全风险?

参考答案

作用:允许第三方合约(如 Uniswap)在得到用户授权的前提下,代为转移用户的代币。解决了"用户不需要先把代币转给协议,协议直接从用户账户扣款"的需求。

金融类比:银行代扣授权。用户签署代扣协议(approve),授权某平台(spender)每月从账户自动扣款(transferFrom),金额不超过授权上限。

安全风险

  1. 无限授权:很多 DApp 让用户 approve 极大额度(uint256 最大值),若合约有漏洞或恶意,可无限转走用户代币
  2. 双重花费攻击(ERC-20 approve 竞争条件):修改 allowance 时,若先 approve(100) 再 approve(50),spender 可能在两笔 approve 之间抢先转走 100,再转走 50,共 150
  3. 解决方案:先 approve(0) 再 approve 新值,或使用 ERC-2612(Permit 签名授权)

T08. 你的 ERC-20 合约中,如果有人调用 transfer(address(0), 100)(转给零地址)会发生什么?
在 USDT(Tether)等真实合约中,这被允许吗?为什么要禁止?

参考答案

在你的合约中:执行了 require(to != address(0), "Transfer to zero address"),会 revert,交易失败,代币不会丢失。

若没有这个保护:转账会"成功",100 个代币会从你的余额扣除,但零地址没有私钥,无人能控制,代币永久丢失(相当于销毁)。

为什么禁止:防止意外"烧币"(burn)。如果需要销毁代币,应该通过专门的 burn() 函数(更明确的意图),而不是意外转给零地址。

真实 USDT:Tether 合约同样有对零地址的检查,防止意外丢失。


🔗 下一模块

完成本模块后 → 模块 04:Web3j 连接节点 → 读余额 → 监听事件

下一步用 Java(Web3j)与你刚部署的合约交互,跑通后端与区块链对接的 demo。

引用资料