2、Solidity基础回顾:合约结构、状态变量与函数、修饰符(modifier)、事件(event)、错误处理(require/revert/assert)
好,咱们正式开始写多签钱包之前,得先把Solidity的底子打牢。说实话,很多新手一上来就冲进业务逻辑,结果被modifier和event搞得晕头转向。我个人习惯是,先把这些基础构件拆开揉碎了讲清楚,后面写多签钱包时你才会觉得「哦,原来如此」。
2.1 合约结构:一个Solidity文件长什么样?
一个标准的Solidity合约,就像一栋房子的骨架。我带你从头到尾捋一遍。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MyWallet {
// 状态变量
address public owner;
uint256 public balance;
// 事件
event Deposit(address indexed from, uint256 amount);
// 修饰符
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
// 构造函数
constructor() {
owner = msg.sender;
}
// 函数
function deposit() public payable {
balance += msg.value;
emit Deposit(msg.sender, msg.value);
}
function withdraw(uint256 amount) public onlyOwner {
require(amount <= balance, "Insufficient balance");
balance -= amount;
payable(msg.sender).transfer(amount);
}
}
嗯,这里要注意几点:
- SPDX许可证:现在Solidity强制要求写,不然编译会警告。我一般用MIT,简单粗暴。
- pragma版本:
^0.8.0表示兼容0.8.x系列。千万别写pragma solidity >=0.4.0这种,太老了,很多新特性用不了。 - 合约命名:首字母大写,驼峰式。我见过有人写
contract mywallet,编译没问题,但看着难受。
2.2 状态变量与函数:合约的「内存」和「行为」
状态变量就是存在链上的数据,函数就是操作这些数据的逻辑。说白了,状态变量是合约的「记忆」,函数是合约的「手脚」。
状态变量的可见性
| 可见性 | 说明 | 我踩过的坑 |
|---|---|---|
public |
自动生成getter函数,外部可读 | 小心!public数组会暴露所有元素,gas可能爆炸 |
internal |
本合约及子合约可访问 | 默认就是这个,省心 |
private |
仅本合约可访问 | 注意:链上数据没有真正的隐私,private只是阻止其他合约读 |
我曾经在审计一个项目时,发现他们把私钥哈希存在private变量里,以为安全。结果链上数据全公开,随便一个节点都能扫到。记住:private ≠ 秘密。
函数的可见性与修饰
function doSomething(uint256 x) public view returns (uint256) {
return x * 2;
}
函数可见性有public、internal、external、private四种。我一般这样用:
- external:只给外部调用的函数,比如用户交互的接口。gas比public省一点。
- public:内外都能调,但内部调用时用
this.f()会走外部调用,gas更贵。 - internal:子合约继承时用,比如多签钱包里的内部逻辑。
- private:极少用,除非你真的不想让子合约碰。
还有个view和pure:view说「我不改状态」,pure说「我连状态都不读」。你想想看,如果函数里写了view却偷偷改了状态变量,编译会报错。但我在审计时见过有人用view函数调了transfer——嗯,那是Solidity 0.4时代的遗留问题,现在不会了。
2.3 修饰符(modifier):代码复用的利器
修饰符是Solidity里我最喜欢的特性之一。它让你把重复的权限检查、前置条件抽出来,函数体只写核心逻辑。
modifier onlyOwner() {
require(msg.sender == owner, "Caller is not owner");
_;
}
function sensitiveAction() public onlyOwner {
// 只有owner能执行
}
那个_是占位符,表示「把原函数体插在这里」。你可以放多个下划线吗?不行,只能一个。我曾经在早期版本里试过,结果编译报错。
修饰符还可以带参数:
modifier checkAmount(uint256 minAmount) {
require(msg.value >= minAmount, "Amount too low");
_;
}
function buy(uint256 price) public payable checkAmount(price) {
// 购买逻辑
}
这里有个坑:修饰符里的require失败,整个交易会回滚。所以别在修饰符里写太重的逻辑,不然gas浪费了还查不到原因。我习惯把修饰符写得尽量轻量,复杂的检查放函数体里。
2.4 事件(event):链上的「日志」
事件是Solidity里最优雅的设计之一。它让你低成本地记录链上发生的事,前端可以监听,链下服务可以索引。
event Transfer(address indexed from, address indexed to, uint256 value);
function transfer(address to, uint256 amount) public {
_transfer(msg.sender, to, amount);
emit Transfer(msg.sender, to, amount);
}
注意indexed关键字:最多三个参数可以加indexed,加了之后可以按这个参数过滤。比如前端监听Transfer事件时,可以只监听某个地址的转账。
我有个经验:事件参数尽量用indexed,但别超过三个。超过三个编译会报错。而且indexed参数不存原始数据,只存哈希后的值——嗯,其实存的是keccak256哈希,但Solidity帮你做了索引,你直接用就行。
事件还有一个妙用:节省gas。如果你需要把数据存到链上供前端读,但又不想花太多gas,可以只存哈希,把原始数据通过事件发出去。前端监听事件拿到原始数据,再验证哈希是否匹配。我在多签钱包里就用这个技巧来记录签名信息。
2.5 错误处理:require、revert、assert
错误处理是智能合约安全的第一道防线。Solidity提供了三种方式,但用法和场景完全不同。
require:前置条件检查
function withdraw(uint256 amount) public {
require(amount <= balance, "Insufficient balance");
require(msg.sender == owner, "Not authorized");
balance -= amount;
payable(msg.sender).transfer(amount);
}
require用于检查输入、权限、状态等前置条件。失败时:
- 回滚所有状态变更
- 退还剩余gas(注意:不是全部gas,已消耗的不退)
- 可以带错误消息(字符串),但消息越长gas越贵
我建议:错误消息控制在32字节以内,超过32字节会多花gas。比如"Not owner"就比"You are not the owner of this contract"省gas。
revert:主动回滚
function riskyOperation(uint256 x) public {
if (x > 100) {
revert("Value too large");
}
// 继续执行
}
revert和require本质一样,但revert更灵活——你可以放在if语句里,或者复杂的条件判断中。我个人习惯:能用require的地方尽量用require,代码更简洁。只有遇到复杂逻辑时才用revert。
assert:内部错误检查
function safeMath(uint256 a, uint256 b) public pure returns (uint256) {
uint256 result = a + b;
assert(result >= a); // 检查溢出
return result;
}
assert用于检查「不应该发生」的错误,比如溢出、不变式被破坏。失败时:
- 回滚所有状态
- 消耗所有剩余gas(这点和require不同!)
- 不退还gas
所以,千万别用assert做用户输入检查。我曾经审计过一个项目,他们把require写成assert,用户输入错误时白白浪费gas。正确的用法是:assert只用于检查合约内部逻辑是否出错,比如数学运算溢出、状态机状态异常等。
核心区别总结:
- require:检查外部输入、权限、前置条件。失败时退还剩余gas。
- revert:主动回滚,适合复杂条件。和require本质相同。
- assert:检查内部错误、不变式。失败时消耗所有gas,慎用。
我的小技巧:在Solidity 0.8.0之后,算术溢出默认会revert,所以assert在溢出检查上的作用变小了。但我还是习惯在关键数学运算后加一个assert,双重保险。
警告:不要用assert来检查用户输入!用户输入错误时,你应该用require退还gas,而不是用assert烧掉用户的gas。这不仅是技术问题,更是用户体验问题。
好了,Solidity基础就回顾到这里。这些知识点在多签钱包里会反复用到——状态变量存owner列表和签名状态,modifier做权限控制,event记录每笔交易,require检查签名是否有效。下一章我们开始搭建多签钱包的骨架,到时候你会觉得这些基础真香。