feat(core/ethereum): integrate EIP-7702 delegation into `EthereumSignTxEIP1559`
What changed, and why it matters
This commit adds experimental support for a new Ethereum feature (EIP-7702) that lets an account temporarily delegate control to a smart contract. The change is feature work, not a bug fix, and includes several safety guardrails: it is blocked unless experimental features are enabled, requires relaxed safety checks for authorizations (but not revocations), rejects cross-chain delegation, and only allows known delegate addresses. There is no direct evidence in the commit that this fixes a security vulnerability.
Treat this as a feature addition rather than an urgent security patch. Reviewers should verify that the EIP-7702 signature digest construction matches the final EIP-7702 specification, that the allowlist lookup cannot be bypassed, that the transaction type override is correct, and that the UI confirmation screens accurately describe the delegation. Because it is experimental, ensure the feature flag and safety-check gating are enforced in all code paths.
Security signals we found
New cryptographic signing path added for EIP-7702 authorization tuples
Transaction type switched from 0x02 to 0x04 when EIP-7702 delegation is present
Cross-chain delegation explicitly rejected (chain_id == 0)
Non-revocation authorizations gated behind relaxed safety checks and an allowlisted delegate address
Revocation (delegate == zero address) permitted under strict safety checks
Experimental feature flag required; no changelog entry
Payment requests, non-zero value, calldata, and unknown networks rejected for EIP-7702 path
Evidence from the diff
The patch integrates EIP-7702 authorization tuple signing into the existing EthereumSignTxEIP1559 flow. It introduces a new transaction envelope type (0x04) when an authorization list is present, computes the EIP-7702 tuple signature over [magic, chain_id, delegate, nonce], and returns the tuple to the host. Validation includes chain_id != 0, non-empty to, empty calldata, zero value, no payment requests, known network, nonce = tx.nonce + 1, and (for non-revocation) a known delegate address from a hardcoded allowlist. Strict safety checks block non-revocation authorizations. The commit is marked experimental and omits a changelog.
Changed components
core/src/apps/ethereum/sign_tx_eip1559.pycore/src/apps/ethereum/sc_constants.py.makopython/src/trezorlib/ethereum.pytests/device_tests/ethereum/test_sign_eip7702.pyInspect captured patch +358 / −11
### core/src/apps/ethereum/sc_constants.py.mako
@@ -171,6 +171,8 @@ _EIP7702_ADDRESSES = {
("63c0c19a282a1b52b07dd5a65b58948a07dae32b", "MetaMask"),
]
}
+if 0 in _EIP7702_ADDRESSES:
+ raise RuntimeError('"All chains" delegation is explicitly not supported')
%>
def lookup_eip7702_address(chain_id: int, address: bytes) -> str | None:
% for chain_id, items in _EIP7702_ADDRESSES.items():
### core/src/apps/ethereum/sign_tx_eip1559.py
@@ -7,8 +7,10 @@
from .keychain import with_keychain_from_chain_id
if TYPE_CHECKING:
+ from trezor.crypto import bip32
from trezor.messages import (
EthereumAccessList,
+ EthereumAuth7702Tuple,
EthereumSignTxEIP1559,
EthereumTxRequest,
)
@@ -19,7 +21,10 @@
from .definitions import Definitions
-_TX_TYPE = const(2)
+_EIP1559_TX_TYPE = const(2) # used for signing EIP-1559 transactions
+
+_EIP7702_TX_TYPE = const(4) # used for signing EIP-7702 transactions
+_EIP7702_TUPLE_MAGIC = const(5) # used for signing EIP-7702 authorization tuples
def access_list_item(item: EthereumAccessList) -> rlp.RLPItem:
@@ -32,8 +37,9 @@ async def sign_tx_eip1559(
keychain: Keychain,
defs: Definitions,
) -> EthereumTxRequest:
- from trezor import TR, wire
+ from trezor import TR
from trezor.ui.layouts import show_continue_in_app
+ from trezor.wire import DataError
from apps.common import paths
@@ -49,13 +55,14 @@ async def sign_tx_eip1559(
# check
if len(msg.max_gas_fee) + len(gas_limit) > 30:
- raise wire.DataError("Fee overflow")
+ raise DataError("Fee overflow")
if len(msg.max_priority_fee) + len(gas_limit) > 30:
- raise wire.DataError("Fee overflow")
+ raise DataError("Fee overflow")
check_common_fields(msg)
# have a user confirm signing
await paths.validate_path(keychain, msg.address_n)
+
sender_bytes = keychain.derive(msg.address_n).ethereum_pubkeyhash()
address_bytes = bytes_from_address(msg.to)
@@ -70,6 +77,14 @@ async def sign_tx_eip1559(
defs.network,
)
+ # Confirm and sign EIP-7702 delegation (may raise on unsupported requests)
+ auth7702_list: list[EthereumAuth7702Tuple] = await _handle_eip7702(
+ msg,
+ keychain,
+ defs,
+ )
+ auth7702_rlp: rlp.RLPList = [i.items for i in auth7702_list]
+
payment_req_verifier = None
if msg.payment_req:
from apps.common.payment_request import PaymentRequestVerifier
@@ -79,7 +94,7 @@ async def sign_tx_eip1559(
msg.payment_req, slip44_id, keychain, amount_size_bytes=32
)
- sha = _start_digest(msg)
+ sha = _start_digest(msg, auth7702_rlp)
initial_data = await request_initial_data(msg, sha)
# Confirm the transaction, using special layouts for staking, yielding and clear-signing (if supported).
@@ -96,16 +111,20 @@ async def sign_tx_eip1559(
create_data_chunk_loader(sha),
)
- digest = _finish_digest(msg, sha)
+ digest = _finish_digest(msg, auth7702_rlp, sha)
# transaction data confirmed, proceed with signing
result = _sign_digest(msg, keychain, digest)
+ # EIP-7702 authorization list (if not empty)
+ if auth7702_list:
+ result.auth7702_list = auth7702_list
+
show_continue_in_app(TR.send__transaction_signed)
return result
-def _start_digest(msg: EthereumSignTxEIP1559) -> HashWriter:
+def _start_digest(msg: EthereumSignTxEIP1559, auth7702_rlp: rlp.RLPList) -> HashWriter:
from .helpers import keccak256
fields: tuple[rlp.RLPItem, ...] = (
@@ -130,24 +149,37 @@ def _start_digest(msg: EthereumSignTxEIP1559) -> HashWriter:
access_list_length = rlp.header_length(payload_length) + payload_length
length += access_list_length
+ tx_type = _EIP1559_TX_TYPE
+ # EIP-7702 authorization list (if not empty)
+ if auth7702_rlp:
+ length += rlp.length(auth7702_rlp)
+ tx_type = _EIP7702_TX_TYPE
+
# hash only `_TX_TYPE`, RLP header and `fields` (see above).
# calldata and access_list will be hashed later.
sha = keccak256()
- sha.append(_TX_TYPE)
+ # different transaction type is used for EIP-7702 authorization
+ sha.append(tx_type)
rlp.write_header(sha, length, rlp.LIST_HEADER_BYTE)
for field in fields:
rlp.write(sha, field)
return sha
-def _finish_digest(msg: EthereumSignTxEIP1559, sha: HashWriter) -> bytes:
+def _finish_digest(
+ msg: EthereumSignTxEIP1559, auth7702_rlp: rlp.RLPList, sha: HashWriter
+) -> bytes:
# write_access list (streaming instead of full materialization)
payload_length = sum(rlp.length(access_list_item(i)) for i in msg.access_list)
rlp.write_header(sha, payload_length, rlp.LIST_HEADER_BYTE)
for item in msg.access_list:
rlp.write(sha, access_list_item(item))
+ # EIP-7702 authorization list (if not empty)
+ if auth7702_rlp:
+ rlp.write(sha, auth7702_rlp)
+
return sha.get_digest()
@@ -168,3 +200,128 @@ def _sign_digest(
req.signature_s = signature[33:]
return req
+
+
+async def _handle_eip7702(
+ msg: EthereumSignTxEIP1559,
+ keychain: Keychain,
+ defs: Definitions,
+) -> list[EthereumAuth7702Tuple]:
+
+ if msg.auth7702 is None:
+ return [] # no EIP-7702 authorization tuples
+
+ from trezor import TR
+ from trezor.ui import layouts
+ from trezor.wire import DataError, ProcessError
+
+ from apps.common import paths, safety_checks
+
+ from .helpers import bytes_from_address, get_account_and_path
+ from .networks import UNKNOWN_NETWORK
+ from .sc_constants import lookup_eip7702_address
+
+ address_n = msg.address_n
+ await paths.validate_path(keychain, address_n)
+
+ chain_id = msg.chain_id
+ if chain_id == 0:
+ raise DataError("EIP-7702: cross-chain delegation")
+ if not msg.to:
+ raise DataError("EIP-7702: empty destination")
+ if msg.data_length:
+ raise DataError("EIP-7702: non-empty calldata")
+ if msg.payment_req is not None:
+ raise DataError("EIP-7702: unsupported payment request")
+ if int.from_bytes(msg.value, "big") != 0:
+ raise DataError("EIP-7702: non-zero value")
+
+ if defs.network is UNKNOWN_NETWORK:
+ raise DataError("EIP-7702: unknown network")
+
+ # authorization tuple nonce must be (tx.nonce + 1)
+ nonce = int.from_bytes(msg.nonce, "big") + 1
+ if nonce >= 0xFFFFFFFFFFFFFFFF:
+ raise DataError("EIP-7702: invalid nonce")
+
+ account, account_path = get_account_and_path(address_n)
+ if account is None or account_path is None:
+ raise DataError("Unknown account")
+
+ network_item = (TR.ethereum__network, defs.network.name, None)
+ delegate_addr = msg.auth7702.delegate
+ delegate_bytes = bytes_from_address(delegate_addr)
+ if delegate_bytes == b"\x00" * 20: # -> revocation
+ # revocation can be done with strict safety checks
+ await layouts.confirm_ethereum_eip7702_revoke(
+ network_item=network_item,
+ account=account,
+ account_path=account_path,
+ nonce=nonce,
+ )
+ else:
+ if safety_checks.is_strict():
+ raise ProcessError(
+ "EIP-7702 authorisation not allowed with strict safety checks"
+ )
+
+ delegate_name = lookup_eip7702_address(chain_id, delegate_bytes)
+ if delegate_name is None:
+ raise DataError("Unknown EIP-7702 delegate address")
+
+ await layouts.confirm_ethereum_eip7702_auth(
+ delegate_name=delegate_name,
+ delegate_addr=delegate_addr,
+ network_item=network_item,
+ account=account,
+ account_path=account_path,
+ nonce=nonce,
+ )
+
+ return [
+ _sign_eip7702_tuple(keychain.derive(address_n), chain_id, delegate_bytes, nonce)
+ ]
+
+
+def _sign_eip7702_tuple(
+ node: bip32.HDNode, chain_id: int, delegate_bytes: bytes, nonce: int
+) -> EthereumAuth7702Tuple:
+ from trezor.crypto.curve import secp256k1
+ from trezor.messages import EthereumAuth7702Tuple
+
+ from .helpers import keccak256
+
+ sha = keccak256()
+ sha.append(_EIP7702_TUPLE_MAGIC)
+
+ fields: rlp.RLPList = [chain_id, delegate_bytes, nonce]
+ rlp.write(sha, fields)
+ digest = sha.get_digest()
+
+ signature = secp256k1.sign(
+ node.private_key(), digest, False, secp256k1.CANONICAL_SIG_ETHEREUM
+ )
+ # EIP-7702 authorization tuple: [chain_id, delegate, nonce, y_parity, r, s]
+ # type SetCodeAuthorization struct {
+ # ChainID uint256.Int
+ # Address common.Address
+ # Nonce uint64
+ # V uint8
+ # R uint256.Int
+ # S uint256.Int
+ # }
+ y_parity: int = signature[0] - 27
+ r = int.from_bytes(signature[1:33], "big")
+ s = int.from_bytes(signature[33:], "big")
+
+ # Note: integers must be minimally encoded into bytestrings for RLP serialization:
+ return EthereumAuth7702Tuple(
+ items=[
+ rlp.int_to_bytes(chain_id),
+ delegate_bytes,
+ rlp.int_to_bytes(nonce),
+ rlp.int_to_bytes(y_parity),
+ rlp.int_to_bytes(r),
+ rlp.int_to_bytes(s),
+ ]
+ )
### python/src/trezorlib/ethereum.py
@@ -17,7 +17,7 @@
import re
import warnings
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, AnyStr, Dict, List, Optional, Union
+from typing import TYPE_CHECKING, Any, AnyStr, Dict, List, Optional, Sequence, Union
from typing_extensions import Self
@@ -222,6 +222,7 @@ class SignTxResult:
v: int
r: bytes
s: bytes
+ auth7702_list: Sequence[Sequence[bytes]] # non-empty for EIP-7702 transactions.
def __getitem__(self, i: int) -> Union[int, bytes]:
"""Used for backwards compatiblity, to allow accessing and unpacking the signature tuple."""
@@ -240,7 +241,12 @@ def from_response(cls, msg: messages.EthereumTxRequest) -> Optional[Self]:
and msg.signature_s is not None
):
# We got an EthereumTxRequest containing the signature which means we are done.
- return cls(v=msg.signature_v, r=msg.signature_r, s=msg.signature_s)
+ return cls(
+ v=msg.signature_v,
+ r=msg.signature_r,
+ s=msg.signature_s,
+ auth7702_list=[i.items for i in msg.auth7702_list],
+ )
else:
return None # We are not done yet.
@@ -352,6 +358,7 @@ def sign_tx_eip1559(
payment_req: Optional[messages.PaymentRequest] = None,
supports_definition_request: Optional[bool] = None,
definition_source: Optional["Source"] = None,
+ auth7702: Optional[messages.EthereumAuth7702] = None,
) -> SignTxResult:
length = len(data)
data, chunk = data[1024:], data[:1024]
@@ -371,6 +378,7 @@ def sign_tx_eip1559(
chunkify=chunkify,
payment_req=payment_req,
supports_definition_request=supports_definition_request,
+ auth7702=auth7702,
)
return _ethereum_sign_loop(session, msg, data, definition_source)
### tests/device_tests/ethereum/test_sign_eip7702.py
@@ -0,0 +1,180 @@
+# This file is part of the Trezor project.
+#
+# Copyright (C) SatoshiLabs and contributors
+#
+# This library is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the License along with this library.
+# If not, see <https://www.gnu.org/licenses/lgpl-3.0.html>.
+
+import pytest
+
+from trezorlib import device, ethereum
+from trezorlib._rlp import encode
+from trezorlib.debuglink import DebugSession as Session
+from trezorlib.ethereum import decode_hex
+from trezorlib.exceptions import TrezorFailure
+from trezorlib.messages import EthereumAuth7702, PaymentRequest, SafetyCheckLevel
+from trezorlib.tools import parse_path
+
+from ...common import parametrize_using_common_fixtures
+from .test_signtx import make_defs
+
+pytestmark = [
+ pytest.mark.altcoin,
+ pytest.mark.ethereum,
+ pytest.mark.models("core", reason="T1 does not support EIP 7702"),
+]
+
+
+def is_revocation(parameters: dict) -> bool:
+ return parameters["delegate"] == "0x0000000000000000000000000000000000000000"
+
+
+# Test vectors validated with Foundry
+# cast wallet sign-auth $ADDRESS --mnemonic $MNEMONIC --mnemonic-derivation-path "m/44'/60'/0'/0/0" --nonce $NONCE --chain $CHAINID
+# To evaluate signature parts: cast from-rlp <result_from_above>
+# format: [chain_id, address, nonce, v, r, s]
+
+
+@parametrize_using_common_fixtures("ethereum/sign_auth_eip7702.json")
+def test_sign_eip7702(session: Session, parameters: dict, result: dict):
+ defs = make_defs(parameters)
+
+ addr = ethereum.get_address(
+ session,
+ n=parse_path(parameters["path"]),
+ )
+
+ def _sign() -> ethereum.SignTxResult:
+ return ethereum.sign_tx_eip1559(
+ session,
+ n=parse_path(parameters["path"]),
+ chain_id=parameters["chain_id"],
+ auth7702=EthereumAuth7702(delegate=parameters["delegate"]),
+ nonce=parameters["tuple_nonce"] - 1, # compute tx nonce
+ definitions=defs,
+ to=addr,
+ value=0,
+ max_priority_fee=0,
+ max_gas_fee=0,
+ gas_limit=0,
+ )
+
+ with pytest.raises(TrezorFailure, match="Experimental features are disabled"):
+ _sign()
+
+ device.apply_settings(session, experimental_features=True)
+
+ # Revocation doesn't require disabling strict safety checks.
+ if not is_revocation(parameters):
+ with pytest.raises(
+ TrezorFailure,
+ match="ProcessError: EIP-7702 authorisation not allowed with strict safety checks",
+ ):
+ _sign()
+
+ # Authorization requires disabling strict safety checks.
+ device.apply_settings(
+ session,
+ safety_checks=SafetyCheckLevel.PromptTemporarily,
+ )
+
+ res = _sign()
+ [auth7702_tuple] = res.auth7702_list
+ chain_id, delegate, nonce, y_parity, r, s = auth7702_tuple
+ assert int.from_bytes(chain_id, "big") == parameters["chain_id"]
+ assert delegate == decode_hex(parameters["delegate"])
+ assert int.from_bytes(nonce, "big") == parameters["tuple_nonce"]
+ assert int.from_bytes(y_parity, "big") == result["sig_v"]
+ assert r.hex() == result["sig_r"]
+ assert s.hex() == result["sig_s"]
+
+
+@parametrize_using_common_fixtures("ethereum/sign_auth_eip7702_errors.json")
+def test_sign_eip7702_errors(session: Session, parameters, result):
+ device.apply_settings(
+ session,
+ safety_checks=SafetyCheckLevel.PromptTemporarily,
+ experimental_features=True,
+ )
+
+ assert result["error"] # make sure it's not an empty string
+ with pytest.raises(TrezorFailure, match=result["error"]):
+ payment_req = None
+ if parameters.get("payment_req"):
+ # Fake payment request - not supported by EIP-7702 transactions
+ payment_req = PaymentRequest(
+ recipient_name="Fake name", signature=b"FAKE SIG"
+ )
+
+ ethereum.sign_tx_eip1559(
+ session,
+ n=parse_path(parameters["path"]),
+ chain_id=parameters["chain_id"],
+ auth7702=EthereumAuth7702(delegate=parameters["delegate"]),
+ nonce=parameters["tuple_nonce"] - 1, # compute tx nonce
+ to=parameters["to_address"],
+ value=parameters["value"],
+ data=bytes.fromhex(parameters["data"]),
+ max_priority_fee=0,
+ max_gas_fee=0,
+ gas_limit=0,
+ payment_req=payment_req,
+ )
+
+
+@parametrize_using_common_fixtures("ethereum/sign_tx_eip7702_mainnet.json")
+def test_sign_eip7702_mainnet(session: Session, parameters: dict, result: dict):
+ # Authorization requires disabling strict safety checks.
+ if is_revocation(parameters):
+ safety_checks = SafetyCheckLevel.Strict
+ else:
+ safety_checks = SafetyCheckLevel.PromptTemporarily
+
+ device.apply_settings(
+ session,
+ safety_checks=safety_checks,
+ experimental_features=True,
+ )
+
+ res = ethereum.sign_tx_eip1559(
+ session,
+ n=parse_path(parameters["path"]),
+ chain_id=parameters["chain_id"],
+ nonce=parameters["nonce"],
+ to=parameters["to_address"],
+ value=parameters["value"],
+ max_priority_fee=parameters["max_priority_fee"],
+ max_gas_fee=parameters["max_gas_fee"],
+ gas_limit=parameters["gas_limit"],
+ auth7702=EthereumAuth7702(delegate=parameters["delegate"]),
+ )
+ auth7702_list = [
+ [i.hex() for i in auth7702_tuple] for auth7702_tuple in res.auth7702_list
+ ]
+ assert auth7702_list == result["auth7702_list_hex"]
+ items = (
+ parameters["chain_id"],
+ parameters["nonce"],
+ parameters["max_priority_fee"],
+ parameters["max_gas_fee"],
+ parameters["gas_limit"],
+ decode_hex(parameters["to_address"]),
+ parameters["value"],
+ b"", # data
+ [], # access list
+ res.auth7702_list,
+ res.v,
+ res.r,
+ res.s,
+ )
+ serialized = b"\x04" + encode(items)
+ assert serialized.hex() == result["tx_bytes_hex"]Why this scored 36/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.