Solidity 检测地址是合约还是 EOA 账户

介绍三种检测以太坊地址类型的方法:address.code.length、tx.origin 与 msg.sender 对比及 codehash 检测。

· ☕ 12 分钟阅读
Solidity 检测地址是合约还是 EOA 账户

Solidity 中如何判断一个地址是钱包地址还是合约地址

参考文章:RareSkills《Three ways to detect if an address is a smart contract》
适合读者:已经会基础 Solidity 开发,想理解 tx.originmsg.sendercode.lengthcodehash 区别的开发者。


1. 先说结论

在 Solidity 里,最推荐的判断方式是使用 address.code.length

function isContract(address target) public view returns (bool) {
    return target.code.length > 0;
}

含义很简单:

  • target.code.length > 0:当前这个地址上有合约字节码,大概率是合约地址。
  • target.code.length == 0:当前这个地址上没有合约字节码,可能是普通钱包,也可能是一些特殊状态下的合约地址。

但是要记住一句非常重要的话:

code.length == 0 不能 100% 证明这个地址永远是钱包地址,只能说明“当前这个时刻,这个地址上没有合约代码”。


2. 钱包地址和合约地址的本质区别

在 EVM 里,地址本身只是一个 20 字节的值。

一个地址是不是合约,核心区别不是地址格式,而是:

这个地址上有没有部署合约字节码。

普通钱包地址,也叫 EOA:Externally Owned Account。

EOA 的特点:

  • 由私钥控制。
  • 没有合约代码。
  • 可以主动发起交易。

合约地址的特点:

  • 由合约代码控制。
  • 地址上有字节码。
  • 不能自己主动发起交易,只能被 EOA 或其他合约调用后执行逻辑。

所以判断一个地址是不是合约,最自然的方法就是:

target.code.length > 0

3. 方法一:msg.sender == tx.origin

3.1 msg.sender 是什么?

msg.sender 表示:

当前这一次函数调用的直接调用者。

例如:

Alice 钱包  --->  Market 合约

Market 合约里:

msg.sender == Alice

如果是这样:

Alice 钱包  --->  ContractA  --->  ContractB

那么在 ContractB 里:

msg.sender == ContractA

因为 ContractB 是被 ContractA 直接调用的。


3.2 tx.origin 是什么?

tx.origin 表示:

整笔交易最开始的发起者,通常是一个钱包地址。

还是这个调用链:

Alice 钱包  --->  ContractA  --->  ContractB

ContractB 里:

msg.sender == ContractA
tx.origin == Alice

3.3 用 msg.sender == tx.origin 判断是不是钱包

有人会这样写:

require(msg.sender == tx.origin, "contract not allowed");

这个判断的意思是:

只有交易的原始发起者,才能直接调用当前函数。

如果一个普通钱包直接调用:

Alice 钱包 ---> Target 合约

那么:

msg.sender == Alice
tx.origin == Alice

检查通过。

如果通过中间合约调用:

Alice 钱包 ---> ContractA ---> Target 合约

那么在 Target 合约里:

msg.sender == ContractA
tx.origin == Alice

检查不通过。


3.4 为什么不推荐这种方式?

不推荐写:

require(msg.sender == tx.origin);

原因有几个。

第一,它只能判断当前调用者 msg.sender,不能判断任意地址。

比如你想检查:

function check(address user) external view returns (bool) {
    // 这里没法用 msg.sender == tx.origin 判断 user 是不是合约
}

msg.sender == tx.origin 只能作用在调用链上,不能判断 user 这个参数。

第二,它会误伤智能合约钱包。

现在越来越多用户使用:

  • 多签钱包,比如 Safe。
  • Account Abstraction 钱包。
  • ERC-4337 风格的钱包。

这些钱包本身就是合约。

如果你写:

require(msg.sender == tx.origin);

这些用户可能无法正常使用你的合约。

第三,这种限制经常被认为是反模式。

