|
| 1 | +// SPDX-License-Identifier: BUSL-1.1 |
| 2 | +pragma solidity ^0.8.0; |
| 3 | + |
| 4 | +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; |
| 5 | + |
| 6 | +import { IHelios } from "./external/interfaces/IHelios.sol"; |
| 7 | +import "./libraries/CircleCCTPAdapter.sol"; |
| 8 | + |
| 9 | +import "./SpokePool.sol"; |
| 10 | + |
| 11 | +/** |
| 12 | + * @notice Spoke pool capable of executing calldata stored in L1 state via storage proof + Helios light client. |
| 13 | + * @dev This contract has one onlyOwner function to be used as an emergency fallback to execute a message to |
| 14 | + * this SpokePool in the case where the light-client is not functioning correctly. The owner is designed to be set |
| 15 | + * to a multisig contract on this chain. |
| 16 | + * @custom:security-contact [email protected] |
| 17 | + */ |
| 18 | +contract Universal_SpokePool is OwnableUpgradeable, SpokePool, CircleCCTPAdapter { |
| 19 | + /// @notice The data store contract that only the HubPool can write to. This spoke pool can only act on |
| 20 | + /// data that has been written to this store. |
| 21 | + address public immutable hubPoolStore; |
| 22 | + |
| 23 | + /// @notice Slot index of the HubPoolStore's relayMessageCallData mapping. |
| 24 | + uint256 public constant HUB_POOL_STORE_CALLDATA_MAPPING_SLOT_INDEX = 0; |
| 25 | + |
| 26 | + /// @notice The address of the Helios L1 light client contract. |
| 27 | + address public immutable helios; |
| 28 | + |
| 29 | + /// @notice The owner of this contract must wait until this amount of seconds have passed since the latest |
| 30 | + /// helios light client update to emergency execute a message. This prevents the owner from executing a message |
| 31 | + /// in the happy case where the light client is being regularly updated. Therefore, this value should be |
| 32 | + /// set to a very high value, like 24 hours. |
| 33 | + uint256 public immutable ADMIN_UPDATE_BUFFER; |
| 34 | + |
| 35 | + /// @notice Stores nonces of calldata stored in HubPoolStore that gets executed via executeMessage() |
| 36 | + /// to prevent replay attacks. |
| 37 | + mapping(uint256 => bool) public executedMessages; |
| 38 | + |
| 39 | + // Warning: this variable should _never_ be touched outside of this contract. It is intentionally set to be |
| 40 | + // private. Leaving it set to true can permanently disable admin calls. |
| 41 | + bool private _adminCallValidated; |
| 42 | + |
| 43 | + /// @notice Event emitted after off-chain agent sees HubPoolStore's emitted StoredCallData event and calls |
| 44 | + /// executeMessage() on this contract to relay the stored calldata. |
| 45 | + event RelayedCallData(uint256 indexed nonce, address caller); |
| 46 | + |
| 47 | + error NotTarget(); |
| 48 | + error AdminCallAlreadySet(); |
| 49 | + error SlotValueMismatch(); |
| 50 | + error AdminCallNotValidated(); |
| 51 | + error DelegateCallFailed(); |
| 52 | + error AlreadyExecuted(); |
| 53 | + error NotImplemented(); |
| 54 | + error AdminUpdateTooCloseToLastHeliosUpdate(); |
| 55 | + |
| 56 | + // All calls that have admin privileges must be fired from within the executeMessage method that validates that |
| 57 | + // the input data was published on L1 by the HubPool. This input data is then executed on this contract. |
| 58 | + // This modifier sets the adminCallValidated variable so this condition can be checked in _requireAdminSender(). |
| 59 | + modifier validateInternalCalls() { |
| 60 | + // Make sure adminCallValidated is set to True only once at beginning of the function, which prevents |
| 61 | + // the function from being re-entered. |
| 62 | + if (_adminCallValidated) { |
| 63 | + revert AdminCallAlreadySet(); |
| 64 | + } |
| 65 | + |
| 66 | + // This sets a variable indicating that we're now inside a validated call. |
| 67 | + // Note: this is used by other methods to ensure that this call has been validated by this method and is not |
| 68 | + // spoofed. |
| 69 | + _adminCallValidated = true; |
| 70 | + |
| 71 | + _; |
| 72 | + |
| 73 | + // Reset adminCallValidated to false to disallow admin calls after this method exits. |
| 74 | + _adminCallValidated = false; |
| 75 | + } |
| 76 | + |
| 77 | + /// @custom:oz-upgrades-unsafe-allow constructor |
| 78 | + constructor( |
| 79 | + uint256 _adminUpdateBufferSeconds, |
| 80 | + address _helios, |
| 81 | + address _hubPoolStore, |
| 82 | + address _wrappedNativeTokenAddress, |
| 83 | + uint32 _depositQuoteTimeBuffer, |
| 84 | + uint32 _fillDeadlineBuffer, |
| 85 | + IERC20 _l2Usdc, |
| 86 | + ITokenMessenger _cctpTokenMessenger |
| 87 | + ) |
| 88 | + SpokePool(_wrappedNativeTokenAddress, _depositQuoteTimeBuffer, _fillDeadlineBuffer) |
| 89 | + CircleCCTPAdapter(_l2Usdc, _cctpTokenMessenger, CircleDomainIds.Ethereum) |
| 90 | + { |
| 91 | + ADMIN_UPDATE_BUFFER = _adminUpdateBufferSeconds; |
| 92 | + helios = _helios; |
| 93 | + hubPoolStore = _hubPoolStore; |
| 94 | + } |
| 95 | + |
| 96 | + function initialize( |
| 97 | + uint32 _initialDepositId, |
| 98 | + address _crossDomainAdmin, |
| 99 | + address _withdrawalRecipient |
| 100 | + ) public initializer { |
| 101 | + __Ownable_init(); |
| 102 | + __SpokePool_init(_initialDepositId, _crossDomainAdmin, _withdrawalRecipient); |
| 103 | + } |
| 104 | + |
| 105 | + /** |
| 106 | + * @notice Relays calldata stored by the HubPool on L1 into this contract. |
| 107 | + * @dev Replay attacks are possible with this _message if this contract has the same address on another chain. |
| 108 | + * @param _messageNonce Nonce of message stored in HubPoolStore. |
| 109 | + * @param _message Message stored in HubPoolStore's relayMessageCallData mapping. Compared against raw value |
| 110 | + * in Helios light client for slot key corresponding to _messageNonce at block number. |
| 111 | + * @param _blockNumber Block number in light client we use to check slot value of slot key |
| 112 | + */ |
| 113 | + function executeMessage( |
| 114 | + uint256 _messageNonce, |
| 115 | + bytes calldata _message, |
| 116 | + uint256 _blockNumber |
| 117 | + ) external validateInternalCalls { |
| 118 | + bytes32 slotKey = getSlotKey(_messageNonce); |
| 119 | + // The expected slot value corresponds to the hash of the L2 calldata and its target, |
| 120 | + // as originally stored in the HubPoolStore's relayMessageCallData mapping. |
| 121 | + bytes32 expectedSlotValue = keccak256(_message); |
| 122 | + |
| 123 | + // Verify Helios light client has expected slot value. |
| 124 | + bytes32 slotValue = IHelios(helios).getStorageSlot(_blockNumber, hubPoolStore, slotKey); |
| 125 | + if (expectedSlotValue != slotValue) { |
| 126 | + revert SlotValueMismatch(); |
| 127 | + } |
| 128 | + |
| 129 | + // Validate state is intended to be sent to this contract. The target could have been set to the zero address |
| 130 | + // which is used by the StorageProof_Adapter to denote messages that can be sent to any target. |
| 131 | + (address target, bytes memory message) = abi.decode(_message, (address, bytes)); |
| 132 | + if (target != address(0) && target != address(this)) { |
| 133 | + revert NotTarget(); |
| 134 | + } |
| 135 | + |
| 136 | + // Prevent replay attacks. The slot key should be a hash of the nonce associated with this calldata in the |
| 137 | + // HubPoolStore, which maps the nonce to the _value. |
| 138 | + if (executedMessages[_messageNonce]) { |
| 139 | + revert AlreadyExecuted(); |
| 140 | + } |
| 141 | + executedMessages[_messageNonce] = true; |
| 142 | + emit RelayedCallData(_messageNonce, msg.sender); |
| 143 | + |
| 144 | + _executeCalldata(message); |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * @notice This function is only callable by the owner and is used as an emergency fallback to execute |
| 149 | + * calldata to this SpokePool in the case where the light-client is not able to be updated. |
| 150 | + * @dev This function will revert if the last Helios update was less than ADMIN_UPDATE_BUFFER seconds ago. |
| 151 | + * @param _message The calldata to execute on this contract. |
| 152 | + */ |
| 153 | + function adminExecuteMessage(bytes memory _message) external onlyOwner validateInternalCalls { |
| 154 | + uint256 heliosHeadTimestamp = IHelios(helios).headTimestamp(); |
| 155 | + if (heliosHeadTimestamp > block.timestamp || block.timestamp - heliosHeadTimestamp < ADMIN_UPDATE_BUFFER) { |
| 156 | + revert AdminUpdateTooCloseToLastHeliosUpdate(); |
| 157 | + } |
| 158 | + _executeCalldata(_message); |
| 159 | + } |
| 160 | + |
| 161 | + /** |
| 162 | + * @notice Computes the EVM storage slot key for a message nonce using the formula keccak256(key, slotIndex) |
| 163 | + * to find the storage slot for a value within a mapping(key=>value) at a slot index. We already know the |
| 164 | + * slot index of the relayMessageCallData mapping in the HubPoolStore. |
| 165 | + * @param _nonce The nonce associated with the message. |
| 166 | + * @return The computed storage slot key. |
| 167 | + */ |
| 168 | + function getSlotKey(uint256 _nonce) public pure returns (bytes32) { |
| 169 | + return keccak256(abi.encode(_nonce, HUB_POOL_STORE_CALLDATA_MAPPING_SLOT_INDEX)); |
| 170 | + } |
| 171 | + |
| 172 | + function _executeCalldata(bytes memory _calldata) internal { |
| 173 | + /// @custom:oz-upgrades-unsafe-allow delegatecall |
| 174 | + (bool success, ) = address(this).delegatecall(_calldata); |
| 175 | + if (!success) { |
| 176 | + revert DelegateCallFailed(); |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + function _bridgeTokensToHubPool(uint256 amountToReturn, address l2TokenAddress) internal override { |
| 181 | + if (_isCCTPEnabled() && l2TokenAddress == address(usdcToken)) { |
| 182 | + _transferUsdc(withdrawalRecipient, amountToReturn); |
| 183 | + } else { |
| 184 | + revert NotImplemented(); |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + // Check that the admin call is only triggered by a executeMessage() call. |
| 189 | + function _requireAdminSender() internal view override { |
| 190 | + if (!_adminCallValidated) { |
| 191 | + revert AdminCallNotValidated(); |
| 192 | + } |
| 193 | + } |
| 194 | +} |
0 commit comments