Solidity 进阶(上)

智能合约开发 04

状态变量的高级模式

你是一个守护巫师银行的守卫,你的任务是确保银行里的道具只能在特定的时间被访问,并且只有授权的巫师才能使用特定的道具。每个道具都有自己的存放地点,从普通金币到隐形斗篷、复活石,它们可以是独立的,也可以是相互关联的。

这个场景就是我们今天要处理的智能合约问题:确保数据的安全性和正确的访问控制。

1. 时间锁定

时间锁定是一种在智能合约中控制函数调用时间的方式,通过这种方式,我们可以限定某些功能只能在特定时间之后可用,或者在某个时间段内可用。

contract TimeLockedWallet {
    uint public unlockTime;
    address payable public owner;

    constructor(address payable _owner, uint _unlockTime) {
        owner = _owner;
        unlockTime = _unlockTime;
    }

    function withdraw() public payable {
        require(block.timestamp > unlockTime, "Wallet is locked");
        require(msg.sender == owner, "You are not the owner");

        owner.transfer(address(this).balance);
    }
}

在这个例子里,我们创建了一个时间锁定钱包,只有在指定的时间之后,钱包的拥有者才能提取里面的资金。

详细解释:

require(block.timestamp > unlockTime, "Wallet is locked");

block.timestamp 是 Solidity 中的一个全局变量,它返回当前块的时间戳,也就是当前块被矿工确认的时间。它可以用在很多地方,比如:实现时间锁定机制,记录事件或操作的时间,以及其他基于时间的逻辑和规则。

unlockTime 是在构造函数中设置的固定时间戳,表示钱包将被解锁的时间,我们可以手动输入,也可以在下面写一个设置解锁时间的函数。

require(msg.sender == owner, "You are not the owner");

msg.sender 是 Solidity 中的一个全局变量,返回当前函数的调用者地址,也就是触发函数执行的用户地址。owner 是合约中声明的一个状态变量,存储钱包所有者的地址。这行代码可以确保只有钱包的所有者才能调用 withdraw 方法。

2. 访问控制

访问控制是智能合约安全的关键部分,它确保只有授权的用户可以执行特定的操作。

contract AccessControl {
    address public admin;

    constructor() {
        admin = msg.sender;
    }

    modifier onlyAdmin() {
        require(msg.sender == admin, "Unauthorized");
        _;
    }

    function changeAdmin(address newAdmin) public onlyAdmin {
        admin = newAdmin;
    }
}

通过使用修饰符 onlyAdmin,我们可以确保 changeAdmin 函数只能由当前的 admin 调用。

代码解释

首先,在合约里声明 admin 变量,用于存储当前管理员的地址,在构造函数中,我们将 admin 设置成当前函数的调用者地址,即 msg.sender

接下来,我们定义了一个修饰符 onlyAdmin。修饰符是 Solidity 中的一种特殊函数,可以修改其他函数的行为。在这个例子中,我们使用 onlyAdmin 修饰符来检查当前函数的调用者是否是当前管理员。

如果检查通过,我们使用 _ 语句来表示继续执行函数的剩余部分。

最后,我们使用 onlyAdmin 修饰符来修饰 changeAdmin 函数。这意味着只有当前管理员可以调用 changeAdmin 函数。

3. 使用映射和数组管理复杂状态

映射和数组是管理复杂状态的强大工具,我们可以使用键值对来存储和检索数据:

contract UserRegistry {
    mapping (address => string) public users;

    function registerUser(address user, string name) public {
        users[user] = name;
    }

    function getUser(address user) public view returns (string) {
        return users[user];
    }
}

我们使用映射来存储用户列表和相应的名称。然后使用 registerUser 函数来添加新用户,并使用 getUser 函数来检索用户的名称。

再看一个数组的示例:

contract TodoList {
    string[] public tasks;

    function addTask(string task) public {
        tasks.push(task);
    }

    function getTask(uint index) public view returns (string) {
        return tasks[index];
    }
}

函数修饰符 (Modifier)

1. 自定义修饰符

在之前访问控制的代码示例中,用到了函数修饰符,这里详细介绍一下。

修饰符是一种修改函数行为的方式,它可以用于执行检查、验证输入或甚至改变执行流程。

我们可以创建一个自定义修饰符:

contract CustomModifier {
    modifier onlyAdmin() {
        require(msg.sender == admin, "Unauthorized");
        _
    }
    function changeAdmin(address newAdmin) public onlyAdmin {
        admin = newAdmin;
    }
}

在这个例子里,自定义修饰符 onlyAdmin 用来检查调用者是否是管理员,如果是,函数就会执行,否则,它将回退并显示错误消息。

2. 常见修饰符

onlyOwner

onlyOwner 是一个常见的 Solidity 模式,它限制了函数的访问权限,只允许合同的所有者访问:

contract OnlyOwner {
    address public owner;

    modifier onlyOwner() {
        require(msg.sender == owner, "Unauthorized");
        _;
    }

    function changeOwner(address newOwner) public onlyOwner {
        owner = newOwner;
    }
}

nonReentrant

