Skip to content

Solidity 判断一个地址是否是合约地址

在 Solidity 判断一个地址是否是合约地址,openzeppelin 提供了一个工具函数 isContract(address account) 来判断:

solidity
    function isContract(address account) internal view returns (bool) {
        // This method relies on extcodesize/address.code.length, which returns 0
        // for contracts in construction, since the code is only stored at the end
        // of the constructor execution.

        return account.code.length > 0;
    }

上面这个函数在其 v4.9.6版本 最后出现,并于下一个发行候选版本 v5.0.0-rc.0 被移除。

本文探究 openzeppelin 移除此函数的原因。

在其ChangeLog可以看到,移除的原因是**“本质上有歧义,可能会被误用”**:

markdown
The following contracts, libraries, and functions were removed:

- `Address.isContract` (because of its ambiguous nature and potential for misuse)

移除此函数的PR为 Remove Address.isContract by JulissaDantes · Pull Request #3945 · OpenZeppelin/openzeppelin-contracts

具体原因在 这里 有解释:

markdown
== Can I restrict a function to EOAs only?

When calling external addresses from your contract it is unsafe to assume that an address is an externally-owned account (EOA) and not a contract. Attempting to prevent calls from contracts is highly discouraged. It breaks composability, breaks support for smart wallets like Gnosis Safe, and does not provide security since it can be circumvented by calling from a contract constructor.

Although checking that the address has code, `address.code.length > 0`, may seem to differentiate contracts from EOAs, it can only say that an address is currently a contract, and its negation (that an address is not currently a contract) does not imply that the address is an EOA. Some counterexamples are:

 - address of a contract in construction
 - address where a contract will be created
 - address where a contract lived, but was destroyed

Furthermore, an address will be considered a contract within the same transaction where it is scheduled for destruction by `SELFDESTRUCT`, which only has an effect at the end of the entire transaction.

总结来说,如果 isContract 判断某地址是合约,那么他的判断一定是准确的;如果 isContract 判断某地址不是合约,那么他的判断并不是准确的,我们不能将此地址看作是 EOA。

也就是说 address.code.length 确实能判断某个地址当前是否是智能合约,但它不能确保某个地址是 EOA,因为:

  • 合约构造期间(in construction):新部署的合约在构造函数中时,address.code.length 仍然是 0。
  • 合约即将被创建(contract to be created):某个地址可以是未来部署合约的目标地址,目前 address.code.length 仍为 0。
  • 已销毁的合约(self-destructed contract):某个合约曾经存在但被销毁,address.code.length 变为 0,但它曾经是合约。

另外,SELFDESTRUCT 只在交易结束时生效,因此在同一交易内,合约即使已经被 SELFDESTRUCT 调用,code.length 仍然大于 0。

举例

假如我们发布了一个 ERC20 Token,我们希望此 Token 不要被转入合约地址,仅可由 EOA 持有,那么我们会在 Token 转账函数中使用 isContract 来判断转账目标地址是否是合约来实现此功能。

场景1

黑客通过在合约A的构造函数中通过transferFrom将 Token 转至合约A地址中,因为新部署的合约在构造函数中时,code.length 仍然是 0,于是绕过了我们的判断。合约A的地址可以通过主网分叉在链下获得,提前对 Token 进行 Approve。

场景2

黑客通过主网分叉在链下获得即将部署的合约A的地址,然后在链上先转账 Token 给合约A的地址,再部署合约A,即可绕过我们的判断。

场景3

黑客先部署合约A,随后销毁合约A,此时根据 EIP-6780提案:合约代码并不会被删除,只是清空存储,并转移 ETH 余额。这意味着合约地址仍然有效,旧代码依然存在。此时address.code.length 变为 0,但他依然保留合约的逻辑,此地址不是 EOA。

最后

综上,openzeppelin的v4.9.6版本中,工具函数 isContract(address account) 被加上了如下的注释:

solidity
    /**
     * @dev Returns true if `account` is a contract.
     *
     * [IMPORTANT]
     * ====
     * It is unsafe to assume that an address for which this function returns
     * false is an externally-owned account (EOA) and not a contract.
     *
     * Among others, `isContract` will return false for the following
     * types of addresses:
     *
     *  - an externally-owned account
     *  - a contract in construction
     *  - an address where a contract will be created
     *  - an address where a contract lived, but was destroyed
     *
     * Furthermore, `isContract` will also return true if the target contract within
     * the same transaction is already scheduled for destruction by `SELFDESTRUCT`,
     * which only has an effect at the end of a transaction.
     * ====
     *
     * [IMPORTANT]
     * ====
     * You shouldn't rely on `isContract` to protect against flash loan attacks!
     *
     * Preventing calls from contracts is highly discouraged. It breaks composability, breaks support for smart wallets
     * like Gnosis Safe, and does not provide security since it can be circumvented by calling from a contract
     * constructor.
     * ====
     */

除了上面的内容外,特别提到:你不能依赖 isContract(address account) 来避免闪电贷的攻击

函数历史

追溯函数 isContract(address account) 的历史,可以看到其最初是这样实现的:

solidity
    function isContract(address account) internal view returns (bool) {
        // This method relies in extcodesize, which returns 0 for contracts in
        // construction, since the code is only stored at the end of the
        // constructor execution.

        uint256 size;
        // solhint-disable-next-line no-inline-assembly
        assembly { size := extcodesize(account) }
        return size > 0;
    }

但是在 2019 年 7 月的 PR Using extcodehash instead of extcodesize for less gas by PhABC · Pull Request #1802 · OpenZeppelin/openzeppelin-contracts 中,提交者表示使用 extcodehash 要比 extcodesize 更节省 gas,于是改成了如下实现:

solidity
    function isContract(address account) internal view returns (bool) {
        // This method relies in extcodesize, which returns 0 for contracts in
        // construction, since the code is only stored at the end of the
        // constructor execution.
        
        // According to EIP-1052, 0x0 is the value returned for not-yet created accounts
        // and 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 is returned
        // for accounts without code, i.e. `keccak256('')`
        bytes32 codehash;
        bytes32 accountHash = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470;
        // solhint-disable-next-line no-inline-assembly
        assembly { codehash := extcodehash(account) }
        return (codehash != 0x0 && codehash != accountHash);
    }

2020 年 7 月,开发者通过测试发现 extcodehash 要比 extcodesize 更浪费 gas(可能是因为EVM的升级),于是在 PR feat: use extcodesize for isContract to reduce gas by julianmrodri · Pull Request #2311 · OpenZeppelin/openzeppelin-contracts 将函数实现重新改回:

solidity
    function isContract(address account) internal view returns (bool) {
        // This method relies in extcodesize, which returns 0 for contracts in
        // construction, since the code is only stored at the end of the
        // constructor execution.

        uint256 size;
        // solhint-disable-next-line no-inline-assembly
        assembly { size := extcodesize(account) }
        return size > 0;
    }

2021 年 1 月,Solidity 0.8.1 发布,Solidity 编译器有一项 feature:

markdown
Code Generator: Reduce the cost of <address>.code.length by using extcodesize directly.

于是 2021 年 12 月,函数的实现经过 PR Replace excodesize assembly with address.code.length by k06a · Pull Request #3025 · OpenZeppelin/openzeppelin-contracts 成为了最后的样子:

solidity
    function isContract(address account) internal view returns (bool) {
        // This method relies on extcodesize/address.code.length, which returns 0
        // for contracts in construction, since the code is only stored at the end
        // of the constructor execution.

        return account.code.length > 0;
    }

最终此函数在 2023 年 9 月发行候选版本 v5.0.0-rc.0 时被移除。