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, slice extracts 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 .code for 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.