带安全保障的 ERC-20
简介
以太坊的优点之一在于,没有任何中心化机构可以修改或撤销你的交易。 以太坊的一大问题也正在于此:没有任何中心化机构有权撤销用户错误或非法交易。 在本文中,你将了解用户在使用 ERC-20 代币时常犯的一些错误,以及如何创建 ERC-20 合约来帮助用户避免这些错误,或赋予中心化机构某些权力(例如冻结帐户)。
请注意,虽然我们将使用 OpenZeppelin ERC-20 代币合约opens in a new tab,但本文不会对其进行详细解释。 你可以在此处找到此信息。
如果你想查看完整的源代码:
- 打开 Remix IDEopens in a new tab。
- 点击克隆 GitHub 图标(
)。 - 克隆 GitHub 仓库
https://github.com/qbzzt/20220815-erc20-safety-rails。 - 打开 contracts > erc20-safety-rails.sol。
创建 ERC-20 合约
在添加安全保障功能之前,我们首先需要 ERC-20 合约。 在本文中,我们将使用 OpenZeppelin 合约向导opens in a new tab。 在另一个浏览器中将其打开,然后遵循以下说明:
-
选择 ERC20。
-
请输入以下设置:
参数 Value 名称 SafetyRailsToken 符号 SAFE 预铸 1000 功能 无 访问控制 Ownable 可升级性 无 -
向上滚动并点击 Open in Remix(适用于 Remix)或 Download 以使用不同的环境。 我将假设你正在使用 Remix,如果你使用其他工具,请做相应更改。
-
我们现在已经拥有一份功能齐全的 ERC-20 合约。 你可以展开
.deps>npm查看导入的代码。 -
编译、部署并试用该合约,看看它是否能作为 ERC-20 合约正常运行。 如果你需要学习如何使用 Remix,请参阅此教程opens in a new tab。
常见错误
这些错误
用户有时会向错误的地址发送代币。 虽然我们无法读懂他们的心思,不知道他们想做什么,但有两种经常发生且易于检测的错误类型:
-
将代币发送到合约自己的地址。 例如,Optimism 的 OP 代币opens in a new tab在不到两个月的时间里累积了超过 120,000opens in a new tab 个 OP 代币。 这代表着一笔巨额财富,而人们很可能就这样白白损失了。
-
将代币发送到空地址,即不对应外部帐户或智能合约的地址。 虽然我没有关于这种情况发生频率的统计数据,但有一次事件可能造成了 20,000,000 代币的损失opens in a new tab。
阻止转账
OpenZeppelin ERC-20 合约包含一个钩子 _beforeTokenTransferopens in a new tab,它在代币转账前被调用。 默认情况下,这个钩子不执行任何操作,但我们可以在其上挂载自己的功能,例如在出现问题时进行回滚检查。
要使用这个钩子,请在构造函数后添加以下函数:
1 function _beforeTokenTransfer(address from, address to, uint256 amount)2 internal virtual3 override(ERC20)4 {5 super._beforeTokenTransfer(from, to, amount);6 }如果你不太熟悉 Solidity,此函数的某些部分对你来说可能比较陌生:
1 internal virtualvirtual 关键字表示,就像我们从 ERC20 继承功能并重写此函数一样,其他合约也可以从我们这里继承并重写此函数。
1 override(ERC20)我们必须明确指定我们正在重写opens in a new tab _beforeTokenTransfer 的 ERC20 代币定义。 通常,从安全角度来看,显式定义比隐式定义要好得多——如果做过的事情就在眼前,你就不会忘记。 这也是我们需要指定正在重写哪个超类的 _beforeTokenTransfer 的原因。
1 super._beforeTokenTransfer(from, to, amount);这行代码调用我们所继承的、且包含此函数的合约的 _beforeTokenTransfer 函数。 在本例中,只有 ERC20 有这个钩子,Ownable 没有。 尽管目前 ERC20._beforeTokenTransfer 不执行任何操作,但我们仍然调用它,以防将来添加新功能(然后我们决定重新部署合约,因为合约在部署后无法更改)。
将要求编写为代码
我们想向函数中添加这些要求:
to地址不能等于address(this),即 ERC-20 合约本身的地址。to地址不能为空,它必须是以下之一:- 一个外部帐户 (EOA)。 我们无法直接检查一个地址是否为 EOA,但可以检查该地址的 ETH 余额。 EOA 几乎总是有余额,即使不再使用也是如此——很难将余额清零到最后一个 wei。
- 一个智能合约。 测试一个地址是否为智能合约要更难一些。 有一个检查外部代码长度的操作码,名为
EXTCODESIZEopens in a new tab,但它不能直接在 Solidity 中使用。 我们必须为此使用 Yulopens in a new tab,它是一种 EVM 汇编语言。 我们也可以使用 Solidity 中的其他值(<address>.code和<address>.codehashopens in a new tab),但它们成本更高。
我们来逐行查看新代码:
1 require(to != address(this), "不能将代币发送到合约地址");这是第一个要求,检查 to 和 this(address) 是否不相同。
1 bool isToContract;2 assembly {3 isToContract := gt(extcodesize(to), 0)4 }我们通过这种方式检查一个地址是否为合约。 我们无法直接从 Yul 接收输出,因此我们定义了一个变量来保存结果(在本例中为 isToContract)。 Yul 的工作方式是,每个操作码都被视为一个函数。 所以我们首先调用 EXTCODESIZEopens in a new tab 来获取合约大小,然后使用 GTopens in a new tab 来检查它是否不为零(我们处理的是无符号整数,所以它当然不可能是负数)。 然后我们将结果写入 isToContract。
1 require(to.balance != 0 || isToContract, "不能将代币发送到空地址");最后,我们进行空地址的实际检查。
管理访问权限
有时候,有一个可以撤销错误的管理员是很有用的。 为了减少潜在的滥用,这个管理员可以是一个多签opens in a new tab,这样就需要多个人同意才能执行一项操作。 本文将介绍两种管理功能:
-
冻结和解冻帐户。 例如,当帐户可能被盗用时,这就很有用。
-
资产清理。
有时,诈骗者会向真实代币的合约发送诈骗代币,以使其看起来合法。 例如,请看这里opens in a new tab。 合法的 ERC-20 合约是 0x4200....0042opens in a new tab。 冒充它的诈骗合约是 0x234....bbeopens in a new tab。
也有可能有人误将合法的 ERC-20 代币发送到我们的合约中,这也是我们希望有办法将这些代币取出的另一个原因。
OpenZeppelin 提供了两种机制来实现管理访问:
Ownableopens in a new tab 合约只有一个所有者。 带有onlyOwner修饰符opens in a new tab的函数只能由该所有者调用。 所有者可以将所有权转让给其他人或完全放弃所有权。 所有其他帐户的权利通常是相同的。AccessControlopens in a new tab 合约具有基于角色的访问控制 (RBAC)opens in a new tab。
为简单起见,本文使用 Ownable。
冻结和解冻合约
冻结和解冻合约需要进行几项更改:
-
一个从地址到布尔值opens in a new tab的映射opens in a new tab,用于跟踪哪些地址被冻结。 所有值的初始值都为零,对于布尔值,这被解释为 false。 这正是我们想要的,因为默认情况下帐户不会被冻结。
1 mapping(address => bool) public frozenAccounts; -
使用事件opens in a new tab来通知所有相关方帐户被冻结或解冻。 从技术上讲,这些操作并不需要事件,但它有助于链下代码能够侦听这些事件并了解正在发生的情况。 当发生可能与其他人相关的事情时,智能合约发出事件被认为是一种良好实践。
这些事件已编入索引,因此可以搜索某个帐户所有被冻结或解冻的时间。
1 // 当帐户被冻结或解冻时2 event AccountFrozen(address indexed _addr);3 event AccountThawed(address indexed _addr); -
用于冻结和解冻帐户的函数。 这两个函数几乎完全相同,因此我们只介绍冻结函数。
1 function freezeAccount(address addr)2 public3 onlyOwner标记为
publicopens in a new tab 的函数可以从其他智能合约调用,也可以通过交易直接调用。1 {2 require(!frozenAccounts[addr], "帐户已被冻结");3 frozenAccounts[addr] = true;4 emit AccountFrozen(addr);5 } // freezeAccount如果帐户已被冻结,则回滚。 否则,冻结它并
emit一个事件。 -
更改
_beforeTokenTransfer以防止资金从冻结帐户中转出。 请注意,资金仍可转入冻结帐户。1 require(!frozenAccounts[from], "该帐户已被冻结");
资产清理
要释放此合约持有的 ERC-20 代币,我们需要在该代币所属的代币合约上调用一个函数,即 transferopens in a new tab 或 approveopens in a new tab。 在这种情况下,没有必要将燃料浪费在许可额度上,我们不如直接转账。
1 function cleanupERC20(2 address erc20,3 address dest4 )5 public6 onlyOwner7 {8 IERC20 token = IERC20(erc20);这是我们在收到地址时为合约创建对象的语法。 我们可以这样做,因为我们的源代码中包含了 ERC20 代币的定义(见第 4 行),并且该文件包含了 IERC20 的定义opens in a new tab,即 OpenZeppelin ERC-20 合约的接口。
1 uint balance = token.balanceOf(address(this));2 token.transfer(dest, balance);3 }这是一个清理函数,所以我们大概不希望留下任何代币。 与其让用户手动获取余额,我们不如将这个过程自动化。
结论
这不是一个完美的解决方案——“用户犯错”问题没有完美的解决方案。 不过,使用这类检查至少可以避免一些错误。 冻结帐户的功能虽然危险,但可以通过拒绝黑客获得被盗资金来限制某些黑客攻击造成的损害。
点击此处查看我的更多作品opens in a new tab。
页面最后更新: 2025年9月4日