Solidity基础与ERC20合约解读
学习Solidity数据类型、函数可见性、修饰器,深入理解ERC20标准合约实现
核心概念
什么是Solidity?
一句话定义: Solidity是以太坊智能合约的主要编程语言,语法类似JavaScript,编译后在EVM上运行。
作为PM为什么要学Solidity?
- 能读懂合约逻辑,评估技术可行性
- 与开发团队高效沟通
- 理解DeFi协议的核心机制
- 识别潜在的安全风险
数据类型详解
值类型 (Value Types)
值类型在赋值时会复制整个值。
// 整数类型
uint256 public balance = 100; // 无符号整数 (0 到 2^256-1)
uint8 public small = 255; // 0 到 255
int256 public negative = -50; // 有符号整数
// 布尔
bool public isActive = true;
// 地址
address public owner = 0x123...;
address payable public wallet; // 可接收ETH的地址
// 字节
bytes32 public hash; // 固定长度32字节
bytes1 public flag = 0x01;uint 大小对照表
| 类型 | 范围 | 常见用途 |
|---|---|---|
| uint8 | 0 ~ 255 | 状态码、小计数 |
| uint32 | 0 ~ 42亿 | 时间戳 |
| uint64 | 0 ~ 1.8×10^19 | 大计数 |
| uint256 | 0 ~ 1.15×10^77 | 默认,Gas最优 |
> 面试点: 为什么默认用uint256?因为EVM是256位机器,uint256操作最高效,小类型反而需要额外转换消耗更多Gas。
引用类型 (Reference Types)
引用类型需要指定数据位置。
// 数组
uint256[] public numbers; // 动态数组
uint256[10] public fixedArray; // 固定长度
// 映射 - DeFi最常用!
mapping(address => uint256) public balances;
mapping(address => mapping(address => uint256)) public allowance;
// 结构体
struct User {
address wallet;
uint256 balance;
bool isActive;
}
mapping(address => User) public users;
// 字符串
string public name = "MyToken";数据位置
| 位置 | 生命周期 | Gas成本 | 用途 |
|---|---|---|---|
| storage | 永久 | 最高 | 状态变量 |
| memory | 函数调用内 | 中等 | 临时变量 |
| calldata | 只读 | 最低 | 外部函数参数 |
// 示例
function process(string calldata input) external { // calldata最省Gas
string memory temp = input; // 复制到memory
// storage变量在合约顶层定义
}函数详解
函数可见性
contract MyContract {
// public - 任何人都能调用
function publicFunc() public { }
// private - 只有本合约能调用
function privateFunc() private { }
// internal - 本合约 + 继承合约
function internalFunc() internal { }
// external - 只能从外部调用
function externalFunc() external { }
}可见性对比表
| 可见性 | 合约内部 | 继承合约 | 外部调用 | Gas效率 |
|---|---|---|---|---|
| public | ✅ | ✅ | ✅ | 中 |
| private | ✅ | ❌ | ❌ | 高 |
| internal | ✅ | ✅ | ❌ | 高 |
| external | ❌ | ❌ | ✅ | 最高 |
> 最佳实践: 对外接口用external,内部逻辑用private或internal
状态修饰符
// view - 只读状态,不修改
function getBalance(address user) public view returns (uint256) {
return balances[user];
}
// pure - 不读不写状态,纯计算
function add(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
// payable - 可以接收ETH
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// 默认 - 可以修改状态,消耗Gas
function transfer(address to, uint256 amount) public {
balances[msg.sender] -= amount;
balances[to] += amount;
}Gas消耗规则
view/pure 函数:
- 外部直接调用: 免费 (不上链)
- 被交易内部调用: 消耗Gas
修改状态函数:
- 总是消耗Gas
- 写Storage最贵函数修饰器 (Modifier)
修饰器用于复用检查逻辑。
// 定义修饰器
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_; // 继续执行函数体
}
modifier validAddress(address _addr) {
require(_addr != address(0), "Invalid address");
_;
}
// 使用修饰器
function withdraw() public onlyOwner {
// 只有owner能执行
}
// 组合多个修饰器
function adminTransfer(address to)
public
onlyOwner
validAddress(to)
{
// 先检查owner,再检查地址有效性
}全局变量
消息上下文
msg.sender // 调用者地址 (最常用)
msg.value // 发送的ETH数量 (单位: wei)
msg.data // 完整的calldata区块信息
block.timestamp // 当前区块时间戳 (秒)
block.number // 当前区块号
block.chainid // 链ID (1=主网, 11155111=Sepolia)安全警告
// ❌ 危险: tx.origin
function transfer() public {
require(tx.origin == owner); // 可被钓鱼攻击!
}
// ✅ 安全: msg.sender
function transfer() public {
require(msg.sender == owner); // 推荐
}> tx.origin vs msg.sender: tx.origin是交易最初发起者,msg.sender是直接调用者。钓鱼合约可以诱导用户调用,此时tx.origin是用户,但msg.sender是钓鱼合约。
事件 (Events)
事件用于记录日志,前端可以监听。
// 定义事件
event Transfer(
address indexed from, // indexed可被过滤
address indexed to,
uint256 value
);
event Approval(
address indexed owner,
address indexed spender,
uint256 value
);
// 触发事件
function transfer(address to, uint256 amount) public {
balances[msg.sender] -= amount;
balances[to] += amount;
emit Transfer(msg.sender, to, amount);
}indexed 关键字
- 最多3个indexed参数
- indexed参数存储在topics中,可高效搜索
- 非indexed参数存储在data中
- 前端用ethers.js监听:
contract.on("Transfer", callback)
ERC20 标准详解
什么是ERC20?
ERC20是以太坊代币的标准接口,定义了代币合约必须实现的函数和事件。
接口定义
interface IERC20 {
// 查询函数
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
// 操作函数
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);
// 事件
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}核心流程图
方式1: 直接转账
用户A ──transfer(B, 100)──→ 用户B
余额变化: A-100, B+100
方式2: 授权转账 (DeFi核心模式)
Step1: 用户A ──approve(DEX, 1000)──→ DEX获得授权
Step2: DEX ──transferFrom(A, Pool, 100)──→ 流动性池
DEX代替A完成转账
为什么需要两步?
→ 合约不能直接动用户的钱
→ 用户必须先approve授权
→ 合约才能用transferFrom操作完整实现
contract ERC20 is IERC20 {
string public name;
string public symbol;
uint8 public decimals = 18;
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
constructor(string memory _name, string memory _symbol) {
name = _name;
symbol = _symbol;
}
function totalSupply() public view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "Transfer to zero address");
require(_balances[msg.sender] >= amount, "Insufficient balance");
_balances[msg.sender] -= amount;
_balances[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 amount) public returns (bool) {
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function transferFrom(
address from,
address to,
uint256 amount
) public returns (bool) {
require(_balances[from] >= amount, "Insufficient balance");
require(_allowances[from][msg.sender] >= amount, "Insufficient allowance");
_balances[from] -= amount;
_balances[to] += amount;
_allowances[from][msg.sender] -= amount;
emit Transfer(from, to, amount);
return true;
}
}Decimals 详解
为什么需要Decimals?
Solidity不支持浮点数,所以用整数表示小数:
实际金额 = 链上数值 / 10^decimals
例如 USDC (decimals=6):
链上值: 1000000
实际值: 1000000 / 10^6 = 1 USDC常见Token Decimals
| Token | Decimals | 1个Token的最小单位 |
|---|---|---|
| ETH | 18 | 1 wei = 10^-18 ETH |
| USDC | 6 | 1 = 10^-6 USDC |
| USDT | 6 | 1 = 10^-6 USDT |
| WBTC | 8 | 1 satoshi = 10^-8 BTC |
| DAI | 18 | 1 = 10^-18 DAI |
处理Decimals的代码
// 前端处理
const decimals = await token.decimals();
const rawBalance = await token.balanceOf(address);
const actualBalance = rawBalance / (10 ** decimals);
// 发送时
const amount = 100; // 想发送100 USDC
const rawAmount = amount * (10 ** 6); // = 100000000
await token.transfer(to, rawAmount);链上实操:读USDC合约
步骤
1. 打开: https://etherscan.io/token/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48
2. 点击 Contract → Read Contract
3. 查看核心信息:
name() → "USD Coin"
symbol() → "USDC"
decimals() → 6
totalSupply() → 除以10^6得到实际供应量4. 查询余额:
balanceOf(任意地址) → 返回值 / 10^6 = USDC数量5. 查看授权:
allowance(owner地址, spender地址) → 授权额度今日思考
问题1: 为什么mapping不能遍历?
- mapping底层是哈希表,key不连续存储
- 不保存所有key的列表
- 如需遍历,需额外维护一个数组存储所有key
- 这是Gas优化的权衡
问题2: approve存在什么安全风险?
- 前端钓鱼: 恶意网站诱导approve给攻击合约
- 无限授权: approve(spender, type(uint256).max) 风险
- 授权覆盖攻击: 改变授权额度时的竞态条件
- 建议: 用多少授权多少,或使用permit (EIP-2612)
问题3: 为什么external比public省Gas?
- external参数直接从calldata读取
- public参数需要复制到memory
- calldata是只读的,不需要额外处理
- 对于大数组参数差异更明显
学习资源
视频教程
| 资源 | 语言 | 说明 |
|---|---|---|
| CryptoZombies | 中/英 | 游戏化学习Solidity |
| Solidity by Example | 英文 | 代码示例驱动 |
| Smart Contract Programmer | 英文 | 深入讲解 |
文档阅读
| 资源 | 说明 |
|---|---|
| Solidity Docs | 官方文档 |
| OpenZeppelin Contracts | 标准实现参考 |
| ERC20标准 | EIP原文 |
工具网站
| 工具 | 用途 |
|---|---|
| Remix IDE | 在线Solidity编辑器 |
| Etherscan | 合约代码查看 |
| OpenZeppelin Wizard | 合约代码生成器 |
面试题准备
Q: view和pure有什么区别?
30秒版本:
view函数可以读取链上状态但不能修改;pure函数既不读也不写状态,只做纯计算。两者外部直接调用都不消耗Gas,但被交易内部调用时会计入Gas。
Q: 为什么ERC20需要approve+transferFrom两步?
30秒版本:
因为智能合约不能直接操作用户资产。用户先approve授权一定额度给合约,合约才能用transferFrom转走代币。这是DeFi交互的基础模式,比如在Uniswap交易前需要先approve。
追问: 有更好的方案吗?
→ EIP-2612 permit: 用签名替代链上approve,省一笔交易Gas
→ EIP-3009: transferWithAuthorization,一步完成
Q: public和external的区别?
30秒版本:
public可以内部和外部调用,external只能外部调用。external更省Gas,因为参数直接从calldata读取,不需要复制到memory。最佳实践:对外接口用external,需要内部调用的用public。
Q: 什么是无限授权(Infinite Approval)?有什么风险?
30秒版本:
无限授权是approve(spender, type(uint256).max),授权最大值给某个合约。好处是只需授权一次,节省Gas;风险是如果该合约被攻击或有漏洞,攻击者可以转走你所有代币。建议:只对信任的协议无限授权,或使用revoke.cash定期检查和撤销授权。
明日预告
Day 6: Solidity基础(2) - 事件与修饰符
- 深入理解Events
- 读懂Uniswap Router合约
- 合约阅读笔记