返回知识库
Day 5

Solidity基础与ERC20合约解读

学习Solidity数据类型、函数可见性、修饰器,深入理解ERC20标准合约实现

2025-01-14
SolidityERC20智能合约approvetransfermapping

核心概念

什么是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 大小对照表

类型范围常见用途
uint80 ~ 255状态码、小计数
uint320 ~ 42亿时间戳
uint640 ~ 1.8×10^19大计数
uint2560 ~ 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,内部逻辑用privateinternal


状态修饰符

// 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

TokenDecimals1个Token的最小单位
ETH181 wei = 10^-18 ETH
USDC61 = 10^-6 USDC
USDT61 = 10^-6 USDT
WBTC81 satoshi = 10^-8 BTC
DAI181 = 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合约
  • 合约阅读笔记