第30节:智能合约编写一般原则
概述
本文聚焦如何写出更加健壮的代码,从变量、结构体、函数、修饰器、代码规范、外部调用、静态调用、事件、合约、项目整体等给出分析视角,供开发着参考,从而写出更加优质的代码。
对于现存合约中,业界已经有一份合约安全分析的整理,即:SWC (Smart Contract Weakness Classification and Test Cases)智能合约弱点分类和测试用例,链接:https://swcregistry.io/ 建议大家逐个看一下,真的非常令人震惊,你总能找到自己的盲点!
本文参考:https://github.com/transmissions11/solcurity
语法检查
变量Variables
V1
- 可以是internal
吗?V2
- 可以是constant
吗?V3
- 它可以是immutable
吗?V4
- 是否设置了可见性? (SWC-108)V5
- 变量的用途和其他重要信息是否使用 natspec 语法写了注释?V6
- 它可以与相邻的存储变量一起打包吗?V7
- 可以将它打包在一个具有多个其他变量的struct中吗?V8
- 使用完整的 256 bit类型,除非与其他变量打包。V9
- 如果它是一个public数组,是否提供了一个单独的函数来返回完整的数组?V10
- 仅当明确阻止子合约访问变量时才使用private
,否则应该internal
以获得灵活性。
结构体Structs
S1
- 是否需要结构?变量可以在Storage中直接packed么?S2
- 它的字段是否打包在一起(如果可能)?S3
- 结构的用途和所有字段是否使用 natspec 语法写了注释?
函数Functions
F1
- 可以是external
吗?F2
- 应该是internal
吗?F3
- 是否应该是payable
?F4
- 可以与另一个类似的函数合并成一个吗?F5
- 验证所有参数是否在安全范围内,即使该函数只能由受信任的用户调用!F6
- 是否遵循效果模式之前的检查? (SWC-107)F7
- 检查提前运行的可能性,例如Approve功能。 (SWC-114)F8
- 是否可能存在gas不足? (SWC-126)F9
- 是否应用了正确的修饰符,例如onlyOwner
/requiresAuth
?F10
- 是否总是对返回值进行赋值了?F11
- 在函数正确运行之前,请写下并测试不应该改变的状态变量。F12
- 在函数运行之后,写下并测试返回值或任何状态不应该更改状态变量。F13
- 命名函数时要小心,因为人们会根据名称来假设函数功能。F14
- 如果一个函数是明确不安全的(为了节省gas等),使用一个笨拙的名字来引起人们注意它的风险。F15
- 是否所有参数、返回值、副作用和其他信息都使用 natspec 语法写了注释?F16
- 如果该函数允许对合约中的另一个用户进行操作,不要假设msg.sender
是正在操作的用户。F17
- 如果函数允许合约处于未初始化状态,请检查明确的initialized
变量。而不要使用owner == address(0)
或其他类似的检查作为替代。F18
- 仅当明确阻止子合约访问变量时才使用private
,否则应该internal
以获得灵活性。F19
- 在有必要(且安全)的业务情况,如果子合约可能希望覆盖父合约函数的行为时,可以使用virtual
。
修饰器Modifiers
M1
- 避免在里面更新状态变量(重入锁除外)M2
- 避免在里面调用external函数M3
- 修饰器的用途和其他重要信息是否使用 natspec 语法写了注释?
代码Code
C1
- 是否使用了SafeMath 或者 0.8 版本进行数学检查? (SWC-101)C2
- 是否有storage slot被多次读取了?C3
- 是否使用了任何可能导致 DoS攻击的(没有上限边界的)循环/数组? (SWC-128)C4
- 仅对长间隔使用block.timestamp
。 (SWC-116)C5
- 不要使用 block.number 来计算经过的时间。 (SWC-116)C7
- 尽可能避免委托调用delegatecall,尤其是对external(即使是受信任的)合约。 (SWC-112)C8
- 在迭代数组时不要更新数组的长度。C9
- 不要使用blockhash()
,比如用来获得随机性等。 (SWC-120)C10
- 是否使用 nonce 和block.chainid
保护签名免受重放(SWC-121)C11
- 确保所有签名都使用 EIP-712。 (SWC-117 SWC-122)C12
-abi.encodePacked()
的输出如果使用 >2 个动态类型,则不应进行散列处理。通常更应该使用abi.encode()
。 (SWC-133)C13
- 小心assembly汇编,不要使用任何任意(arbitrary)数据。 (SWC-127)C14
- 不要通过特定的ETH余额来做逻辑判断(会被selfdestruct攻击)。 (SWC-132)C15
- 避免gas不足。 (SWC-126)C16
- 私有数据不是私有的。 (SWC-136)C17
- 更新memory中的结构/数组不会在storage中修改它。C18
- 从不shadow状态变量。 (SWC-119)C19
- 不要改变函数参数。C20
- 即时计算价值是否比存储价值更便宜?C21
- 是否所有状态变量都从正确的合约中读取(master还是clone)?C22
- 是否正确使用了比较运算符(>
、<
、>=
、<=
),尤其是为了防止差一(多一次或 者少一次)错误?C23
- 是否正确使用了逻辑运算符(==
、!=
、&&
、||
、!
),尤其是为了防止差一错误?C24
- 在除法之前总是乘法,除非乘法可能溢出。C25
- Magic numbers(魔数)是否被具有直观名称的常数替换?C26
- 如果 ETH 的接收者有一fallback功能,它会导致 DoS 吗? (SWC-113)C27
- 使用 SafeERC20或进行安全检查返回值。C28
- 不要在循环中使用msg.value
。C29
- 如果递归委托调用是可能的,不要使用msg.value
(比如如果合约继承了Multicall
/Batchable
)。C30
- 不要假设msg.sender
始终是相关用户。C31
- 不要使用assert()
除非用于模糊测试或形式验证。 (SWC-110)C32
- 不要使用tx.origin
进行授权。 (SWC-115)C33
- 不要使用address.transfer()
或address.send()
。请改用.call.value(...)("")
。 (SWC-134)C34
- 使用低级 low-level调用时,在调用之前确保合约存在。C35
- 调用具有多个参数的函数时,使用命名参数语法。C36
- 不要在assembly中使用create2。更应该使用salt语法合约创建。C37
- 不要使用assembly来访问 chainid 或合约代码/大小/哈希。更应该使用最新的Solidity 语法。C38
- 将变量设置为零值时,建议使用delete
关键字(0
、false
、""
等)。C39
- 尽可能多地评论“为什么”。C40
- 如果使用晦涩的语法或编写非常规代码,请注释“what”。C41
- 在复杂场景和定点数学旁边,请添加注释解释 + 示例输入/输出。C42
- 如果对合约进行了优化,要评论留下注释,并且注明本次优化节约了多少gas。C43
- 对有意避免某些优化的地方,要进行注释说明,以及估计如果实施它们将会或不会节省的gas数量。C44
- 在不可能发生上溢/下溢,或者上溢/下溢在人类时间尺度(计数器等)上不现实的情况下使用“unchecked”代码块。同时注明使用unchecked节省多少gas。C45
- 不要依赖于 Solidity 的算术运算符优先规则。除了使用括号来覆盖默认运算符优先级之外,还应该明确使用括号来强调它。C46
- 传递给逻辑/比较运算符(&&
/||
/>=
/==
/etc)的表达式不应有副作用。C47
- 在执行可能导致精度损失的算术运算的操作时,要确保它有利于系统中的正确参与者,并明确添加注释。C48
- 当需要使用重入锁的时候,一定要注明使用的原因。C49
- 模糊测试时,使用如取模方式来限定输入值(例如x = x % 10000 + 1
以限制从 1 到 10,000)。C50
- 尽可能使用三元表达式来简化分支逻辑。C51
- 当对多个地址进行操作时,问问自己如果它们地址相同会发生什么。
外部调用External Calls
X1
- 是否真的需要external合约调用?X2
- 如果有错误,会导致 DoS 吗? (SWC-113)X3
- 如果重入当前函数会有害吗?X4
- 如果重入另一个函数会有害吗?X5
- 是否检查结果并处理错误? (SWC-104)X6
- 如果使用了所有提供的gas会发生什么?X7
- 如果它返回大量数据,它会导致调用合约中的gas不足吗?X8
- 如果你正在调用一个特定的函数,不要认为返回了success
就意味着这个函数存在(幻像函数phantom Function)。
静态调用Static Calls
S1
- 是否真的需要external合约调用?S2
- 它在接口文件中,真的定义为view吗?S3
- 如果出现错误,是否会导致 DoS? (SWC-113)S4
- 如果调用进入无限循环,会导致 DoS 吗?
事件Events
E1
- 是否应该索引任何字段(indexed)?E2
- 相关操作的创建者是否包含在索引字段中(indexed)?E3
- 不要索引动态类型,如字符串或字节。E4
- 事件何时发出并且所有字段都使用 natspec 记录?E5
- 是否在发出事件的函数中操作的所有users/ids 都存储为索引字段?E6
- 不用在发送事件参数中调用函数或者计算表达式,因为执行顺序是不可预测的。
合同Contract
T1
- 使用 SPDX 许可证标识符。T2
- 是否每个修改了状态变量的函数都发出了事件?T3
- 检查一下继承关系是否正确,是否简单且线性。 (SWC-125)T4
- 如果合约应该接受转移的 ETH,请使用receive() external payable
函数。T5
- 写下并测试状态变量和不变量之间的关系。T6
- 合约的作用以及它与其他人的交互方式是否使用 natspec 进行了记录?T7
- 如果另一个合约必须继承它以解锁其全部功能,则该合约应标记为“抽象”。T8
- 为构造函数中设置的任何non-immutable变量emit事件,当在其他地方发生变化时发出事件。T9
- 避免过度继承,因为它掩盖了复杂性并鼓励过度抽象。T10
- 始终使用命名的导入语法来明确声明哪些合约是从另一个文件导入的。T11
- 按文件夹/包对导入进行分组。用空行分隔组。外部依赖组应该首先出现,然后是mock/testing合约(如果相关),最后是本地local导入。T12
- 用@notice
natspec 注释总结合约的目的和功能。在@dev
natspec 注释中记录合约如何与项目内部/外部的其他合约交互。
项目Project
P1
- 使用正确的许可证(如果您依赖 GPL 代码等,则必须使用 GPL)。P2
- 对所有内容进行单元测试。P3
- 尽可能多的模糊测试。P4
- 尽可能使用符号执行symbolic execution。P5
- 运行 Slither/Solhint 并查看所有发现。
一般审核方法
- 阅读项目的文档、规范和白皮书,了解智能合约的用途。
- 在检查代码之前,构建一个您期望合同看起来像的心智模型。
- 浏览合同以了解项目的架构。像 Surya 这样的工具可以派上用场。
- 将架构与您的心智模型进行比较。调查令人惊讶的领域。
- 创建威胁模型并列出理论上的高级攻击向量。
- 看看可以进行价值交换的领域。尤其是像
transfer
、transferFrom
、send
、call
、delegatecall
和selfdestruct
这样的函数。反向的分析,从而确保是合理安全的。 - 查看与外部合同交互的区域,并确保所有关于它们的假设都是有效的(例如股价只会上涨等)
- 对合同进行一般性的逐行审查。
- 从威胁模型中每个参与者的角度进行另一次审查。
- 浏览项目的测试 + 代码覆盖率,并深入了解缺乏覆盖率的区域。
- 运行 Slither/Solhint 等工具并查看其输出。
- 查看相关项目及其审计,以检查是否存在任何类似问题或疏忽。
小结
安全无小事,写智能合约不难,但是如何写出没有安全问题的合约,那就不是刷刷手机就能解决的问题。
我们无法避免所有问题,但是尽可能多的排除人为故障导致,永远是些合约的第一准则,希望上面的内容对你有收获!
加V入群:dukeweb3,公众号:阿杜在新加坡,一起抱团拥抱web3,下期见!
关于作者:国内第一批区块链布道者;2017年开始专注于区块链教育(btc, eth, fabric),目前base新加坡,专注海外defi,dex,元宇宙等业务方向。