本文分析了 Web3 项目常见的八大工程错误,包括访问控制薄弱、忽略重入风险、盲目依赖审计、升级设计不当、业务逻辑缺陷等。作者强调 Web3 代码的不可篡改性使得技术决策至关重要,开发者应遵循先更新状态后交互的原则,并建立完善的监控与响应机制。

大多数 Web3 产品失败并不是因为想法不好。事实上,其中许多产品起步非常强势。
它们发布了,人们参与了,使用量开始增长,然后一些微小的地方出了问题。并不总是戏剧性的黑客攻击。有时只是一个遗漏的检查、一个错误的假设,或者一个在当时看来并不关键的设计决策。
突然之间,资金被困住了。用户失去了信心。进度变慢了。
这个领域的不同之处在于,错误不会悄无声息地消失。一旦智能合约部署,就是这样了。你不能像在传统后端那样直接推送修复补丁。系统是实时运行的,无论你发布了什么,都会变成现实。
这就是为什么 Web3 中的工程决策比看起来更有分量。它们不仅仅是技术选择——它们决定了一个产品是否真的能在野外生存。
在基础层面,访问控制仅仅是关于谁被允许做什么。但在实践中,这是最容易出错的地方之一。
function withdrawFunds() public {
payable(msg.sender).transfer(address(this).balance);
}
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "Not authorized");
_;
}
function withdrawFunds() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
许多团队过于依赖前端来“保护”函数。UI 隐藏了某些按钮,所以它感觉很安全——但合约本身仍然是完全开放的。
其他时候,管理函数被意外地保持为公开。或者角色根本没有经过清晰的思考。谁被允许暂停系统?谁可以升级它?谁可以移动资金?
这些问题往往回答得太晚了。
从第一天起就将访问控制视为头等大事。
在合约内部强制执行权限,而不是在界面中。从角色的角度思考,而不仅仅是一个单一的 owner。养成单独审查访问逻辑的习惯,因为当它混入其他所有事情中时,很容易被忽视。
重入是那些众所周知但仍出现在实际系统中的问题之一。
它通常归结为顺序问题。
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
payable(msg.sender).call{value: amount}("");
balances[msg.sender] -= amount;
}
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
合约在更新其内部状态之前发送了资金。这个微小的顺序决策为在余额减少之前进行重复调用打开了大门。
有时它甚至不明显,特别是当多个合约相互交互且调用流变得复杂时。
坚持一个简单的规则:先更新你的状态,然后与外部合约交互。
检查 → 效果 → 交互 (checks → effects → interactions) 模式的存在是有原因的。如果某些东西感觉哪怕只有一点点敏感,添加一个重入保护 (reentrancy guard) 是以很小的代价换取大量的保护。
审计很重要,但它们经常被误解。
审计并不意味着你的系统是“安全的”。它只意味着有人在特定时间点审查了你代码的特定版本。
团队有时将审计视为终点。一旦完成,他们就会快速行动,修改代码、添加功能、调整逻辑。
但这些变更并不总是以同样程度的严谨性进行审查。
还有一种倾向是只关注代码层面的漏洞,而忽视经济风险或边缘情况的行为。
将审计视为一层保护,而不是整个策略。
内部审查仍然重要。测试仍然重要。部署后,监控与之前的一切同样重要。
可升级性听起来像是一个安全网。实际上,它引入了自己的一系列风险。
有时升级功能没有得到妥善限制。有时代理模式实现不正确。有时存储布局 (storage layouts) 的改变会悄无声息地破坏一切。
在其他情况下,根本没有明确的治理——这意味着升级取决于单个密钥或决策者。
function upgrade(address newImplementation) public {
implementation = newImplementation;
}
可升级性不仅关乎更改代码;它关乎控制。
谁有权决定什么被更改?这些决策是如何被批准的?存在哪些保障措施?
使用成熟的模式(如 UUPS 或透明代理)会有所帮助。引入多签 (multi-sig) 批准或治理层也是如此。
但关键是将可升级性视为一个系统设计问题,而不仅仅是一个技术特性。
并非每一次失败都源于漏洞。有些源于在实际条件下无法成立的假设。
一个协议依赖于单一的 Oracle。奖励可以被操纵。激励措施的表现不如预期。Flash loans 暴露了在开发过程中不明显的弱点。
uint price = oracle.getPrice();
如果该单一数值被操纵(即使是短暂地),它就可能成为故障点。
你必须在设计时考虑到对抗性行为。
记住我以便更快登录
使用多个数据源。通过 TWAP 等机制平滑价格输入。对你的 Tokenomics 进行压力测试。模拟攻击。
因为如果某件事可以被利用,它最终会被利用。
即使在 Web3 中,系统的许多部分也存在于链下。
前端、RPC 提供商、索引器 (indexers)、API——这些都是关键组成部分。
一个单一的 RPC 提供商宕机,应用突然停止工作。索引器滞后,用户看到错误的数据。API 在负载下成为瓶颈。
这些都不会出现在智能合约本身中——但它仍然会影响用户。
冗余。
多个 RPC 提供商。备用系统。索引逻辑和前端逻辑的清晰分离。以及能在出现异常时告知你的监控。
假设这些组件在某些时刻会失败并据此进行设计会更安全。
智能合约不会“猜测”你的意思。如果你不验证输入,它们将接受给出的任何内容。
function deposit(uint amount) public {
balances[msg.sender] += amount;
}
function deposit(uint amount) public {
require(amount > 0, "Invalid amount");
balances[msg.sender] += amount;
}
零值潜入。意料之外的输入破坏了假设。边缘情况被忽视。
单独来看,这些似乎微不足道。但它们会累积——有时它们会为更大的问题创造缺口。
明确允许的内容。
定义范围。处理边缘情况。用不应该奏效的输入进行测试,而不仅仅是那些应该奏效的输入。
在没有监控的情况下发布就像在没有可见性的情况下运行系统。
直到出现问题你才知道发生了什么,而到那时,通常已经太晚了。
可疑活动未被察觉。没有办法暂停系统。没有警报。没有计划。
当事情发生时,团队在没有任何准备的情况下进行实时反应。
监控、警报和明确的响应计划。
即使是简单的机制——如暂停函数或交易警报——也能产生巨大的影响。运行模拟场景有助于团队在实际情况发生时反应更快。
使用经过验证的库,如 OpenZeppelin。尽可能保持简单。复杂性往往会引入风险。
进行广泛且激进的测试。单元测试、集成测试、Fork 测试、Fuzzing——这些都有助于尽早发现问题。
使用多签名钱包。添加时间锁 (time-locks)。逐步推进,而不是一次性全部铺开。
持续监控。运行 Bug 赏金计划。根据实际使用情况不断改进。
避免错误并不是要让一切都变得完美。
而是要构建能够处理压力、意外行为和现实条件的系统。
假设你的系统将会受到考验,因为它确实会。
为那个现实而设计。
Web3 中的错误是昂贵的,因为它们会向外扩散。
它们影响用户、资金、信任以及产品的长期未来。
而且通常情况下,成功与失败的区别不在于想法,而在于该想法被执行得多么仔细。
在 Ancilar,重点是构建那些一旦上线就能真正支撑得住的系统。
这意味着思考范畴超越了智能合约——进入架构、基础设施以及在压力下一切如何表现。
目标不仅仅是发布。而是构建经久耐用的东西。
如果你正在 Web3 领域构建并希望避免昂贵的工程错误,你可以在这里联系:
https://www.ancilar.com/contactUs
- 原文链接: medium.com/@ancilartech/...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!