Solidity 中如何判断一个地址是钱包地址还是合约地址
参考文章:RareSkills《Three ways to detect if an address is a smart contract》
适合读者:已经会基础 Solidity 开发,想理解tx.origin、msg.sender、code.length、codehash区别的开发者。
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 里测试:
- 传入你自己的钱包地址,通常返回
false。 - 传入某个已经部署的合约地址,返回
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?
因为合约部署分两个阶段:
- 执行 constructor。
- 把最终 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:学习时应该怎么测试这些知识?
你可以写三个合约:
AddressChecker:检查target.code.length。Victim:要求msg.sender.code.length == 0。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();
}
}
测试步骤:
- 先部署
Victim。 - 再部署
Attacker,构造参数传入Victim地址。 - 查看
Attacker.success。 - 你会发现它是
true。
这说明:
msg.sender.code.length == 0
不能绝对防止合约调用。
最终总结
Solidity 里判断一个地址是不是合约,最推荐使用:
target.code.length > 0
但是你必须理解它的边界:
- 已部署合约:
code.length > 0。 - 普通钱包:
code.length == 0。 - constructor 期间的合约:
code.length == 0。 - 未来可能部署合约的地址:现在可能是
0,以后可能大于0。 - 某些 selfdestruct 场景:以前可能有代码,之后可能没有代码。
所以最准确的表达不是:
判断地址是不是钱包。
而是:
判断这个地址当前有没有合约代码。
在实际开发中,不要轻易禁止合约地址。更好的做法是针对具体风险设计具体防护。