很多开发者用它来“禁止合约调用”,但这通常不是一个好的安全设计。因为它可能挡不住你真正想防的攻击,却挡住了正常用户、合约钱包和协议集成。


4. 方法二:address.code.length

4.1 基础用法

这是最推荐的方式:

function isContract(address target) public view returns (bool) {
    return target.code.length > 0;
}

解释:

target.code

表示这个地址上的运行时代码。

target.code.length

表示这个地址上的代码长度。

如果长度大于 0,说明这个地址当前部署了合约代码。


4.2 示例合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract AddressChecker {
    function isContract(address target) external view returns (bool) {
        return target.code.length > 0;
    }
}

你可以在 Remix 里测试:

  1. 传入你自己的钱包地址,通常返回 false
  2. 传入某个已经部署的合约地址,返回 true

5. code.length 的几个重要陷阱

5.1 构造函数期间,合约的 code.length 是 0

这是最重要的陷阱。

一个合约正在 constructor 里执行时,它的运行时代码还没有真正存到链上。

所以在构造函数期间:

address(this).code.length == 0

这意味着,一个合约可以在自己的构造函数里调用另一个合约,并让对方误以为它不是合约。

例子:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract Victim {
    function onlyEOA() external view returns (bool) {
        require(msg.sender.code.length == 0, "contract not allowed");
        return true;
    }
}

contract Attacker {
    bool public success;

    constructor(address victim) {
        success = Victim(victim).onlyEOA();
    }
}

Attacker 的 constructor 正在执行时,它还没有完成部署。

因此在 Victim 看来:

msg.sender == Attacker 地址
msg.sender.code.length == 0

所以 Victim.onlyEOA() 会通过。

这说明:

不要把 msg.sender.code.length == 0 当成绝对可靠的“禁止合约调用”方案。


5.2 一个现在没有代码的地址,将来可能部署合约

假设你现在检查:

target.code.length == 0

这只能说明:

当前这个区块、当前这个时刻,target 上没有代码。

但将来这个地址可能通过 CREATE2 部署合约。

所以不能把这个判断理解成:

这个地址永远是钱包。

更准确的说法是:

这个地址目前没有合约代码。


5.3 曾经是合约的地址,将来可能没有代码

在一些支持 selfdestruct 效果的链或场景中,一个地址以前可能有合约代码,之后代码被移除。

所以:

target.code.length == 0

也不能说明这个地址历史上从来不是合约。


6. 判断 msg.sender 和判断任意地址有什么区别?

6.1 判断 msg.sender

require(msg.sender.code.length == 0, "contract not allowed");

这表示:

当前直接调用者不能是已经部署完成的合约。

但是构造函数中的合约可以绕过。

所以这个限制并不绝对。


6.2 判断任意地址

function isContract(address target) external view returns (bool) {
    return target.code.length > 0;
}

这表示:

检查 target 当前是否有合约代码。

如果 target 是已经部署完成的合约,那么结果可靠地是 true

如果 target.code.length == 0,只能说明当前没有代码,不能说明它永远不是合约。


7. 常见使用场景:ERC721 的 safeTransferFrom

你学习 NFT 合约时应该见过:

safeTransferFrom(from, to, tokenId)

它和普通的:

transferFrom(from, to, tokenId)

区别之一是:

如果 to 是合约地址,ERC721 会检查这个合约是否实现了 onERC721Received

为什么要这样?

因为如果 NFT 被转到一个不会处理 NFT 的合约里,这个 NFT 可能会永远卡在那个合约里。

所以 ERC721 会做类似这样的判断:

if (to.code.length > 0) {
    // 调用 onERC721Received
}

如果目标地址是合约,就要求它正确返回 ERC721 接收标识。

这就是 code.length 的一个合理使用场景:

不是为了歧视合约用户,而是为了确认目标合约能不能安全接收某种资产。


8. 方法三:address.codehash

8.1 codehash 是什么?

address.codehash 返回的是:

某个地址上合约代码的 keccak256 哈希。

例如:

bytes32 hash = target.codehash;

