Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions tests/core/eth-module/test_eth_sign_authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Tests for w3.eth.sign_authorization() (EIP-7702 DevEx helper).

Covers both the sync Eth and async AsyncEth variants. The helper is a
thin local-signing convenience wrapper — it does NOT send an RPC call for
the signature itself, but it *does* read chain state (chainId,
get_transaction_count) when those values are not supplied explicitly.
"""
from unittest.mock import (
AsyncMock,
MagicMock,
patch,
)

import pytest

from eth_account import Account
from eth_account.datastructures import SignedSetCodeAuthorization

from web3 import Web3
from web3.providers.eth_tester import EthereumTesterProvider
from web3.providers.eth_tester.main import AsyncEthereumTesterProvider


# ---------------------------------------------------------------------------
# fixtures
# ---------------------------------------------------------------------------

# A well-known test private key (never use in production)
_PRIVATE_KEY = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
_ACCOUNT = Account.from_key(_PRIVATE_KEY)

# A stable contract address to delegate to
_CONTRACT_ADDR = "0xaBcD000000000000000000000000000000001234"


@pytest.fixture()
def w3():
return Web3(EthereumTesterProvider())


@pytest.fixture()
async def async_w3():
return Web3(AsyncEthereumTesterProvider()) # type: ignore[arg-type]


# ---------------------------------------------------------------------------
# sync tests
# ---------------------------------------------------------------------------


class TestSignAuthorization:
"""Tests for Eth.sign_authorization (sync)."""

def test_returns_signed_set_code_authorization(self, w3: Web3) -> None:
"""sign_authorization returns a SignedSetCodeAuthorization object."""
auth = w3.eth.sign_authorization(_CONTRACT_ADDR, _PRIVATE_KEY)
assert isinstance(auth, SignedSetCodeAuthorization)

def test_address_matches_input(self, w3: Web3) -> None:
"""The authorization address field matches the supplied contract address."""
auth = w3.eth.sign_authorization(_CONTRACT_ADDR, _PRIVATE_KEY)
assert auth.address == w3.to_checksum_address(_CONTRACT_ADDR)

def test_auto_populates_chain_id(self, w3: Web3) -> None:
"""chain_id is auto-populated from the connected network when omitted."""
auth = w3.eth.sign_authorization(_CONTRACT_ADDR, _PRIVATE_KEY)
assert auth.chainId == w3.eth.chain_id

def test_explicit_chain_id_overrides_auto(self, w3: Web3) -> None:
"""An explicit chain_id kwarg is used instead of the network's chain ID."""
auth = w3.eth.sign_authorization(
_CONTRACT_ADDR, _PRIVATE_KEY, chain_id=999
)
assert auth.chainId == 999

def test_chain_id_zero_creates_chain_agnostic_auth(self, w3: Web3) -> None:
"""chain_id=0 produces an authorization replayable on any EVM network."""
auth = w3.eth.sign_authorization(
_CONTRACT_ADDR, _PRIVATE_KEY, chain_id=0
)
assert auth.chainId == 0

def test_auto_populates_nonce_from_tx_count(self, w3: Web3) -> None:
"""nonce defaults to the on-chain transaction count of the signing account."""
expected_nonce = w3.eth.get_transaction_count(_ACCOUNT.address)
auth = w3.eth.sign_authorization(_CONTRACT_ADDR, _PRIVATE_KEY)
assert auth.nonce == expected_nonce

def test_explicit_nonce_overrides_auto(self, w3: Web3) -> None:
"""An explicit nonce kwarg is used instead of the on-chain count."""
auth = w3.eth.sign_authorization(
_CONTRACT_ADDR, _PRIVATE_KEY, nonce=42
)
assert auth.nonce == 42

def test_signature_fields_present(self, w3: Web3) -> None:
"""The returned auth includes signature fields (yParity, r, s)."""
auth = w3.eth.sign_authorization(_CONTRACT_ADDR, _PRIVATE_KEY)
assert hasattr(auth, "yParity")
assert hasattr(auth, "r")
assert hasattr(auth, "s")

