Repository: ethereum/eth-account

Issue:
A new type of transaction is needed for EIP-7702
### What feature should we add?

EIP-7702 style stransactions will be available on the public testnet on feb 28th 2025
https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7702.md

Most of the changes needed for EIP-7702 are boils down to having a new type of transaction able to carry an authorizationList

Discussion:
joseluu: Thanks for your review, I will fix all this
fselmo: Thanks so much for this @joseluu! I added the functionality to the `LocalAccount` which is nice if you want to use `acct.sign_authorization()` without passing in a private key (and this should bubble up to web3.py too which will be quite nice).

I also added some more tests and I ended up adding the model as a pydantic model because I think we can really leverage serialization better across the stack to web3.py. This is an attempt to start down that line of thinking.

I also added the `authorization` (address that authorized) as well as the signature and hash to the returned signed object. I think this can be handy to include if we already have that data available while signing.

I'm going to fix the docs issues next and then I am good with merging this! Thanks again. It's always nice to get some solid contributions like these 😃 
fselmo: @kclowes this should be updated. Requesting a re-review for when you have some time.

Patch:
diff --git a/docs/eth_account.typed_transactions.rst b/docs/eth_account.typed_transactions.rst
index 8896c87..1fdd0ec 100644
--- a/docs/eth_account.typed_transactions.rst
+++ b/docs/eth_account.typed_transactions.rst
@@ -32,3 +32,11 @@ Blob Transaction
    :members:
    :undoc-members:
    :show-inheritance:
+
+Set Code Transaction
+--------------------
+
+.. automodule:: eth_account.typed_transactions.set_code_transaction
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/eth_account/_utils/transaction_utils.py b/eth_account/_utils/transaction_utils.py
index 3b48f80..04f5dc1 100644
--- a/eth_account/_utils/transaction_utils.py
+++ b/eth_account/_utils/transaction_utils.py
@@ -10,11 +10,18 @@ from toolz import (
 
 from eth_account._utils.validation import (
     is_rlp_structured_access_list,
+    is_rlp_structured_authorization_list,
     is_rpc_structured_access_list,
+    is_rpc_structured_authorization_list,
+)
+from eth_account.datastructures import (
+    CustomPydanticModel,
 )
 from eth_account.types import (
     AccessList,
+    AuthorizationList,
     RLPStructuredAccessList,
+    RLPStructuredAuthorizationList,
     TransactionDictType,
 )
 
@@ -56,12 +63,36 @@ def set_transaction_type_if_needed(
             ):
                 # blob txn - type 3
                 transaction_dict = assoc(transaction_dict, "type", "0x3")
+            elif "authorizationList" in transaction_dict:
+                # set code txn - type 4
+                transaction_dict = assoc(transaction_dict, "type", "0x4")
             else:
                 # dynamic fee txn - type 2
                 transaction_dict = assoc(transaction_dict, "type", "0x2")
     return transaction_dict
 
 
+def json_serialize_classes_in_transaction(val: Any) -> Any:
+    """
+    Serialize class objects in a transaction using expected defined instructions.
+    Pydantic models are serialized with:
+
+    - ``mode="json"``           Uses the json encoder to serialize the model.
+    - ``by_alias=True``:        Uses the alias generator to turn all non-excluded fields
+                                into lowerCamelCase dicts.
+    - ``exclude=val._exclude:   Fields excluded for serialization are defined within a
+                                ``_exclude`` property on the pydantic model.
+    """
+    if isinstance(val, CustomPydanticModel):
+        return val.recursive_model_dump()
+    elif isinstance(val, dict):
+        return {k: json_serialize_classes_in_transaction(v) for k, v in val.items()}
+    elif isinstance(val, (list, tuple)):
+        return val.__class__(json_serialize_classes_in_transaction(v) for v in val)
+    else:
+        return val
+
+
 # JSON-RPC to rlp transaction structure
 def transaction_rpc_to_rlp_structure(dictionary: Dict[str, Any]) -> Dict[str, Any]:
     """
@@ -72,6 +103,16 @@ def transaction_rpc_to_rlp_structure(dictionary: Dict[str, Any]) -> Dict[str, An
         dictionary = dissoc(dictionary, "accessList")
         rlp_structured_access_list = _access_list_rpc_to_rlp_structure(access_list)
         dictionary = assoc(dictionary, "accessList", rlp_structured_access_list)
+
+    authorization_list = dictionary.get("authorizationList")
+    if authorization_list:
+        dictionary = dissoc(dictionary, "authorizationList")
+        rlp_structured_authorization_list = _authorization_list_rpc_to_rlp_structure(
+            authorization_list
+        )
+        dictionary = assoc(
+            dictionary, "authorizationList", rlp_structured_authorization_list
+        )
     return dictionary
 
 
@@ -93,17 +134,46 @@ def _access_list_rpc_to_rlp_structure(
     )
 
 
+def _authorization_list_rpc_to_rlp_structure(
+    authorization_list: AuthorizationList,
+) -> RLPStructuredAuthorizationList:
+    if not is_rpc_structured_authorization_list(authorization_list):
+        raise ValueError(
+            "provided object not formatted as JSON-RPC-structured authorization list"
+        )
+    # flatten each dict into a tuple of its values
+    return tuple(
+        (
+            d["chainId"],
+            d["address"],
+            d["nonce"],
+            d["yParity"],
+            d["r"],
+            d["s"],
+        )
+        for d in authorization_list
+    )
+
+
 # rlp to JSON-RPC transaction structure
 def transaction_rlp_to_rpc_structure(dictionary: Dict[str, Any]) -> Dict[str, Any]:
     """
     Convert an rlp-structured transaction to a JSON-RPC-structured transaction.
     """
     access_list = dictionary.get("accessList")
+    authorization_list = dictionary.get("authorizationList")
     if access_list:
         dictionary = dissoc(dictionary, "accessList")
         rpc_structured_access_list = _access_list_rlp_to_rpc_structure(access_list)
         dictionary = assoc(dictionary, "accessList", rpc_structured_access_list)
-
+    if authorization_list:
+        dictionary = dissoc(dictionary, "authorizationList")
+        rpc_structured_authorization_list = _authorization_list_rlp_to_rpc_structure(
+            authorization_list
+        )
+        dictionary = assoc(
+            dictionary, "authorizationList", rpc_structured_authorization_list
+        )
     return dictionary
 
 
@@ -115,3 +185,23 @@ def _access_list_rlp_to_rpc_structure(
 
     # build a dictionary with appropriate keys for each tuple
     return tuple({"address": t[0], "storageKeys": t[1]} for t in access_list)
+
+
+def _authorization_list_rlp_to_rpc_structure(
+    authorization_list: RLPStructuredAuthorizationList,
+) -> AuthorizationList:
+    if not is_rlp_structured_authorization_list(authorization_list):
+        raise ValueError(
+            "provided object not formatted as rlp-structured authorization list"
+        )
+    return tuple(
+        {
+            "chainId": t[0],
+            "address": t[1],
+            "nonce": t[2],
+            "yParity": t[3],
+            "r": t[4],
+            "s": t[5],
+        }
+        for t in authorization_list
+    )
diff --git a/eth_account/_utils/validation.py b/eth_account/_utils/validation.py
index e6c363b..5dbb0e0 100644
--- a/eth_account/_utils/validation.py
+++ b/eth_account/_utils/validation.py
@@ -102,6 +102,83 @@ def is_rlp_structured_access_list(val: Any) -> bool:
     return True
 
 
+def is_rpc_structured_authorization_list(val: Any) -> bool:
+    """Returns true if 'val' is a valid JSON-RPC structured access list."""
+    if not is_list_like(val):
+        return False
+    if len(val) == 0:
+        return False
+    for d in val:
+        if not is_dict(d):
+            return False
+        if len(d) != 6:
+            return False
+        chain_id = d.get("chainId")
+        address = d.get("address")
+        nonce = d.get("nonce")
+        y_parity = d.get("yParity")
+        signer_r = d.get("r")
+        signer_s = d.get("s")
+        if chain_id is None:
+            return False
+        if not is_int_or_prefixed_hexstr(chain_id):
+            return False
+        if nonce is None:
+            return False
+        if not is_int_or_prefixed_hexstr(nonce):
+            return False
+        if not is_address(address):
+            return False
+        if y_parity is None:
+            return False
+        if y_parity not in (0, 1, "0x0", "0x1"):
+            return False
+        if signer_r is None:
+            return False
+        if not is_int_or_prefixed_hexstr(signer_r):
+            return False
+        if signer_s is None:
+            return False
+        if not is_int_or_prefixed_hexstr(signer_s):
+            return False
+    return True
+
+
+def is_rlp_structured_authorization_list(val: Any) -> bool:
+    """Returns true if 'val' is a valid rlp-structured access list."""
+    if not is_list_like(val):
+        return False
+    for item in val:
+        if not is_list_like(item):
+            return False
+        if len(item) != 6:
+            return False
+        chain_id, address, nonce, y_parity, signer_r, signer_s = item
+        if chain_id is None:
+            return False
+        if not is_int_or_prefixed_hexstr(chain_id):
+            return False
+        if nonce is None:
+            return False
+        if not is_int_or_prefixed_hexstr(nonce):
+            return False
+        if not is_address(address):
+            return False
+        if y_parity is None:
+            return False
+        if y_parity not in ("0x0", "0x1", 0, 1):
+            return False
+        if signer_r is None:
+            return False
+        if not is_int_or_prefixed_hexstr(signer_r):
+            return False
+        if signer_s is None:
+            return False
+        if not is_int_or_prefixed_hexstr(signer_s):
+            return False
+    return True
+
+
 # type ignored because curry doesn't preserve typing
 @curry  # type: ignore[misc]
 def is_sequence_of_bytes_or_hexstr(
diff --git a/eth_account/account.py b/eth_account/account.py
index aa3fd0a..dbfd0d5 100644
--- a/eth_account/account.py
+++ b/eth_account/account.py
@@ -1,6 +1,9 @@
 from collections.abc import (
     Mapping,
 )
+from copy import (
+    copy,
+)
 import json
 import os
 from typing import (
@@ -39,6 +42,9 @@ from eth_typing import (
     Hash32,
     HexStr,
 )
+from eth_utils import (
+    to_canonical_address,
+)
 from eth_utils.curried import (
     combomethod,
     hexstr_if_str,
@@ -74,6 +80,7 @@ from eth_account.account_local_actions import (
 )
 from eth_account.datastructures import (
     SignedMessage,
+    SignedSetCodeAuthorization,
     SignedTransaction,
 )
 from eth_account.hdaccount import (
@@ -93,7 +100,11 @@ from eth_account.signers.local import (
 from eth_account.typed_transactions import (
     TypedTransaction,
 )
+from eth_account.typed_transactions.set_code_transaction import (
+    Authorization,
+)
 from eth_account.types import (
+    AuthorizationDict,
     Blobs,
     Language,
     PrivateKeyType,
@@ -675,12 +686,10 @@ class Account(AccountLocalActions):
         Sign a transaction using a local private key.
 
         It produces signature details and the hex-encoded transaction suitable for
-        broadcast using :meth:`w3.eth.sendRawTransaction()
-        <web3.eth.Eth.sendRawTransaction>`.
+        broadcast using :meth:`w3.eth.send_raw_transaction()<web3.eth.Eth.send_raw_transaction>`.
 
-        To create the transaction dict that calls a contract, use contract object:
-        `my_contract.functions.my_function().buildTransaction()
-        <http://web3py.readthedocs.io/en/latest/contracts.html#methods>`_
+        To create the transaction dict that calls a contract, use
+        :meth:`my_contract.functions.myFunction().build_transaction()<web3.contract.ContractFunction.build_transaction>`.
 
         Note: For non-legacy (typed) transactions, if the transaction type is not
         explicitly provided, it may be determined from the transaction parameters of
@@ -729,7 +738,7 @@ class Account(AccountLocalActions):
              r=48949965662841329840326477994465373664672499148507933176648302825256944281697,
              s=1123041608316060268133200864147951676126406077675157976022772782796802590165,
              v=1)
-            >>> w3.eth.sendRawTransaction(signed_df_tx.raw_transaction)  # doctest: +SKIP
+            >>> w3.eth.send_raw_transaction(signed_df_tx.raw_transaction)  # doctest: +SKIP
 
         .. doctest:: python
 
@@ -752,7 +761,7 @@ class Account(AccountLocalActions):
              r=11971260903864915610009019893820767192081275151191539081612245320300335068143,
              s=35365272040292958794699923036506252105590820339897221552886630515981233937234,
              v=2709)
-            >>> w3.eth.sendRawTransaction(signed_legacy_tx.raw_transaction)  # doctest: +SKIP
+            >>> w3.eth.send_raw_transaction(signed_legacy_tx.raw_transaction)  # doctest: +SKIP
 
         .. doctest:: python
 
@@ -783,7 +792,7 @@ class Account(AccountLocalActions):
              r=107355854401379915513092408112372039746594668141865279802319959599514133709188,
              s=6729502936685237038651223791038758905953302464070244934323623239104475448298,
              v=1)
-            >>> w3.eth.sendRawTransaction(signed_al_tx.raw_transaction)  # doctest: +SKIP
+            >>> w3.eth.send_raw_transaction(signed_al_tx.raw_transaction)  # doctest: +SKIP
 
         .. doctest:: python
 
@@ -819,7 +828,7 @@ class Account(AccountLocalActions):
              r=14319949980593194209648175507603206696573324965145502821772573913457715875718,
              s=9129184742597516615341309773045281461399831333162885393648678700392065987233,
              v=1)
-            >>> w3.eth.sendRawTransaction(signed_blob_tx.raw_transaction)  # doctest: +SKIP
+            >>> w3.eth.send_raw_transaction(signed_blob_tx.raw_transaction)  # doctest: +SKIP
         """  # noqa: E501
         if not isinstance(transaction_dict, Mapping):
             raise TypeError(
@@ -1043,3 +1052,97 @@ class Account(AccountLocalActions):
         )
         message_hash = _hash_eip191_message(signable_message)
         return cast(SignedMessage, self._sign_hash(message_hash, private_key))
+
+    @combomethod
+    def sign_authorization(
+        self,
+        authorization_dict: AuthorizationDict,
+        private_key: PrivateKeyType,
+    ) -> SignedSetCodeAuthorization:
+        r"""
+        Sign an authorization using a local private key to be included in a EIP-7702 transaction.
+
+        :param dict authorization_dict: the required keys are: ``chainId``, ``address``, and ``nonce``
+        :param private_key: the private key to sign the data with
+        :type private_key: hex str, bytes, int or :class:`eth_keys.datatypes.PrivateKey`
+        :returns: the dictionary with the signature fields added, suitable for inclusion in a EIP-7702 transaction
+        :rtype: dict
+
+        .. NOTE::
+            You need to sign one or more authorizations from an EOA willing to have a smart contract code associated with the EOA for the life of the transaction.
+            The variable ``auth`` in the code below is the authorization dictionary containing the following keys:
+
+            - ``chainId`` is the chain id for the chain where the EOA is located. If ``0`` is specified, authorization is signed for all chains
+            - ``address`` is the address of the smart contract code to be associated with the EOA, as bytes
+            - ``nonce`` is the nonce of the EOA, used to prevent replay attacks
+
+            To create a transaction that associates the code with the EOA, you need to create a transaction with an ``authorizationList``, representing a list of signed authorizations for the transaction.
+
+        .. doctest:: python
+
+            >>> from eth_account import Account
+
+            >>> key = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+            >>> auth = {
+            ...     "chainId": 1337,
+            ...     "address": "0x5ce9454909639d2d17a3f753ce7d93fa0b9ab12e",
+            ...     "nonce": 0,
+            ... }
+
+            >>> signed_auth = Account.sign_authorization(auth, key)
+            >>> signed_auth
+            SignedSetCodeAuthorization(chain_id=1337,
+             address=b'\\\xe9EI\tc\x9d-\x17\xa3\xf7S\xce}\x93\xfa\x0b\x9a\xb1.',
+             nonce=0,
+             y_parity=0,
+             r=13559089679153465389530080958732165835506259206033627652019224547096284912193,
+             s=4909893498344689684383026337158184903862784607939660326835239278207516636646,
+             signature='0x1dfa2c3c5b3bf61e364f7ce7ed744aef498fe26c981de726b139e0461c545a410adae6b7a1ff6137fb698a9973f4706cf8fe56aba87e8241b76042af587175e600',
+             authorization_hash=HexBytes('0x5a1bfd35dee8528cbcbe2dad796fec10b7ad15fa67ecc77c427d0d57bc5e2737'))
+
+            >>> tx = {
+            ...     "gas": 100000,
+            ...     "maxFeePerGas": 2000000000,
+            ...     "maxPriorityFeePerGas": 2000000000,
+            ...     "data": "0x616263646566",
+            ...     "nonce": 34,
+            ...     "to": "0x09616C3d61b3331fc4109a9E41a8BDB7d9776609",
+            ...     "value": "0x5af3107a4000",
+            ...     "accessList": (),
+            ...     "authorizationList": [signed_auth],
+            ...     "chainId": 1337,
+            ... }
+            >>> signed = Account.sign_transaction(tx, key)
+            >>> w3.eth.send_raw_transaction(signed.raw_transaction)  # doctest: +SKIP
+
+        .. _EIP-7702: https://eips.ethereum.org/EIPS/eip-7702
+        """  # noqa: E501
+        if not isinstance(authorization_dict, Mapping):
+            raise TypeError(
+                f"authorization_dict must be dict-like, got {repr(authorization_dict)}"
+            )
+
+        authority_key = self._parse_private_key(private_key)
+
+        # prevent mutating the original input
+        authorization_dict = copy(authorization_dict)
+
+        chain_id = authorization_dict["chainId"]
+        code_address = to_canonical_address(authorization_dict["address"])
+        nonce = authorization_dict["nonce"]
+
+        unsigned_authorization = Authorization(chain_id, code_address, nonce)
+        authorization_hash = unsigned_authorization.hash()
+        signature = authority_key.sign_msg_hash(authorization_hash)
+        [v, r, s] = signature.vrs
+
+        return SignedSetCodeAuthorization(
+            chain_id=chain_id,
+            address=code_address,
+            nonce=nonce,
+            y_parity=v,
+            r=r,
+            s=s,
+            signature=signature,
+            authorization_hash=authorization_hash,
+        )
diff --git a/eth_account/account_local_actions.py b/eth_account/account_local_actions.py
index 16a748f..913dd47 100644
--- a/eth_account/account_local_actions.py
+++ b/eth_account/account_local_actions.py
@@ -26,6 +26,9 @@ from eth_account.datastructures import (
 from eth_account.messages import (
     SignableMessage,
 )
+from eth_account.typed_transactions.set_code_transaction import (
+    SignedAuthorization,
+)
 from eth_account.types import (
     Blobs,
     PrivateKeyType,
@@ -84,3 +87,12 @@ class AccountLocalActions(ABC):
         full_message: Optional[Dict[str, Any]] = None,
     ) -> SignedMessage:
         pass
+
+    @combomethod
+    @abstractmethod
+    def sign_authorization(
+        self,
+        private_key: PrivateKeyType,
+        authorization: Dict[str, Any],
+    ) -> SignedAuthorization:
+        pass
diff --git a/eth_account/datastructures.py b/eth_account/datastructures.py
index 8ed2fe7..659498e 100644
--- a/eth_account/datastructures.py
+++ b/eth_account/datastructures.py
@@ -1,15 +1,33 @@
 from typing import (
     Any,
     NamedTuple,
+    Set,
     SupportsIndex,
     Tuple,
     Union,
     overload,
 )
 
+from eth_keys.datatypes import (
+    Signature,
+)
+from eth_typing import (
+    ChecksumAddress,
+)
+from eth_utils import (
+    to_checksum_address,
+)
 from hexbytes import (
     HexBytes,
 )
+from pydantic import (
+    BaseModel,
+    ConfigDict,
+    field_serializer,
+)
+from pydantic.alias_generators import (
+    to_camel,
+)
 
 
 class SignedTransaction(
@@ -76,3 +94,85 @@ class SignedMessage(
             return getattr(self, index)
         else:
             raise TypeError("Index must be an integer, slice, or string")
+
+
+class CustomPydanticModel(BaseModel):
+    """
+    Classes inheriting from this base model need only define any excluded fields as
+    ``_exclude: Set[str]``. This base class includes the instructions for how nested
+    pydantic models should be json-serialized.
+    """
+
+    _exclude: Set[str] = set()
+
+    model_config = ConfigDict(
+        arbitrary_types_allowed=True,
+        populate_by_name=True,
+        alias_generator=to_camel,
+    )
+
+    def recursive_model_dump(self) -> Any:
+        """
+        Recursively serialize the model, respecting nested `_exclude` fields.
+        """
+        output = {}
+        for field_name, field_value in self:
+            if field_name in self._exclude:
+                continue
+            elif isinstance(field_value, CustomPydanticModel):
+                output_key = self.model_fields[field_name].alias
+                output[output_key] = field_value.recursive_model_dump()
+            elif isinstance(field_value, list):
+                output_key = self.model_fields[field_name].alias
+                output[output_key] = [
+                    (
+                        item.recursive_model_dump()
+                        if isinstance(item, CustomPydanticModel)
+                        else item
+                    )
+                    for item in field_value
+                ]
+            else:
+                output_key = self.model_fields[field_name].alias
+                serializer = getattr(self, f"serialize_{field_name}", None)
+                if serializer:
+                    if hasattr(serializer, "wrapped"):
+                        output[output_key] = serializer.wrapped(serializer, field_value)
+                    else:
+                        output[output_key] = serializer(field_value)
+                else:
+                    output[output_key] = field_value
+
+        return output
+
+
+class SignedSetCodeAuthorization(CustomPydanticModel):
+    chain_id: int
+    address: bytes
+    nonce: int
+    y_parity: int
+    r: int
+    s: int
+    signature: Signature
+    authorization_hash: HexBytes
+
+    _exclude = {"signature", "authorization_hash", "authority"}
+
+    @classmethod
+    @field_serializer("address")
+    def serialize_address(cls, value: bytes) -> ChecksumAddress:
+        return to_checksum_address(value)
+
+    @property
+    def authority(self) -> bytes:
+        """
+        Return the address of the authority that signed the authorization.
+
+        In order to prevent any potential confusion or mal-intent, the authority is
+        always derived from the signature and the authorization hash, rather than
+        statically assigned. This value should be verified against the expected
+        authority for a signed authorization.
+        """
+        return self.signature.recover_public_key_from_msg_hash(
+            self.authorization_hash
+        ).to_canonical_address()
diff --git a/eth_account/hdaccount/_utils.py b/eth_account/hdaccount/_utils.py
index dc16004..6384e0a 100644
--- a/eth_account/hdaccount/_utils.py
+++ b/eth_account/hdaccount/_utils.py
@@ -21,7 +21,7 @@ SECP256K1_N = int(
 )
 
 
-def normalize_string(txt: Union[str, bytes]) -> str:
+def unicode_decompose_string(txt: Union[str, bytes]) -> str:
     if isinstance(txt, bytes):
         utxt = txt.decode("utf8")
     elif isinstance(txt, str):
@@ -32,6 +32,17 @@ def normalize_string(txt: Union[str, bytes]) -> str:
     return unicodedata.normalize("NFKD", utxt)
 
 
+def unicode_compose_string(txt: Union[str, bytes]) -> str:
+    if isinstance(txt, bytes):
+        utxt = txt.decode("utf8")
+    elif isinstance(txt, str):
+        utxt = txt
+    else:
+        raise ValidationError("String value expected")
+
+    return unicodedata.normalize("NFKC", utxt)
+
+
 def sha256(data: bytes) -> bytes:
     return hashlib.sha256(data).digest()
 
diff --git a/eth_account/hdaccount/mnemonic.py b/eth_account/hdaccount/mnemonic.py
index ae9d947..744175e 100644
--- a/eth_account/hdaccount/mnemonic.py
+++ b/eth_account/hdaccount/mnemonic.py
@@ -48,9 +48,9 @@ from eth_account.types import (
 )
 
 from ._utils import (
-    normalize_string,
     pbkdf2_hmac_sha512,
     sha256,
+    unicode_decompose_string,
 )
 
 VALID_ENTROPY_SIZES = [16, 20, 24, 28, 32]
@@ -151,7 +151,7 @@ class Mnemonic:
 
     @classmethod
     def detect_language(cls, raw_mnemonic: str) -> Language:
-        mnemonic = normalize_string(raw_mnemonic)
+        mnemonic = unicode_decompose_string(raw_mnemonic)
 
         words = set(mnemonic.split(" "))
         matching_languages = {
@@ -227,7 +227,7 @@ class Mnemonic:
 
         :param str mnemonic: Mnemonic string
         """
-        words = normalize_string(mnemonic).split(" ")
+        words = unicode_decompose_string(mnemonic).split(" ")
         num_words = len(words)
 
         if num_words not in VALID_WORD_COUNTS:
@@ -281,11 +281,11 @@ class Mnemonic:
         :param str passphrase: Encryption passphrase used to secure the mnemonic
         :returns bytes: 64 bytes of raw seed material from PRNG
         """
-        mnemonic = normalize_string(checked_mnemonic)
+        mnemonic = unicode_decompose_string(checked_mnemonic)
         # NOTE: This domain separater ("mnemonic") is added per BIP39 spec
         # to the passphrase
         # https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki#from-mnemonic-to-seed  # blocklint: URL pragma  # noqa: E501
-        salt = "mnemonic" + normalize_string(passphrase)
+        salt = "mnemonic" + unicode_decompose_string(passphrase)
         # From BIP39:
         #   To create a binary seed from the mnemonic, we use the PBKDF2 function with a
         # mnemonic sentence (in UTF-8 NFKD) used as the password and the string
diff --git a/eth_account/signers/local.py b/eth_account/signers/local.py
index 4a9428b..0a1b79f 100644
--- a/eth_account/signers/local.py
+++ b/eth_account/signers/local.py
@@ -158,3 +158,9 @@ class LocalAccount(BaseAccount):
                 full_message=full_message,
             ),
         )
+
+    def sign_authorization(self, authorization: Dict[str, Any]) -> SignedMessage:
+        return cast(
+            SignedMessage,
+            self._publicapi.sign_authorization(authorization, private_key=self.key),
+        )
diff --git a/eth_account/typed_transactions/__init__.py b/eth_account/typed_transactions/__init__.py
index 7d4b882..7c0ba51 100644
--- a/eth_account/typed_transactions/__init__.py
+++ b/eth_account/typed_transactions/__init__.py
@@ -7,6 +7,9 @@ from .blob_transactions.blob_transaction import (
 from .dynamic_fee_transaction import (
     DynamicFeeTransaction,
 )
+from .set_code_transaction import (
+    SetCodeTransaction,
+)
 from .typed_transaction import (
     TypedTransaction,
 )
diff --git a/eth_account/typed_transactions/base.py b/eth_account/typed_transactions/base.py
index dc94ff8..753819d 100644
--- a/eth_account/typed_transactions/base.py
+++ b/eth_account/typed_transactions/base.py
@@ -74,6 +74,23 @@ TYPED_TRANSACTION_FORMATTERS = merge(
         "maxFeePerGas": hexstr_if_str(to_int),
         "maxFeePerBlobGas": hexstr_if_str(to_int),
         "blobVersionedHashes": apply_formatter_to_array(hexstr_if_str(to_bytes)),
+        "authorizationList": apply_formatter_to_array(
+            apply_formatters_to_dict(
+                {
+                    "chainId": hexstr_if_str(to_int),
+                    "nonce": hexstr_if_str(to_int),
+                    "address": apply_one_of_formatters(
+                        (
+                            (is_string, hexstr_if_str(to_bytes)),
+                            (is_bytes, identity),
+                        )
+                    ),
+                    "yParity": hexstr_if_str(to_int),
+                    "r": hexstr_if_str(to_int),
+                    "s": hexstr_if_str(to_int),
+                }
+            ),
+        ),
     },
 )
 
diff --git a/eth_account/typed_transactions/set_code_transaction.py b/eth_account/typed_transactions/set_code_transaction.py
new file mode 100644
index 0000000..a526c9a
--- /dev/null
+++ b/eth_account/typed_transactions/set_code_transaction.py
@@ -0,0 +1,288 @@
+from typing import (
+    Any,
+    Dict,
+    Optional,
+    Tuple,
+    cast,
+)
+
+from eth_rlp import (
+    HashableRLP,
+)
+from eth_utils import (
+    keccak,
+)
+from eth_utils.curried import (
+    apply_formatters_to_dict,
+)
+from eth_utils.toolz import (
+    dissoc,
+    merge,
+    partial,
+    pipe,
+)
+from hexbytes import (
+    HexBytes,
+)
+import rlp
+from rlp.sedes import (
+    Binary,
+    CountableList,
+    big_endian_int,
+    binary,
+)
+
+from eth_account._utils.transaction_utils import (
+    json_serialize_classes_in_transaction,
+    set_transaction_type_if_needed,
+    transaction_rlp_to_rpc_structure,
+    transaction_rpc_to_rlp_structure,
+)
+from eth_account._utils.validation import (
+    LEGACY_TRANSACTION_VALID_VALUES,
+    is_int_or_prefixed_hexstr,
+    is_rpc_structured_access_list,
+    is_rpc_structured_authorization_list,
+)
+from eth_account.types import (
+    Blobs,
+)
+
+from .access_list_transaction import (
+    access_list_sede_type,
+)
+from .base import (
+    TYPED_TRANSACTION_FORMATTERS,
+    _TypedTransactionImplementation,
+)
+
+
+class Authorization(HashableRLP):
+    magic = 5  # '0x05' 'MAGIC' as per specification EIP-7702
+    fields = (
+        ("chainId", big_endian_int),
+        ("address", Binary.fixed_length(20, allow_empty=False)),
+        ("nonce", big_endian_int),
+    )
+
+    def hash(self) -> HexBytes:
+        """
+        :returns: the hash of the encoded bytestring
+        :rtype: ~hexbytes.main.HexBytes
+        """
+        return HexBytes(
+            pipe(
+                self,
+                rlp.encode,
+                lambda val: bytes([self.__class__.magic]) + val,  # (0x05 || rlp([...]))
+                keccak,
+            )
+        )
+
+
+class SignedAuthorization(HashableRLP):
+    _authority: bytes
+
+    fields = (
+        ("chainId", big_endian_int),
+        ("address", Binary.fixed_length(20, allow_empty=False)),
+        ("nonce", big_endian_int),
+        ("yParity", big_endian_int),
+        ("r", big_endian_int),
+        ("s", big_endian_int),
+    )
+
+
+class SetCodeTransaction(_TypedTransactionImplementation):
+    """
+    Represents a set code transaction as per EIP-7702.
+    """
+
+    transaction_type = 4  # '0x04'
+
+    unsigned_transaction_fields = (
+        ("chainId", big_endian_int),
+        ("nonce", big_endian_int),
+        ("maxPriorityFeePerGas", big_endian_int),
+        ("maxFeePerGas", big_endian_int),
+        ("gas", big_endian_int),
+        ("to", Binary.fixed_length(20, allow_empty=True)),
+        ("value", big_endian_int),
+        ("data", binary),
+        ("accessList", access_list_sede_type),
+        ("authorizationList", CountableList(SignedAuthorization)),
+    )
+
+    signature_fields = (
+        ("v", big_endian_int),
+        ("r", big_endian_int),
+        ("s", big_endian_int),
+    )
+
+    transaction_field_defaults = {
+        "type": b"0x4",
+        "chainId": 0,
+        "nonce": 0,
+        "to": b"",
+        "value": 0,
+        "data": b"",
+        "accessList": [],
+        "authorizationList": [],
+    }
+
+    _unsigned_transaction_serializer = type(
+        "_unsigned_transaction_serializer",
+        (HashableRLP,),
+        {
+            "fields": unsigned_transaction_fields,
+        },
+    )
+
+    _signed_transaction_serializer = type(
+        "_signed_transaction_serializer",
+        (HashableRLP,),
+        {
+            "fields": unsigned_transaction_fields + signature_fields,
+        },
+    )
+
+    def __init__(self, dictionary: Dict[str, Any]):
+        self.dictionary = dictionary
+
+    @classmethod
+    def assert_valid_fields(cls, dictionary: Dict[str, Any]) -> None:
+        transaction_valid_values = merge(
+            LEGACY_TRANSACTION_VALID_VALUES,
+            {
+                "type": is_int_or_prefixed_hexstr,
+                "maxPriorityFeePerGas": is_int_or_prefixed_hexstr,
+                "maxFeePerGas": is_int_or_prefixed_hexstr,
+                "accessList": is_rpc_structured_access_list,
+                "authorizationList": is_rpc_structured_authorization_list,
+            },
+        )
+
+        if "v" in dictionary and dictionary["v"] == 0:
+            # This is insane logic that is required because the way we evaluate
+            # correct types is in the `if not all()` branch below, and 0 obviously
+            # maps to the int(0), which maps to False... This was not an issue in
+            # non-typed transaction because v=0, couldn't exist with the chain offset.
+            dictionary["v"] = "0x0"
+        valid_fields = apply_formatters_to_dict(
+            transaction_valid_values,
+            dictionary,
+        )  # type: Dict[str, Any]
+        if not all(valid_fields.values()):
+            invalid = {
+                key: dictionary[key] for key, valid in valid_fields.items() if not valid
+            }
+            raise TypeError(f"Transaction had invalid fields: {repr(invalid)}")
+
+    @classmethod
+    def from_dict(
+        cls, dictionary: Dict[str, Any], blobs: Optional[Blobs] = None
+    ) -> "SetCodeTransaction":
+        """
+        Builds a SetCodeTransaction from a dictionary.
+        Verifies that the dictionary is well-formed.
+        """
+        if blobs is not None:
+            raise ValueError("Blob data is not supported for `SetCodeTransaction`.")
+
+        dictionary = set_transaction_type_if_needed(dictionary)
+        # pre-process any signed authorization pydantic models into dicts
+        dictionary = json_serialize_classes_in_transaction(dictionary)
+
+        # Validate fields.
+        cls.assert_valid_fields(dictionary)
+        sanitized_dictionary = pipe(
+            dictionary,
+            dict,
+            partial(merge, cls.transaction_field_defaults),
+            apply_formatters_to_dict(TYPED_TRANSACTION_FORMATTERS),
+        )
+
+        # We have verified the type, we can safely remove it from the dictionary,
+        # given that it is not to be included within the RLP payload.
+        transaction_type = sanitized_dictionary.pop("type")
+        if transaction_type != cls.transaction_type:
+            raise ValueError(
+                f"expected transaction type {cls.transaction_type}, "
+                f"got {transaction_type}"
+            )
+        return cls(dictionary=sanitized_dictionary)
+
+    @classmethod
+    def from_bytes(cls, encoded_transaction: HexBytes) -> "SetCodeTransaction":
+        """Builds an SetCodeTransaction from a signed encoded transaction."""
+        if not isinstance(encoded_transaction, HexBytes):
+            raise TypeError(f"expected Hexbytes, got type: {type(encoded_transaction)}")
+        if not (
+            len(encoded_transaction) > 0
+            and encoded_transaction[0] == cls.transaction_type
+        ):
+            raise ValueError("unexpected input")
+        # Format is (0x04 || TransactionPayload)
+        # We strip the prefix, and RLP unmarshal the payload into our
+        # signed transaction serializer.
+        transaction_payload = encoded_transaction[1:]
+        rlp_serializer = cls._signed_transaction_serializer
+        dictionary = rlp_serializer.from_bytes(transaction_payload).as_dict()  # type: ignore  # noqa: E501
+        rpc_structured_dict = transaction_rlp_to_rpc_structure(dictionary)
+        rpc_structured_dict["type"] = cls.transaction_type
+        return cls.from_dict(rpc_structured_dict)
+
+    def as_dict(self) -> Dict[str, Any]:
+        """Returns this transaction as a dictionary."""
+        dictionary = self.dictionary.copy()
+        dictionary["type"] = self.__class__.transaction_type
+        return dictionary
+
+    def hash(self) -> bytes:
+        """
+        Hashes this SetCodeTransaction to prepare it for signing.
+        As per the EIP-7702 specifications, the signature is a secp256k1 signature over
+        ``keccak256(0x04 || rlp([chainId, nonce, max_priority_fee_per_gas,
+        max_fee_per_gas, gasLimit, to, value, data, accessList, authorizationList])).``
+        """
+        # Remove signature fields.
+        transaction_without_signature_fields = dissoc(self.dictionary, "v", "r", "s")
+        # RPC-structured transaction to rlp-structured transaction
+        rlp_structured_txn_without_sig_fields = transaction_rpc_to_rlp_structure(
+            transaction_without_signature_fields
+        )
+        rlp_serializer = self.__class__._unsigned_transaction_serializer
+        hash_ = pipe(
+            rlp_serializer.from_dict(rlp_structured_txn_without_sig_fields),  # type: ignore  # noqa: E501
+            lambda val: rlp.encode(val),  # rlp([...])
+            lambda val: bytes([self.__class__.transaction_type])
+            + val,  # (0x04 || rlp([...]))  # noqa: E501
+            keccak,  # keccak256(0x04 || rlp([...]))
+        )
+        return cast(bytes, hash_)
+
+    def payload(self) -> bytes:
+        """
+        Returns this transaction's payload as bytes.
+
+        Here, the transaction payload is:
+
+            TransactionPayload = rlp([chainId,
+            nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit,
+            to, value, data,
+            accessList,
+            authorizationsList,
+            signatureYParity, signatureR, signatureS])
+        """
+        if not all(k in self.dictionary for k in "vrs"):
+            raise ValueError("attempting to encode an unsigned transaction")
+        rlp_serializer = self.__class__._signed_transaction_serializer
+        rlp_structured_dict = transaction_rpc_to_rlp_structure(self.dictionary)
+        payload = rlp.encode(rlp_serializer.from_dict(rlp_structured_dict))  # type: ignore  # noqa: E501
+        return cast(bytes, payload)
+
+    def vrs(self) -> Tuple[int, int, int]:
+        """Returns (v, r, s) if they exist."""
+        if not all(k in self.dictionary for k in "vrs"):
+            raise ValueError("attempting to encode an unsigned transaction")
+        return (self.dictionary["v"], self.dictionary["r"], self.dictionary["s"])
diff --git a/eth_account/typed_transactions/typed_transaction.py b/eth_account/typed_transactions/typed_transaction.py
index 83087d1..bd0d190 100644
--- a/eth_account/typed_transactions/typed_transaction.py
+++ b/eth_account/typed_transactions/typed_transaction.py
@@ -41,6 +41,9 @@ from .blob_transactions.blob_transaction import (
 from .dynamic_fee_transaction import (
     DynamicFeeTransaction,
 )
+from .set_code_transaction import (
+    SetCodeTransaction,
+)
 
 
 class TypedTransaction:
@@ -51,6 +54,7 @@ class TypedTransaction:
      * EIP-2930's AccessListTransaction
      * EIP-1559's DynamicFeeTransaction
      * EIP-4844's BlobTransaction
+     * EIP-7702's SetCodeTransaction
 
     """
 
@@ -86,12 +90,15 @@ class TypedTransaction:
         # Switch on the transaction type to choose the correct constructor.
         transaction_type = pipe(dictionary["type"], hexstr_if_str(to_int))
         transaction: Any
+
         if transaction_type == AccessListTransaction.transaction_type:
             transaction = AccessListTransaction
         elif transaction_type == DynamicFeeTransaction.transaction_type:
             transaction = DynamicFeeTransaction
         elif transaction_type == BlobTransaction.transaction_type:
             transaction = BlobTransaction
+        elif transaction_type == SetCodeTransaction.transaction_type:
+            transaction = SetCodeTransaction
         else:
             raise TypeError(f"Unknown Transaction type: {transaction_type}")
         return cls(
@@ -108,18 +115,25 @@ class TypedTransaction:
             raise ValueError("unexpected input")
 
         transaction: Union[
-            "DynamicFeeTransaction", "AccessListTransaction", "BlobTransaction"
+            "DynamicFeeTransaction",
+            "AccessListTransaction",
+            "BlobTransaction",
+            "SetCodeTransaction",
         ]
 
-        if encoded_transaction[0] == AccessListTransaction.transaction_type:
+        encoded_tx_type = encoded_transaction[0]
+        if encoded_tx_type == AccessListTransaction.transaction_type:
             transaction_type = AccessListTransaction.transaction_type
             transaction = AccessListTransaction.from_bytes(encoded_transaction)
-        elif encoded_transaction[0] == DynamicFeeTransaction.transaction_type:
+        elif encoded_tx_type == DynamicFeeTransaction.transaction_type:
             transaction_type = DynamicFeeTransaction.transaction_type
             transaction = DynamicFeeTransaction.from_bytes(encoded_transaction)
-        elif encoded_transaction[0] == BlobTransaction.transaction_type:
+        elif encoded_tx_type == BlobTransaction.transaction_type:
             transaction_type = BlobTransaction.transaction_type
             transaction = BlobTransaction.from_bytes(encoded_transaction)
+        elif encoded_tx_type == SetCodeTransaction.transaction_type:
+            transaction_type = SetCodeTransaction.transaction_type
+            transaction = SetCodeTransaction.from_bytes(encoded_transaction)
         else:
             # The only known transaction types should be explicit if/elif branches.
             raise TypeError(
diff --git a/eth_account/types.py b/eth_account/types.py
index 42280c9..5b99872 100644
--- a/eth_account/types.py
+++ b/eth_account/types.py
@@ -3,6 +3,8 @@ from typing import (
     Any,
     Dict,
     Sequence,
+    Tuple,
+    TypedDict,
     Union,
 )
 
@@ -10,6 +12,7 @@ from eth_keys.datatypes import (
     PrivateKey,
 )
 from eth_typing import (
+    HexAddress,
     HexStr,
 )
 from hexbytes import (
@@ -23,6 +26,24 @@ PrivateKeyType = Union[Bytes32, int, HexStr, PrivateKey]
 AccessList = Sequence[Dict[str, Union[HexStr, Sequence[HexStr]]]]
 RLPStructuredAccessList = Sequence[Sequence[Union[HexStr, Sequence[HexStr]]]]
 
+
+class AuthorizationDict(TypedDict):
+    chainId: int
+    address: HexAddress
+    nonce: int
+
+
+class SignedAuthorizationDict(AuthorizationDict):
+    yParity: int
+    r: HexStr
+    s: HexStr
+
+
+AuthorizationList = Sequence[SignedAuthorizationDict]
+RLPStructuredAuthorizationList = Sequence[
+    Tuple[int, HexAddress, int, int, HexStr, HexStr]
+]
+
 TransactionDictType = Dict[str, Union[AccessList, bytes, HexStr, int]]
 
 
diff --git a/newsfragments/315.feature.rst b/newsfragments/315.feature.rst
new file mode 100644
index 0000000..a9b7aaf
--- /dev/null
+++ b/newsfragments/315.feature.rst
@@ -0,0 +1,1 @@
+Implement EIP-7702 ``SetCodeTransaction``.


Test patch:
diff --git a/tests/core/test_accounts.py b/tests/core/test_accounts.py
index 6299ae2..e444426 100644
--- a/tests/core/test_accounts.py
+++ b/tests/core/test_accounts.py
@@ -10,6 +10,7 @@ from eth_keys import (
 from eth_utils import (
     is_checksum_address,
     to_bytes,
+    to_checksum_address,
     to_hex,
     to_int,
 )
@@ -921,3 +922,47 @@ def test_sign_typed_data_produces_expected_hash(
         ).signature
         == expected_sig
     )
+
+
+def test_sign_authorization(acct):
+    auth = {
+        "chainId": 1,
+        "address": "0x5ce9454909639D2D17A3F753ce7d93fa0b9aB12E",
+        "nonce": 0,
+    }
+
+    signed_auth = acct.sign_authorization(auth, PRIVATE_KEY_AS_HEXSTR)
+    assert signed_auth.chain_id == 1
+    assert signed_auth.address == to_bytes(
+        hexstr="0x5ce9454909639D2D17A3F753ce7d93fa0b9aB12E"
+    )
+    assert signed_auth.nonce == 0
+    assert signed_auth.authority == to_bytes(hexstr=ACCT_ADDRESS)
+
+    # assert the authority is the same as the recovered authority, effectively also
+    # asserting that the auth hash and signature are correct
+    assert acct._recover_hash(
+        signed_auth.authorization_hash, vrs=signed_auth.signature.vrs
+    ) == to_checksum_address(signed_auth.authority)
+
+
+def test_sign_authorization_from_local_acct():
+    local_acct = Account.from_key(PRIVATE_KEY_AS_HEXSTR)
+    auth = {
+        "chainId": 1,
+        "address": "0x5ce9454909639D2D17A3F753ce7d93fa0b9aB12E",
+        "nonce": 0,
+    }
+
+    signed_auth = local_acct.sign_authorization(auth)
+    assert signed_auth.chain_id == 1
+    assert signed_auth.address == to_bytes(
+        hexstr="0x5ce9454909639D2D17A3F753ce7d93fa0b9aB12E"
+    )
+    assert signed_auth.nonce == 0
+
+    # assert the authority is the same as the recovered authority, effectively also
+    # asserting that the auth hash and signature are correct
+    assert Account._recover_hash(
+        signed_auth.authorization_hash, vrs=signed_auth.signature.vrs
+    ) == to_checksum_address(signed_auth.authority)
diff --git a/tests/core/test_mnemonic.py b/tests/core/test_mnemonic.py
index 8c33bab..7369f49 100644
--- a/tests/core/test_mnemonic.py
+++ b/tests/core/test_mnemonic.py
@@ -22,11 +22,14 @@
 #
 import pytest
 
+from eth_account.hdaccount._utils import (
+    unicode_compose_string,
+)
 from eth_account.hdaccount.mnemonic import (
     Language,
     Mnemonic,
     ValidationError,
-    normalize_string,
+    unicode_decompose_string,
 )
 
 
@@ -105,9 +108,13 @@ def test_expand(lang):
     words = m.generate()
     for word in words.split(" "):  # Space delinates in languages not excluded above
         # BIP39 can support word expansion with as little as 4 characters
-        norm_word = normalize_string(word)
+        # we count composed characters as they are actually one character
+        # from the user perspective
+        norm_word = unicode_compose_string(word)
         for size in range(4, len(norm_word)):
-            assert m.expand(norm_word[: size + 1]) == word
+            # words from the list have been normalized in decomposed form (NFKD)
+            # they should be compared in decomposed form as well
+            assert m.expand(unicode_decompose_string(norm_word[:size])) == word
 
 
 @pytest.mark.parametrize("lang", Mnemonic.list_languages_enum())
@@ -116,13 +123,16 @@ def test_generation_with_enum(lang, num_words):
     m = Mnemonic(lang)
     mnemonic = m.generate(num_words)
     assert m.is_mnemonic_valid(mnemonic)
-    # NOTE: Sometimes traditional chinese can return characters that are also
-    # valid simplified chinese characters. In that scenario, the detection
-    # algorithm will assume simplified.
-    if lang == Language("chinese_simplified"):
-        assert "chinese" in Mnemonic.detect_language(mnemonic).value
-    else:
+    try:
         assert Mnemonic.detect_language(mnemonic) == lang
+    except AssertionError:
+        if lang != Language.CHINESE_TRADITIONAL:
+            # NOTE: Sometimes traditional chinese can return characters that are also
+            # valid simplified chinese characters. In that scenario, the detection
+            # algorithm will assume simplified.
+            raise
+        assert Mnemonic.detect_language(mnemonic) == Language.CHINESE_SIMPLIFIED
+
     assert len(Mnemonic.to_seed(mnemonic)) == 64
 
 
@@ -133,13 +143,15 @@ def test_generation_with_string(lang, num_words):
         m = Mnemonic(lang)
     mnemonic = m.generate(num_words)
     assert m.is_mnemonic_valid(mnemonic)
-    # NOTE: Sometimes traditional chinese can return characters that are also
-    # valid simplified chinese characters. In that scenario, the detection
-    # algorithm will assume simplified.
-    if lang == "chinese_simplified":
-        assert "chinese" in Mnemonic.detect_language(mnemonic).value
-    else:
+    try:
         assert Mnemonic.detect_language(mnemonic) == Language(lang)
+    except AssertionError:
+        # NOTE: Sometimes traditional chinese can return characters that are also
+        # valid simplified chinese characters. In that scenario, the detection
+        # algorithm will assume simplified.
+        if lang != "chinese_traditional":
+            raise
+        assert Mnemonic.detect_language(mnemonic) == Language.CHINESE_SIMPLIFIED
     assert len(Mnemonic.to_seed(mnemonic)) == 64
 
 
@@ -569,7 +581,9 @@ def test_japanese_mnemonics(entropy, expected_mnemonic, passphrase, expected_see
     assert m.is_mnemonic_valid(mnemonic)
     # NOTE For some reason, the strings weren't appearing in normalized form as
     #      copied rrom BIP39 test vectors
-    assert normalize_string(mnemonic) == normalize_string(expected_mnemonic)
+    assert unicode_decompose_string(mnemonic) == unicode_decompose_string(
+        expected_mnemonic
+    )
 
     seed = Mnemonic.to_seed(mnemonic, passphrase)
     assert seed.hex() == expected_seed
diff --git a/tests/core/test_transaction_utils.py b/tests/core/test_transaction_utils.py
index b63b127..07bbf2f 100644
--- a/tests/core/test_transaction_utils.py
+++ b/tests/core/test_transaction_utils.py
@@ -1,8 +1,23 @@
 import pytest
+from typing import (
+    List,
+)
+
+from eth_keys.datatypes import (
+    Signature,
+)
+from hexbytes import (
+    HexBytes,
+)
 
 from eth_account._utils.transaction_utils import (
     _access_list_rlp_to_rpc_structure,
     _access_list_rpc_to_rlp_structure,
+    json_serialize_classes_in_transaction,
+)
+from eth_account.datastructures import (
+    CustomPydanticModel,
+    SignedSetCodeAuthorization,
 )
 
 # access list example from EIP-2930
@@ -29,6 +44,38 @@ RPC_STRUCTURED_ACCESS_LIST = [
 ]
 
 
+TEST_SIGNED_AUTHORIZATION = SignedSetCodeAuthorization(
+    chain_id=22,
+    address=b"\x00" * 19 + b"\x01",
+    nonce=1999,
+    y_parity=1,
+    r=123456789,
+    s=987654321,
+    signature=Signature(b"\x00" * 65),
+    authorization_hash=HexBytes("0x"),
+)
+
+
+class PydanticTestClassInner(CustomPydanticModel):
+    int_value: int
+    str_value: str
+    authorization_list: List[SignedSetCodeAuthorization]
+    excluded_field1: str
+    excluded_field2: int
+
+    _exclude = {"excluded_field1", "excluded_field2"}
+
+
+class PydanticTestClass(CustomPydanticModel):
+    int_value: int
+    nested_model: PydanticTestClassInner
+    excluded_field1: str
+    excluded_field2: int
+    excluded_field3: HexBytes
+
+    _exclude = {"excluded_field1", "excluded_field2", "excluded_field3"}
+
+
 @pytest.mark.parametrize(
     "access_list",
     (
@@ -80,3 +127,43 @@ def test_access_list_rlp_to_rpc_structure_raises_when_not_rlp_access_list(access
         ValueError, match="provided object not formatted as rlp-structured access list"
     ):
         _access_list_rlp_to_rpc_structure(access_list)
+
+
+def test_class_serializer():
+    serialized = json_serialize_classes_in_transaction(
+        {
+            "testPydanticModel": PydanticTestClass(
+                int_value=1,
+                nested_model=PydanticTestClassInner(
+                    int_value=2,
+                    str_value="3",
+                    authorization_list=[TEST_SIGNED_AUTHORIZATION],
+                    excluded_field1="4",
+                    excluded_field2=5,
+                ),
+                excluded_field1="6",
+                excluded_field2=7,
+                excluded_field3=HexBytes("0x08"),
+            ),
+        }
+    )
+
+    assert serialized == {
+        "testPydanticModel": {
+            "intValue": 1,
+            "nestedModel": {
+                "intValue": 2,
+                "strValue": "3",
+                "authorizationList": [
+                    {
+                        "chainId": 22,
+                        "address": "0x" + "00" * 19 + "01",
+                        "nonce": 1999,
+                        "yParity": 1,
+                        "r": 123456789,
+                        "s": 987654321,
+                    }
+                ],
+            },
+        },
+    }
diff --git a/tests/core/test_typed_transactions.py b/tests/core/test_typed_transactions.py
index 911ac77..84ab895 100644
--- a/tests/core/test_typed_transactions.py
+++ b/tests/core/test_typed_transactions.py
@@ -1,13 +1,20 @@
 import pytest
 
+from eth_keys.datatypes import (
+    Signature,
+)
 from hexbytes import (
     HexBytes,
 )
 
+from eth_account.datastructures import (
+    SignedSetCodeAuthorization,
+)
 from eth_account.typed_transactions import (
     AccessListTransaction,
     BlobTransaction,
     DynamicFeeTransaction,
+    SetCodeTransaction,
     TypedTransaction,
 )
 
@@ -339,8 +346,150 @@ TEST_CASES = [
             "s": "0x3040e5f117d33a7873d8bfa8fad8bf5d1163db2ce4e3de5ea414a45e199b5b2d",
         },
     },
+    {
+        "expected_type": SetCodeTransaction,
+        "expected_hash": "0xc3b8fd19170b73ab1981db5be074eb6b920cae2c58fc222d73041ebcdc6ce99b",  # noqa: E501
+        "expected_raw_transaction": "0x04f901188501a58877101284ee6b27f984ee6b2807830186a094e3f1413e071332840db2735f809cf3240c4a425580b844a9059cbb0000000000000000000000003d3dc402103535134968adf08fab95b1012c1cb5000000000000000000000000000000000000000000000000000000000000029ac0f861f85f8501a5887710943e6c95d880401e4e36ee62f4ebded346e1adf42d0201a031f464e6a607fc66e0f982a2d21498799b0571760a272f3d903aca6594cef595a0343b0b143924a5db42a3200ae72d5c6dd03633847bc2da025cff5214e28708ec01a0a9c1555a0d706755bbdb7537c80f4e6a06ba0719ec03174190ccc934207c805da012ad4e24ae01171ee8a64b18ab772c88141e490498bde39d12e773f422f389c8",  # noqa: E501
+        "transaction": {
+            "gas": 100000,
+            "maxFeePerGas": 4000000007,
+            "maxPriorityFeePerGas": 3999999993,
+            "data": "0xa9059cbb0000000000000000000000003d3dc402103535134968adf08fab95b1012c1cb5000000000000000000000000000000000000000000000000000000000000029a",  # noqa: E501
+            "nonce": 18,
+            "to": "0xe3f1413e071332840dB2735F809Cf3240C4a4255",
+            "value": 0,
+            "type": 4,
+            "accessList": [],
+            "chainId": 7072151312,
+            "authorizationList": [
+                {
+                    "chainId": 7072151312,
+                    "address": "0x3e6c95d880401e4e36ee62f4ebded346e1adf42d",
+                    "nonce": 2,
+                    "yParity": 1,
+                    "r": "0x31f464e6a607fc66e0f982a2d21498799b0571760a272f3d903aca6594cef595",  # noqa: E501
+                    "s": "0x343b0b143924a5db42a3200ae72d5c6dd03633847bc2da025cff5214e28708ec",  # noqa: E501
+                }
+            ],
+            "v": 1,
+            "r": "0xa9c1555a0d706755bbdb7537c80f4e6a06ba0719ec03174190ccc934207c805d",
+            "s": "0x12ad4e24ae01171ee8a64b18ab772c88141e490498bde39d12e773f422f389c8",
+        },
+    },
+    {
+        "expected_type": SetCodeTransaction,
+        "expected_hash": "0xc3b8fd19170b73ab1981db5be074eb6b920cae2c58fc222d73041ebcdc6ce99b",  # noqa: E501
+        "expected_raw_transaction": "0x04f901188501a58877101284ee6b27f984ee6b2807830186a094e3f1413e071332840db2735f809cf3240c4a425580b844a9059cbb0000000000000000000000003d3dc402103535134968adf08fab95b1012c1cb5000000000000000000000000000000000000000000000000000000000000029ac0f861f85f8501a5887710943e6c95d880401e4e36ee62f4ebded346e1adf42d0201a031f464e6a607fc66e0f982a2d21498799b0571760a272f3d903aca6594cef595a0343b0b143924a5db42a3200ae72d5c6dd03633847bc2da025cff5214e28708ec01a0a9c1555a0d706755bbdb7537c80f4e6a06ba0719ec03174190ccc934207c805da012ad4e24ae01171ee8a64b18ab772c88141e490498bde39d12e773f422f389c8",  # noqa: E501
+        "transaction": {
+            "gas": 100000,
+            "maxFeePerGas": 4000000007,
+            "maxPriorityFeePerGas": 3999999993,
+            "data": "0xa9059cbb0000000000000000000000003d3dc402103535134968adf08fab95b1012c1cb5000000000000000000000000000000000000000000000000000000000000029a",  # noqa: E501
+            "nonce": 18,
+            "to": "0xe3f1413e071332840dB2735F809Cf3240C4a4255",
+            "value": 0,
+            "type": 4,
+            "accessList": [],
+            "chainId": 7072151312,
+            "authorizationList": [
+                SignedSetCodeAuthorization(
+                    chain_id=7072151312,
+                    address=b">l\x95\xd8\x80@\x1eN6\xeeb\xf4\xeb\xde\xd3F\xe1\xad\xf4-",
+                    nonce=2,
+                    y_parity=1,
+                    r=22595136657293516951860802422974352017713294017347016159649668416801694741909,  # noqa: E501
+                    s=23624588567578401597292901415360791985725988995245670164012927046435484403948,  # noqa: E501
+                    # signature and hash are not tested here as they are not serialized
+                    signature=Signature(b"\x00" * 65),
+                    authorization_hash=HexBytes(f"0x{'00' * 32}"),
+                )
+            ],
+            "v": 1,
+            "r": "0xa9c1555a0d706755bbdb7537c80f4e6a06ba0719ec03174190ccc934207c805d",
+            "s": "0x12ad4e24ae01171ee8a64b18ab772c88141e490498bde39d12e773f422f389c8",
+        },
+    },
+    {
+        "expected_type": SetCodeTransaction,
+        "expected_hash": "0x22367af37fbccc318e9849ac2b6bb115081312a012160d934de9c4d2e10c7f31",  # noqa: E501
+        "expected_raw_transaction": "0x04f901748501a58877101284ee6b27f984ee6b2807830186a094e3f1413e071332840db2735f809cf3240c4a425580b844a9059cbb0000000000000000000000003d3dc402103535134968adf08fab95b1012c1cb5000000000000000000000000000000000000000000000000000000000000029af85bf85994095e7baea6a6c7c4c2dfeb977efac326af552d87f842a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001f861f85f8501a5887710943e6c95d880401e4e36ee62f4ebded346e1adf42d0201a031f464e6a607fc66e0f982a2d21498799b0571760a272f3d903aca6594cef595a0343b0b143924a5db42a3200ae72d5c6dd03633847bc2da025cff5214e28708ec01a0a9c1555a0d706755bbdb7537c80f4e6a06ba0719ec03174190ccc934207c805da012ad4e24ae01171ee8a64b18ab772c88141e490498bde39d12e773f422f389c8",  # noqa: E501
+        "transaction": {
+            "gas": 100000,
+            "maxFeePerGas": 4000000007,
+            "maxPriorityFeePerGas": 3999999993,
+            "data": "0xa9059cbb0000000000000000000000003d3dc402103535134968adf08fab95b1012c1cb5000000000000000000000000000000000000000000000000000000000000029a",  # noqa: E501
+            "nonce": 18,
+            "to": "0xe3f1413e071332840dB2735F809Cf3240C4a4255",
+            "value": 0,
+            "type": 4,
+            "chainId": 7072151312,
+            "authorizationList": [
+                {
+                    "chainId": 7072151312,
+                    "address": "0x3e6C95D880401e4E36Ee62f4eBDed346e1aDf42d",
+                    "nonce": 2,
+                    "yParity": 1,
+                    "r": "0x31f464e6a607fc66e0f982a2d21498799b0571760a272f3d903aca6594cef595",  # noqa: E501
+                    "s": "0x343b0b143924a5db42a3200ae72d5c6dd03633847bc2da025cff5214e28708ec",  # noqa: E501
+                }
+            ],
+            "accessList": (
+                {
+                    "address": "0x095E7BAea6a6c7c4c2DfeB977eFac326aF552d87",
+                    "storageKeys": (
+                        "0x0000000000000000000000000000000000000000000000000000000000000000",  # noqa: E501
+                        "0x0000000000000000000000000000000000000000000000000000000000000001",  # noqa: E501
+                    ),
+                },
+            ),
+            "v": 1,
+            "r": "0xa9c1555a0d706755bbdb7537c80f4e6a06ba0719ec03174190ccc934207c805d",
+            "s": "0x12ad4e24ae01171ee8a64b18ab772c88141e490498bde39d12e773f422f389c8",
+        },
+    },
+    # taken from EEST Prague tests, test id:
+    # `tests/prague/eip7702_set_code_tx/test_set_code_txs_2.py::test_double_auth[fork_Prague-state_test-second_delegation_DelegationTo.CONTRACT_A-first_delegation_DelegationTo.CONTRACT_B]`  # noqa: E501
+    {
+        "expected_type": SetCodeTransaction,
+        "expected_hash": "0x8a18f7a8f47670098b55d580258f8231fe057dba47f980c1b7320298a9bfc487",  # noqa: E501
+        "expected_raw_transaction": "0x04f9011d0180800783030d409400000000000000000000000000000000000012008080c0f8b8f85a809400000000000000000000000000000000000011008001a0ac427f776ac7ad34aafce7f63eceb1cde3eface529edeac41327496a0cdead7ea07571612216f35c4666ff4101d5ccf93fff270ced7d93664c4d7e8f5048a80198f85a809400000000000000000000000000000000000010000180a07b91d27c441ad90c7e4f127433b8fb33c04c78284b1b67b6603df12a1364e265a064780c93a04fbeb27eb3f98dcfae6af75a827471b398f86b82e598baa3f4966f80a03074805d7025deb8803f8687fbb60b5cc346804fffe0ea58a3efe0ed126c14d4a06b3367eee5089bc00db6631a5f1f9ed25a5a526d240d1aba845e0411a134f97e",  # noqa: 501
+        "transaction": {
+            "nonce": 0,
+            "maxPriorityFeePerGas": 0,
+            "maxFeePerGas": 7,
+            "gas": 200000,
+            "to": "0x0000000000000000000000000000000000001200",
+            "value": 0,
+            "data": "0x",
+            "accessList": (),
+            "authorizationList": [
+                {
+                    "chainId": 0,
+                    "address": "0x0000000000000000000000000000000000001100",
+                    "nonce": 0,
+                    "yParity": 1,
+                    "r": "0xac427f776ac7ad34aafce7f63eceb1cde3eface529edeac41327496a0cdead7e",  # noqa: E501
+                    "s": "0x7571612216f35c4666ff4101d5ccf93fff270ced7d93664c4d7e8f5048a80198",  # noqa: E501
+                },
+                SignedSetCodeAuthorization(
+                    chain_id=0,
+                    address=b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00",  # noqa: E501
+                    nonce=1,
+                    y_parity=0,
+                    r=55892125917073124639845531850251761096026021061906423766531483903865175466597,  # noqa: E501
+                    s=45443393307051138335427421723677224041668544703452310926862473140750133794415,  # noqa: E501
+                    # signature and hash are not tested here as they are not serialized
+                    signature=Signature(b"\x00" * 65),
+                    authorization_hash=HexBytes(f"0x{'00' * 32}"),
+                ),
+            ],
+            "chainId": 1,
+            "v": 0,
+            "r": "0x3074805d7025deb8803f8687fbb60b5cc346804fffe0ea58a3efe0ed126c14d4",
+            "s": "0x6b3367eee5089bc00db6631a5f1f9ed25a5a526d240d1aba845e0411a134f97e",
+        },
+    },
 ]
-
 TEST_CASE_IDS = [
     "al-non-empty-list",
     "al-empty-list",
@@ -353,6 +502,10 @@ TEST_CASE_IDS = [
     "blob-no-access-list",
     "blob-int-values-and-access-list",
     "blob-no-explicit-type",
+    "sc-one-auth",
+    "sc-pydantic",
+    "sc-with-access-list",
+    "sc-eest-dict-and-pydantic-auths",
 ]
 
 
@@ -364,8 +517,8 @@ TEST_CASE_IDS = [
 def test_hash(test_case):
     expected = test_case["expected_hash"]
     transaction = TypedTransaction.from_dict(test_case["transaction"])
-    hash = transaction.hash()
-    actual = HexBytes(hash).to_0x_hex()
+    hash_ = transaction.hash()
+    actual = HexBytes(hash_).to_0x_hex()
     assert actual == expected