owned this note
owned this note
Published
Linked with GitHub
# General EL→CL Message Bus
## 🎯 Motivation
There's a need to pass messages from Execution Layer to Consensus Layer.
Current and potential use cases include deposits, withdrawal credential initiated exits (EIP-7002), validator consolidations & custom validators ceilings (EIP-7251). In general, the need for these messages steems from the fact that most withdrawal credentials reside on Execution Layer and are expected to have a modicum of control over validators on Beacon Chain. The desire to have more EL-CL messages will emerge every time there's a substantial change to beacon change operations.
This desire is currently hard to fulfil, because every message queue from EL to CL currently is a custom job. There are deposits (EIP-6110), exits (EIP-7002), and they require changing block structure, complex mitigations for DoS attacks etc.
Key difficulty that make these message buses harder than a regular BeaconOperation are that:
- on EL there is no easily accessible knowledge about beacon chain, meaning filtering invalid messages is impossible; meaning that simplest way to prevent DoS on beacon chain is economic
- on beacon chain there's no easy way to check for EL events, meaning everything that originates on EL requires a new bespoke method to get across, changing block structure, writing custom and non-generic handlers
We propose to implement a generic message bus on execution layer that would be closer to how mempool operates: messages submitted to this bus in general MAY appear in Beacon Chain but are not required to; and for them to be handled there should be a corresponding Beacon Chain operation submitted by a block proposer. We argue that will make development of EL→CL operations much easier, better generalized, better DoS protected and thus more secure.
## 🌟 Basic Design
### Execution Layer
It's proposed that the execution layer has a smart contract to which anyone can send an arbitrary message. The contract computes the Merkle root of the tree of all messages, which is further used to prove the fact of sending.
```solidity
interface MessageBusContract {
getMessageRoot() external view;
sentMessage(uint256 messageType, bytes messagePayload) external;
}
```
### Consensus Layer
It's proposed that the message bus root is stored on the consensus layer in `execution_payload`.
```python
class ExecutionPayload(Container):
# ...
# [New in MessageBus]
message_bus_root: Bytes32
```
The `state` includes a list containing the latest message bus roots. The `message_bus_roots` list is updated when a block is processed in `process_execution_payload`.
The presence of recent roots guarantees the verifiability of the proof within some finite time.
```python
class BeaconState(Container):
# ...
# [New in MessageBus]
message_bus_roots: Vector[Bytes32, BLOCKS_PER_MESSAGE_BUS_ROOTS_VECTOR]
```
```python
def process_execution_payload(state: BeaconState, body: BeaconBlockBody, execution_engine: ExecutionEngine) -> None:
# ...
# [New in MessageBus]
state.message_bus_roots[payload.block_number % BLOCKS_PER_MESSAGE_BUS_ROOTS_VECTOR] = payload.message_bus_root
```
### Proof of Message
It's proposed that operations that require signaling from the execution layer are included in a block along with Merkle proof.
```python
class BeaconBlockBody(Container):
# ...
# [New in MessageBus]
some_operation: List[SomeOperation, MAX_SOME_OPERATIONS]
```
```python
class SomeOperation(Container):
message_bus_signal: MessageBusSignal
data: SomeOperationData
```
```python
class MessageBusSignal(Container):
proof: Vector[Bytes32, MESSAGE_BUS_CONTRACT_TREE_DEPTH + 1]
message_sender: ExecutionAddress
message_index: uint64
```
An included proof must be checked against the list of roots stored in `state.message_bus_roots` to confirm that a message that forms an operation has been sent from the execution layer.
```python
def process_some_operation(state: BeaconState, operation: SomeOperation) -> None:
# Reconstruct source message payload from operation data
source_message_payload = serialize(operation.data)
# Compute leaf from message type, sender and payload
leaf = hash(
SOME_OPERATION_MESSAGE_TYPE +
operation.message_bus_signal.message_sender +
source_message_payload
)
assert is_valid_message_bus_proof(
state,
leaf,
operation.message_bus_signal.proof,
operation.message_bus_signal.message_index
)
# ...
```
```python
def is_valid_message_bus_proof(
state: BeaconState,
leaf: Bytes32,
branch: Sequence[Bytes32],
index: uint64
) -> None:
for root in state.message_bus_roots:
if is_valid_merkle_branch(
leaf=leaf,
branch=branch,
depth=MESSAGE_BUS_CONTRACT_TREE_DEPTH + 1,
index=index,
root=root,
):
return True
else:
return False
```
To reiterate, the basic design only requires two things from beacon chain clients: including a merkle root of message bus in the block header, and storing the list of latest BLOCKS_PER_MESSAGE_BUS_ROOTS_VECTOR roots. Any business logic is built on top of that and is approached via case by case basis.
## 🧪 Usage Examples
There are multiple ways message bus can be used:
- guarantee-less initiation of beacon chain operations ("UDP-style")
- guaranteed initiation of beacon chain operations (potentially, with a specific fee)
- providing data for beacon chain operations initiated on beacon chain
### Without Delivery Guarantee
The proposed solution can be used for processing of signals from a withdrawal address such as:
- triggered exit
- partial withdrawal
- switching of withdrawal credentials type
- setting custom ceiling
- validators consolidation
#### One Way Operations
Let's look at a simple scenario for one way operation using the example of a triggered exit operation (unlike 7002 implemetation, without guaranteed exit initiation).
**Execution Layer**. The holder of a withdrawal address sends a transaction to the message bus contract. The transaction contains an operation type and a payload that specifies the validator's public key to exit. The contract computes a leaf and updates the branches of the tree. The leaf includes the message type, msg.sender, and payload.
```solidity
bytes32 leaf = sha256(abi.encodePacked(messageType, msg.sender, messagePayload);
```
**Consensus Layer**. There are no block validity rules or slahsing consequences that require the exit messages to be processed. Anyone may generate a merkle proof for the sent message, form an operation object and sends it to the corresponding operations pool using the consensus layer node API. Default implementations for beacon chain clients include forming operations on processing the block. Valid operations are broadcasted by the consensus client via p2p. Proposer dequeues at most `MAX_TRIGGERED_EXITS` valid operations from the pool and includes them in the block.
Correctness check on beacon chain side means that invalid messages (e.g. for a validator that is already exited) processing stops on the local level, thus not spamming the blockspace or the network, though still requires spending local resources.
```python
class BeaconBlockBody(Container):
# ...
# [New in MessageBus]
triggered_exits: List[TriggeredExit, MAX_TRIGGERED_EXITS]
```
```python
class TriggeredExit(Container):
message_bus_signal: MessageBusSignal
data: TriggeredExitData
```
```python
class TriggeredExitData(Container):
pubkey: BLSPubkey
```
Next, the operations are processed in `process_triggered_exits` method and initiates the validator exit:
```python
def process_triggered_exit(state: BeaconState, triggered_exit: TriggeredExit) -> None:
source_message_payload = serialize(triggered_exit.data)
signal = triggered_exit.message_bus_signal
leaf = hash(TRIGGERED_EXIT_MESSAGE_TYPE + signal.message_sender + source_message_payload)
# Verify message bus proof
assert is_valid_message_bus_proof(state, leaf, signal.proof, signal.message_index)
validator_pubkeys = [v.pubkey for v in state.validators]
validator_index = ValidatorIndex(validator_pubkeys.index(pubkey))
validator = state.validators[index]
# Verify withdrawal address
assert (
withdrawal_credentials[:1] == ETH1_ADDRESS_WITHDRAWAL_PREFIX
and signal.message_sender[12:] != validator.withdrawal_credentials[12:]
)
# ...
# Initiate exit
initiate_validator_exit(state, validator_index)
```
This operation should have the same rate of successful inclusion as volunatary exit operation - that is to say, it is not guaranteed to work but should work almost always in practice.
Operations can also have ETH-denominated inclusion incventives bolted to them if there's a reason to think it is needed (e.g. like with slashing incentives).
### With Delivery Guarantee
==TODO==
TLDR - messages from some contracts can be made must-include by block validity rules. E.g. 7002 can be redone in a way that 7002 precompile is pushing a message into message bus instead of in block header directly, and if all messages from that contract are not processed; WIP how to make it better.
These contracts are used to separate "must include" messages from "may include" messages and to, potentially, introduce some EL-specific checks, or a fee mechanism.
### Just passing data around
==TODO==
TLDR - imagine a consolidation operation that has to be initiated or signed by a validator, but also must include a sign-off from withdrawal credentials.
## Problems and difficulties
#### Reusable Operations
Many operations can only be initated one time, or at least only one time within a limited timeframe (e.g. wc-triggered exit or a partial withdrawal of the "specify the amount to keep and withdraw the rest" variety). Messages to initiate them do not need replay protection. A more challenging scenario is operations that can be processed multiple times.
If there's a need to introduce such EL-initiated operation (currently we don't see it), there could be a mechanism to verify that a message proof has not been used before for another operation. An example of such a mechanism would be to check the message index against the list of previously used messages. The number of previously used messages can be limited, and messages outside the stored range can be considered expired.
```python
class BeaconState(Container):
# ...
# [New in MessageBus]
message_bus_used_indeces: Vector[uint64, MAX_MESSAGE_BUS_USED_INDECES]
```
```python
def process_unique_message_bus_signal(state: BeaconState, signal: MessageBusSignal):
min_index = state.message_bus_used_indeces[0]
# Verify the message is not expired
assert(signal.message_index > min_index)
# Verify the message has not been used
assert(signal.message_index not in state.message_bus_used_indeces)
# Update the list
state.message_bus_used_indeces = sorted(state.message_bus_used_indeces + [signal.message_index])[0: MAX_MESSAGE_BUS_USED_INDECES]
```
#### Operations Inclusion Incentive
We think that unless there is an active disincentive to process the message, having clients including operations to the queue by default would be enough to have very high chance of inclusion. But it is better if the proposer has an incentive to include operations from the pool.
Some operations such as triggered exit and partial withdrawal may not have additional rewards for their inclusion, since the result of their processing will be a slight increase in rewards for all the rest validators. Inclusion of other operations such as setting custom ceiling, switching of withdrawal credentials type or validators consolidation can be rewarded.
#### Complex interactions operations
Proposed message bus in one way data bridge without a (by default) guaranteed delivery that might be processed on beacon chain after firing on EL. It doesn't make easier any logic that require first checking the state of beacon chain (e.g. to determine the fee size), or returning data from beacon chain and handling it. If we can avoid such operations at all that would be best. We currently don't see the strict need to have these, though calcultating the fee for 7002 based on beacon chain data would be nice.
If not indroducing such operations is not possible, it has to be done with a bespoke one-off implementations.