Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add EIP: EOA private key deactivation/reactivation #9193

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions EIPS/eip-7851.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
---
eip: 7851
title: EOA private key deactivation/reactivation
description: Introduce a new precompiled contract for EOAs with delegated code to deactivate or reactivate private keys.
author: Liyi Guo (@colinlyguo)
discussions-to: https://ethereum-magicians.org/t/eip-7851-eoa-private-key-deactivation-reactivation/22344
status: Draft
type: Standards Track
category: Core
created: 2024-12-27
requires: 20, 2612, 7701, 7702
colinlyguo marked this conversation as resolved.
Show resolved Hide resolved
---

## Abstract

This EIP introduces a precompiled contract that enables Externally Owned Accounts (EOAs) with delegated control to smart contracts via [EIP-7702](./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](./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 recovery options with reactivation. The flexible deactivate and reactivate design also paves the way for native account abstraction. e.g. [EIP-7701](./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` (tentative) |

### 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:

- Active state: `0xef0100 || address`, the private key is active and can sign transactions.
- Deactivated state: `0xef0100 || address || 0x00`, the private key is deactivated and cannot sign transactions.

### Precompiled contract

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

- Returns a precompile contract error and consumes all provided gas if:
- Gas is insufficient.
- Called via `STATICCALL` (i.e. in a read-only context).
- Caller is not an EOA with delegated code (prefix `0xef0100` as per [EIP-7702](./eip-7702)).
- Updates caller's delegated code based on length:
- 23 bytes (`0xef0100 || address`): Appends `0x00` to deactivate private key authorization.
- 24 bytes (`0xef0100 || address || 0x00`): Removes last byte `0x00` to activate private key authorization.
- Saves updated code as caller's new account code.

### Transaction validation
colinlyguo marked this conversation as resolved.
Show resolved Hide resolved

If the account is verified as an EOA with a delegated code (begins with the prefix `0xef0100`), the following validations MUST be performed:

- Transactions signed by the private key MUST be rejected if the delegated code is in the deactivated state (i.e., `24` bytes long).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think extra clarification here is necessary. What does "rejected" mean? I am assuming that "rejected" here means that the transactions which do not meet this rule can be included in the block, however any state changes like EVM execution, publishing blobs, or authorizing (EIP-7702) are not done and instead the transaction immediately exits. However, this does mean that these state changes are still done:

  • Nonce update
  • Paying for gas (so balance update)

Also, for this rejection: is the entire gas limit consumed or only the intrinsic gas (which includes for instance the calldata fee)

Copy link
Author

@colinlyguo colinlyguo Dec 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true. after rethink, "rejected" means the transaction cannot be included in a block at all, as it is filtered out during the pre-check phase of the execution-layer consensus rules. No EVM execution or state changes (e.g., nonce updates or gas payment) occur. The transaction pool should implement the same validation to prevent invalid transactions from being propagated.

This will introduce an additional storage read when checking transaction validity though (thanks to your comment below).

Copy link
Author

@colinlyguo colinlyguo Dec 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added clarifications in this commit: 7f93c93.

also removing the basic 21000 gas discussion, since it seems to be unrelated now.

- Any [EIP-7702](./eip-7702) authorization from an authority with deactivated delegated code MUST be considered invalid and skipped.

### Gas Cost

No changes to the base transaction gas cost (`21000`) are required, as the additional valid check for the deactivation status is minimal. Thus it is reasonable to consider the overhead covered by the base gas cost.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check if the current account is deactivated actually is more expensive than a base transaction due to the nature of the state trie. To read an account from the trie, one gets these items:

  • Nonce
  • Balance
  • codeHash
  • storageHash

In order to read the actual code (to figure out if the account is delegated and thus to also perform the private key deactivation check) we have to actual read from the database again (to find what code codeHash points to). Since this is a disk read this is expensive and since this is now necessary for all transactions this might be a reason to increase the base transaction cost.

Copy link
Author

@colinlyguo colinlyguo Dec 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it makes sense. I missed this (code read cost).

In the first draft before opening this pr, the proposal plans to add a new bool (1 byte) field in the account state, so that it can be read together with nonce, balance, codeHash, and storageHash.

this may add some complexities in backward compatibility discussions while keeping the basic cost of transactions cheaper. do you think it makes sense to change the implementation of the draft to this method? so that the basic transaction cost can be kept unchanged.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

21000**
**

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@colinlyguo This extra field in state will make this rather complex, because you are adding an extra field to the account state. This also means there will be logic introduced when enabling this EIP: each time if an account is hit, there will be logic to see if this account is "upgraded" to have this boolean in state. If this is not the case, then it could either add this flag to the state, or it could also do it only when necessary (so, if a transaction is sent, there will first be a check if the private key is deactivated. To check, first see if there is a boolean field at all: if this is not the case, then we can know for sure that this is an "old" account state and the private key is thus active. We can then add this field to the state. But this mechanism of checking and adding these fields will make the test vectors for this very big and it will also add a lot of somewhat boilerplate "upgrade" code to ensure the old accounts will update to the new ones (with this private key enabled flag). I would not recommend it.

I do recall in the past there have been discussions of using the nonce field. One could, for instance, use the highest bit of the nonce for this flag. One could use this flag to see if the accounts private key is disabled. What is handy here is that the nonce data is already in the account, so no extra disk lookups are necessary (when compared to putting it in the code).

However some nonce-limiting EIPs are around: https://eips.ethereum.org/EIPS/eip-3338, https://eips.ethereum.org/EIPS/eip-4803, putting a flag in nonce would likely throw away the benefits/rationales of these EIPs (4803 ensures that it can be decoded as int).

Copy link
Author

@colinlyguo colinlyguo Dec 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree. introduced a new field in the account state will add a lot of unnecessary "upgrade" code, which should be avoided.

adding a flag in nonce looks like a good idea.

for EIP-3338 which limits the nonce <= 2^52, using the highest bit of 2^52 seems not to make a large difference:

  • the number is still large, 2^51 ~= 2.25 * 10^15.
  • compared with 2^64-1, it's also quite small (same as 2^52).

For forward compatibility, can move the "deactivated bit" based on the nonce length after an upgrade. i.e. modifying the transaction verification rules.

an alternative to avoid encoding in the highest bit would be to encode the "deactivated bit" in the lowest bit of nonce, the actual nonce = nonce / 2.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok


The `PER_EMPTY_ACCOUNT_COST` and `PER_AUTH_BASE_COST` constants defined in [EIP-7702](./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 implementing this feature include:

- Adding a new transaction type: A new transaction type could deactivate/reactive EOA private keys. This would complicate reactivation, as the contract would need to serve as the authorizer for reactivation, increasing protocol complexity.
- Deploying a regular smart contract: A regular deployed contract could track the `deactivated` status of each `address`. This approach would break the base transaction gas cost of `21000` in transaction validation, as accessing the `deactivated` status would require additional address and storage lookups, increasing gas usage.

### In-protocol reactivation

This approach ensures maximum compatibility with future migrations. EOAs can reactivate their private keys, delegate their accounts to an [EIP-7701](./eip-7701) contract, and then deactivate their private keys again. This avoids the limitations of upgradable contracts. e.g. to remove legacy proxy contracts when EOF contracts become available, thereby reducing gas overhead, one can reactivate the EOA and delegate to an EOF proxy contract.

### `5000` Gas `PRECOMPILE_GAS_COST`

The `5000` gas cost is sufficient to cover validation, computation, and storage updates for the delegated code.

### Alternative EOA migration approach

One alternative migration approach involves using a hard fork to edit all existing and new EOAs to upgradable smart contracts using EOA's ECDSA signatures. Users can then upgrade these smart contracts to achieve more granular permission control. However, this approach is incompatible with EOAs that have already delegated to smart contracts, as it overwrites the existing smart contract implementations. The 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](./eip-7702) to ensure forward compatibility. If future prefixes such as `0xef0101` are introduced, changing the prefix (e.g. to `0xef01ff`) makes it unclear which prefix to restore upon reactivation.

### Avoiding account state changes

Another alternative is to add a new field to the account state to store the `deactivated` status. However, this approach complicates the account state. It also brings changes in the account trie structure and RLP encoding used in networking, which complicates the implementation.

### Forwards compatibility for removing EOAs

After all existing and future EOAs have been migrated to smart contracts. It's natural and easy to deprecate this EIP:

- Removing the precompiled contract.
- Removing validation of the deactivation status since all EOAs are smart contracts.
- The appended `0x00` byte can be optionally removed from the delegated code.

## Backwards Compatibility

This EIP maintains backwards compatibility with existing EOAs and contracts. Additional checks are added after this EIP to reject:

- Transactions signed by an EOA with a deactivated private key.
- EIP-7702 authorizations from an EOA with a deactivated private key.

## Test Cases

```python
# 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

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
assert gas_left == 10000 - PrecompiledContract.GAS_COST

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

# Test 2: Error cases
error, gas_left = precompile.execute(caller, state_db, gas=10000, static=True)
assert error == b"cannot call in static context"
assert gas_left == 0

error, gas_left = precompile.execute(caller, state_db, gas=PrecompiledContract.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 contract with a single STOP opcode
error, gas_left = precompile.execute(caller, state_db, gas=10000)
assert error == b"invalid delegated code prefix"
assert gas_left == 0
```

## Reference Implementation

```python
class PrecompiledContract:
DELEGATED_CODE_PREFIX = bytes.fromhex("ef0100") # EIP-7702 prefix
GAS_COST = 5000 # PRECOMPILE_GAS_COST

def execute(self, caller, state_db, gas, static=False):
"""
Toggle EOA's private key authorization between active/deactivated states.

Parameters:
- caller: The address calling the contract
- state_db: The state database
- gas: Gas provided for execution
- static: 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.GAS_COST:
return b"insufficient gas", 0

# Check static call
if static:
return b"cannot call in static context", 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 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 code length", 0

return b"", gas - self.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

### Unchanged gas consumption for transactions

This EIP does not introduce additional gas costs for transactions. The validation of the deactivation status is performed by checking the presence of the appended `0x00` byte in the account's delegated code. This check is computationally lightweight compared to operations like accessing storage.

### Additional status check during transaction validation

The deactivation status is determined by checking the length of the delegated code. This check is computationally trivial and comparable to other account state checks, such as nonce and balance validation. Since it is integrated into the transaction validation process, it does not introduce any significant additional computational overhead.

### Risk of asset freezing

For a malicious wallet, it could deliberately deactivate the account and block reactivation, effectively freezing assets. In this case, the risk is inherent to delegating control and not caused by this protocol.

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 by using thoroughly audited wallets that fully support this EIP.

### Permit extension for [ERC-20](./eip-20)

This EIP does not revoke [ERC-2612](./eip-2612) permissions. EOAs supporting this EIP can still authorize transfers by calling the `permit` function of [ERC-20](./eip-20) tokens. This issue also exists with [EIP-7702](./eip-7702). If wanting to support deactivated EOAs, [ERC-20](./eip-20) contracts may need to be upgraded.

### Message replay across EVM-compatible chains

For deactivation enabled by EOA signed transactions, the replay protection mechanism provided by [EIP-155](./eip-155), if enabled, can effectively prevent cross-chain message replay.

For contract-based deactivation/reactivation, 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](../LICENSE.md).
Loading