2. Solidity基础安全规范:变量作用域与可见性、整数溢出与SafeMath、require与assert的正确使用

各位同学,今天我们来聊聊Solidity开发中最基础、但也最容易踩坑的三个安全点。说实话,我见过太多合约因为这几个小问题被攻破,损失动辄几百万美元。咱们一个一个说清楚。

2.1 变量作用域与可见性:别让你的数据裸奔

先问个问题:你写的合约里,哪些数据是外人能看到的?哪些是能改的?

Solidity里变量的可见性分四种:publicinternalprivateexternal。很多人以为private就是真的私密了——其实不是。链上数据都是公开的,private只是不让别的合约读,但链下随便查。

⚠️ 重要提醒: 永远不要把密码、私钥这类敏感信息存在链上,哪怕你用了private修饰符。我在审计中见过有人把管理员私钥哈希存成private变量,以为安全了——结果链上数据一查就出来了。

我个人习惯这样分配可见性:

  • 状态变量:能用internal就别用public。除非你确定需要对外暴露读取接口。
  • 函数:能用external就用external,省gas。内部调用用internal
  • 映射和数组public会自动生成getter,但小心——如果映射的key是敏感数据,别人可以遍历。

来看个反面教材:

// ❌ 错误示例
contract BadVisibility {
    uint256 private secretNumber = 42;  // 以为别人看不到?
    
    function guess(uint256 _num) public view returns (bool) {
        return _num == secretNumber;
    }
}

// ✅ 正确做法
contract GoodVisibility {
    uint256 internal secretNumber;  // 至少不让外部合约直接读
    
    // 用事件来记录,而不是直接暴露
    event NumberSet(uint256 indexed number);
    
    function setNumber(uint256 _num) internal {
        secretNumber = _num;
        emit NumberSet(_num);
    }
}
💡 小技巧: 如果你真的需要存储敏感数据,考虑用keccak256哈希后存储,或者用链下存储+链上验证的模式。我在一个DeFi项目里就用过这种方案,效果不错。

2.2 整数溢出与SafeMath:老生常谈但年年出事

整数溢出这个问题,说白了就是:数字太大装不下了,结果绕回最小值。比如uint8最大是255,你加1就变成0了。

在Solidity 0.8版本之前,这是个大坑。我记得2018年有个著名的交易所合约,就是因为溢出漏洞,被人薅走了几万个ETH。从那以后,SafeMath库就成了标配。

现在Solidity 0.8+已经内置了溢出检查,但别高兴太早——有几个场景你还是要小心:

场景 风险等级 说明
普通加减乘除(0.8+) 编译器自动检查,溢出会revert
unchecked块内运算 你手动关闭了检查,必须自己保证安全
类型转换(如uint256转uint8) 截断时可能丢失高位数据
减法运算(0.8以下) 极高 下溢会导致余额暴增

我曾经审计过一个项目,他们在unchecked块里做循环累加,结果循环次数一多就溢出了。嗯,这个漏洞差点让项目方损失几百万。

来看看怎么正确使用:

// ✅ 推荐做法(Solidity 0.8+)
contract SafeMathDemo {
    uint256 public totalSupply;
    
    function mint(uint256 _amount) external {
        // 编译器自动检查溢出
        totalSupply += _amount;
    }
    
    // 如果你确实需要节省gas,用unchecked但要小心
    function batchMint(uint256 _count) external {
        uint256 start = totalSupply;
        unchecked {
            // 这里要确保_count不会导致溢出
            require(_count < 1e18, "too many");
            totalSupply = start + _count;
        }
    }
}

// ❌ 错误做法(0.8以下版本必须用SafeMath)
contract UnsafeDemo {
    using SafeMath for uint256;
    
    function transfer(address to, uint256 amount) external {
        // 0.8以下版本,直接加减可能溢出
        balances[msg.sender] -= amount;  // 如果余额不够,会下溢!
        balances[to] += amount;
    }
}
🔑 核心原则: 能用编译器检查就别自己写。如果非要用unchecked,一定要在注释里写清楚为什么安全,并且加个边界检查。

2.3 require与assert的正确使用:别搞混了

这两个函数看起来差不多,但用途天差地别。我见过太多人把assertrequire用,结果出事了都不知道怎么查。

简单说:

  • require:用于检查输入条件、权限、余额等。失败了会退还剩余gas。
  • assert:用于检查不应该发生的内部错误。失败了会消耗所有gas。

为什么会这样?因为assert是用来抓bug的——如果它失败了,说明你的代码逻辑有严重问题,应该立即停止并消耗所有gas来惩罚调用者(其实是为了让开发者重视)。

我个人习惯这样用:

// ✅ 正确用法
contract RequireVsAssert {
    address public owner;
    uint256 public totalSupply;
    
    constructor() {
        owner = msg.sender;
    }
    
    function withdraw(uint256 _amount) external {
        // require:检查外部条件
        require(msg.sender == owner, "Not owner");
        require(_amount <= address(this).balance, "Insufficient balance");
        
        payable(msg.sender).transfer(_amount);
    }
    
    function updateSupply(uint256 _newSupply) internal {
        uint256 oldSupply = totalSupply;
        totalSupply = _newSupply;
        
        // assert:检查内部逻辑一致性
        // 新供应量不应该比旧的小(除非有特殊逻辑)
        assert(totalSupply >= oldSupply);
    }
}

// ❌ 错误用法
contract BadUsage {
    function transfer(address to, uint256 amount) external {
        // 这里应该用require,因为输入可能不合法
        assert(amount > 0);  // ❌ 应该用require
        assert(balances[msg.sender] >= amount);  // ❌ 应该用require
        
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}
⚠️ 避坑指南: 我曾经审计过一个项目,他们把所有的条件检查都用assert。结果用户输入了一个非法参数,直接消耗了所有gas,用户气得不行。记住:require是给用户看的,assert是给开发者看的。

最后总结一下这三个安全点的关系:

  • 可见性决定了谁能碰你的数据——别让不该看的人看到。
  • 溢出检查决定了你的数字会不会突然变成0——别让数学背叛你。
  • require/assert决定了你的合约会不会乱吞gas——别让用户骂你。

这三个问题,每一个我都见过真实案例。说实话,写合约就像盖房子——地基没打好,装修再漂亮也没用。咱们先把这些基础安全规范刻在脑子里,再谈什么DeFi、NFT这些花活。

下一章我们聊聊重入攻击和访问控制,那才是真正让人头疼的东西。先消化今天的内容,有问题随时问。