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;
}

函数可见性有publicinternalexternalprivate四种。我一般这样用:

  • external:只给外部调用的函数,比如用户交互的接口。gas比public省一点。
  • public:内外都能调,但内部调用时用this.f()会走外部调用,gas更贵。
  • internal:子合约继承时用,比如多签钱包里的内部逻辑。
  • private:极少用,除非你真的不想让子合约碰。

还有个viewpureview说「我不改状态」,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");
    }
    // 继续执行
}

revertrequire本质一样,但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检查签名是否有效。下一章我们开始搭建多签钱包的骨架,到时候你会觉得这些基础真香。