掌握区块链原生开发者在实战中总结出的精要建议,避开 90% 常见的合约陷阱。
本文聚焦 以太坊,智能合约,安全开发,Solidity,重入漏洞,安全检查,代码审计 七大关键词。每一条经验都来自真实攻防对抗,可以直接落地到 DApp、DeFi、GameFi 等场景。
外部调用:区块链上的“高危动作”
1. 永远假设外部合约不可信
在对外部地址发起调用前,先问自己一句:
“如果这个地址被恶意接管,我的合约能承受多大损失?”
养成以下习惯,可显著降低风险:
- 统一变量前缀:
TrustedXXX / UntrustedXXX
。 - 在函数命名中显式提示,如:
makeUntrustedWithdrawal
。
// 反面案例:看名字完全看不出信任边界
function withdrawTo(address token) external { IERC20(token).transfer(msg.sender, 100); }
// 安全写法:一目了然
UntrustedToken(token).safeTransfer(msg.sender, 100);
2. 遵循 checks-effects-interactions 顺序
调外部合约 之前:
require
检查一切前置条件;- 状态先更新;
- 再真正发起外部调用。
这样即使被「重入」也不会重复扣款。
3. 放弃 .transfer()
和 .send()
- 它们固定给 2300 gas,跟上一次硬分叉后 gas 计价变化,导致 fallback 不够用。
- 改
.call{value: amt}("")
,并务必判断返回值(见下一节)。
👉 查看如何一行代码消灭 70% gas 溢出 bug →
4. 妥善处理返回值
- 高级调用形式
SomeContract.someFunc()
失败会直接抛出; - 低级调用
.call()
,.delegatecall()
会返回bool success
,必须判断。
(bool success, ) = addr.call{value: amount}("");
require(success, "CALL_FAILED");
5. 主动转账不如被动提取
“用户自己取钱”优于“合约主动转账”。即使提取失败,也不影响拍卖主流程:
// 反面:锁死竞价
(bool ok,) = highestBidder.call{value: highestBid}("");
require(ok);
// 安全:引入二次提取
mapping(address => uint) refunds;
refunds[highestBidder] += highestBid;
6. delegatecall
绝不碰用户输入地址
delegatecall
会把运行上下文完全交给目标逻辑——对方一个 selfdestruct
,你的整个合约瞬间蒸发。
遗忘的角落:余额与数据公开
以太币可强制存入
- 攻击者可于部署前硬塞余额,任何 “
require(address(this).balance == 0)
” 检查形同虚设。 - 正确姿势:用内部记账变量管理资产,而非
balance
。
链上数据人人都可阅读
如果涉及盲拍、隐写、随机数:
- 先发布盲投(Keccak256 值的 commitment),
- 再在一个公开阶段披露原像,
- 最后线性验算,防止“晚交反悔”。
Solidity 语法层的细节地雷
整数最小值的“负负得负”陷阱
int8 x = -128;
assert(-x == -128); // true
应对措施:
- 否定前做边界检测;
- 使用更大的 int 类型;
- 乘 / 除以 -1 时同样要小心。
三兄弟:assert
vs require
vs revert
assert
:仅检查开发过程中的「不变量」,Gas 全部消耗。require
:验证输入或外部返回,失败可返回未用 gas。revert
:与require+revert message
等效,但可用于复杂前置逻辑。
Modifier 里别做“真功夫”
Modifier 的代码先于函数体执行,容易破坏 checks-effects-interactions:
modifier isVoter() { registry.isVoter(msg.sender); _; }
// registry 如果重入 vote(),会直接击穿权限边界
→ 建议 把调用放回函数体,用 modifier 做纯 require
,不对外交互。
极易踩坑的十五个小细节
- 可见性显性声明:缺省等同于 public,但阅读者一眼看不出。
- pragma 锁定版本:避免“线上进了一个周末的新 bug”。
- 明文使用事件:重要的状态变更一定
emit
,比查历史交易省心。 - 弃用 tx.origin:身份验证仅用
msg.sender
,斩断钓鱼攻击链。 - 时间戳可被矿工前后移动 15 秒:如需随机,请参考 VRF。
- block.number 不可靠:不要把它当秒表。
- 多重继承顺序右→左线性叠加,变量重名容易“被覆盖”。
- 接口优于裸 address:编译期即可发现类型错配。
- extcodesize 在 constructor 内为 0:别把它当“EOA 判定神器”。
- 整数除法一律向下取整:保留分子/分母或用放大因子抵消精度损失。
- fallback/receive 保持极简:记录事件即可,别做账务逻辑,防止 2300 gas 爆炸。
- 函数需收到 Ether 必标记
payable
,否则 revert。 - 强制类型转换会隐藏溢出:用 SafeMath、自定义库或 solidity ≥0.8.
- 日志库/组件的别名如
keccak256
优于sha3
。
👉 立即查看 Solidity 8 大最佳实践速查表 →
随堂 FAQ:你关心的 6 个高频问题
Q1:新项目必须做合约审计吗?
绝对必要。漏掉一个逻辑漏洞造成的损失远超数千元审计费。建议采用第三方审计+Bug Bounty+持续集成扫描的多层防线。
Q2:.call()
返回 true 就代表转账一定成功?
不一定,只代表 CALL 执行未 revert。某些 Token 的转账仅返回 false 不 throw,记得再对接口做 bool 检查。
Q3:开发时就想用模糊测试(fuzzing),有现成工具吗?
推荐 Foundry(forge test --fuzz
)或 Hardhat + Echidna。与单元测试共用,最快 30 分钟即可上手。
Q4:Modifiers 还能不能用?
可以,但应仅限于状态读取或校验逻辑,严禁含外部调用。复杂权限可在内部函数 internal _checkPermission
里统一封装。
Q5:重入攻击到底怎么测试?
写一个简单的攻击合约,利用 reentrancy()
回调目标函数并 counting 调用深度;或使用 OpenZeppelin ReentrancyGuard
的测试用例作为蓝本。
Q6:fallback 和 receive 有何区别?
receive()
—— 仅接收纯以太转账,无 calldata。fallback()
—— 匹配不到函数或 calldata 非空都会进来。Solidity >=0.6 开始推荐拆分使用。
送一句「长者经验」
安全开发不是一次性的,而是迭代流程。
把以上检查单扔进 CI,每次合并即扫描高风险函数,比事后才哭“早知道”要实在得多。祝你写出固若金汤的以太坊智能合约!