1. Setup
For this guide, we'll use a minimal setup with uv as the dependency manager and Titanoboa to compile and test Vyper contracts.
If you're unfamiliar with Vyper, it's an EVM programming language with Python-like syntax. It's simple and direct, making it a great choice for developing efficient and secure smart contracts.
Create the project
Initialize a new project with uv:
uv init <project_name>
Create a virtual environment:
uv venv
Install Titanoboa:
uv add titanoboa
Ready!
2. Vyper Built-in Features
Now that Titanoboa is installed, how do we detect whether an address is a smart contract?
The .is_contract Property
All address variables in Vyper have an .is_contract property.
.is_contract → bool
Returns a boolean indicating whether a contract is deployed at the address.
It's that simple!
Create a file named Detector.vy inside a contracts folder:
contracts/Detector.vy
# pragma version ==0.4.3
@external
@pure
def is_target_a_contract(target: address) -> bool:
return target.is_contract
Let's Test It!
In the main.py file at the project root, write the following:
main.py
import boa
USER = "0xC14fF56E720f79d08413CE00533256433D2ED928"
USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
VITALIK_ETH = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
def main():
with boa.fork("https://ethereum-rpc.publicnode.com"):
detector = boa.load("contracts/Detector.vy")
assert detector.is_target_a_contract(USDC)
assert not detector.is_target_a_contract(USER)
assert not detector.is_target_a_contract(VITALIK_ETH)
if __name__ == "__main__":
main()
Run the script:
uv run main.py
The Problem: EIP-7702
When performing an assertion on Vitalik's wallet, Vyper identifies it as a contract. Why?
This happens because the wallet uses EIP-7702 delegation. Technically, the account now has code, but how can we distinguish this case from a traditional smart contract?
3. Special Case: EIP-7702
To handle this, we need to inspect the code stored at the address.
If the code corresponds to EIP-7702 delegation, we'll consider the address not to be a traditional smart contract, even though .is_contract may return True.
The bytecode associated with EIP-7702 delegation starts with the same three bytes:
0xef0100
This prefix is the delegation designator. It indicates that an account delegates execution to another address.
We can read the address's .code property to inspect these bytes.
.code → Bytes
Returns the code associated with the address.
Updated Implementation
With this adjustment, we can distinguish between a traditional contract and an externally owned account (EOA) using EIP-7702 delegation.
contracts/Detector.vy
# pragma version ==0.4.3
@external
@view
def is_target_a_contract_and_not_an_eoa(_target: address) -> bool:
if not _target.is_contract:
return False
# EIP-7702 delegation designator
eip_7702: Bytes[3] = x"ef0100"
# If the first three bytes match, treat it as a delegated EOA
return slice(_target.code, 0, 3) != eip_7702
Tips
-
Why use
slice? In Vyper,sliceextracts a portion of a byte sequence. Here, it reads only the first three bytes, which is sufficient to check the prefix. - EIP-7702 context: This EIP allows EOAs to delegate execution to smart contract code, complicating traditional contract detection methods based on code presence.
4. Other Detection Methods
Although .is_contract is a native Vyper property, you may encounter other approaches in older codebases or Solidity projects.
Method 1: Comparing msg.sender and tx.origin
This approach is sometimes used to check whether the caller is an EOA or another contract.
If msg.sender == tx.origin, the caller is often assumed to be a regular wallet. However, this only validates the current caller; it does not let you check an arbitrary address.
# pragma version ==0.4.3
@external
@view
def only_humans():
# If they are equal, the caller is traditionally considered an EOA.
assert msg.sender == tx.origin
The relevant properties are:
-
msg.sender → address: The immediate caller of the current call. -
tx.origin → address: The original account that initiated the transaction.
Caution: This check is not a reliable way to establish that a caller is human or that it cannot be a smart wallet. It can also break compatibility with account abstraction and smart-wallet systems.
Method 2: Checking Code Size with .codesize
A classic approach is to check whether the address has any code.
If the code size is greater than zero, the address is traditionally considered a contract.
# pragma version ==0.4.3
@external
@view
def is_target_a_contract(target: address) -> bool:
return target.codesize > 0
However, code-size checks share the same EIP-7702 limitations as other methods based on detecting whether code exists.
5. Which Method Should You Use?
For modern Vyper development:
-
.is_contract— The preferred option when you want a readable, direct check for whether an address has code associated with it. -
msg.sender == tx.origin— Avoid using this as a general contract-detection or security mechanism. It is limited to the current caller and can break compatibility with smart wallets. -
.codesize > 0— A classic alternative, but it has the same EIP-7702 limitations as other code-size checks. -
Inspecting
.codefor the EIP-7702 prefix — Useful when your application specifically needs to distinguish delegated EOAs from traditional contracts.
The key takeaway: Choose your detection method based on what you actually need to establish. An address having code, being an EOA, and being controlled by a human are three different properties.
Top comments (0)