我们应当遵循的那些 Solidity 编码规范
本文的目的是记录在代码审查或审计中出现的与官方 Solidity 风格指南 的常见偏差,而不是重新讨论该指南。这里的一些项目不在风格指南中,但却是 Solidity 开发者常见的风格错误。
当然,你的代码在没有它的情况下也能编译,但你会收到警告,所以请消除这个警告。
你可能见过如下的 pragma:
pragma solidity ^0.8.0;
和
pragma solidity 0.8.26;
你应该使用哪个,何时使用?如果你是编译和部署合约的人,你知道你正在使用的 Solidity 版本,因此为了清晰起见,你应该将 Solidity 版本固定为你正在使用的编译器。
另一方面,如果你正在为其他人扩展创建库,例如 OpenZeppelin 和 Solady 所做的,你不应该固定 pragma,因为你不知道最终用户将使用哪个编译器版本。
而不是这样做:
import "@openzepplin/contracts/token/ERC20/ERC20.sol";
这样做:
import "@openzeppelin/contracts@4.9.3/token/ERC20/ERC20.sol";
你可以通过点击 GitHub 左侧的分支下拉菜单并点击标签,然后选择最新版本来获取最新版本。使用最新的干净(非 rc,即非发布候选)版本。
如果你不对导入进行版本控制,而底层库更新了,你的代码可能会无法编译或表现出意外行为。
而不是这样做
import "@openzeppelin/contracts@4.9.3/token/ERC20/ERC20.sol";
这样做
import {ERC20} from "@openzeppelin/contracts@4.9.3/token/ERC20/ERC20.sol";
如果导入文件中定义了多个合约或库,你将污染命名空间。如果编译器优化器没有删除它,这将导致死代码(你不应该依赖这一点)。
如果你使用 智能合约安全工具 像 Slither,这将被自动捕获。但一定要删除这些。不要害怕删除代码。
natspec(自然语言规范)的目的是提供易于人类阅读的内联文档。
下面是合约的 natspec 示例。
/// @title Foo 协议的流动性代币
/// @author Foo Incorporated
/// @notice 非技术读者的说明
/// @dev 给理解 Solidity 的人的说明
contract LiquidityToken {
}
函数应首先按“外部性”排序,然后按“状态改变性”排序。
它们的顺序应如下:接收和回退函数(如果适用),外部函数,公共函数,内部函数和私有函数。
在这些组中,可支付函数放在顶部,然后是非可支付函数,然后是视图函数,然后是纯函数。
contract ProperLayout {
// 类型声明,例如 using Address for address
// 状态变量
address internal owner;
uint256 internal _stateVar;
uint256 internal _starteVar2;
// 定义事件
event Foo();
event Bar(address indexed sender);
// 定义错误
error NotOwner();
error FooError();
error BarError();
// 修饰器
modifier onlyOwner() {
if (msg.sender != owner) {
revert NotOwner();
}
_;
}
// 函数
constructor() {
}
receive() external payable {
}
falback() external payable {
}
// 函数首先按以下顺序分组
// - 外部
// - 公共
// - 内部
// - 私有
// 注意外部函数按它们可以修改或与状态交互的程度“降序”排列
function foo() external payable {
}
function bar() external {
}
function baz() external view {
}
function qux() external pure {
}
// 公共函数
function fred() public {
}
function bob() public view {
}
// 内部函数
// 内部视图函数
// 内部纯函数
// 私有函数
// 私有视图函数
// 私有纯函数
}
如果你看到数字 100 就静静地坐在代码中,那是什么?100 百分比?100 基点?
通常,数字应作为常量写在合约的顶部。
不写
uint256 secondsPerDay = 60 * 60 * 24;
这样做:
1 days;
而不是写
require(msg.value == 10**18 / 10, "must send 0.1 ether");
这样做
require(msg.value == 0.1 ether, "must send 0.1 ether");
不这样做
uint256 private constant BASIS_POINTS_DENOMINATOR = 10000
这样做
uint256 private constant BASIS_POINTS_DENOMINATOR = 10_000
virtual 修饰符意味着“可以被子合约重写”。但如果你知道你不会重写该函数(因为你是部署者),那么这个修饰符就是多余的。只需删除它。
以下是正确的:
// 可见性(可支付性),[virtual],[override],[自定义]
function foo() public payable onlyAdmin {
}
function bar() internal view virtual override onlyAdmin {
}
有时被称为“Solidity 注释风格”,它的正式名称是 natspec:
规则与合约 natspec 类似,除了我们还根据函数参数和返回值指定参数。
这可以是描述参数名称的好方法,而不使用冗长的参数变量。
/// @notice 存入 ERC20 代币
/// @dev 触发存款事件
/// @dev 如果代币未在白名单中,则会回滚
/// @dev 如果合约未获得 ERC20 的批准,则会回滚
/// @param token 要存入的 ERC20 代币的地址
/// @param amount 要存入的 ERC20 代币的数量
/// @returns 用户收到的流动性代币的数量
function deposit(address token, uint256 amount) public returns (uint256) {
}
// 如果合约继承了函数,你也可以继承它们的 natspec
/// @inheritdoc Lendable
function calculateAccumulatedInterest(address token, uint256 since) public override view returns (uint256 interest) {
}
对于开发者参数,最好通知它可以进行何种状态更改,例如触发事件、发送以太、自毁等。
notice 和 param natspec 会被 Etherscan 读取。
你可以在以下屏幕截图中查看 Etherscan 从哪里获得该信息,截图显示了 代码。
这应该是不言自明的。如果代码被注释掉,那就是杂乱。
命名是编写良好代码的更难方面之一,但这对可读性有很大帮助。
一些建议:
避免使用“通用名词”,如“用户”,更精确的,例如“管理员”、“买家”、“卖家”。
“数据”这个词通常是模糊的指示。与其使用“userData”,不如用“userAccount”。
不要用两个不同的名词来指代同一个现实世界实体。例如,如果“depositor”和“liquidityProvider”在现实世界中指的是同一个实体,就只使用一个术语,不要在代码中同时使用两个。
在变量名称中包含单位。与其使用“interestRate”,不如 “interestRatesBasisPoints”或“feeInWei”。
状态改变函数的名称中应包含动词。
在区分内部变量和函数与覆盖状态变量的函数参数时,要一致使用下划线。如果在变量前加下划线表示“内部”,确保在其他上下文中不用于表示其他含义,例如,与状态变量同名的函数参数。
使用“get”来查看数据,使用“set”来更改数据是一种广泛遵循的编程约定。考虑将其纳入。
完成代码编写后,离开电脑,然后在 15 分钟后回来,问自己每个变量和函数名称是否尽可能精确。这种刻意的努力对你来说比任何检查表都更有帮助,因为你比任何人都更了解代码库的意图。
如果你有很多存储变量,可以在一个合约中定义所有存储变量,然后从该合约继承以获得对这些存储变量的访问。
如果你的函数需要大量参数,请使用结构体来传递信息。
如果你需要很多导入,可以将所有文件和类型导入到一个 Solidity 文件中,然后导入该文件(你需要故意打破命名导入的规则)。
使用库将同一类别的函数分组在一起,使文件更小。
组织大型代码库是一门艺术。学习它的最佳方法是研究大型成熟项目的代码库。
此检查表用于我们的 solidity bootcamp 进行代码审查。
参加 OpenSpace 集训营 ,可以学习到更多高阶知识
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!