写这篇文章的目的并不是要以高高在上的姿态对待刚起步的开发者。在审查了许多 Solidity 开发者的代码后,我们发现一些错误出现的频率较高,并将它们列在了这里。
这绝不是 Solidity 开发者可能犯的错误的详尽列表。中级甚至经验丰富的开发者也会犯这些错误。
然而,这些错误在学习的早期阶段更有可能发生,因此值得将它们列出来。
1. 先除法后乘法
在 Solidity 中,除法运算应始终是最后一步操作,因为除法会向下取整。
例如,如果我们想计算应该支付某人 33.33% 的利息,错误的做法是:
interest = principal / 3_333 * 10_000;
如果本金小于 3,333,利息将向下取整为零。相反,应按以下方式计算利息:
interest = principal * 10_000 / 3_333;
以下是第一个例子中四舍五入导致失败,而在第二个例子中成功的数学原理:
**// Wrong way:**
If principal = 3000,
interest = principal / 3333 * 10000
interest = 3000 / 3333 * 10000
interest = 0 * 10000 (rounding down in division)
interest = 0
// **Correct Calculation:**
If principal = 3000,
interest = principal * 10000 / 3333
interest = 3000 * 10000 / 3333
interest = 30000000 / 3333 interest approx 9000
使用 Slither 捕获问题
Slither 是 Trail of Bits 开发的一款静态分析工具,它通过解析代码库来进行模式匹配,以发现常见错误。
如果我们创建以下(有缺陷的)合约 interest.sol
contract Interest {
// 1 basis point is 0.01% or 1/10_000
function calculateInterest(uint256 principal, uint256 interestBasisPoints) public pure returns (uint256 interest){
interest = principal / 10_000 * interestBasisPoints;
}
}
然后在终端中运行
slither interest.sol
我们会得到以下警告:

在这种情况下,它的意思是我们在乘法之前进行了除法,这通常是应该避免的。
2. 不遵循 check-effects-interaction 模式
在 Solidity 中,遵循 “check-effects-interaction”(检查-生效-交互)模式对于防止重入(re-entrancy)攻击至关重要。这意味着调用另一个合约或向另一个地址发送 ETH 应该是函数中的最后一步操作。如果不这样做,合约可能会受到恶意攻击。
以下合约 BadBank 没有遵循 check-effects-interaction 模式,因此其中的 ETH 可能会被排空。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
// DO NOT USE
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 is the BadBank because the BadBank
// called `receive()` when it transfered either
while (msg.sender.balance >= 1 ether) {
BadBank(msg.sender).withdraw();
}
}
}
你可以 在这里使用 Remix 测试代码。下面的视频演示了这次黑客攻击。
这种黑客攻击之所以可能,是因为 BadBank 的 withdraw() 函数在更新余额之前调用了 BankDrainer 中的 receive() 函数。发送以太币等同于调用另一个合约上的 receive() 或 fallback() 函数。
因此,始终将调用另一个智能合约的函数或发送以太币作为最后一步操作。这种类型的攻击被称为*重入(re-entrancy)*攻击。你可以在我们的 重入攻击文章 中了解更多有关此攻击的信息。
当我们在上面的代码上运行 Slither 时,Slither 会给出两个警告:

