Solidity 初学者常见的 20 个错误
我们的意图并不是在这篇文章中对刚入门的开发者居高临下。经过对众多 Solidity 开发者代码的审查,我们发现有些错误更为常见,并在此列出。
这绝不是 Solidity 开发者可能犯的错误的详尽列表。中级甚至经验丰富的开发者也可能犯这些错误。
然而,这些错误更有可能在学习初期被犯下,因此值得列出。
在 Solidity 中,除法操作应始终是最后的操作,因为除法会将数字向下取整。
例如,如果我们想计算应该支付给某人 33.33%的利息,错误的做法是:
interest = principal / 3_333 * 10_000;
如果本金小于 3,333,利息将向下取整为零。相反,利息应按以下方式计算:
interest = principal * 10_000 / 3_333;
以下是第一个例子中取整失败和第二个例子中成功的数学原理:
**// 错误的方式:**
如果本金 = 3000,
interest = principal / 3333 * 10000
interest = 3000 / 3333 * 10000
interest = 0 * 10000 (除法中向下取整)
interest = 0
// **正确的计算:**
如果本金 = 3000,
interest = principal * 10000 / 3333
interest = 3000 * 10000 / 3333
interest = 30000000 / 3333 interest approx 9000
Slither是 Trail of Bits 的一个静态分析工具,用于解析代码库以模式匹配常见错误。
如果我们创建以下(有缺陷的)合约interest.sol
contract Interest {
// 1 个基点是 0.01%或 1/10_000
function calculateInterest(uint256 principal, uint256 interestBasisPoints) public pure returns (uint256 interest){
interest = principal / 10_000 * interestBasisPoints;
}
}
然后在终端运行
slither interest.sol
我们会收到以下警告:
在这种情况下,它表示我们在乘法之前进行了除法,这通常是需要避免的。
在 Solidity 中,遵循“检查-效果-交互”模式对于防止重入攻击至关重要。这意味着调用另一个合约或向另一个地址发送 ETH 应该是函数中的最后一个操作。未能这样做可能会使合约容易受到恶意攻击。
以下合约BadBank
未遵循检查-效果-交互,因此可能会被耗尽 ETH。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
// 不要使用
contract BadBank {
mapping(address => uint256) public balances;
constructor()
payable {
require(msg.value == 10 ether, "deposit 10 eth");
}
function deposit()
external
payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
(bool ok, ) = msg.sender.call{value: balances[msg.sender]}("");
require(ok, "transfer failed");
balances[msg.sender] = 0;
}
}
以下攻击合约可用于耗尽银行:
contract BankDrainer {
function steal(BadBank bank) external payable {
require(msg.value == 1 ether, "send deposit 1 eth");
bank.deposit{value: 1 ether}();
bank.withdraw();
}
receive() external payable {
// msg.sender 是 BadBank,因为 BadBank
// 在转账时调用了`receive()`
while (msg.sender.balance >= 1 ether) {
BadBank(msg.sender).withdraw();
}
}
}
你可以在 Remix 中测试代码 。
以下视频演示了该攻击。
https://img.learnblockchain.cn/video/re-entrancy.mp4
这种攻击之所以可能,是因为 BadBank 的withdraw()
函数在更新余额之前调用了BankDrainer
中的receive()
函数。发送以太币相当于调用另一个合约的receive()
或fallback()
函数。
因此,始终在最后调用另一个智能合约的函数或发送以太币。这里的攻击类别称为重入。你可以在我们的重入攻击文章中了解更多关于此攻击的信息。
当我们在上述代码上运行 Slither 时,Slither 给出了两个警告:
第一个警告,即“向任意用户发送 eth”是一个误报。确实,任何人都可以调用 withdraw,但他们可以提取的金额仅限于他们的余额(至少最初是这样!)。
然而,Slither 确实正确检测到了重入漏洞。
Solidity 有两个方便的函数transfer()
和send()
用于从合约向目标发送以太币。然而,你不应该使用这些函数。
Consensys 博客关于为什么不应该使用 transfer 或 send是一篇经典文章,每个 Solidity 开发者都必须在某个时候阅读。
为什么这些函数存在?
在 DAO 攻击之后,以太坊分裂为以太坊和以太坊经典,开发者非常害怕重入攻击。为了避免此类攻击,引入了transfer()
和send()
,因为它们限制了接收者可用的 gas 量。这样可以通过剥夺接收者执行进一步代码所需的 gas 来防止重入。
示例场景:
你可以将之前示例中的
(bool ok, ) = msg.sender.call{value: balances[msg.sender]}("");
require(ok, "transfer failed");
替换为 payable(msg.sender).transfer(balances[msg.sender]);
,你会发现银行不再容易受到攻击。
然而,当合约期望接收到足够的 gas 以响应传入的以太币时,这将破坏集成。例如,如果目标合约尝试将 ETH 记入发送者,这将失败,因为它没有足够的 gas 来完成记账。
考虑以下示例:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
contract GoodBank {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 balance = balances[msg.sender];
balances[msg.sender] = 0;
(bool ok, ) = msg.sender.call{value: balance}("");
require(ok, "transfer failed");
}
receive() external payable {
balances[msg.sender] += msg.value;
}
}
contract SendToBank {
address owner;
constructor() {
owner = msg.sender;
}
function depositInBank(
address bank
) external payable {
require(msg.sender == owner, "not owner");
// 这一行将失败
payable(bank).transfer(msg.value);
}
function withdrawBank(
address payable bank
) external {
require(msg.sender == owner, "not owner");
// 这将触发接收函数
GoodBank(bank).withdraw();
// 接收函数已完成
// 现在这个合约有了余额
// 将其发送给所有者
(bool ok, ) = msg.sender.call{value: address(this).balance}("");
require(ok, "transfer failed");
}
// 我们需要这个来从银行接收以太币
receive() external payable {
}
}
你可以在 Remix 中测试上面的代码 。
交易失败是因为在增加发送者的余额时,receive()
耗尽了 gas。
所以不要使用transfer
或send
,也不要编写可重入代码。第一个选项是用address(receiver).call{value: amountToSend}("")
替换transfer
或send
。或者,可以使用 OpenZeppelin 的 Address 库来实现相同的功能。以下是两种方法的示例:
import {Address} from "@openzeppelin/contracts/utils/Address.sol";
contract SendEthExample {
using Address for address payable;
// 这两个函数做同样的事情。注意 OZ 需要可支付地址,但低级调用不需要
function sendSomeEthV1(address receiver, uint256 amount) external payable {
payable(receiver).sendValue(amount);
}
function sendSomeEthV2(address receiver, uint256 amount) external payable {
(bool ok, ) = receiver.call{value: amount}("");
require(ok, "transfer failed");
}
}
Slither 不会对使用 transfer 或 send 提供警告,但你仍然应该避免使用它们。
Solidity 有点令人困惑,因为从合约的角度来看,有两种方法可以确定“谁在调用我”:一种是tx.origin
,另一种是msg.sender
。
tx.origin
是签署交易的钱包。msg.sender
是直接调用者。如果一个钱包直接调用一个合约
钱包 → 合约
那么从合约的角度来看,钱包既是msg.sender
也是tx.origin
。
现在考虑如果钱包调用一个中间合约,然后中间合约再调用最终合约:
钱包 → 中间合约 → 最终合约
从最终合约的角度来看,钱包是tx.origin
,中间合约是msg.sender
。
使用tx.origin
来识别调用者会带来安全漏洞。假设用户被钓鱼攻击,调用了一个恶意中间合约
钱包 → 恶意中间合约 → 最终合约
在这种情况下,恶意中间合约获得了钱包的所有权限,允许它执行钱包被授权执行的任何操作——例如转移资金。
要了解更多关于msg.sender
和tx.origin
之间的区别,请参阅我们的文章检测一个地址是否是智能合约 。
Slither 不会对tx.origin
提供警告。
ERC-20 标准仅规定,如果用户尝试转移超过其余额的代币,应该抛出错误。然而,如果转账因其他原因失败,标准并未明确说明应该发生什么。
ERC-20transfer
的函数签名是:
function transfer(address _to, uint256 _value) public returns (bool success);
这暗示ERC-20 代币在失败时应该返回false
。
实际上,ERC-20 代币的实现方式不一致:有些在失败时会回滚,有些则根本不返回任何布尔值(即不遵循函数签名)。
库SafeERC20
处理这两种类型的 ERC-20 代币。具体来说,它会对地址进行transfer
调用,并
SafeERC20
会将回退向上传递。这处理了在失败时回退但不一定返回布尔值的代币。以下是如何使用来自 OpenZeppelin 的 SafeERC20
库:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts@5.0.0/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts@5.0.0/token/ERC20/ERC20.sol";
contract SafeTransferDemo {
using SafeERC20 for IERC20;
function deposit(
IERC20 token,
uint256 amount)
external {
token.safeTransferFrom(msg.sender, address(this), amount);
}
// withdraw function not shown
}
contract MyToken is ERC20("MyToken", "MT") {
constructor() {
// 铸造 10,000 个代币的供应量
// 给部署者
_mint(msg.sender, 10_000 * 1e18);
}
}
在 Solidity 0.8.0 之前,如果数学运算导致的值大于变量可以容纳的值,变量可能会溢出。为此,来自 OpenZeppelin 的 SafeMath 库变得流行。以下是该库如何防止加法溢出:
function add(uint256 x, uint256 y) internal pure returns (uint256) {
uint256 sum = x + y;
require(sum >= x || sum >= y, "overflow");
return sum;
}
和 x 或 y 相比,和应该总是更大。如果不是这种情况,则发生了溢出,函数会回退。
在旧的代码库中,你经常会看到这行代码:
using SafeMath for uint256;
以及以这种方式进行的数学运算:
uint256 sum = x.add(y);
然而,在 Solidity 0.8.0 或更高版本中不应这样做,因为编译器在幕后添加了内置的溢出检查。因此,使用 SafeMath 库进行基本算术运算会使代码可读性降低且效率低下,而没有额外的安全收益。
让我们使用一个最小的例子。你能发现问题吗?
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol";
contract NFTSale is ERC721("MyTok", "MT") {
uint256 public price;
uint256 public currentId;
function setPrice(
uint256 price_
) public {
price = price_;
}
function buyNFT() external payable {
require(msg.value == price, "wrong price");
currentId++;
_mint(msg.sender, currentId);
}
}
任何人都可以调用 setPrice()
并在调用 buyNFT()
之前将其设置为零。
每当你编写一个公共或外部函数时,问问自己是否应该限制谁可以调用该函数。以下是上述问题的一个微妙变化:
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol";
contract NFTSale is ERC721("MyTok", "MT") {
uint256 public price;
address owner;
uint256 public currentId;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "onlyOwner");
_;
}
function setPrice(
uint256 price_
) public onlyOwner {
price = price_;
}
function buyNFT() external payable {
require(msg.value == price, "wrong price");
currentId++;
_mint(msg.sender, currentId);
}
}
在这里,开发者添加了一个 onlyOwner 修饰符,它只允许指定的用户访问。在上述示例中,访问控制修饰符确保只有合约所有者可以设置价格,如 setPrice
函数中所示。
可以无限增长的数组是有问题的,因为遍历它们的交易成本可能会变得非常高。
以下合约接受以太坊捐赠并将捐赠者添加到一个数组中。稍后,所有者将调用 distributeNFTs()
并为所有捐赠者铸造一个 NFT。然而,如果有很多捐赠者,完成捐赠可能会变得过于昂贵。
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts@5.0.0/access/Ownable.sol";
contract GiveNFTToDonors is ERC721("MyTok", "MT"), Ownable(msg.sender) {
address[] donors;
uint256 currentId;
receive() external payable {
require(msg.value >= 0.1 ether, "donation too small");
donors.push(msg.sender);
}
function distributeNFTs() external onlyOwner {
for (uint256 i = 0; i < donors.length; i++) {
currentId++;
_mint(msg.sender, currentId);
}
}
}
函数 distributeNFTs() 将尝试遍历整个捐赠者数组。然而,如果数组中的捐赠者列表很大,这个循环将导致非常高的 gas 成本,使交易不可行。Slither 会给你一个关于这种情况的警告,类似于以下内容:
解决方案被称为“拉取而非推送”。与其将每个接收者的 NFT 发送给他们,不如让他们调用一个函数,该函数在地址调用时将 NFT 转移到该地址。
每当你编写一个公共函数时,明确写下你期望传递给函数参数的值,并确保 require
语句强制执行。例如,人们不应该能够提取超过其余额的金额。人们不应该能够提取他们没有存入的资产。
考虑以下示例:
contract LendingProtocol is Ownable {
function offerLoan(
uint256 amount,
uint256 interest,
uint256 duration)
external {}
function setProtocolFee(
uint256 feeInBasisPoints)
external
onlyOwner {}
}
设计者应该考虑哪些参数是合理的。超过 1000% 的利率是不合理的。非常短的期限,例如 1 小时,也是不可接受的。
同样,setProtocolFee
函数应该对所有者可以设置的费用有一个合理的上限,否则用户可能会在使用协议的费用突然上升到不合理的水平时感到惊讶。
要实现合理性检查,我们只需添加 require
语句来限制可接受的输入范围。
在设计公共函数时,总是要考虑哪些参数范围对函数参数有意义。
在 Solidity 中,一些 bug 是由于代码缺失而不是代码错误导致的。以下的 NFT 铸造合约允许所有者指定谁可以铸造 NFT 以及数量。(这不是一种节省 gas 的方式,但我们想要关注的是这里的原则)。
这是代码,你能发现缺少了什么吗?
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts@5.0.0/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts@5.0.0/access/Ownable2Step.sol";
contract MissingCode is ERC721("MissingCode", "MC"), Ownable(msg.sender) {
uint256 id;
mapping(address => uint256) public amountAllowedToMint;
function mint(
uint256 amount
) external {
require(amount < amountAllowedToMint[msg.sender],
"not enough allocation");
for (uint256 i = 0; i < amount; i++) {
id++;
_mint(msg.sender, id);
}
}
function setAmountAllowedToMint(
address[] calldata minters,
uint256[] calldata amounts
) external onlyOwner {
require(minters.length == amounts.length,
"length mismatch");
for (uint256 i = 0; i < minters.length; i++) {
amountAllowedToMint[minters[i]] = amounts[i];
}
}
}
问题在于买家铸造的数量没有从amountAllowedToMint
中扣除,因此“限制”并没有真正应用。映射中的一个地址可以多次调用mint()
。
在_mint()
函数之后应该有一行amountAllowedToMint[msg.sender] -= amount
。
当你阅读 Solidity 库的代码时,你经常会看到类似这样的内容
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
在顶部。因为这个原因,较新的开发者往往会盲目地复制这种模式。
然而,用^0.8.0
设置 Solidity 版本只适用于库。发布库的作者不知道后来的程序员将用哪个确切版本来编译它,所以他们只设置了一个最低版本。
作为部署应用程序的开发者,你知道你正在使用哪个版本的编译器来编译代码。因此,你应该将版本锁定为你使用的确切版本,以便其他人审计代码时更清楚你使用的 Solidity 编译器版本。例如,不要写pragma solidity ^0.8.0
,而是写确切的版本pragma solidity 0.8.26
。这将为审计代码的人提供更清晰的信息。
我们在一个单独的博客文章中记录了 Solidity 样式指南 。
以下是要点:
fallback()
和receive()
(如果合约有的话)external
函数,public
函数,internal
函数和pure
函数payable
函数优先payable
非view
函数view
函数最后在以太坊中,没有原生方法可以列出发送到特定智能合约的所有交易,除非在区块浏览器中搜索这些信息。然而,这可以通过让合约发出事件来实现。
以下是关于事件的一些一般规则:
address
参数都应该是indexed
的,以便于深入了解特定钱包的活动。view
和pure
函数不应包含事件,因为它们不改变状态。你可以在我们的文章中阅读更多关于 Solidity 和以太坊中的事件 。
一般来说,如果你更改了存储变量或在合约中移动了以太币,你应该发出一个事件。
如果没有实际测试过,如何知道合约在它将遇到的每种可能情况下都能正常工作?
在我们看来,智能合约在没有单元测试的情况下被部署是有些令人惊讶的。这不应该是这种情况。
请参阅我们的教程 Solidity 单元测试 。
如果你计算100/3
,你将得到33
,即使“正确”的答案是33.33333
,因为 Solidity 不支持浮点数。在这种情况下,0.3333
的任何单位都消失了,因为你被迫在使用除法时“向下舍入”。以下是除法的黄金法则:
总是舍入以使用户损失或协议获益。
例如,如果你在计算用户需要支付多少费用,那么除法将导致估计值低于应有的值。在上面的例子中,用户获得了0.3333
的折扣。
如果我们计算100/3
来确定_智能合约支付给用户_的金额,那么智能合约将少付给用户。这是正确的做法。用户将无法从协议中榨取价值。
另一方面,如果我们计算100/3
来确定_用户应该支付给智能合约_的金额,那么我们有一个问题,因为用户支付的金额比应有的少0.333
。如果用户能够以0.333
的利润出售该资产,那么他们可以重复这个过程,直到耗尽协议!
在这种情况下,正确的做法是将除法结果加一,以便我们在小数中失去的部分得以恢复。也就是说,我们应该计算用户支付的金额为100/3 + 1
,因此用户必须支付34
来获得价值33.333
的资产。他们失去的少量价值将防止他们抢劫智能合约。
了解更多关于如何正确处理分数的信息,请参阅我们的定点数学文章。
没有必要重新发明格式化 Solidity 代码的轮子。你可以在 Foundry 中使用forge fmt
或使用工具solfmt
。这将使你的代码更容易被审阅者阅读。
以下代码不必要地难以阅读:
contract GoodBank {
mapping(address=>uint256) public balances;
function withdraw () external {
uint256 balance=balances[msg.sender];
balances[msg.sender] = 0;
(bool ok,) =msg.sender.call{value: balance}("");
require(ok,"transfer failed");
}
receive() external payable {
balances[msg.sender]+=msg.value;
}
}
它应该通过格式化工具运行,以使间距更加统一:
contract GoodBank {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 balance = balances[msg.sender];
balances[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: balance}("");
require(ok, "transfer failed");
}
receive() external payable {
balances[msg.sender] += msg.value;
}
}
_msgSender()
新的 Solidity 开发者常常对 OpenZeppelin 合约中频繁使用_msgSender()
感到困惑。例如,以下是 OpenZeppelin ERC-20 库中使用_msgSender()
的代码:
除非你正在构建支持无 gas 或元交易的合约,否则请使用常规的msg.sender
而不是_msgSender()
。
_msgSender()
是由 OpenZeppelin 合约 Context.sol 创建的一个函数:
这仅用于支持元交易的合约中。
元交易或无 gas 交易是指中继者代表用户发送交易并为其支付 gas。由于交易来自中继者,msg.sender
不会是“原始”发送者。使用元交易的智能合约在交易中其他地方编码“真实”的msg.sender
,并通过重写_msgSender()
函数来表示“真实”的msg.sender
。
如果你没有做这些事情,就没有理由使用_msgSender()
。请使用msg.sender
。
虽然我们没有经常看到这种情况发生,但每次发生都会导致极其灾难性的后果。如果你将 API 密钥或私钥放在.env
文件中,请始终将.env
文件添加到.gitignore
文件中。
抢跑交易是 Solidity 合约中的一个反直觉问题,因为它的类似情况在 Web2 编程中很少发生。
考虑以下合约,它允许 NFT 的卖家与买家在一次交易中用 USDC 交换。这在理论上有一个好处,即双方都不必先发送他们的代币并信任对方会发送他们的代币。
然而,它有一个抢跑交易的漏洞。卖家可以在交换交易挂起时更改交换价格。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts@5.0.0/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol";
contract BadSwapERC20ForNFT is Ownable(msg.sender) {
using SafeERC20 for IERC20;
uint256 price;
IERC20 token;
IERC721 nft;
address public seller;
constructor(IERC721 nft_, IERC20 token_) {
nft = nft_;
token = token_;
seller = msg.sender;
}
function setPrice(uint256 price_) external {
require(msg.sender == seller, "only seller");
price = price_;
}
// 买家调用此函数
function atomicSwap(uint256 nftId) external
// 需要卖家和买家都先批准他们的代币
token.safeTransferFrom(msg.sender, owner(), price);
nft.transferFrom(owner(), msg.sender, nftId);
}
}
每当用户有代币从他们那里转移时,用户应始终被要求传递数据以指定他们愿意发送的最大金额,以便卖家不能在购买交易挂起时更改价格。
以下 NFT 销售被编程为每次购买后价格上涨 5%。它与上面的例子有类似的问题。买家签署交易时的价格可能与交易确认时的价格不同。如果 10 个买家同时发送购买交易,那么其中 9 个将支付比他们预期更高的价格。
当合约计算从用户那里转移多少代币时,用户应指定他们允许从其账户转移的最大限额。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/contracts@5.0.0/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts@5.0.0/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts@5.0.0/access/Ownable2Step.sol";
contract BadNFTSale is ERC721("BadNFT", "BNFT"), Ownable(msg.sender) {
using SafeERC20 for IERC20;
uint256 price = 100e6; // USDC / USDT 有 6 位小数
IERC20 immutable token;
uint256 id;
constructor(IERC20 token_) {
token = token_;
}
function buyNFT() external {
token.safeTransferFrom(msg.sender, owner(), price);
price = price * 105 / 100;
id++;
_mint(msg.sender, id);
}
}
还有一个更微妙的问题:在买家的交易仍在挂起时,所有者可能会更改代币!现在,买家不太可能批准合约使用新代币,因此transferFrom
可能会失败。但在一个更复杂的合约中,这可能会成为一个需要注意的问题。
智能合约需要考虑用户多次进行相同交易的可能性。请考虑以下示例:
contract DepositAndWithdraw {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] = msg.value;
}
function withdraw(
uint256 amount
) external {
require(
amount <= balances[msg.sender],
"insufficient balance"
);
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
如果deposit
被调用两次,那么第一次的余额将被第二次交易覆盖,那笔钱将丢失。例如,如果用户以 1 ETH 的值调用deposit()
,然后再次以 2 ETH 的值调用deposit()
,那么该地址的余额将是 2 ETH,即使他们存入了 3 ETH。修正方法是增加余额,即balances[msg.sender] += msg.value;
。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!