nonReentrant 修饰符用于防止重入攻击。什么是重入攻击?当一个合约调用另一个合约,而该合约又调用回原始合约,形成一个递归循环,这可能会耗尽合约的资金或者发生其他意外的行为。这就是重入攻击。

nonReetrant 修饰符通过设置一个「重入锁」来防止重入攻击,当函数被调用时,重入锁被设置为 true,然后在函数被调用之前,修饰符检查该重入锁,如果它已经被设置,则修饰符将回退交易以防止重入攻击。

contract NonReentrant {
    bool private reentrancyLock;

    modifier nonReentrant() {
        require(!reentrancyLock, "Reentrancy detected");
        reentrancyLock = true;
        _;
        reentrancyLock = false;
    }

    function withdraw() public nonReentrant {
        // Withdraw funds
    }
}

事件与日志记录

1. 事件的定义与触发

事件是一种通知机制,用于通知外部合约或应用程序某些事情已经发生了。

事件有如下特征:

  1. 异步:它们不会阻塞智能合约的执行。

  2. 解耦:智能合约触发事件后,不需要知道谁在监听,应用程序监听的时候,也不需要知道谁触发了事件。

事件使用 event 关键字定义,并使用 emit 关键字触发,例如:

contract MyContract {
    event MyEvent(address indexed user, uint amount);

    function doSomething() public {
        //...
        emit MyEvent(msg.sender, 100);
    }
}

合约里定义了一个名为 MyEvent 的事件,该事件带有两个参数:useramount。当 doSomething() 函数被调用时,它触发 MyEvent 事件,并将 msg.sender100 作为参数传递。

2. 如何在前端应用中监听和响应事件

智能合约的安全

除了前面提到的重入攻击,智能合约还有一些常见的安全漏洞。

1. 溢出和下溢

在智能合约中,溢出和下溢发生在变量超过其最大或最小值时,比如:

contract OverflowExample {
    uint256 public balance;
    function addBalance(uint256 amount) public {
        balance += amount; // 潜在溢出
    }
}

这个合约把用户的余额存储在一个 uint256 变量中,该变量的最大值为 2^256 - 1。如果我们尝试将大量金额添加到余额中,超过最大值,溢出将发生。

同样,如果我们尝试从余额中减去大量金额,导致值低于 0,下溢将发生。

后果:

合约可能会表现出不可预测的行为从而产生意外的结果,从而造成资金损失。

预防:

  • 使用安全数学库:使用像 OpenZeppelin 的 SafeMath 或 Chainlink 的 SafeMath 等库来执行算术操作。

  • 输入验证:验证用户输入,以确保其落在预期的范围内。

  • 在执行算术操作之前,对变量进行范围检查。

  • 在使用 uint256 变量时要小心,因为它们很容易溢出或下溢。

示例:使用 SafeMath 库来处理所有数学运算

using SafeMath for uint256;

function add(uint256 a, uint256 b) public pure returns (uint256) {
    return a.add(b);
}

2. 未经授权的访问

未经授权的用户或合约访问智能合约中的敏感函数或数据时,将会导致安全漏洞、数据篡改、甚至资产盗窃。

假设我们有一个管理用户资金的合约,我们想确保只有用户自己才能提取资金。如果我们不实施适当的访问控制,攻击者可能会调用 withdraw 函数并盗窃用户的资金。

contract UnauthorizedAccessExample {
    address public owner;
    uint256 public balance;

    constructor() public {
        owner = msg.sender;
    }

    function withdraw(uint256 amount) public {
        balance -= amount;
        // 潜在安全漏洞
    }
}

预防

  • 使用访问修饰符,如 public、private 和 internal,来限制访问敏感函数和数据。

  • 使用函数修饰符,如 onlyOwner 或 onlyAuthorized,来限制访问敏感函数。

  • 身份验证和授权

  • 数据加密

  • 定期测试和审计合约

3. 安全工具和审计技巧

静态分析工具:Silther:静态分析 Solidity 代码,识别常见的安全问题和不良编码习惯。

形式化验证:VeriSol:使用形式化方法验证智能合约的逻辑正确性,确保合约行为符合预期。

安全审计:进行专业的第三方审计,确保合约在部署前没有安全隐患。

流程:

  1. 审计前的自检:使用自动化工具检查明显的安全问题。
  2. 第三方审计:由专业团队进行深度的代码审查。
  3. 修复与迭代:根据审计报告修复问题,迭代合约代码。

模拟合约审计

尝试对一个简单的众筹合约进行安全审计:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract Crowdfunding {
    mapping(address => uint) public contributions;
    uint public totalContributions;
    uint public goal;
    address public admin;

    constructor(uint _goal) {
        goal = _goal;
        admin = msg.sender;
    }

    function contribute() public payable {
        contributions[msg.sender] += msg.value;
        totalContributions += msg.value;
    }

    function withdraw() public {
        require(msg.sender == admin, "Only admin can withdraw");
        require(address(this).balance >= goal, "Goal not reached");
        payable(admin).transfer(address(this).balance);
    }
}

审计步骤:

  1. 检查 withdraw 函数以确保只有在达到目标时才能提现。
  2. 验证对 contribute 函数的所有调用是否正确更新了状态变量。
  3. 使用工具如 Slither 检查潜在的安全风险。