Try   HackMD

Generalized message bus for execution layer to consensus layer communication


About

Generalized message bus (GMB) is a generic data pipe from the execution layer of Ethereum to the consensus layer. The implementation is similar to the DepositContract mechanism and looks like:

  1. Execution layer smart contracts emits an event that is conformant to generalized message bus format and contains all the necessary data to be parsed on consensus layer.

  2. The consensus layer client listens to events from a fixed list of execution layer smart contracts, collects and processes events emitted by them, similarly to how they listen to deposit events now. There is a fixed list of allowed events per specific
    contract address.

  3. For every correct event from a fixed allow-list of event bus smart contracts a pre-configured handler will be called, i.e.
    withdrawer_exit for MessageBusType.withdrawer_exit type. Message data is parsed from byte array in data field.

Why

With introduction of 0x1 withdrawal credentials and rise of decentralized staking pools at least two new types of messages are proposed to be sent from execution layer to the consensus layer, with potentially other new types needed as the protocol evolves.

Staker-initiated validator exits are paramount to maintain trust-minimized stakeing pools (proposal). Key rotation is a good security practice overall. If we want 0x1-type smart contract withdrawal credentials to be first-class citizens a message bus from execution layer is needed.

Implementing a decent part of business logic on execution layer and an uniform parsing logic on consensus layer clients instead of ad-hoc handling per message type would simplify the architecture of a protocol.

Anatomy of a GMB message

A message is emitted on execution layer side via EVM event mechanism from a smart-contract from an allow-list of message bus smart-contracts, then parsed, and processed on beacon chain client.

The execution layer side

Message of a GMB is a data structure:

struct Message {
    uint256 messageType;
    bytes data;    
}

event MessageEvent(Message message);

Depending on type, data is parsed to a specific payload for that type.
For example, a payload of SampleMessageData:

struct SampleMessageData {
    address sender;
    uint8 number;
}

Emitting an event with a such message payload:

// SPDX-License-Identifier: MIT

pragma solidity 0.8.7;

contract IBeaconChainMessageBus {
    struct Message {
        uint256 messageType;
        bytes data;
    }

    event MessageEvent(Message message);
}

contract SampleMessageBus is IBeaconChainMessageBus {
    uint256 constant SAMPLE_MESSAGE_TYPE = 10;

    struct SampleMessageData {
        address sender;
        uint8 n;
    }

    function submit(uint8 n) external {
        SampleMessageData memory sampleMessageData = SampleMessageData(msg.sender, n);
        bytes memory encodedData = abi.encode(sampleMessageData);
        emit MessageEvent(Message(SAMPLE_MESSAGE_TYPE, encodedData));
    }
}

The consensus layer side

On the consensus layer side, we propose to implement a mapping from addresses of trusted contracts to lists of the events types expected from address. Emitters to message types relation is many-to-many: different smart contracts can emit identical messages, and one emitter can send multiple types of events potentially. This mapping should be mutable, depending on the block height where the event was emitted, to give core developers an ability to limit lifetime of an emitter contract. E.g. if there was a contract to emit key rotation message introduced in block X but later found to be a potetential DoS vector and deprecated after block Y, it would be an allowed emitter for this key rotation messages only in blocks X to Y.

class MessageBusType(enum):
    SampleMessage: int = 10
    SomethingElse: int = 22

Each received event is validated and processed as follows:

class MessageBusEvent:
    source: AddressT
    message_type: MessageBusTypeT
    data: bytes

    
def execution_layer_messages(block_height: int)->List[MessageBusEvent]:
    allowed_emitters = allowed_emitters_for_block(block_height)
    el_messages = collect_execution_layer_messages(block_height, allowed_emitters) #collects all suitable events for the block from all allowed emitters
    return el_messages


def process_messages(messages: List[MessageBusEvent]) -> None:
    for msg in messages:
        if msg.message_type == MessageBusType.SampleMessage:
            process_sample_event(msg.data)
        elif msg.message_type == MessageBusType.SomethingElse:
                ...

Allowed emitters function might look like this

def allowed_emitters_for_block(block_height: int) -> Dict[AddressT, List[MessageBusTypeT]]:
    if block_height >= 777777:
        return {
            '0x0123...9': [MessageBusType.SampleMessage],
            '0x0987...1': [MessageBusType.SomethingElse]
        }
    elif block_height >= 666666:
        return {
            '0x0123...9': [MessageBusType.SomethingElse],
        }

Example of a parser and a handler for messages with the SAMPLE_MESSAGE_TYPE.

def parse_sample_message_payload(data: bytes) -> Tuple[AddressT, int]:
    address, number = abi.decode(data)
    return address, number
    

def process_sample_event(data: bytes) -> None:    
    address, number = parse_sample_message_payload(data)
    print(f'{address} sends {number}')

Test cases

  • If msg.sender not in AllowedEmitters for the specific block, message should not be processed
  • The type emitted by msg.sender should be in the list of allowed types for this address for all processed messages
  • Messages should be porcessed only by the specific handlers per type

Security consideration

This proposal opens up a vector for DoS attack from execution layer to consensus layer, if emitting a message on the former is cheaper than processing a message on the latter. To protect from this, expensive to process messages should be expensive to emit, or rate-limited, or both. We think that for many types of messages current execution layer tx fees are enough to prevent DoS, however.

The specific additional protection mechanism should be designed on case by case basis. For example, one could just charge an additional fee:

// SPDX-License-Identifier: MIT

pragma solidity 0.8.7;

contract IBeaconChainMessageBus {
    struct Message {
        uint256 messageType;
        bytes data;
    }

    event MessageEvent(Message message);
}

contract HeavyComputing is IBeaconChainMessageBus {
    uint256 constant HEAVY_COMPUTING_TYPE = 11;
    uint256 constant MINIMAL_FEE = 10 ** 16;

    modifier hasFee() {
        require(msg.value > MINIMAL_FEE);
        _;
    }

    function submit(bytes memory inputData) external hasFee payable {
        emit MessageEvent(Message(HEAVY_COMPUTING_TYPE, inputData));
    }
}
Threat Mitigation(s)
An attacker is able to use the GMB to organize a DoS attack on the consensus layer clients. 1. To charge an additional fee comparable with the computational cost of processing a specific type of message.
2. Set limits on the number of messages per sender/message type per epoch.

Simple summary

Add a generic data pipe between the execution layer and the consensus layer. It allow to handle actions on the consensus layer on more flexible and simply manner, e.g. perform key rotation or staker-initiated validator exits.