|
| 1 | +// SPDX-License-Identifier: GPL-3.0-only |
| 2 | +pragma solidity ^0.8.24; |
| 3 | + |
| 4 | +import { IEnsoCCIPReceiverDefensive } from "../interfaces/IEnsoCCIPReceiverDefensive.sol"; |
| 5 | +import { IEnsoRouter, Token, TokenType } from "../interfaces/IEnsoRouter.sol"; |
| 6 | +import { CCIPReceiver, Client } from "chainlink-ccip/applications/CCIPReceiver.sol"; |
| 7 | +import { Ownable, Ownable2Step } from "openzeppelin-contracts/access/Ownable2Step.sol"; |
| 8 | +import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; |
| 9 | +import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; |
| 10 | + |
| 11 | +/// @title EnsoCCIPReceiverDefensive |
| 12 | +/// @author Enso |
| 13 | +/// @notice Destination-side CCIP receiver that validates source chain/sender, enforces replay |
| 14 | +/// protection, and forwards a single bridged ERC-20 to Enso Shortcuts via the Enso Router. |
| 15 | +/// @dev The contract: |
| 16 | +/// - Relies on Chainlink CCIP’s router gating via {CCIPReceiver}. |
| 17 | +/// - Adds allowlists for source chain selectors and source senders (per chain). |
| 18 | +/// - Guards against duplicate delivery with a messageId map. |
| 19 | +/// - Expects exactly one ERC-20 in `destTokenAmounts`; amount must be non-zero. |
| 20 | +/// - Executes Shortcuts through a self-call pattern (`try this.execute(...)`) so we can |
| 21 | +/// catch and handle reverts and sweep funds to a fallback receiver in the payload. |
| 22 | +contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, Ownable2Step, Pausable { |
| 23 | + using SafeERC20 for IERC20; |
| 24 | + |
| 25 | + uint256 private constant VERSION = 1; |
| 26 | + |
| 27 | + /// @dev Immutable Enso Router used to dispatch tokens + call Shortcuts. |
| 28 | + /// forge-lint: disable-next-item(screaming-snake-case-immutable) |
| 29 | + IEnsoRouter private immutable i_ensoRouter; |
| 30 | + |
| 31 | + /// @dev Allowlist by source chain selector. |
| 32 | + /// forge-lint: disable-next-item(mixed-case-variable) |
| 33 | + mapping(uint64 sourceChainSelector => bool isAllowed) private s_allowedSourceChain; |
| 34 | + |
| 35 | + /// @dev Per-(chain selector, sender) allowlist. |
| 36 | + /// Key is computed as: keccak256(abi.encode(sourceChainSelector, sender)), |
| 37 | + /// where `sender` is the EVM address decoded from `Any2EVMMessage.sender` bytes. |
| 38 | + /// forge-lint: disable-next-item(mixed-case-variable) |
| 39 | + mapping(bytes32 key => bool isAllowed) private s_allowedSender; |
| 40 | + |
| 41 | + mapping(bytes32 messageId => bool isEscrow) private s_escrowMessage; |
| 42 | + |
| 43 | + /// @dev Replay protection: tracks CCIP message IDs that were executed successfully (or handled). |
| 44 | + /// forge-lint: disable-next-item(mixed-case-variable) |
| 45 | + mapping(bytes32 messageId => bool wasExecuted) private s_executedMessage; |
| 46 | + |
| 47 | + /// @notice Initializes the receiver with the CCIP router and Enso Router. |
| 48 | + /// @dev The owner is set via {Ownable} base (passed in to support 2-step ownership if desired). |
| 49 | + /// @param _owner Address to set as initial owner. |
| 50 | + /// @param _ccipRouter Address of the CCIP Router on the destination chain. |
| 51 | + /// @param _ensoRouter Address of the Enso Router that will execute Shortcuts. |
| 52 | + constructor(address _owner, address _ccipRouter, address _ensoRouter) Ownable(_owner) CCIPReceiver(_ccipRouter) { |
| 53 | + i_ensoRouter = IEnsoRouter(_ensoRouter); |
| 54 | + } |
| 55 | + |
| 56 | + /// @notice CCIP router callback: validates message, enforces replay protection, and dispatches. |
| 57 | + /// @dev Flow: |
| 58 | + /// 1) Check duplicate by messageId (fail fast). |
| 59 | + /// 2) Check allowlisted source chain and sender (decoded from `message.sender`). |
| 60 | + /// 3) Enforce exactly one ERC-20 delivered (and non-zero amount). |
| 61 | + /// 4) Decode payload `(receiver, estimatedGas, shortcutData)`. |
| 62 | + /// 5) Optional gas self-check (if `estimatedGas` > 0). |
| 63 | + /// 6) Mark executed, attempt `execute(...)` via self-call; on failure, sweep token to `receiver`. |
| 64 | + /// @param _message The CCIP Any2EVM message with metadata, payload, and delivered tokens. |
| 65 | + function _ccipReceive(Client.Any2EVMMessage memory _message) internal override { |
| 66 | + ( |
| 67 | + address token, |
| 68 | + uint256 amount, |
| 69 | + address receiver, |
| 70 | + bytes memory shortcutData, |
| 71 | + ErrorCode errorCode, |
| 72 | + bytes memory errorData |
| 73 | + ) = _validateMessage(_message); |
| 74 | + |
| 75 | + if (errorCode != ErrorCode.NO_ERROR) { |
| 76 | + emit IEnsoCCIPReceiverDefensive.MessageValidationFailed(_message.messageId, errorCode, errorData); |
| 77 | + |
| 78 | + RefundKind refundKind = _getRefundPolicy(errorCode); |
| 79 | + if (refundKind == RefundKind.NONE) { |
| 80 | + // NOTE: ErrorCode.ALREADY_EXECUTED → no-op; |
| 81 | + return; |
| 82 | + } |
| 83 | + if (refundKind == RefundKind.TO_RECEIVER) { |
| 84 | + s_executedMessage[_message.messageId] = true; |
| 85 | + IERC20(token).safeTransfer(receiver, amount); |
| 86 | + return; |
| 87 | + } |
| 88 | + if (refundKind == RefundKind.TO_ESCROW) { |
| 89 | + s_executedMessage[_message.messageId] = true; |
| 90 | + emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); |
| 91 | + s_escrowMessage[_message.messageId] = true; |
| 92 | + } |
| 93 | + |
| 94 | + // NOTE: make sure this is caught in development |
| 95 | + revert EnsoCCIPReceiver_UnsupportedRefundKind(refundKind); |
| 96 | + } |
| 97 | + |
| 98 | + s_executedMessage[_message.messageId] = true; |
| 99 | + |
| 100 | + // Attempt Shortcuts execution; on failure, sweep funds to the fallback receiver. |
| 101 | + try this.execute(token, amount, shortcutData) { |
| 102 | + emit IEnsoCCIPReceiverDefensive.ShortcutExecutionSuccessful(_message.messageId); |
| 103 | + } catch (bytes memory err) { |
| 104 | + emit IEnsoCCIPReceiverDefensive.ShortcutExecutionFailed(_message.messageId, err); |
| 105 | + IERC20(token).safeTransfer(receiver, amount); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + function decodeMessageData(bytes calldata _data) external view returns (address, uint256, bytes memory) { |
| 110 | + if (msg.sender != address(this)) { |
| 111 | + revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_OnlySelf(); |
| 112 | + } |
| 113 | + |
| 114 | + return abi.decode(_data, (address, uint256, bytes)); |
| 115 | + } |
| 116 | + |
| 117 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 118 | + function execute(address _token, uint256 _amount, bytes calldata _shortcutData) external { |
| 119 | + if (msg.sender != address(this)) { |
| 120 | + revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_OnlySelf(); |
| 121 | + } |
| 122 | + Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(_token, _amount) }); |
| 123 | + IERC20(_token).forceApprove(address(i_ensoRouter), _amount); |
| 124 | + |
| 125 | + i_ensoRouter.routeSingle(tokenIn, _shortcutData); |
| 126 | + } |
| 127 | + |
| 128 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 129 | + function pause() external onlyOwner { |
| 130 | + _pause(); |
| 131 | + } |
| 132 | + |
| 133 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 134 | + function setAllowedSender(uint64 _sourceChainSelector, address _sender, bool _isAllowed) external onlyOwner { |
| 135 | + s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)] = _isAllowed; |
| 136 | + emit IEnsoCCIPReceiverDefensive.AllowedSenderSet(_sourceChainSelector, _sender, _isAllowed); |
| 137 | + } |
| 138 | + |
| 139 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 140 | + function setAllowedSourceChain(uint64 _sourceChainSelector, bool _isAllowed) external onlyOwner { |
| 141 | + s_allowedSourceChain[_sourceChainSelector] = _isAllowed; |
| 142 | + emit IEnsoCCIPReceiverDefensive.AllowedSourceChainSet(_sourceChainSelector, _isAllowed); |
| 143 | + } |
| 144 | + |
| 145 | + /// @dev currently only for malformed messages, as multiple tokens are not supported by CCIP |
| 146 | + function sweepMessageInEscrow( |
| 147 | + bytes32 _messageId, |
| 148 | + address _token, |
| 149 | + uint256 _amount, |
| 150 | + address _to |
| 151 | + ) |
| 152 | + external |
| 153 | + onlyOwner |
| 154 | + { |
| 155 | + if (!s_escrowMessage[_messageId]) { |
| 156 | + revert EnsoCCIPReceiver_MissingEscrow(_messageId); |
| 157 | + } |
| 158 | + delete s_escrowMessage[_messageId]; |
| 159 | + |
| 160 | + IERC20(_token).safeTransfer(_to, _amount); |
| 161 | + emit EscrowSwept(_messageId, _token, _amount, _to); |
| 162 | + } |
| 163 | + |
| 164 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 165 | + function unpause() external onlyOwner { |
| 166 | + _unpause(); |
| 167 | + } |
| 168 | + |
| 169 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 170 | + function getEnsoRouter() external view returns (address) { |
| 171 | + return address(i_ensoRouter); |
| 172 | + } |
| 173 | + |
| 174 | + function isMessageInEscrow(bytes32 _messageId) external view returns (bool) { |
| 175 | + return s_escrowMessage[_messageId]; |
| 176 | + } |
| 177 | + |
| 178 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 179 | + function isSenderAllowed(uint64 _sourceChainSelector, address _sender) external view returns (bool) { |
| 180 | + return s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)]; |
| 181 | + } |
| 182 | + |
| 183 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 184 | + function isSourceChainAllowed(uint64 _sourceChainSelector) external view returns (bool) { |
| 185 | + return s_allowedSourceChain[_sourceChainSelector]; |
| 186 | + } |
| 187 | + |
| 188 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 189 | + function version() external pure returns (uint256) { |
| 190 | + return VERSION; |
| 191 | + } |
| 192 | + |
| 193 | + /// @inheritdoc IEnsoCCIPReceiverDefensive |
| 194 | + function wasMessageExecuted(bytes32 _messageId) external view returns (bool) { |
| 195 | + return s_executedMessage[_messageId]; |
| 196 | + } |
| 197 | + |
| 198 | + function _getRefundPolicy(ErrorCode _errorCode) private pure returns (RefundKind) { |
| 199 | + if (_errorCode == ErrorCode.NO_ERROR || _errorCode == ErrorCode.ALREADY_EXECUTED) { |
| 200 | + return RefundKind.NONE; |
| 201 | + } |
| 202 | + if ( |
| 203 | + _errorCode == ErrorCode.PAUSED || _errorCode == ErrorCode.SOURCE_CHAIN_NOT_ALLOWED |
| 204 | + || _errorCode == ErrorCode.SENDER_NOT_ALLOWED || _errorCode == ErrorCode.INSUFFICIENT_GAS |
| 205 | + ) { |
| 206 | + return RefundKind.TO_RECEIVER; |
| 207 | + } |
| 208 | + if ( |
| 209 | + _errorCode == ErrorCode.MALFORMED_MESSAGE_DATA || _errorCode == ErrorCode.NO_TOKENS |
| 210 | + || _errorCode == ErrorCode.NO_TOKEN_AMOUNT || _errorCode == ErrorCode.TOO_MANY_TOKENS |
| 211 | + ) { |
| 212 | + return RefundKind.TO_ESCROW; |
| 213 | + } |
| 214 | + |
| 215 | + // NOTE: make sure this is caught in development |
| 216 | + revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_UnsupportedErrorCode(_errorCode); |
| 217 | + } |
| 218 | + |
| 219 | + function _validateMessage(Client.Any2EVMMessage memory _message) |
| 220 | + private |
| 221 | + view |
| 222 | + returns ( |
| 223 | + address token, |
| 224 | + uint256 amount, |
| 225 | + address receiver, |
| 226 | + bytes memory shortcutData, |
| 227 | + ErrorCode errorCode, |
| 228 | + bytes memory errorData |
| 229 | + ) |
| 230 | + { |
| 231 | + bytes32 messageId = _message.messageId; |
| 232 | + if (s_executedMessage[messageId]) { |
| 233 | + errorData = abi.encode(messageId); |
| 234 | + return (token, amount, receiver, shortcutData, ErrorCode.ALREADY_EXECUTED, errorData); |
| 235 | + } |
| 236 | + |
| 237 | + Client.EVMTokenAmount[] memory destTokenAmounts = _message.destTokenAmounts; |
| 238 | + if (destTokenAmounts.length == 0) { |
| 239 | + return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKENS, errorData); |
| 240 | + } |
| 241 | + |
| 242 | + if (destTokenAmounts.length > 1) { |
| 243 | + return (token, amount, receiver, shortcutData, ErrorCode.TOO_MANY_TOKENS, errorData); |
| 244 | + } |
| 245 | + |
| 246 | + token = destTokenAmounts[0].token; |
| 247 | + amount = destTokenAmounts[0].amount; |
| 248 | + |
| 249 | + if (amount == 0) { |
| 250 | + return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKEN_AMOUNT, errorData); |
| 251 | + } |
| 252 | + |
| 253 | + uint256 estimatedGas; |
| 254 | + // TODO: find an assembly alternative... |
| 255 | + try this.decodeMessageData(_message.data) returns ( |
| 256 | + address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData |
| 257 | + ) { |
| 258 | + receiver = decodedReceiver; |
| 259 | + estimatedGas = decodedEstimatedGas; |
| 260 | + shortcutData = decodedShortcutData; |
| 261 | + } catch { |
| 262 | + return (token, amount, receiver, shortcutData, ErrorCode.MALFORMED_MESSAGE_DATA, errorData); |
| 263 | + } |
| 264 | + |
| 265 | + if (paused()) { |
| 266 | + return (token, amount, receiver, shortcutData, ErrorCode.PAUSED, errorData); |
| 267 | + } |
| 268 | + |
| 269 | + uint64 sourceChainSelector = _message.sourceChainSelector; |
| 270 | + if (!s_allowedSourceChain[sourceChainSelector]) { |
| 271 | + errorData = abi.encode(sourceChainSelector); |
| 272 | + return (token, amount, receiver, shortcutData, ErrorCode.SOURCE_CHAIN_NOT_ALLOWED, errorData); |
| 273 | + } |
| 274 | + |
| 275 | + address sender = abi.decode(_message.sender, (address)); |
| 276 | + if (!s_allowedSender[_getAllowedSenderKey(sourceChainSelector, sender)]) { |
| 277 | + errorData = abi.encode(sourceChainSelector, sender); |
| 278 | + return (token, amount, receiver, shortcutData, ErrorCode.SENDER_NOT_ALLOWED, errorData); |
| 279 | + } |
| 280 | + |
| 281 | + uint256 availableGas = gasleft(); |
| 282 | + if (estimatedGas != 0 && availableGas < estimatedGas) { |
| 283 | + errorData = abi.encode(availableGas, estimatedGas); |
| 284 | + return (token, amount, receiver, shortcutData, ErrorCode.INSUFFICIENT_GAS, errorData); |
| 285 | + } |
| 286 | + |
| 287 | + return (token, amount, receiver, shortcutData, ErrorCode.NO_ERROR, errorData); |
| 288 | + } |
| 289 | + |
| 290 | + /// @dev Computes the composite allowlist key for (chainSelector, sender). |
| 291 | + /// ABI-equivalent to: |
| 292 | + /// keccak256(abi.encode(chainSelector, sender)) |
| 293 | + /// and implemented in Yul to avoid an extra temporary allocation. |
| 294 | + /// Semantics are identical to the high-level version. |
| 295 | + /// |
| 296 | + /// Canonicality (no masking required): |
| 297 | + /// - `sender` is a canonical Solidity `address`, either decoded via |
| 298 | + /// `abi.decode(...,(address))` from `Any2EVMMessage.sender` or received |
| 299 | + /// as a public/external ABI parameter. In both cases the VM zero-extends |
| 300 | + /// it to a full 32-byte word when written to memory. |
| 301 | + /// - `chainSelector` is a `uint64` and is zero-extended to 32 bytes by the ABI/VM. |
| 302 | + /// |
| 303 | + /// @param _chainSelector The CCIP source chain selector (uint64). |
| 304 | + /// @param _sender The source application address decoded from `Any2EVMMessage.sender`. |
| 305 | + /// @return allowKey keccak256(abi.encode(_chainSelector, _sender)). |
| 306 | + function _getAllowedSenderKey(uint64 _chainSelector, address _sender) private pure returns (bytes32 allowKey) { |
| 307 | + assembly ("memory-safe") { |
| 308 | + let ptr := mload(0x40) |
| 309 | + mstore(ptr, _chainSelector) |
| 310 | + mstore(add(ptr, 0x20), _sender) |
| 311 | + allowKey := keccak256(ptr, 0x40) |
| 312 | + } |
| 313 | + } |
| 314 | +} |
0 commit comments