如果目标地址是合约,codehash 通常就是这个合约字节码的哈希。


8.2 为什么不推荐用 codehash 判断合约?

因为它比 code.length 更复杂,而且没有带来必要收益。

对于没有合约代码的地址,codehash 可能出现不同情况:

  • 地址没有余额,也没有代码:可能返回 bytes32(0)
  • 地址有 ETH 余额,但没有代码:可能返回空数据的哈希值 keccak256("")
  • 地址有合约代码:返回代码的哈希。

也就是说,用 codehash 判断时,你要考虑更多情况。

但你真正关心的只是:

有没有代码?

所以直接用:

target.code.length > 0

更简单。


9. 在真实业务里,是否应该禁止合约地址?

大多数情况下,不建议简单粗暴禁止合约地址。

例如:

require(msg.sender == tx.origin, "contract not allowed");

或者:

require(msg.sender.code.length == 0, "contract not allowed");

这些写法可能导致:

  • Safe 多签用户无法使用。
  • 智能合约钱包用户无法使用。
  • 其他协议无法集成你的合约。
  • 构造函数调用仍然可能绕过。

更好的思路是:

你应该针对具体风险设计具体防护,而不是简单禁止合约调用。

例如:

场景一:防止重入

不要靠禁止合约地址,而是用:

nonReentrant

场景二:防止机器人抢跑

不要以为禁止合约就能解决 MEV 或机器人问题。很多机器人也可以通过 EOA 操作。

场景三:防止资产转入不支持的合约

这种情况可以使用 code.length 检查目标地址是否是合约。如果是合约,再检查它是否实现了对应接口。

ERC721 的 safeTransferFrom 就是这种思路。


10. 和你的 NFT Marketplace 项目的关系

你现在做的是 NFT 交易市场。

在你的项目里,可能会遇到几个相关点。

10.1 买家是合约地址怎么办?

如果你的 buy() 最后执行:

nft.safeTransferFrom(order.seller, msg.sender, order.tokenId);

如果 msg.sender 是普通钱包,通常没问题。

如果 msg.sender 是合约,那么这个合约必须实现 onERC721Received,否则 safeTransferFrom 会失败。

这反而是安全的。

如果你不想支持合约买家,可以限制,但一般不建议。


10.2 卖家是合约地址怎么办?

如果卖家是合约钱包,比如 Safe,它持有 NFT,并授权你的市场合约,理论上也可以出售 NFT。

如果你使用:

require(msg.sender == tx.origin);

就会把这类卖家挡掉。

所以你的 Marketplace 一般不应该加这种限制。


10.3 你的市场合约需要判断买家是不是合约吗?

通常不需要。

你只需要使用:

safeTransferFrom

让 ERC721 自己去处理“目标地址是合约时是否能接收 NFT”的检查。


11. 推荐记忆方式

你可以这样记:

tx.origin:整笔交易的最初发起者。
msg.sender:当前函数的直接调用者。
address.code.length:当前地址上的代码长度。
address.codehash:当前地址代码的哈希,不推荐用于简单判断。

判断合约地址的优先级:

推荐:address.code.length > 0
不推荐:msg.sender == tx.origin
不推荐:address.codehash

但是还要记住:

code.length > 0 说明当前一定有代码。
code.length == 0 只说明当前没有代码,不等于永远是钱包。
constructor 期间的合约 code.length 是 0。

答疑环节

Q1:能不能 100% 判断一个地址是不是钱包地址?

严格来说,不能。

你只能判断一个地址当前是否有合约代码。

target.code.length == 0

只能说明当前没有代码,不代表这个地址永远不会部署合约,也不代表它历史上不是合约。


Q2:code.length > 0 能不能 100% 说明当前是合约?

可以认为是。

如果一个地址当前有字节码,那么它当前就是合约地址。

但是反过来不成立:

code.length == 0

不能 100% 说明它是普通钱包。


Q3:为什么 constructor 期间 code.length == 0

因为合约部署分两个阶段:

  1. 执行 constructor。
  2. 把最终 runtime bytecode 存到链上。

