Skip to content

Commit afd501c

Browse files
committed
feat: WIP - EnsoCCIPReceiverDefensive - no reverts
1 parent 51d1b9c commit afd501c

File tree

2 files changed

+467
-0
lines changed

2 files changed

+467
-0
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
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

Comments
 (0)