EIP-7851 - Deactivate/Reactivate a Delegated EOA's Key

Created 2024-12-27
Status Draft
Category Core
Type Standards Track
Authors
Requires

Abstract

This EIP introduces a precompiled contract that enables Externally Owned Accounts (EOAs) with delegated control to smart contracts via EIP-7702 to deactivate or reactivate their private keys. This design does not require additional storage fields or account state changes. By leveraging delegated code, reactivation can be performed securely through mechanisms such as social recovery.

Motivation

EIP-7702 enables EOAs to gain smart contract capabilities, but the private key of the EOA still retains full control over the account.

With this EIP, EOAs can fully migrate to smart contract wallets, while retaining private key recovery options with reactivation. The flexible deactivate and reactivate design also paves the way for native account abstraction. e.g. EIP-7701.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Parameters

Constant Value
PRECOMPILE_ADDRESS 0xTBD
PRECOMPILE_GAS_COST 5000

Delegated code encoding

The deactivation status is encoded by appending or removing the 0x00 byte at the end of the delegated code. The transitions between two states are as follows:

Precompiled contract

A new precompiled contract is introduced at address PRECOMPILE_ADDRESS. It costs PRECOMPILE_GAS_COST and executes the following logic:

Transaction validation

For EOAs with delegated code (begins with the prefix 0xef0100), the following validations MUST be performed:

Gas cost

The PER_EMPTY_ACCOUNT_COST and PER_AUTH_BASE_COST constants defined in EIP-7702 remain unchanged, since account code will be loaded during the authorization validation. This EIP only adds a code length check, which is a small overhead compared to existing logic.

Rationale

Using a precompiled contract

Alternative methods for deactivating and reactivating EOA private keys include:

In-protocol reactivation

This approach ensures maximum compatibility with future migrations. EOAs can reactivate their private keys, delegate their accounts to an EIP-7701 contract, and then deactivate their private keys again. This avoids the limitations of contract upgrades. e.g. to remove legacy proxy contracts (reducing gas overhead) when upgrading to EOF contracts, one can reactivate the EOA and redelegate it to an EOF proxy contract.

Reactivation can only be performed by the delegated contract. Since reactivating the private key grants full control over the wallet, including the ability to replace the delegated wallet, wallets must implement this interface with strict security measures. These measures should treat reactivation as the highest level of authority, equivalent to full ownership of the wallet.

The reactivation process is recommended to include a signed authorization from the private key. This signature payload should consist of the chain ID and a random challenge (e.g. a hash value of other signatures) to ensure that: (i) the request is non-replayable in other chains, and (ii) the the private key owner is also included in the reactivation process.

Users should delegate their EOAs only to wallets that have been thoroughly audited and follow best practices for security.

5000 gas PRECOMPILE_GAS_COST

The 5000 gas cost is sufficient for the precompiled contract's validation logic and storage updates.

Alternative EOA deprecation approach

One alternative deprecation approach involves using a hard fork to edit all existing and new EOAs to pre-written upgradeable smart contracts, which utilize the original EOA private key for authorization. Users can add and replace keys, or upgrade the smart contracts to other implementations. However, this approach is incompatible with EOAs already delegated to smart contracts, as it will overwrite the existing smart contract implementations. This EIP aims to fill this migration gap.

Avoiding delegated code prefix modification

This EIP appends a byte (0x00) to the delegated code instead of modifying the prefix (0xef0100) of EIP-7702 to ensure forward compatibility. If new prefixes such as 0xef0101 are introduced in the future, changing the prefix to represent the deactivated status (e.g. 0xef01ff) makes it unclear which prefix to restore (0xef0100 or 0xef0101) upon reactivation.

Avoiding account state changes

An alternative is to add a deactivated field in the account state to track the status. However, this approach will introduce backward compatibility logic and more test vectors related to this optional field when enabling this EIP, because the field is not present in existing accounts.

Backwards Compatibility

This EIP maintains backwards compatibility with existing EOAs and contracts.

Test Cases

# Initialize the state database and precompiled contract
state_db = StateDB()
precompile = PrecompiledContract()

# Test 1: Valid activation and deactivation
caller = "0x0123"
delegated_addr = bytes.fromhex("1122334455667788990011223344556677889900")
active_code = PrecompiledContract.DELEGATED_CODE_PREFIX + delegated_addr # Active state

state_db.set_code(caller, active_code)
error, gas_left = precompile.execute(caller, state_db, gas=10000)
assert error == b""
assert state_db.get_code(caller) == active_code + b"\x00"  # Deactivated state
assert gas_left == 10000 - PrecompiledContract.PRECOMPILE_GAS_COST