第一个警告说它“将 eth 发送给任意用户”(sends eth to an arbitrary user),这是一个误报。确实任何人都可以调用 withdraw,但他们能提取的金额受限于他们的余额(至少最初是这样!)。
然而,Slither 确实正确地检测到了重入漏洞。
3. 使用 transfer 或 send
Solidity 有两个方便的函数 transfer() 和 send(),用于从合约向目标地址发送以太币。然而,你不应该使用这些函数。
Consensys 关于为什么不应使用 transfer 或 send 的博客文章 是一篇经典之作,每位 Solidity 开发者都必须在某个阶段阅读。
为什么会存在这些函数?
在导致 Ethereum 分叉为 Ethereum 和 Ethereum Classic 的 The 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");
// THIS LINE WILL FAIL
payable(bank).transfer(msg.value);
}
function withdrawBank(
address payable bank
) external {
require(msg.sender == owner, "not owner");
// this triggers the receive function
GoodBank(bank).withdraw();
// the receive function has completed
// and now this contract has a balance
// send it to the owner
(bool ok, ) = msg.sender.call{value: address(this).balance}("");
require(ok, "transfer failed");
}
// we need this to receive Ether from the bank
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;
// both these functions do the same thing. Note that OZ requires
// payable addresses, but a low-level call does not
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 提供警告,但你仍然应该避免使用它们。
4. 使用 tx.origin 而不是 msg.sender
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 的警告。
5. 对 ERC-20 没有使用 safeTransfer
ERC-20 标准仅规定,如果用户试图转移超过其余额的代币,代币应抛出错误。然而,如果转账由于某些其他原因失败,该标准并未明确说明应发生什么情况。
ERC-20 transfer 的函数签名是:
function transfer(address _to, uint256 _value) public returns (bool success);
这意味着 ERC-20 代币在失败时应该返回 false。
在实践中,ERC-20 代币的实现方式并不一致:有些在失败时会回滚(revert),而有些根本不返回任何布尔值(即不遵守函数签名)。
库 SafeERC20 能够处理这两种类型的 ERC-20 代币。具体来说,它向该地址发起 transfer 调用,并且:
- 如果发生回滚,
SafeERC20会将回滚冒泡抛出。这处理了那些在失败时回滚但不一定返回布尔值的代币。 - 如果没有回滚,它会检查是否返回了数据
- 如果没有返回数据,并且发现代币地址是一个 空地址 而不是智能合约,该库将回滚。
- 如果返回了数据,并且返回值是一个
false,则 SafeERC20 将回滚。
- 否则,该库不回滚,表示转账成功。
以下是应该如何使用 OpenZeppelin 的 SafeERC20 库:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/[email protected]/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/[email protected]/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() {
// mint the supply of 10_000 tokens
// to the deployer
_mint(msg.sender, 10_000 * 1e18);
}
}
6. 在 Solidity 0.8.0(或更高版本)中使用 safeMath
在 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 库会降低代码的可读性和效率,且没有额外的安全收益。
7. 忘记访问控制
让我们用一个最小的例子。你能发现问题吗?
import "@openzeppelin/[email protected]/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);
}
}
任何人都可以在调用 buyNFT() 之前调用 setPrice() 并将其设置为零。
每当你编写一个 public 或 external 函数时,问问自己是否应该对谁能调用该函数施加限制。以下是上述问题的一个微妙变体:
import "@openzeppelin/[email protected]/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 修饰器(modifier),它仅授予指定用户访问权限。在上述示例中,正如 setPrice 函数中所见,访问控制修饰器确保了只有合约所有者可以设置价格。
8. 循环中进行昂贵的操作
可以无限增长的数组是有问题的,因为在它们之上进行循环迭代的交易成本可能会变得极高。
以下合约接受以太币捐赠并将捐赠者添加到一个数组中。之后,所有者将调用 distributeNFTs() 并为所有捐赠者铸造一个 NFT。然而,如果有许多捐赠者,所有者完成捐赠的成本可能会变得太高。
import "@openzeppelin/[email protected]/token/ERC721/ERC721.sol";
import "@openzeppelin/[email protected]/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 会针对这种情况给出类似于以下的警告:

这个问题的解决方案被称为“拉取胜于推送(pull over push)”。与其将 NFT 发送给每个接收者,不如让他们调用一个函数,如果该地址调用了该函数,则将 NFT 转移给该地址。
9. 缺少对函数输入的合理性检查(Sanity Checks)
每当你编写一个公共函数时,请明确写出你期望传递给函数参数的值,并确保通过 require 语句强制执行。例如,人们不应能够提取超过其余额的资金。人们不应能够提取他们没有存入的资产。
考虑以下示例:
contract LendingProtocol is Ownable {
function offerLoan(
uint256 amount,
uint256 interest,
uint256 duration)
external {}
function setProtocolFee(
uint256 feeInBasisPoints)
external
onlyOwner {}
}
设计者应思考在这里什么参数是合理的。超过 1000% 的利率是不合理的。极短的期限(例如 1 小时)也是不合理的。
同样,setProtocolFee 函数应对所有者能够设置的费用设定一个合理的上限,否则当使用协议的费用突然飙升至不合理的水平时,用户可能会大吃一惊。
为了实现合理性检查,我们只需添加 require 语句来界定输入的可接受范围。
在设计公共函数时,始终要考虑函数参数的合理范围是多少。
10. 代码缺失
Solidity 中的某些 bug 是由于代码缺失而非代码错误造成的。以下 NFT 铸造合约允许所有者指定谁被允许铸造 NFT 以及可以铸造的数量。(这不是一种在 gas 上高效的实现方式,但我们想把重点放在当前的原则上)。
这是代码,你能发现缺少了什么吗?
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/[email protected]/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/[email protected]/token/ERC721/ERC721.sol";
import "@openzeppelin/[email protected]/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。
11. 未固定 Solidity 的 pragma 版本
当你阅读 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。这将明确指定版本,方便其他人审计代码。
12. 不遵循代码风格指南
我们在另一篇博客文章中记录了 Solidity 风格指南。
以下是重点内容:
constructor是第一个函数- 然后是
fallback()和receive()(如果合约有的话) - 接着是
external函数、public函数、internal函数和pure函数 - 在每个分组内:
payable函数排在最前面- 随后是非
payable且非view的函数 - 而
view函数排在最后
13. 缺少日志或未正确索引日志
在 Ethereum 中,除了在区块浏览器中搜索此信息外,没有原生方法可以列出发送到特定智能合约的所有交易。然而,这可以通过让合约触发(emit)事件来实现。
以下是关于事件的一些一般规则:
- 任何可以改变状态变量(storage variable)的函数都应触发一个事件。
- 该事件应包含足够的信息,以便审计日志的人员能够确定当时状态变量所取的值。
- 事件中的任何
address参数都应使用indexed进行索引,以便可以轻松深入查询特定钱包的活动。 view和pure函数不应包含事件,因为它们不改变状态。
你可以在我们关于 Solidity 和 Ethereum 中的事件 的文章中阅读更多相关内容。
一般来说,如果你更改了状态变量或有以太币进出合约,你应该触发一个事件。
14. 不写单元测试
如果不进行实际测试,你怎么知道合约在可能遇到的每种场景下都能正常工作?
在我们看来,智能合约在没有经过单元测试的情况下就被部署是有些令人惊讶的。不应该是这样。
请参见我们关于 Solidity 单元测试的教程。
15. 取整方向错误
如果你计算 100/3,你将得到 33,即使“正确”的答案是 33.33333,因为 Solidity 不支持浮点数。在这种情况下,无论你测量什么单位,其中的 0.3333 都消失了,因为在使用除法时你被迫“向下取整”。以下是除法的黄金法则:
始终向有利于协议或不利于用户的方向取整。
例如,如果你正在计算用户购买某物需要支付多少钱,那么除法会导致估算值低于其实际应有数值。在上面的例子中,用户得到了 0.3333 的折扣。
情况 1:计算协议支付多少金额
如果我们计算 100/3 是为了确定智能合约支付给用户的金额,那么智能合约会少付给用户。**这是正确的做法。**用户将无法从协议中榨取价值。
情况 2:计算用户支付多少金额
另一方面,如果我们计算 100/3 是为了确定用户应向智能合约支付多少金额,那么我们就遇到问题了,因为用户支付的金额比他们应付的少了 0.333。如果用户能够以 0.333 的利润出售该资产,那么他们可以重复这个过程,直到排空整个协议!
在这种情况下,正确的做法是在除法的结果上加一,以便我们弥补在小数部分丢失的数值。也就是说,我们应该将用户支付的金额计算为 100/3 + 1,这样用户就要为价值 33.333 的资产支付 34。他们损失的这点微小价值将防止他们盗取智能合约的资金。
在我们的 定点数学(fixed-point math) 文章中了解有关如何正确处理分数的更多信息。
16. 不运行代码格式化工具
在格式化 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;
}
}
17. 在不支持元交易(metatransactions)的合约中使用 _msgSender()
新的 Solidity 开发者常常对 OpenZeppelin 合约中频繁使用的 _msgSender() 感到困惑。例如,以下是使用 _msgSender() 的 OpenZeppelin ERC-20 库:

除非你在构建支持无 gas 或元交易(metatransactions)的合约,否则应使用常规的 msg.sender 而不是 _msgSender()。
_msgSender() 是由 OpenZeppelin 合约 Context.sol 创建的一个函数:

这仅在支持元交易的合约中使用。
元交易或无 gas 交易是指中继者(relayer)代表用户发送交易并为他们支付 gas 费用。由于交易来自中继者,msg.sender 将不会是“原始”发送者。使用元交易的智能合约将“真实”的 msg.sender 编码在交易的其他位置,并通过重写 _msgSender() 函数来指明“真实”的 msg.sender。
如果你没有做这些事情,那就没有理由使用 _msgSender()。请改用 msg.sender。
18. 意外将 API 密钥或私钥提交到 GitHub
虽然我们没有看到这种情况非常频繁地发生,但仅有的几次发生都导致了极其灾难性的后果。如果你将 API 密钥或私钥放在 .env 文件中,请始终将 .env 文件添加到 .gitignore 文件中。
19. 未考虑抢跑(frontrunning)、滑点(slippage)或交易签名与执行之间的延迟
抢跑(Frontrunning)是 Solidity 合约中一个反直觉的问题,因为它的类似情况在 Web2 编程中很少出现。
示例 1:在买单交易等待期间更改价格
考虑以下合约,它允许 NFT 的卖家在一次交易中与买家交换 USDC。理论上,它的好处是双方都不必先发送他们的代币去信任对手方也会发送代币。
然而,它存在抢跑漏洞。卖家可以在交换交易处于挂起(pending)状态时更改交换价格。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/[email protected]/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/[email protected]/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_;
}
// the buyer calls this function
function atomicSwap(uint256 nftId) external
// requires both the seller and buyer
// to approve their tokens first
token.safeTransferFrom(msg.sender, owner(), price);
nft.transferFrom(owner(), msg.sender, nftId);
}
}
每当用户有代币从他们那里被转移时,都应该始终要求用户传入数据,以指定他们愿意发送的最大金额,从而确保卖家无法在购买交易处于挂起状态时更改价格。
示例 2:价格随每次购买而上涨的 NFT
以下 NFT 销售被编程为每次购买价格上涨 5%。它具有与上面类似的问题。买家签署交易时的价格可能与交易确认时的价格不同。如果 10 个买家同时发送购买交易,那么其中 9 个将支付比他们预期更高的价格。
当合约计算从用户处转移多少代币时,用户应指定允许从其账户转移的最大金额限制。
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import "@openzeppelin/[email protected]/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/[email protected]/token/ERC721/ERC721.sol";
import "@openzeppelin/[email protected]/access/Ownable2Step.sol";
contract BadNFTSale is ERC721("BadNFT", "BNFT"), Ownable(msg.sender) {
using SafeERC20 for IERC20;
uint256 price = 100e6; // USDC / USDT have 6 decimals
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 可能会失败。但在一个实际可能具有多次授权的更复杂的合约中,这将是一个需要注意的问题。
20. 函数未考虑用户多次进行同一交易的情况
智能合约需要考虑到用户多次执行同一交易的可能性。考虑以下示例:
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;。