def test_chain_id_rpc_not_called_when_explicit(self, w3: Web3) -> None:
"""w3.eth.chain_id is NOT accessed when chain_id is supplied explicitly."""
with patch.object(
type(w3.eth), "chain_id", new_callable=lambda: property(MagicMock(side_effect=AssertionError("chain_id should not be called")))
):
# If chain_id property were accessed, this would raise
try:
auth = w3.eth.sign_authorization(
_CONTRACT_ADDR, _PRIVATE_KEY, chain_id=1
)
assert auth.chainId == 1
except AssertionError:
pytest.fail("w3.eth.chain_id was unexpectedly accessed")

def test_nonce_rpc_not_called_when_explicit(self, w3: Web3) -> None:
"""w3.eth.get_transaction_count is NOT called when nonce is supplied explicitly."""
original_gtc = w3.eth.get_transaction_count
calls = []

def spy(*args, **kwargs):
calls.append((args, kwargs))
return original_gtc(*args, **kwargs)

with patch.object(w3.eth, "get_transaction_count", side_effect=spy):
w3.eth.sign_authorization(
_CONTRACT_ADDR, _PRIVATE_KEY, nonce=7
)
assert calls == [], "get_transaction_count should not be called when nonce is explicit"

def test_result_usable_as_authorization_list_item(self, w3: Web3) -> None:
"""The result can be placed directly in an authorizationList transaction field."""
auth = w3.eth.sign_authorization(_CONTRACT_ADDR, _PRIVATE_KEY)
# Build a minimal type-4 tx params dict — just checking structure
tx_params = {
"from": _ACCOUNT.address,
"to": _ACCOUNT.address,
"gas": 21000,
"maxFeePerGas": w3.to_wei(1, "gwei"),
"maxPriorityFeePerGas": w3.to_wei(1, "gwei"),
"nonce": w3.eth.get_transaction_count(_ACCOUNT.address),
"chainId": w3.eth.chain_id,
"authorizationList": [auth],
}
# Just verify the dict is accepted by sign_transaction (structural test)
signed = _ACCOUNT.sign_transaction(tx_params)
assert signed.raw_transaction


# ---------------------------------------------------------------------------
# async tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
class TestAsyncSignAuthorization:
"""Tests for AsyncEth.sign_authorization."""

async def test_returns_signed_set_code_authorization(
self, async_w3: Web3
) -> None:
auth = await async_w3.eth.sign_authorization( # type: ignore[attr-defined]
_CONTRACT_ADDR, _PRIVATE_KEY
)
assert isinstance(auth, SignedSetCodeAuthorization)

async def test_auto_populates_chain_id(self, async_w3: Web3) -> None:
auth = await async_w3.eth.sign_authorization( # type: ignore[attr-defined]
_CONTRACT_ADDR, _PRIVATE_KEY
)
chain_id = await async_w3.eth.chain_id # type: ignore[misc]
assert auth.chainId == chain_id

async def test_explicit_chain_id(self, async_w3: Web3) -> None:
auth = await async_w3.eth.sign_authorization( # type: ignore[attr-defined]
_CONTRACT_ADDR, _PRIVATE_KEY, chain_id=1337
)
assert auth.chainId == 1337

async def test_explicit_nonce(self, async_w3: Web3) -> None:
auth = await async_w3.eth.sign_authorization( # type: ignore[attr-defined]
_CONTRACT_ADDR, _PRIVATE_KEY, nonce=5
)
assert auth.nonce == 5
46 changes: 46 additions & 0 deletions web3/eth/async_eth.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Any,
Awaitable,
Callable,
Optional,
Sequence,
cast,
overload,
Expand Down Expand Up @@ -86,6 +87,7 @@
LogReceipt,
LogsSubscriptionArg,
Nonce,
SetCodeAuthorizationParams,
SignedTx,
SimulateV1Payload,
SimulateV1Result,
Expand Down Expand Up @@ -657,6 +659,50 @@ async def sign_typed_data(
) -> HexStr:
return await self._sign_typed_data(account, data)

# EIP-7702: sign_authorization (local signing + chain-state auto-fill)

