// SPDX-License-Identifier: MIT pragma solidity 0.8.17; // ══════════════════════════════ LIBRARY IMPORTS ══════════════════════════════ import {Attestation} from "../libs/memory/Attestation.sol"; import {BaseMessage, BaseMessageLib, MemView} from "../libs/memory/BaseMessage.sol"; import {ByteString, CallData} from "../libs/memory/ByteString.sol"; import {ORIGIN_TREE_HEIGHT, SNAPSHOT_TREE_HEIGHT} from "../libs/Constants.sol"; import { AlreadyExecuted, AlreadyFailed, DisputeTimeoutNotOver, DuplicatedSnapshotRoot, IncorrectDestinationDomain, IncorrectMagicValue, IncorrectOriginDomain, IncorrectSnapshotRoot, GasLimitTooLow, GasSuppliedTooLow, MessageOptimisticPeriod, NotaryInDispute } from "../libs/Errors.sol"; import {SafeCall} from "../libs/SafeCall.sol"; import {MerkleMath} from "../libs/merkle/MerkleMath.sol"; import {Header, Message, MessageFlag, MessageLib} from "../libs/memory/Message.sol"; import {Receipt, ReceiptLib} from "../libs/memory/Receipt.sol"; import {Request} from "../libs/stack/Request.sol"; import {SnapshotLib} from "../libs/memory/Snapshot.sol"; import {AgentFlag, AgentStatus, MessageStatus} from "../libs/Structures.sol"; import {Tips} from "../libs/stack/Tips.sol"; import {ChainContext} from "../libs/ChainContext.sol"; import {TypeCasts} from "../libs/TypeCasts.sol"; // ═════════════════════════════ INTERNAL IMPORTS ══════════════════════════════ import {AgentSecured} from "../base/AgentSecured.sol"; import {ExecutionHubEvents} from "../events/ExecutionHubEvents.sol"; import {InterfaceInbox} from "../interfaces/InterfaceInbox.sol"; import {IExecutionHub} from "../interfaces/IExecutionHub.sol"; import {IMessageRecipient} from "../interfaces/IMessageRecipient.sol"; // ═════════════════════════════ EXTERNAL IMPORTS ══════════════════════════════ import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; /// @notice `ExecutionHub` is a parent contract for `Destination`. It is responsible for the following: /// - Executing the messages that are proven against the saved Snapshot Merkle Roots. /// - Base messages are forwarded to the specified message recipient, ensuring that the original /// execution request is fulfilled correctly. /// - Manager messages are forwarded to the local `AgentManager` contract. /// - Keeping track of the saved Snapshot Merkle Roots (which are accepted in `Destination`). /// - Keeping track of message execution Receipts, as well as verify their validity. abstract contract ExecutionHub is AgentSecured, ReentrancyGuardUpgradeable, ExecutionHubEvents, IExecutionHub { using Address for address; using BaseMessageLib for MemView; using ByteString for MemView; using MessageLib for bytes; using ReceiptLib for bytes; using SafeCall for address; using SafeCast for uint256; using TypeCasts for bytes32; /// @notice Struct representing stored data for the snapshot root /// @param notaryIndex Index of Notary who submitted the statement with the snapshot root /// @param attNonce Nonce of the attestation for this snapshot root /// @param attBN Summit block number of the attestation for this snapshot root /// @param attTS Summit timestamp of the attestation for this snapshot root /// @param index Index of snapshot root in `_roots` /// @param submittedAt Timestamp when the statement with the snapshot root was submitted /// @param notaryV V-value from the Notary signature for the attestation // TODO: tight pack this struct SnapRootData { uint32 notaryIndex; uint32 attNonce; uint40 attBN; uint40 attTS; uint32 index; uint40 submittedAt; uint256 sigIndex; } /// @notice Struct representing stored receipt data for the message in Execution Hub. /// @param origin Domain where message originated /// @param rootIndex Index of snapshot root used for proving the message /// @param stateIndex Index of state used for the snapshot proof /// @param executor Executor who successfully executed the message struct ReceiptData { uint32 origin; uint32 rootIndex; uint8 stateIndex; address executor; } // TODO: include nonce? // 24 bits available for tight packing // ══════════════════════════════════════════════════ STORAGE ══════════════════════════════════════════════════════ /// @notice (messageHash => status) /// @dev Messages coming from different origins will always have a different hash /// as origin domain is encoded into the formatted message. /// Thus we can use hash as a key instead of an (origin, hash) tuple. mapping(bytes32 => ReceiptData) private _receiptData; /// @notice First executor who made a valid attempt of executing a message. /// Note: stored only for messages that had Failed status at some point of time mapping(bytes32 => address) private _firstExecutor; /// @dev All saved snapshot roots bytes32[] internal _roots; /// @dev Tracks data for all saved snapshot roots mapping(bytes32 => SnapRootData) internal _rootData; /// @dev gap for upgrade safety uint256[46] private __GAP; // solhint-disable-line var-name-mixedcase // ═════════════════════════════════════════════ MESSAGE EXECUTION ═════════════════════════════════════════════════ /// @inheritdoc IExecutionHub function execute( bytes memory msgPayload, bytes32[] calldata originProof, bytes32[] calldata snapProof, uint8 stateIndex, uint64 gasLimit ) external nonReentrant { // This will revert if payload is not a formatted message payload Message message = msgPayload.castToMessage(); Header header = message.header(); bytes32 msgLeaf = message.leaf(); // Ensure message was meant for this domain if (header.destination() != localDomain) revert IncorrectDestinationDomain(); // Ensure message was not sent from this domain if (header.origin() == localDomain) revert IncorrectOriginDomain(); // Check that message has not been executed before ReceiptData memory rcptData = _receiptData[msgLeaf]; if (rcptData.executor != address(0)) revert AlreadyExecuted(); // Check proofs validity SnapRootData memory rootData = _proveAttestation(header, msgLeaf, originProof, snapProof, stateIndex); // Check if optimistic period has passed uint256 proofMaturity = block.timestamp - rootData.submittedAt; if (proofMaturity < header.optimisticPeriod()) revert MessageOptimisticPeriod(); uint256 paddedTips; bool success; // Only Base/Manager message flags exist if (header.flag() == MessageFlag.Base) { // This will revert if message body is not a formatted BaseMessage payload BaseMessage baseMessage = message.body().castToBaseMessage(); success = _executeBaseMessage(header, proofMaturity, gasLimit, baseMessage); paddedTips = Tips.unwrap(baseMessage.tips()); } else { // gasLimit is ignored when executing manager messages success = _executeManagerMessage(header, proofMaturity, message.body()); } if (rcptData.origin == 0) { // This is the first valid attempt to execute the message => save origin and snapshot proof rcptData.origin = header.origin(); rcptData.rootIndex = rootData.index; rcptData.stateIndex = stateIndex; if (success) { // This is the successful attempt to execute the message => save the executor rcptData.executor = msg.sender; } else { // Save as the "first executor", if execution failed _firstExecutor[msgLeaf] = msg.sender; } _receiptData[msgLeaf] = rcptData; } else { if (!success) revert AlreadyFailed(); // There has been a failed attempt to execute the message before => don't touch origin and snapshot root // This is the successful attempt to execute the message => save the executor rcptData.executor = msg.sender; _receiptData[msgLeaf] = rcptData; } emit Executed(header.origin(), msgLeaf, success); if (!_passReceipt(rootData.notaryIndex, rootData.attNonce, msgLeaf, paddedTips, rcptData)) { // Emit event with the recorded tips so that Notaries could form a receipt to submit to Summit emit TipsRecorded(msgLeaf, paddedTips); } } // ═══════════════════════════════════════════════════ VIEWS ═══════════════════════════════════════════════════════ /// @inheritdoc IExecutionHub function getAttestationNonce(bytes32 snapRoot) external view returns (uint32 attNonce) { return _rootData[snapRoot].attNonce; } /// @inheritdoc IExecutionHub function isValidReceipt(bytes memory rcptPayload) external view returns (bool isValid) { // This will revert if payload is not a receipt // This will revert if receipt refers to another domain return _isValidReceipt(rcptPayload.castToReceipt()); } /// @inheritdoc IExecutionHub function messageStatus(bytes32 messageHash) external view returns (MessageStatus status) { ReceiptData memory rcptData = _receiptData[messageHash]; if (rcptData.executor != address(0)) { return MessageStatus.Success; } else if (_firstExecutor[messageHash] != address(0)) { return MessageStatus.Failed; } else { return MessageStatus.None; } } /// @inheritdoc IExecutionHub function messageReceipt(bytes32 messageHash) external view returns (bytes memory rcptPayload) { ReceiptData memory rcptData = _receiptData[messageHash]; // Return empty payload if there has been no attempt to execute the message if (rcptData.origin == 0) return ""; return _messageReceipt(messageHash, rcptData); } // ══════════════════════════════════════════════ INTERNAL LOGIC ═══════════════════════════════════════════════════ /// @dev Passes message content to recipient that conforms to IMessageRecipient interface. function _executeBaseMessage(Header header, uint256 proofMaturity, uint64 gasLimit, BaseMessage baseMessage) internal returns (bool) { // Check that gas limit covers the one requested by the sender. // We let the executor specify gas limit higher than requested to guarantee the execution of // messages with gas limit set too low. Request request = baseMessage.request(); if (gasLimit < request.gasLimit()) revert GasLimitTooLow(); // TODO: check that the discarded bits are empty address recipient = baseMessage.recipient().bytes32ToAddress(); // Forward message content to the recipient, and limit the amount of forwarded gas if (gasleft() <= gasLimit) revert GasSuppliedTooLow(); // receiveBaseMessage(origin, nonce, sender, proofMaturity, version, content) bytes memory payload = abi.encodeCall( IMessageRecipient.receiveBaseMessage, ( header.origin(), header.nonce(), baseMessage.sender(), proofMaturity, request.version(), baseMessage.content().clone() ) ); // Pass the base message to the recipient, return the success status of the call return recipient.safeCall({gasLimit: gasLimit, msgValue: 0, payload: payload}); } /// @dev Uses message body for a call to AgentManager, and checks the returned magic value to ensure that /// only "remoteX" functions could be called this way. function _executeManagerMessage(Header header, uint256 proofMaturity, MemView body) internal returns (bool) { // TODO: introduce incentives for executing Manager Messages? CallData callData = body.castToCallData(); // Add the (origin, proofMaturity) values to the calldata bytes memory payload = callData.addPrefix(abi.encode(header.origin(), proofMaturity)); // functionCall() calls AgentManager and bubbles the revert from the external call bytes memory magicValue = address(agentManager).functionCall(payload); // We check the returned value here to ensure that only "remoteX" functions could be called this way. // This is done to prevent an attack by a malicious Notary trying to force Destination to call an arbitrary // function in a local AgentManager. Any other function will not return the required selector, // while the "remoteX" functions will perform the proofMaturity check that will make impossible to // submit an attestation and execute a malicious Manager Message immediately, preventing this attack vector. if (magicValue.length != 32 || bytes32(magicValue) != callData.callSelector()) revert IncorrectMagicValue(); return true; } /// @dev Passes the message receipt to the Inbox contract, if it is deployed on Synapse Chain. /// This ensures that the message receipts for the messages executed on Synapse Chain are passed to Summit /// without a Notary having to sign them. function _passReceipt( uint32 attNotaryIndex, uint32 attNonce, bytes32 messageHash, uint256 paddedTips, ReceiptData memory rcptData ) internal returns (bool) { // Do nothing if contract is not deployed on Synapse Chain if (localDomain != synapseDomain) return false; // Do nothing for messages with no tips (TODO: introduce incentives for manager messages?) if (paddedTips == 0) return false; return InterfaceInbox(inbox).passReceipt({ attNotaryIndex: attNotaryIndex, attNonce: attNonce, paddedTips: paddedTips, rcptPayload: _messageReceipt(messageHash, rcptData) }); } /// @dev Saves a snapshot root with the attestation data provided by a Notary. /// It is assumed that the Notary signature has been checked outside of this contract. function _saveAttestation(Attestation att, uint32 notaryIndex, uint256 sigIndex) internal { bytes32 root = att.snapRoot(); if (_rootData[root].submittedAt != 0) revert DuplicatedSnapshotRoot(); // TODO: consider using more than 32 bits for the root index _rootData[root] = SnapRootData({ notaryIndex: notaryIndex, attNonce: att.nonce(), attBN: att.blockNumber(), attTS: att.timestamp(), index: _roots.length.toUint32(), submittedAt: ChainContext.blockTimestamp(), sigIndex: sigIndex }); _roots.push(root); } // ══════════════════════════════════════════════ INTERNAL VIEWS ═══════════════════════════════════════════════════ /// @dev Checks if receipt body matches the saved data for the referenced message. /// Reverts if destination domain doesn't match the local domain. function _isValidReceipt(Receipt rcpt) internal view returns (bool) { // Check if receipt refers to this chain if (rcpt.destination() != localDomain) revert IncorrectDestinationDomain(); bytes32 messageHash = rcpt.messageHash(); ReceiptData memory rcptData = _receiptData[messageHash]; // Check if there has been a single attempt to execute the message if (rcptData.origin == 0) return false; // Check that origin and state index fields match if (rcpt.origin() != rcptData.origin || rcpt.stateIndex() != rcptData.stateIndex) return false; // Check that snapshot root and notary who submitted it match in the Receipt bytes32 snapRoot = rcpt.snapshotRoot(); (address attNotary,) = _getAgent(_rootData[snapRoot].notaryIndex); if (snapRoot != _roots[rcptData.rootIndex] || rcpt.attNotary() != attNotary) return false; // Check if message was executed from the first attempt address firstExecutor = _firstExecutor[messageHash]; if (firstExecutor == address(0)) { // Both first and final executors are saved in receipt data return rcpt.firstExecutor() == rcptData.executor && rcpt.finalExecutor() == rcptData.executor; } else { // Message was Failed at some point of time, so both receipts are valid: // "Failed": finalExecutor is ZERO // "Success": finalExecutor matches executor from saved receipt data address finalExecutor = rcpt.finalExecutor(); return rcpt.firstExecutor() == firstExecutor && (finalExecutor == address(0) || finalExecutor == rcptData.executor); } } /** * @notice Attempts to prove the validity of the cross-chain message. * First, the origin Merkle Root is reconstructed using the origin proof. * Then the origin state's "left leaf" is reconstructed using the origin domain. * After that the snapshot Merkle Root is reconstructed using the snapshot proof. * The snapshot root needs to have been submitted by an undisputed Notary. * @dev Reverts if any of the checks fail. * @param header Memory view over the message header * @param msgLeaf Message Leaf that was inserted in the Origin Merkle Tree * @param originProof Proof of inclusion of Message Leaf in the Origin Merkle Tree * @param snapProof Proof of inclusion of Origin State Left Leaf into Snapshot Merkle Tree * @param stateIndex Index of Origin State in the Snapshot * @return rootData Data for the derived snapshot root */ function _proveAttestation( Header header, bytes32 msgLeaf, bytes32[] calldata originProof, bytes32[] calldata snapProof, uint8 stateIndex ) internal view returns (SnapRootData memory rootData) { // Reconstruct Origin Merkle Root using the origin proof // Message index in the tree is (nonce - 1), as nonce starts from 1 // This will revert if origin proof length exceeds Origin Tree height bytes32 originRoot = MerkleMath.proofRoot(header.nonce() - 1, msgLeaf, originProof, ORIGIN_TREE_HEIGHT); // Reconstruct Snapshot Merkle Root using the snapshot proof // This will revert if: // - State index is out of range. // - Snapshot Proof length exceeds Snapshot tree Height. bytes32 snapshotRoot = SnapshotLib.proofSnapRoot(originRoot, header.origin(), snapProof, stateIndex); // Fetch the attestation data for the snapshot root rootData = _rootData[snapshotRoot]; // Check if snapshot root has been submitted if (rootData.submittedAt == 0) revert IncorrectSnapshotRoot(); // Check that Notary who submitted the attestation is not in dispute if (_notaryDisputeExists(rootData.notaryIndex)) revert NotaryInDispute(); // Check that Notary who submitted the attestation isn't in post-dispute timeout if (_notaryDisputeTimeout(rootData.notaryIndex)) revert DisputeTimeoutNotOver(); } /// @dev Formats the message execution receipt payload for the given hash and receipt data. function _messageReceipt(bytes32 messageHash, ReceiptData memory rcptData) internal view returns (bytes memory rcptPayload) { // Determine the first executor who tried to execute the message address firstExecutor = _firstExecutor[messageHash]; if (firstExecutor == address(0)) firstExecutor = rcptData.executor; // Determine the snapshot root that was used for proving the message bytes32 snapRoot = _roots[rcptData.rootIndex]; (address attNotary,) = _getAgent(_rootData[snapRoot].notaryIndex); // ExecutionHub does not store the tips, // the Notary will have to derive the proof of tips from the message payload. return ReceiptLib.formatReceipt({ origin_: rcptData.origin, destination_: localDomain, messageHash_: messageHash, snapshotRoot_: snapRoot, stateIndex_: rcptData.stateIndex, attNotary_: attNotary, firstExecutor_: firstExecutor, finalExecutor_: rcptData.executor }); } }