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