3. 智能合约基础回顾:合约结构、状态变量与函数、修饰符、事件、继承与接口
说实话,很多刚入行的朋友一上来就冲进 DeFi 的池子里写聚合器,结果被 gas 费优化和重入攻击搞得焦头烂额。我个人的经验是——先把地基打牢。这一节咱们就快速过一遍 Solidity 的核心语法,重点讲那些你在流动性聚合器里真正会用到的部分。
核心要点:本章不是入门教程,而是帮你把零散的知识点串起来。如果你已经写过一些合约,可以跳过基础部分,直接看「避坑指南」和「实战建议」。
3.1 合约结构:一个 Solidity 文件长什么样?
先看一个最简的合约骨架。我习惯把结构拆成三块:状态声明、事件定义、函数实现。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SimpleStorage {
// 1. 状态变量
uint256 private storedData;
// 2. 事件
event DataStored(uint256 indexed newValue);
// 3. 构造函数
constructor() {
storedData = 0;
}
// 4. 函数
function set(uint256 x) public {
storedData = x;
emit DataStored(x);
}
function get() public view returns (uint256) {
return storedData;
}
}
嗯,这里要注意:pragma 版本号一定要写对。我在项目中遇到过有人用 ^0.8.0 但实际部署在 0.8.20 上,结果某些内置函数的行为变了,排查了半天。
3.2 状态变量:链上的「数据库」
状态变量就是存在区块链上的数据。说白了,你每改一次,全网节点都得跟着存一笔。
| 类型 | 示例 | 存储位置 | Gas 成本 |
|---|---|---|---|
| 基本类型 | uint256, address, bool |
storage | 高(写操作) |
| 映射 | mapping(address => uint256) |
storage | 中等 |
| 数组 | uint256[] |
storage | 动态数组操作较贵 |
| 结构体 | struct User { ... } |
storage | 按字段计算 |
我个人习惯把频繁读取但不常改的数据用 immutable 或 constant 修饰。比如聚合器里的路由地址,一旦部署就不变了,用 address immutable public router 能省不少 gas。
实战技巧:在流动性聚合器里,mapping 几乎无处不在。比如记录每个用户的流动性份额:mapping(address => uint256) public liquidityBalances;。但注意,mapping 不能遍历,所以需要配合数组来维护用户列表。
3.3 函数与修饰符:控制谁可以做什么
函数是合约的「动作」。修饰符则是给函数加一层「门禁」。
// 自定义修饰符
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// 使用修饰符
function withdraw(uint256 amount) public onlyOwner {
payable(owner).transfer(amount);
}
你想想看,如果没有修饰符,任何人都能调用 withdraw,那合约里的钱就全没了。我曾经在审计一个聚合器时发现,开发者把 onlyOwner 写成了 onlyowner(大小写错误),结果修饰符根本没生效——嗯,这种低级错误其实挺常见的。
函数可见性有四种:public、private、internal、external。我一般这样用:
- external:对外暴露的接口,比如用户调用的 swap 函数
- public:既对外又对内,但 gas 比 external 高一点
- internal:子合约可以调用,比如内部计算逻辑
- private:只有本合约能用,比如敏感的状态更新
3.4 事件:链上的「日志系统」
事件是 Solidity 里最被低估的特性。很多人觉得它只是用来打印日志,其实它在 DeFi 里扮演着关键角色——前端监听、链下索引、甚至跨链通信都靠它。
event Swap(
address indexed sender,
address indexed tokenIn,
address indexed tokenOut,
uint256 amountIn,
uint256 amountOut
);
function swap(...) external {
// ... 交换逻辑
emit Swap(msg.sender, tokenIn, tokenOut, amountIn, amountOut);
}
注意 indexed 关键字。加了它,这个参数就可以被前端快速过滤。但最多只能有三个 indexed 参数。我建议把最重要的三个字段(比如发送者、输入代币、输出代币)加上 indexed,剩下的用普通参数。
避坑指南:我曾经在写聚合器时,忘记在关键操作后 emit 事件。结果前端一直显示「交易失败」,实际上链上已经成功了。后来花了半天才定位到是事件没触发。记住:所有资产转移、权限变更、状态更新,都必须 emit 事件。
3.5 继承与接口:代码复用的艺术
Solidity 支持多重继承,但用不好容易出问题。我个人的原则是:能用接口就别用抽象合约,能用组合就别用继承。
// 接口定义
interface IUniswapV2Router {
function swapExactTokensForTokens(
uint256 amountIn,
uint256 amountOutMin,
address[] calldata path,
address to,
uint256 deadline
) external returns (uint256[] memory amounts);
}
// 合约实现
contract Aggregator is IUniswapV2Router {
function swapExactTokensForTokens(...) external override returns (uint256[] memory) {
// 具体实现
}
}
为什么推荐接口?因为接口只定义函数签名,不包含实现。这样你的聚合器可以同时对接 Uniswap、SushiSwap、PancakeSwap 等多个 DEX,只要它们都实现了同一个接口。
继承时要注意构造函数的调用顺序。Solidity 使用 C3 线性化算法,说白了就是「最远亲优先」。我建议用 virtual 和 override 关键字显式标注,避免歧义。
3.6 本章知识体系图
下面这张图帮你理清合约各部件之间的关系。我画的时候特意把「事件」放在中间,因为它在 DeFi 里连接了链上和链下。
从图里能看出来,状态变量是合约的「记忆」,函数是「动作」,修饰符是「门禁」,事件是「喇叭」,继承和接口是「工具箱」。在流动性聚合器里,这五样东西缺一不可。
3.7 实战建议:从基础到聚合器
最后分享几个我在写聚合器时总结的经验:
- 状态变量尽量少:每多一个状态变量,部署成本就高一点。能用
calldata传参就别存链上。 - 事件要完整:所有资产流动都要记录。前端和索引器全靠事件来同步状态。
- 接口要抽象:不要写死某个 DEX 的地址。用接口 + 工厂模式,方便后期添加新的流动性池。
- 修饰符别滥用:每个修饰符都会增加 gas。能用
require解决的,就别单独写一个修饰符。
一个小练习:试着用本章的知识,写一个最简单的流动性池合约。包含:一个 mapping 记录用户余额,一个 deposit 函数,一个 withdraw 函数,以及对应的事件。写完后检查一下——事件有没有 emit?修饰符有没有漏掉?
好了,基础就回顾到这里。下一节咱们会深入 Solidity 的高级特性,重点讲那些在 DeFi 聚合器里真正让你头疼的东西——比如重入锁、闪电贷、以及 gas 优化技巧。