Solidity 进阶(上)
状态变量的高级模式
你是一个守护巫师银行的守卫,你的任务是确保银行里的道具只能在特定的时间被访问,并且只有授权的巫师才能使用特定的道具。每个道具都有自己的存放地点,从普通金币到隐形斗篷、复活石,它们可以是独立的,也可以是相互关联的。
这个场景就是我们今天要处理的智能合约问题:确保数据的安全性和正确的访问控制。
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. 事件的定义与触发
事件是一种通知机制,用于通知外部合约或应用程序某些事情已经发生了。
事件有如下特征:
-
异步:它们不会阻塞智能合约的执行。
-
解耦:智能合约触发事件后,不需要知道谁在监听,应用程序监听的时候,也不需要知道谁触发了事件。
事件使用 event
关键字定义,并使用 emit
关键字触发,例如:
contract MyContract {
event MyEvent(address indexed user, uint amount);
function doSomething() public {
//...
emit MyEvent(msg.sender, 100);
}
}
合约里定义了一个名为 MyEvent
的事件,该事件带有两个参数:user
和 amount
。当 doSomething()
函数被调用时,它触发 MyEvent
事件,并将 msg.sender
和 100
作为参数传递。
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:使用形式化方法验证智能合约的逻辑正确性,确保合约行为符合预期。
安全审计:进行专业的第三方审计,确保合约在部署前没有安全隐患。
流程:
- 审计前的自检:使用自动化工具检查明显的安全问题。
- 第三方审计:由专业团队进行深度的代码审查。
- 修复与迭代:根据审计报告修复问题,迭代合约代码。
模拟合约审计
尝试对一个简单的众筹合约进行安全审计:
// 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);
}
}
审计步骤:
- 检查
withdraw
函数以确保只有在达到目标时才能提现。 - 验证对
contribute
函数的所有调用是否正确更新了状态变量。 - 使用工具如 Slither 检查潜在的安全风险。