web3.py Patterns: EIP-7702 Explainer
The second era of account abstraction on Ethereum is upon us.
Quick history
In 2021, ERC-4337 introduced a pattern for account abstraction that did not require a network upgrade. The specification offered smart contract wallet functionality via primitives called UserOperations
and "bundlers" that submit those operations in batches. That pattern included new contract templates and an entirely new public mempool to support those transactions in a censorship resistant and permissionless manner. The contracts and mempool went live in early 2023, ushering in the first era of account abstraction on Ethereum.
The Pectra network upgrade – scheduled for mainnet on May 7 – introduces the next phase of maturation for account abstraction on Ethereum. Pectra includes EIP-7702 which specifies a new transaction type for linking smart contract code to an EOA. Note that EIP-7702 is compatible with existing 4337 infrastructure, but additionally allows users to upgrade an existing EOA. Once upgraded, users can leverage 4337 bundlers, any other bundler, or execute more complex transactions themselves.
The expectation is that these account abstraction patterns will unlock massive improvements in user experience. An obvious low-hanging fruit is to use batching to remove the need to sign and send two or more transactions for each token swap you want to perform (i.e., approve + swap), but there's a large design space waiting to be explored by app developers when they can easily sponsor their users' sophisticated transactions. In web2 terminology: app developers can now more easily factor in a potential user's first few (or all) transactions into the cost of user acquisition.
High-level: how does it work?
The 7702 standard is flexible in that you may provide any deployed contract address to upgrade the EOA with. You can select a contract that permits your account to authorize batched transactions, utilize social recovery features, passkey signing, spending limits, a combination of these or additional features. In practice, there may end up being just a few well-audited and well-publicized templates that account for most of the EOA upgrades, but any wallet provider is free to develop their own template.
Going a layer deeper: EIP-7702 introduces a new transaction type (type 4, SET_CODE_TX_TYPE
) to the Ethereum protocol for the special purpose of storing a deployed contract pointer alongside an EOA. The EOA can then sign and execute transactions against itself as if it were a deployed smart contract. Further, the EOA can sign and pass off the transaction to another party (a "bundler") to execute, enabling accounts – even those with zero ETH balance – to interact with the network in arbitrarily complex ways.
Lower-level: how does it work?
The specification introduces the concept of an authorization
for the EOA to sign. A signed authorization is a data payload that includes a chain ID, contract template address, nonce of the EOA, and signature values:
authorization = [chain_id, address, nonce, y_parity, r, s]
The type 4 transaction includes a new field, authorizationList
, where you may include one or more signed authorizations:
tx = {
"authorizationList" = [auth1, auth2, ....]
"gas": ...,
"maxFeePerGas": ...,
"maxPriorityFeePerGas": ...,
...
}
Finally, you may execute that smart contract code within the same transaction or in subsequent (e.g., type 2) transactions by populating the transaction's data
field. In the example below, we'll demonstrate setting the code of an EOA and executing contract code within the same transaction.
Why can authorizationList
handle multiple authorizations? In short: bundlers. Each EOA can only have a single code value at any given time. If multiple authorizations are signed by the same EOA, only the last will be processed. However, processing multiple authorizations from unique users is exactly the work of a bundler. Given that each authorization must already be signed by an EOA, authorizations can be submitted in batches by a bundler operator without concern of tampering.
(For more nitty gritty details, reference the EIP.)
A worked example
Notes:
1) You may skip to the source code of the example here.
2) Support for the new transaction type is available in eth-account as of v0.13.6.
The sample code in this section is a minimum viable example of a type 4 transaction. It sets the code of an EOA to that of a "multicall" contract in order to batch multiple contract interactions in one transaction. In reality, the expectation is that wallets will support users to leverage fully-featured smart contract accounts, but for our purposes, the multicall contract is a good stepping stone for understanding the mechanism.
If unfamiliar, multicall contracts have been around forever and are unrelated to these new account abstraction EIPs. They provides an interface for batching multiple contract interactions in one transaction. Optionally, those calls can be atomic, meaning if one interaction fails, the entire batch fails. This example will encode three contract interactions and execute them within the multicall contract's aggregate
function.
As already mentioned, the canonical account abstraction example is the simplification of token swaps, i.e., approving tokens to be moved by a DEX and performing the swap for another token within the same transaction (or multiple swaps in the same transaction). That example workflow requires several contracts to demonstrate, so we'll lean on a simpler, contrived example that gets the point across.
Fun fact: Why couldn't existing multicall contracts achieve this functionality already? ERC-20 approvals are set to themsg.sender
. Until 7702,msg.sender
would be the multicall contract address when executing, not your EOA.
Script outline
Here are the broad strokes of where we're headed. The final Python file will:
1. import dependencies
2. instantiate a w3 instance
3. check the starting state of the contract
4. load an EOA account from a private key
5. build an authorization of the multicall contract
6. sign the authorization with the EOA
7. build and encode the data to provide to the aggregate function
8. build the type 4 transaction
9. sign and send the transaction
10. verify the results
I'll assume you have a working familiarity with web3.py and eth-account already.
A contract to test against
Here comes that contrived example. On the Hoodi testnet, a deployed Vyper contract stores a number
and a locked
status. In order to update the number
, the contract must be unlocked. By the end of this example, we'll have an EOA augmented with multicall
functionality which will unlock, update the number, then lock the contract again – three contract interactions executed within one transaction.
First, the Vyper contract for reference:
# pragma version 0.4.0
# @license MIT
number: public(uint256)
locked: public(bool)
@deploy
def __init__(initial_number: uint256):
self.number = initial_number
self.locked = True
@external
def set_number(new_number: uint256):
if self.locked:
raise("Counter is locked")
self.number = new_number
@external
def lock():
self.locked = True
@external
def unlock():
self.locked = False
@external
def get_number() -> uint256:
return self.number
Given the deployed contract address and ABI, web3.py can be used to query the current state of the locked
and number
variables.
# 1. import dependencies
from web3 import Web3, HTTPProvider
from artifacts import COUNTER_ADDRESS, COUNTER_ABI
import json
# ...etc.
COUNTER_ADDRESS = Web3.to_checksum_address(COUNTER_ADDRESS)
COUNTER_ABI = json.loads(COUNTER_ABI)
# 2. instantiate w3
w3 = Web3(HTTPProvider("https://ethereum-hoodi-rpc.publicnode.com"))
# 3. check the starting state of the contract
counter_contract = w3.eth.contract(address=COUNTER_ADDRESS, abi=COUNTER_ABI)
locked = counter_contract.functions.locked().call()
number = counter_contract.functions.number().call()
print(f"Counter state: locked={locked}, number={number}")
Signing an authorization
Next, we'll authorize setting the multicall contract address as the EOA code. The eth-account
library includes a sign_authorization
method to simplify the process:
# 4. load EOA account from private key
acct = Account.from_key(os.getenv("TEST_PK"))
nonce = w3.eth.get_transaction_count(acct.address)
# 5. build an authorization utilizing the multicall contract
auth = {
"chainId": HOODI_CHAIN_ID,
"address": MULTICALL_ADDRESS,
"nonce": nonce + 1,
}
# 6. sign the auth with the EOA
signed_auth = acct.sign_authorization(auth)
Build the type 4 transaction
Finally, the signed_auth
gets included in an authorizationList
field along with the rest of a typical transaction payload.
# build the type 4 transaction
tx = {
"chainId": HOODI_CHAIN_ID,
"nonce": nonce,
"gas": 1_000_000,
"maxFeePerGas": 10**10,
"maxPriorityFeePerGas": 10**9,
"to": acct.address,
"authorizationList": [signed_auth],
"data": encode_multicall_data(),
}
(Arbitrarily high gas fees utilized here)
Because we're executing this transaction ourselves, we can include the data
payload. Building that payload is a whole song and dance, but in summary: encode each contract interaction you want to perform, then encode the multicall aggregate
function with the list of interactions to produce the final byte string.
# 7. build and encode the data to provide to aggregate function
def encode_multicall_data():
multicall_contract = w3.eth.contract(address=MULTICALL_ADDRESS, abi=MULTICALL_ABI)
counter_contract = w3.eth.contract(address=COUNTER_ADDRESS, abi=COUNTER_ABI)
lock_calldata = counter_contract.encode_abi("lock")
unlock_calldata = counter_contract.encode_abi("unlock")
new_number = 6
set_number_calldata = counter_contract.encode_abi("set_number", args=[new_number])
# Structure the calls for the aggregate function
# aggregate((address target, bytes callData)[])
calls = [
(COUNTER_ADDRESS, unlock_calldata),
(COUNTER_ADDRESS, set_number_calldata),
(COUNTER_ADDRESS, lock_calldata),
]
# Encode the outer call data for the aggregate function
return multicall_contract.encode_abi("aggregate", args=[calls])
With a fully formed transaction payload, all that remains is to sign it, send it, then verify that the state of the world matches expectations.
Full script
All together now: (or if you prefer, a gist)
# 1. import dependencies
from eth_account import Account
from dotenv import load_dotenv
import os
from web3 import Web3, HTTPProvider
from abstract_const import MULTICALL_ABI, MULTICALL_ADDRESS, COUNTER_ADDRESS, COUNTER_ABI
import json
load_dotenv()
MULTICALL_ADDRESS = Web3.to_checksum_address(MULTICALL_ADDRESS)
MULTICALL_ABI = json.loads(MULTICALL_ABI)
COUNTER_ADDRESS = Web3.to_checksum_address(COUNTER_ADDRESS)
COUNTER_ABI = json.loads(COUNTER_ABI)
HOODI_CHAIN_ID = 560048
# 2. instantiate w3
w3 = Web3(HTTPProvider("https://ethereum-hoodi-rpc.publicnode.com"))
# 3. check the starting state of the contract
counter_contract = w3.eth.contract(address=COUNTER_ADDRESS, abi=COUNTER_ABI)
locked = counter_contract.functions.locked().call()
number = counter_contract.functions.number().call()
print(f"Counter state: locked={locked}, number={number}")
# 4. load EOA account from private key
acct = Account.from_key(os.getenv("TEST_PK"))
acct_code = w3.eth.get_code(acct.address)
nonce = w3.eth.get_transaction_count(acct.address)
print(f"Account code before: {acct_code.hex()}")
print(f"Account nonce before: {nonce}")
# 5. build an authorization utilizing the multicall contract
auth = {
"chainId": HOODI_CHAIN_ID,
"address": MULTICALL_ADDRESS,
"nonce": nonce + 1,
}
# 6. sign the auth with the EOA
signed_auth = acct.sign_authorization(auth)
# 7. build and encode the data to provide to aggregate function
def calculate_multicall_data():
multicall_contract = w3.eth.contract(address=MULTICALL_ADDRESS, abi=MULTICALL_ABI)
counter_contract = w3.eth.contract(address=COUNTER_ADDRESS, abi=COUNTER_ABI)
lock_calldata = counter_contract.encode_abi("lock")
unlock_calldata = counter_contract.encode_abi("unlock")
new_number = 6
set_number_calldata = counter_contract.encode_abi("set_number", args=[new_number])
# Structure the calls for the aggregate function
# aggregate((address target, bytes callData)[])
calls = [
(COUNTER_ADDRESS, unlock_calldata),
(COUNTER_ADDRESS, set_number_calldata),
(COUNTER_ADDRESS, lock_calldata),
]
# Encode the outer call data for the aggregate function
return multicall_contract.encode_abi("aggregate", args=[calls])
# 8. build the type 4 transaction
tx = {
"chainId": HOODI_CHAIN_ID,
"nonce": nonce,
"gas": 1_000_000,
"maxFeePerGas": 10**10,
"maxPriorityFeePerGas": 10**9,
"to": acct.address,
"authorizationList": [signed_auth],
"data": calculate_multicall_data(),
}
# 9. sign and send the transaction
signed_tx = acct.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
# 10. verify the results
counter_contract = w3.eth.contract(address=COUNTER_ADDRESS, abi=COUNTER_ABI)
current_number = counter_contract.functions.number().call()
is_locked = counter_contract.functions.locked().call()
print(f"Counter state after: locked={is_locked}, number={current_number}")
new_acct_code = w3.eth.get_code(acct.address)
new_nonce = w3.eth.get_transaction_count(acct.address)
print(f"Account code after: {new_acct_code.hex()}")
print(f"Account nonce after: {new_nonce}")
Assuming you've got the contract ABIs and deployment addresses accounted for, as well as a funded Hoodi testnet account, you should see something that resembles this output after executing the script:
Counter state: locked=True, number=4
Account code before:
Account nonce before: 15
Counter state after: locked=True, number=6
Account code after: ef0100ca11bde05977b3631167028862be2a173976ca11
Account nonce after: 17
You'll notice that the get_code
method returns a "delegation indicator" that includes the special purpose opcode 0xef
. See the EIP for more context.
Abort
Once you set the code of your EOA, it persists until you update it. After executing the script above, subsequent type 2 transactions can be made – just omit the authorizationList
field in the transaction.
You are free to authorize a new contract or reset the code value of your contract altogether by sending another type 4 transaction with a signed authorization to the zero address. For example:
auth = {
"chainId": HOODI_CHAIN_ID,
"address": "0x0000000000000000000000000000000000000000",
"nonce": nonce + 1,
}
tx = {
"chainId": HOODI_CHAIN_ID,
"nonce": nonce,
"gas": ...,
"maxFeePerGas": ...,
"maxPriorityFeePerGas": ...,
"to": acct.address,
"authorizationList": [signed_auth],
}
# then sign and send
What's next
The 7702 primitives are available for use today in eth-account and web3.py, but a next step is to build a convenience API within web3.py. If you'd like to contribute to that discussion, please add your thoughts to this GitHub issue.
From here, you may be ready to dive into the deep end of the 4337 usage patterns. The most recent iteration of their contracts includes simple and full-featured template deployments with 7702 support. Their homepage includes links to documentation, conference talks, and communities.
The design space unlocked here is vast and interesting. Although wallet providers will lead the way in unlocking a much improved baseline experience, I expect to see many more creative explorations in hackathon environments. Got ideas of your own? Join the Ethereum Python community server and share what you're cooking. 🔥🐍
Looking for more content or features? Let us know in the feedback form!