error, gas_left = precompile.execute(caller, state_db, gas=10000)
assert error == b""
assert state_db.get_code(caller) == active_code  # Reactivated state
assert gas_left == 10000 - PrecompiledContract.PRECOMPILE_GAS_COST

# Test 2: Error cases
error, gas_left = precompile.execute(caller, state_db, gas=10000, read_only=True)
assert error == b"STATICCALL disallows state modification"
assert gas_left == 0

error, gas_left = precompile.execute(caller, state_db, gas=PrecompiledContract.PRECOMPILE_GAS_COST-1)
assert error == b"insufficient gas"
assert gas_left == 0

# EOA without delegated code
caller = "0x4567"
error, gas_left = precompile.execute(caller, state_db, gas=10000)
assert error == b"invalid delegated code prefix"
assert gas_left == 0

# Small contract code
caller = "0x89ab"
state_db.set_code(caller, bytes.fromhex("00")) # a fake contract
error, gas_left = precompile.execute(caller, state_db, gas=10000)
assert error == b"invalid delegated code prefix"
assert gas_left == 0

Reference Implementation

class PrecompiledContract:
    DELEGATED_CODE_PREFIX = bytes.fromhex("ef0100")
    PRECOMPILE_GAS_COST = 5000

    def execute(self, caller, state_db, gas, read_only=False):
        """
        Switch the private key state of delegated EOAs between active and deactivated.

        Parameters:
        - caller: The address calling the contract
        - state_db: The state database
        - gas: Gas provided for execution
        - read_only: Whether called in read-only context

        Returns:
        - Tuple of (result, gas_left)
          result: error bytes on failure, empty bytes on success
          gas_left: remaining gas, 0 on error
        """
        # Check gas
        if gas < self.PRECOMPILE_GAS_COST:
            return b"insufficient gas", 0

        # Check STATICCALL
        if read_only:
            return b"STATICCALL disallows state modification", 0

        # Get and validate caller's code
        code = state_db.get_code(caller)
        if not code.startswith(self.DELEGATED_CODE_PREFIX):
            return b"invalid delegated code prefix", 0

        # Update delegated code based on length
        if len(code) == 23:  # Active state
            state_db.set_code(caller, code + b"\x00")  # Deactivate
        elif len(code) == 24:  # Deactivated state
            state_db.set_code(caller, code[:-1])  # Activate
        else: # Although this is not possible, it's added for completeness
            return b"invalid delegated code length", 0

        return b"", gas - self.PRECOMPILE_GAS_COST

class StateDB:
    """Simplified state database, omitting other account fields"""
    def __init__(self):
        self.accounts = {}

    def get_code(self, addr):
        return self.accounts.get(addr, {}).get("code", b"")

    def set_code(self, addr, value):
        if addr not in self.accounts:
            self.accounts[addr] = {}
        self.accounts[addr]["code"] = value

Security Considerations

Additional checks during transaction validation

The deactivation status is determined by checking the length of the delegated code. This check introduces an additional storage read and check comparable to account state read and checks, such as nonce validation. It is integrated into the transaction validity check of the transaction pool and consensus rules, ensuring that invalid transactions are filtered out before propagation and execution.

Additional checks for EIP-7702 transactions

In applying authorizations of an EIP-7702 transaction, an additional check is introduced to validate the length of the delegated code. Since EIP-7702 already requires retrieving the code in authorization validation, the extra cost of verifying its length is negligible.

Risk of asset freezing

In the worst case, a malicious wallet, through bypassing permission controls, could steal assets, and deactivate the account by calling the precompiled contract, it could also block reactivation, and thus freeze the account. In this case, the risk is inherent to delegating control and not solely introduced by this EIP.

The risk also exists when the delegated wallet does not support reactivation or implements a flawed reactivation interface, combined with partially functional or non-functional asset transfers. These issues could prevent the user from reactivating the account and result in partial or complete asset freezing. Users can mitigate these risks using thoroughly audited wallets that support this EIP.

Permit extension for ERC-20

This EIP does not revoke ERC-2612 permissions. EOAs with deactivated private keys can still authorize transfers by calling the permit function of ERC-20 tokens supporting ERC-2612. This issue also exists with EIP-7702. To support deactivated EOAs, ERC-20 contracts should deprecate the permit function, or a new opcode could be introduced to help check the deactivated status of the EOA.

Message replay across EVM-compatible chains

For deactivation through EOA-signed transactions, the replay protection mechanism provided by EIP-155, if enabled, can effectively prevent cross-chain message replay.

For deactivation or reactivation called by the delegated contract, the contract should ensure that the chain ID is part of the message validation process (or implement alternative replay protection mechanisms) to prevent cross-chain message replay.

Copyright

Copyright and related rights waived via CC0.