async def sign_authorization(
self,
address: Address | ChecksumAddress,
private_key: Any,
*,
chain_id: Optional[int] = None,
nonce: Optional[Nonce] = None,
) -> "SignedSetCodeAuthorization":
"""Sign an EIP-7702 authorization for the given contract address.

Async counterpart of :meth:`Eth.sign_authorization`. Auto-populates
``chainId`` and ``nonce`` from chain state when they are not supplied
explicitly.

:param address: the contract address the EOA will delegate code execution to.
:param private_key: the private key of the EOA signing the authorization.
:param chain_id: override the chain ID; defaults to ``await w3.eth.chain_id``.
:param nonce: override the authorization nonce; defaults to the current
on-chain transaction count of the signing account.
:returns: a :class:`~eth_account.datastructures.SignedSetCodeAuthorization`
suitable for inclusion in the ``authorizationList`` of a type-4 transaction.

Example::

auth = await w3.eth.sign_authorization(contract.address, my_private_key)
"""
from eth_account import Account

signer = Account.from_key(private_key)
effective_chain_id = chain_id if chain_id is not None else await self.chain_id # type: ignore[misc]
effective_nonce = (
nonce
if nonce is not None
else await self.get_transaction_count(signer.address)
)
auth_params: SetCodeAuthorizationParams = {
"chainId": effective_chain_id,
"address": address,
"nonce": effective_nonce,
}
return self.account.sign_authorization(auth_params, private_key)

# eth_getUncleCountByBlockHash
# eth_getUncleCountByBlockNumber

Expand Down
63 changes: 63 additions & 0 deletions web3/eth/eth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
TYPE_CHECKING,
Any,
Callable,
Optional,
Sequence,
cast,
overload,
Expand Down Expand Up @@ -79,6 +80,7 @@
LogReceipt,
MerkleProof,
Nonce,
SetCodeAuthorizationParams,
SignedTx,
SimulateV1Payload,
SimulateV1Result,
Expand Down Expand Up @@ -627,6 +629,67 @@ def modify_transaction(
mungers=[default_root_munger],
)

# EIP-7702: sign_authorization (local, no RPC call for signing)

def sign_authorization(
self,
address: Address | ChecksumAddress,
private_key: Any,
*,
chain_id: Optional[int] = None,
nonce: Optional[Nonce] = None,
) -> "SignedSetCodeAuthorization":
"""Sign an EIP-7702 authorization for the given contract address.

A convenience wrapper around :meth:`w3.eth.account.sign_authorization`
that auto-populates ``chainId`` from the connected network and ``nonce``
from the current on-chain transaction count of the signing account when
those values are not supplied explicitly.

:param address: the contract address the EOA will delegate code
execution to (the ``address`` field of the authorization tuple).
:param private_key: the private key of the EOA signing the
authorization; accepts the same formats as
:meth:`eth_account.Account.sign_authorization`.
:param chain_id: override the chain ID used in the authorization
tuple. Defaults to ``w3.eth.chain_id`` (i.e. the chain the
connected node is on). Pass ``0`` to create a chain-agnostic
authorization that is replayable on any EVM network.
:param nonce: override the authorization nonce. Defaults to the
current on-chain transaction count of the signing account.
See the EIP-7702 specification for nonce semantics — the nonce
in the authorization tuple prevents replays independently of the
transaction nonce.
:returns: a :class:`~eth_account.datastructures.SignedSetCodeAuthorization`
suitable for inclusion in the ``authorizationList`` field of an
EIP-7702 (type 4) transaction.

Example::

acct = w3.eth.account.from_key(my_private_key)
contract = w3.eth.contract(address=contract_address, abi=abi)

auth = w3.eth.sign_authorization(contract.address, my_private_key)

tx = {
"from": acct.address,
"to": acct.address,
"authorizationList": [auth],
"data": contract.encode_abi("myFunction", args=[arg1, arg2]),
}
signed = acct.sign_transaction(tx)
w3.eth.send_raw_transaction(signed.raw_transaction)
"""
from eth_account import Account

signer = Account.from_key(private_key)
auth_params: SetCodeAuthorizationParams = {
"chainId": chain_id if chain_id is not None else self.chain_id,
"address": address,
"nonce": nonce if nonce is not None else self.get_transaction_count(signer.address),
}
return self.account.sign_authorization(auth_params, private_key)

# eth_newFilter, eth_newBlockFilter, eth_newPendingTransactionFilter

filter: Method[Callable[[str | FilterParams | HexStr | None], Filter]] = Method(
Expand Down