在 constructor 还没执行完之前,合约地址虽然已经可以被使用,但代码还没有正式部署完成。

所以这个阶段:

address(this).code.length == 0

Q4:如果我想禁止合约调用,用什么方式最好?

一般不建议禁止合约调用。

如果你只是为了防重入,应该用 ReentrancyGuard

如果你是为了防机器人,禁止合约也不可靠,因为机器人可以用普通钱包调用。

如果你是为了防止资产转到不能处理资产的合约,应该像 ERC721 那样使用接口检查,而不是完全禁止合约地址。


Q5:tx.origin 有没有正常使用场景?

有,但很少。

在业务权限控制中,通常不要使用 tx.origin

尤其不要写:

require(tx.origin == owner);

权限判断应该使用:

require(msg.sender == owner);

因为 tx.origin 可能带来钓鱼式调用风险。


Q6:为什么 msg.sender == tx.origin 会影响 Safe 多签钱包?

Safe 多签钱包本身是合约。

如果 Safe 调用你的合约,那么在你的合约里:

msg.sender == Safe 合约地址
tx.origin == 某个签名执行者的钱包地址

因此:

msg.sender == tx.origin

为 false。

你的函数就会拒绝 Safe 用户。


Q7:ERC721 的 safeTransferFrom 为什么要检查 to.code.length

因为如果 to 是合约,NFT 转进去之后,必须确认这个合约知道如何接收 NFT。

否则 NFT 可能卡死在合约里。

所以 ERC721 会在目标是合约时,调用:

onERC721Received

如果目标合约没有正确实现,就阻止转账。


Q8:我的 NFT Marketplace 是否需要自己写 isContract

正常情况下不需要。

你使用:

safeTransferFrom(order.seller, msg.sender, tokenId)

ERC721 内部会处理目标地址是否是合约,以及合约能不能接收 NFT。

你的市场合约更应该关注:

  • 订单是否存在。
  • 订单是否 ACTIVE。
  • 是否过期。
  • 买家支付金额是否正确。
  • 卖家是否仍然拥有 NFT。
  • 市场是否仍然被授权。

Q9:为什么不推荐用 codehash

因为你只是想知道有没有代码,而 codehash 返回的是代码哈希。

对于没有代码的地址,它还可能因为是否有余额产生不同结果。

所以它比 code.length 麻烦,而且没有必要。


Q10:学习时应该怎么测试这些知识?

你可以写三个合约:

  1. AddressChecker:检查 target.code.length
  2. Victim:要求 msg.sender.code.length == 0
  3. Attacker:在 constructor 里调用 Victim

通过这个实验,你会直观看到:

constructor 期间的合约可以让 msg.sender.code.length == 0 成立。


练习代码:constructor 绕过 code.length 检查

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

contract Victim {
    function onlyEOA() external view returns (bool) {
        require(msg.sender.code.length == 0, "contract not allowed");
        return true;
    }
}

contract Attacker {
    bool public success;

    constructor(address victim) {
        success = Victim(victim).onlyEOA();
    }
}

测试步骤:

  1. 先部署 Victim
  2. 再部署 Attacker,构造参数传入 Victim 地址。
  3. 查看 Attacker.success
  4. 你会发现它是 true

这说明:

msg.sender.code.length == 0

不能绝对防止合约调用。


最终总结

Solidity 里判断一个地址是不是合约,最推荐使用:

target.code.length > 0

但是你必须理解它的边界:

  • 已部署合约:code.length > 0
  • 普通钱包:code.length == 0
  • constructor 期间的合约:code.length == 0
  • 未来可能部署合约的地址:现在可能是 0,以后可能大于 0
  • 某些 selfdestruct 场景:以前可能有代码,之后可能没有代码。

所以最准确的表达不是:

判断地址是不是钱包。

而是:

判断这个地址当前有没有合约代码。

在实际开发中,不要轻易禁止合约地址。更好的做法是针对具体风险设计具体防护。

💬 评论