Table of Contents
Two new system level predeploys are introduced for managing cross chain messaging along with
an update to the L1Block
contract with additional functionality.
Constant | Value |
---|---|
Address | 0x4200000000000000000000000000000000000022 |
The CrossL2Inbox
is responsible for executing a cross chain message on the destination chain.
It is permissionless to execute a cross chain message on behalf of any user.
To ensure safety of the protocol, the Message Invariants must be enforced.
The following fields are required for executing a cross chain message:
Name | Type | Description |
---|---|---|
_msg |
bytes |
The message payload, matching the initiating message. |
_id |
Identifier |
A Identifier pointing to the initiating message. |
_target |
address |
Account that is called with _msg . |
The message payload of the executing message.
This must match the emitted payload of the initiating message identified by _id
.
A pointer to the _msg
in a remote (or local) chain.
The message Identifier
of the executing message.
This is required to enforce the message executes an existing and valid initiating message.
By including the Identifier
in the calldata, it makes static analysis much easier for block builders.
It is impossible to check that the Identifier
matches the cross chain message on chain. If the block
builder includes a message that does not correspond to the Identifier
, their block will be reorganized
by the derivation pipeline.
A possible upgrade path to this contract would involve adding a new function. If any fields in the Identifier
change, then a new 4byte selector will be generated by solc.
Messages are broadcast, not directed. Upon execution the caller can specify which address
to target:
there is no protocol enforcement on what this value is.
The _target
is called with the _msg
as input.
In practice, the _target
will be a contract that needs to know the schema of the _msg
so that it can be decoded.
It MAY call back to the CrossL2Inbox
to authenticate
properties about the _msg
using the information in the Identifier
.
The ExecutingMessage
event represents an executing message. It MUST be emitted on every call
to executeMessage
.
event ExecutingMessage(bytes,bytes);
The data encoded in the event contains the Identifier
and the msg
.
The following pseudocode shows the deserialization:
(bytes memory identifier, bytes memory log) = abi.decode(log.data, (bytes, bytes));
Identifier id = abi.decode(identifier, (Identifier));
It is not possible to use solidity structs directly in events, which is why it is ABI encoded
into bytes
first.
A simple implementation of the executeMessage
function is included below.
function executeMessage(Identifier calldata _id, address _target, bytes calldata _msg) public payable {
require(_id.timestamp <= block.timestamp);
require(L1Block.isInDependencySet(_id.chainid));
assembly {
tstore(ORIGIN_SLOT, _id.origin)
tstore(BLOCKNUMBER_SLOT, _id.blocknumber)
tstore(LOG_INDEX_SLOT, _id.logIndex)
tstore(TIMESTAMP_SLOT, _id.timestamp)
tstore(CHAINID_SLOT, _id.chainid)
}
bool success = SafeCall.call({
_target: _target,
_value: msg.value,
_calldata: _msg
});
require(success);
emit ExecutingMessage(abi.encode(_id), _msg);
}
Note that the executeMessage
function is payable
to enable relayers to earn in the gas paying asset.
The Identifier
MUST be exposed via public
getters so that contracts can call back to authenticate
properties about the _msg
.
Constant | Value |
---|---|
Address | 0x4200000000000000000000000000000000000023 |
MESSAGE_VERSION |
uint256(0) |
EXPIRY_WINDOW |
uint256(7200) |
The L2ToL2CrossDomainMessenger
is a higher level abstraction on top of the CrossL2Inbox
that
provides features necessary for secure transfers ERC20 tokens between L2 chains.
Messages sent through the L2ToL2CrossDomainMessenger
on the source chain receive both replay protection
as well as domain binding, ie the executing transaction can only be valid on a single chain.
- Only callable by the
CrossL2Inbox
- The
Identifier.origin
MUST beaddress(L2ToL2CrossDomainMessenger)
- The
_destination
chain id MUST be equal to the local chain id - The
CrossL2Inbox
cannot call itself
- The message MUST have not been successfully relayed
- The
EXPIRY_WINDOW
MUST have elapsed since the message first failed to be relayed - The expired message MUST not have been previously sent back to source
- The expired message MUST not be relayable after being sent back
- Only callable by the
CrossL2Inbox
- The message source MUST be
block.chainid
- The
Identifier.origin
MUST beaddress(L2ToL2CrossDomainMessenger)
- The
expiredMessages
mapping MUST only contain messages that originated in this chain and failed to be relayed on destination. - Already expired messages MUST NOT be relayed.
Versioning is handled in the most significant bits of the nonce, similarly to how it is handled by
the CrossDomainMessenger
.
function messageNonce() public view returns (uint256) {
return Encoding.encodeVersionedNonce(nonce, MESSAGE_VERSION);
}
To enable interoperability between chains that use a custom gas token, there is no native support for
sending ether
between chains. ether
must first be wrapped into WETH before sending between chains.
The L2ToL2CrossDomainMessenger
uses a similar interface to the L2CrossDomainMessenger
but
the _minGasLimit
is removed to prevent complexity around EVM gas introspection and the _destination
chain is included instead.
The initiating message is represented by the following event:
event SentMessage(bytes message) anonymous;
The bytes
are an ABI encoded call to relayMessage
. The event is defined as anonymous
so that no topics
are prefixed to the abi encoded call.
An explicit _destination
chain and nonce
are used to ensure that the message can only be played on a single remote
chain a single time. The _destination
is enforced to not be the local chain to avoid edge cases.
There is no need for address aliasing as the aliased address would need to commit to the source chain's chain id
to create a unique alias that commits to a particular sender on a particular domain and it is far more simple
to assert on both the address and the source chain's chain id rather than assert on an unaliased address.
In both cases, the source chain's chain id is required for security. Executing messages will never be able to
assume the identity of an account because msg.sender
will never be the identity that initiated the message,
it will be the L2ToL2CrossDomainMessenger
and users will need to callback to get the initiator of the message.
function sendMessage(uint256 _destination, address _target, bytes calldata _message) external {
require(_destination != block.chainid);
bytes memory data = abi.encodeCall(L2ToL2CrossDomainMessenger.relayMessage, (_destination, block.chainid, messageNonce(), msg.sender, _target, _message));
emit SentMessage(data);
nonce++;
}
Note that sendMessage
is not payable
.
When relaying a message through the L2ToL2CrossDomainMessenger
, it is important to require that
the _destination
equal to block.chainid
to ensure that the message is only valid on a single
chain. The hash of the message is used for replay protection.
It is important to ensure that the source chain is in the dependency set of the destination chain, otherwise it is possible to send a message that is not playable.
When a message fails to be relayed, only the timestamp at which it
first failed along with its source chain id are stored. This is
needed for calculation of the failed message's expiry. The source chain id
is also required to simplify the function signature of sendExpire
.
function relayMessage(uint256 _destination, uint256 _source, uint256 _nonce, address _sender, address _target, bytes memory _message) external payable {
require(msg.sender == address(CROSS_L2_INBOX));
require(_destination == block.chainid);
require(CROSS_L2_INBOX.origin() == address(this));
bytes32 messageHash = keccak256(abi.encode(_destination, _source, _nonce, _sender, _target, _message));
require(sentMessages[messageHash] == false);
assembly {
tstore(CROSS_DOMAIN_MESSAGE_SENDER_SLOT, _sender)
tstore(CROSS_DOMAIN_MESSAGE_SOURCE_SLOT, _source)
}
sentMessages[messageHash] = true;
bool success = SafeCall.call({
_target: _target,
_value: msg.value,
_calldata: _message
});
if (!success) {
emit FailedRelayedMessage(messageHash);
if (failedMessages[messageHash].timestamp == 0) {
failedMessages[messageHash] = FailedMessage({timestamp: block.timestamp, sourceChainId: _source});
}
return;
}
successfulMessages[messageHash] = true;
delete failedMessages[messageHash];
emit RelayedMessage(messageHash);
};
Note that the relayMessage
function is payable
to enable relayers to earn in the gas paying asset.
To enable cross chain authorization patterns, both the _sender
and the _source
MUST be exposed via public
getters.
When expiring a message that failed to be relayed on the destination chain
to the source chain, it's crucial to ensure the message can only be sent back
to the L2ToL2CrossDomainMessenger
contract in its source chain.
This function has no auth, which allows anyone to expire a given message hash.
The EXPIRY_WINDOW
variable is added to give the users enough time to replay their
failed messages and to prevent malicious actors from performing a griefing attack
by expiring messages upon arrival.
Once the expired message is sent to the source chain, the message on the local chain is set
as successful in the successfulMessages
mapping to ensure non-replayability and deleted
from failedMessages
. An initiating message is then emitted to relayExpire
function sendExpire(bytes32 _expiredHash) external nonReentrant {
if (successfulMessages[_expiredHash]) revert MessageAlreadyRelayed();
(uint256 messageTimestamp, uint256 messageSource) = failedMessages[_expiredHash];
if (block.timestamp < messageTimestamp + EXPIRY_WINDOW) revert ExpiryWindowHasNotEnsued();
delete failedMessages[_expiredHash];
successfulMessages[_expiredHash] = true;
bytes memory data = abi.encodeCall(
L2ToL2CrossDomainMessenger.expired,
(_expiredHash, messageSource)
);
emit SentMessage(data);
}
When relaying an expired message, only message hashes of actual failed messages should be stored, for this we must ensure the origin of the log, and caller are all expected contracts.
It's also important to ensure only the hashes of messages that were initiated in this chain are accepted.
If all checks have been successful, the message has is stored in the
expiredMessages
mapping. This enables smart contracts to read from it and
check whether a message expired or not, and handle this case accordingly.
function relayExpire(bytes32 _expiredHash, uint256 _messageSource) external {
if (_messageSource != block.chainid) revert IncorrectMessageSource();
if (expiredMessages[_expiredHash] != 0) revert ExpiredMessageAlreadyRelayed();
if (msg.sender != Predeploys.CROSS_L2_INBOX) revert ExpiredMessageCallerNotCrossL2Inbox();
if (CrossL2Inbox(Predeploys.CROSS_L2_INBOX).origin() != Predeploys.L2_TO_L2_CROSS_DOMAIN_MESSENGER) {
revert CrossL2InboxOriginNotL2ToL2CrossDomainMessenger();
}
expiredMessages[_expiredHash] = block.timestamp;
emit MessageHashExpired(_expiredHash);
}
Constant | Value |
---|---|
Address | 0x4200000000000000000000000000000000000015 |
DEPOSITOR_ACCOUNT |
0xDeaDDEaDDeAdDeAdDEAdDEaddeAddEAdDEAd0001 |
The L1Block
contract MUST include method setConfig(ConfigType, bytes)
for setting the system's static values, which
are defined as values that only change based on the chain operator's input. This function serves to reduce the size of
the L1 Attributes transaction, as well as to reduce the need to add specific one off functions. It can only be called by
DEPOSITOR_ACCOUNT
.
The ConfigType
enum is defined as follows:
enum ConfigType {
SET_GAS_PAYING_TOKEN,
ADD_DEPENDENCY,
REMOVE_DEPENDENCY
}
The second argument to setConfig
is a bytes
value that is ABI encoded with the necessary values for the ConfigType
.
ConfigType | Value |
---|---|
SET_GAS_PAYING_TOKEN |
abi.encode(token, decimals, name, symbol) |
ADD_DEPENDENCY |
abi.encode(chainId) |
REMOVE_DEPENDENCY |
abi.encode(chainId) |
where
-
token
is the gas paying token's address (typeaddress
) -
decimals
is the gas paying token's decimals (typeuint8
) -
name
is the gas paying token's name (typebytes32
) -
symbol
is the gas paying token's symbol (typebytes32
) -
chainId
is the chain id intended to be added or removed from the dependency set (typeuint256
)
Calls to setConfig
MUST originate from SystemConfig
and are forwarded to L1Block
by OptimismPortal
.
L1Block
is updated to include the set of allowed chains. These chains are added and removed through setConfig
calls
with ADD_DEPENDENCY
or REMOVE_DEPENDENCY
, respectively. The maximum size of the dependency set is type(uint8).max
,
and adding a chain id when the dependency set size is at its maximum MUST revert. If a chain id already in the
dependency set, such as the chain's chain id, is attempted to be added, the call MUST revert. If a chain id that is not
in the dependency set is attempted to be removed, the call MUST revert. If the chain's chain id is attempted to be
removed, the call also MUST revert.
L1Block
MUST provide a public getter to check if a particular chain is in the dependency set called
isInDependencySet(uint256)
. This function MUST return true when a chain id in the dependency set, or the chain's chain
id, is passed in as an argument, and false otherwise. Additionally, L1Block
MUST provide a public getter to return the
dependency set called dependencySet()
. This function MUST return the array of chain ids that are in the dependency set.
L1Block
MUST also provide a public getter to get the dependency set size called dependencySetSize()
. This function
MUST return the length of the dependency set array.
TODO