From bdc0010aac302f07dd4ae6c1454b1d9f477b7665 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Mon, 27 Oct 2025 14:32:38 +0100 Subject: [PATCH 01/21] chore: installed Chainlink CCIP dependencies --- dependencies/chainlink-ccip-1.6.2 | 1 + foundry.toml | 1 + remappings.txt | 1 + soldeer.lock | 6 ++++++ 4 files changed, 9 insertions(+) create mode 160000 dependencies/chainlink-ccip-1.6.2 diff --git a/dependencies/chainlink-ccip-1.6.2 b/dependencies/chainlink-ccip-1.6.2 new file mode 160000 index 0000000..0e3e0fc --- /dev/null +++ b/dependencies/chainlink-ccip-1.6.2 @@ -0,0 +1 @@ +Subproject commit 0e3e0fc5c0f70f0d50dca66b139142ddf3009294 diff --git a/foundry.toml b/foundry.toml index f3263b2..43fcba3 100644 --- a/foundry.toml +++ b/foundry.toml @@ -36,6 +36,7 @@ remappings_location = "txt" [dependencies] account-abstraction-v7 = { version = "0.7.0", git = "https://github.com/eth-infinitism/account-abstraction.git", rev = "7af70c8993a6f42973f520ae0752386a5032abe7" } +chainlink-ccip = { version = "1.6.2", git = "https://github.com/smartcontractkit/chainlink-ccip.git", rev = "0e3e0fc5c0f70f0d50dca66b139142ddf3009294"} devtools = { version = "0.0.1", git = "https://github.com/LayerZero-Labs/devtools.git", rev = "ac8912867862f6dd737b0febabd8d3cb8f142df7" } enso-weiroll = { version = "1.4.1", git = "https://github.com/EnsoBuild/enso-weiroll.git", rev = "900250114203727ff236d3f6313673c17c2d90dd" } forge-std = { version = "1.9.7", git = "https://github.com/foundry-rs/forge-std.git", tag = "v1.9.7" } diff --git a/remappings.txt b/remappings.txt index 9b9468f..0f13a77 100644 --- a/remappings.txt +++ b/remappings.txt @@ -34,3 +34,4 @@ safe-tools-0.2.0/=dependencies/safe-tools-0.2.0/src/ solady-0.1.22/=dependencies/solady-0.1.22/src/ v4-core-4.0.0/=dependencies/v4-core-4.0.0/src/ v4-periphery-4.0.0/=dependencies/v4-periphery-4.0.0/src/ +chainlink-ccip-1.6.2/=dependencies/chainlink-ccip-1.6.2/chains/evm/contracts/ diff --git a/soldeer.lock b/soldeer.lock index 62d9be6..fcf1d28 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -10,6 +10,12 @@ version = "0.7.0" git = "https://github.com/eth-infinitism/account-abstraction.git" rev = "7af70c8993a6f42973f520ae0752386a5032abe7" +[[dependencies]] +name = "chainlink-ccip" +version = "1.6.2" +git = "https://github.com/smartcontractkit/chainlink-ccip.git" +rev = "0e3e0fc5c0f70f0d50dca66b139142ddf3009294" + [[dependencies]] name = "devtools" version = "0.0.1" From a7b9fe1fa0ef92f18522a69da526ef919daff337 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Tue, 28 Oct 2025 16:00:58 +0100 Subject: [PATCH 02/21] feat: added EnsoCCIPReceiver --- .bash/forge-fmt.sh | 5 + .bash/forge-lint.sh | 29 ++++++ foundry.toml | 12 +++ package.json | 6 +- remappings.txt | 3 +- src/bridge/EnsoCCIPReceiver.sol | 145 +++++++++++++++++++++++++++ src/interfaces/IEnsoCCIPReceiver.sol | 117 +++++++++++++++++++++ 7 files changed, 313 insertions(+), 4 deletions(-) create mode 100755 .bash/forge-fmt.sh create mode 100755 .bash/forge-lint.sh create mode 100644 src/bridge/EnsoCCIPReceiver.sol create mode 100644 src/interfaces/IEnsoCCIPReceiver.sol diff --git a/.bash/forge-fmt.sh b/.bash/forge-fmt.sh new file mode 100755 index 0000000..7bba0c3 --- /dev/null +++ b/.bash/forge-fmt.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +echo "πŸ”Ž Running forge fmt (default profile in foundry.toml)..." +forge fmt diff --git a/.bash/forge-lint.sh b/.bash/forge-lint.sh new file mode 100755 index 0000000..fade695 --- /dev/null +++ b/.bash/forge-lint.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +echo "πŸ”Ž Running forge lint (default profile in foundry.toml)..." + +# Capture BOTH stdout and stderr, but don't fail on forge's exit code +set +e +RAW_OUTPUT="$(forge lint --color never 2>&1)" +set -e + +# Show raw linter output (so CI logs display everything) +printf '%s\n' "$RAW_OUTPUT" + +# Normalize line endings (remove any CRs from CRLF) +SANITIZED="$(printf '%s\n' "$RAW_OUTPUT" | tr -d '\r')" + +# Extract diagnostic lines that start with a severity (allow optional leading spaces), +# and ignore codesize warnings +DIAGNOSTICS="$(printf '%s\n' "$SANITIZED" \ + | grep -a -E '^[[:space:]]*(high|med|low|info|gas|warning|note)\[' \ + | grep -vF '[codesize]' \ + || true)" + +if [ -n "$DIAGNOSTICS" ]; then + echo "❌ Linting failed: either fix or disable [high|med|low|info|gas|warning|note] before committing." + exit 1 +fi + +echo "βœ… Pre-commit checks passed." diff --git a/foundry.toml b/foundry.toml index 43fcba3..913165d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -17,6 +17,10 @@ via_ir = true [fmt] bracket_spacing = true contract_new_lines = false +ignore = [ + # --- Already audited contracts START --- + # --- Already audited contracts FINISH --- +] int_types = "long" line_length = 120 multiline_func_header = "all" @@ -26,6 +30,14 @@ sort_imports = true tab_width = 4 wrap_comments = true +[lint] +lint_on_build = false +ignore = [ + # --- Already audited contracts START --- + # --- Already audited contracts FINISH --- + 'test/**/*.sol', +] + [soldeer] recursive_deps = true remappings_generate = false # NB: temporary disabled to avoid compilation issues diff --git a/package.json b/package.json index 085740d..9f81c59 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "scripts": { "foundry:update": "foundryup && forge soldeer update && forge remappings", "prepare": "husky", - "format": "prettier --check .", - "format:fix": "prettier --write .", - "format:fix:sol": "forge fmt", + "format": "prettier --check . && forge fmt --check", + "format:fix": "prettier --write . && forge fmt", + "lint:fix": "prettier --write . && ./.bash/forge-lint.sh", "test:enso_checkout:fork": "forge test --match-path 'test/fork/enso-checkout/*.t.sol'", "test:enso_checkout:unit": "forge test --match-path 'test/unit/concrete/{delegate/ensoReceiver,factory/erc4337CloneFactory,paymaster/signaturePaymaster}/*.t.sol'", "test:enso_checkout:mutation": "node scripts/runEnsoCheckoutMutationTests.mjs" diff --git a/remappings.txt b/remappings.txt index 0f13a77..07b223c 100644 --- a/remappings.txt +++ b/remappings.txt @@ -16,7 +16,6 @@ safe-tools/=dependencies/safe-tools-0.2.0/src/ solady/=dependencies/solady-0.1.22/src/ solmate/=dependencies/solady-0.1.22/lib/solmate/src/ @ensdomains/=dependencies/v4-core-4.0.0/node_modules/@ensdomains/ -@openzeppelin/=dependencies/@openzeppelin-contracts-5.2.0/ forge-gas-snapshot/=dependencies/v4-periphery-4.0.0/lib/permit2/lib/forge-gas-snapshot/src/ hardhat/=dependencies/v4-core-4.0.0/node_modules/hardhat/ permit2/=dependencies/v4-periphery-4.0.0/lib/permit2/ @@ -34,4 +33,6 @@ safe-tools-0.2.0/=dependencies/safe-tools-0.2.0/src/ solady-0.1.22/=dependencies/solady-0.1.22/src/ v4-core-4.0.0/=dependencies/v4-core-4.0.0/src/ v4-periphery-4.0.0/=dependencies/v4-periphery-4.0.0/src/ +chainlink-ccip=dependencies/chainlink-ccip-1.6.2/chains/evm/contracts/ chainlink-ccip-1.6.2/=dependencies/chainlink-ccip-1.6.2/chains/evm/contracts/ +@openzeppelin/contracts@5.0.2/utils/introspection/IERC165.sol=dependencies/@openzeppelin-contracts-5.2.0/contracts/utils/introspection/IERC165.sol diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol new file mode 100644 index 0000000..287801b --- /dev/null +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.24; + +import { IEnsoCCIPReceiver } from "../interfaces/IEnsoCCIPReceiver.sol"; +import { IEnsoRouter, Token, TokenType } from "../interfaces/IEnsoRouter.sol"; +import { CCIPReceiver, Client } from "chainlink-ccip/applications/CCIPReceiver.sol"; +import { Ownable, Ownable2Step } from "openzeppelin-contracts/access/Ownable2Step.sol"; +import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; + +/// @title EnsoCCIPReceiver +/// @author Enso +/// @notice Destination-side CCIP receiver that validates source chain/sender, enforces replay +/// protection, and forwards a single bridged ERC-20 to Enso Shortcuts via the Enso Router. +/// @dev The contract: +/// - Relies on Chainlink CCIP’s router gating via {CCIPReceiver}. +/// - Adds allowlists for source chain selectors and source senders (per chain). +/// - Guards against duplicate delivery with a messageId map. +/// - Expects exactly one ERC-20 in `destTokenAmounts`; amount must be non-zero. +/// - Executes Shortcuts through a self-call pattern (`try this.execute(...)`) so we can +/// catch and handle reverts and sweep funds to a fallback receiver in the payload. +contract EnsoCCIPReceiver is Ownable2Step, CCIPReceiver, IEnsoCCIPReceiver { + using SafeERC20 for IERC20; + + /// @dev Immutable Enso Router used to dispatch tokens + call Shortcuts. + IEnsoRouter private immutable i_ensoRouter; + + /// @dev Allowlist by source chain selector. + mapping(uint64 sourceChainSelector => bool isAllowed) private s_allowedSourceChain; + /// @dev Allowlist of source senders per chain selector. + mapping(uint64 sourceChainSelector => mapping(address sender => bool isAllowed)) private s_allowedSender; + /// @dev Replay protection: tracks CCIP message IDs that were executed successfully (or handled). + mapping(bytes32 messageId => bool wasExecuted) private s_executedMessage; + + /// @notice Initializes the receiver with the CCIP router and Enso Router. + /// @dev The owner is set via {Ownable} base (passed in to support 2-step ownership if desired). + /// @param _owner Address to set as initial owner. + /// @param _ccipRouter Address of the CCIP Router on the destination chain. + /// @param _ensoRouter Address of the Enso Router that will execute Shortcuts. + constructor(address _owner, address _ccipRouter, address _ensoRouter) Ownable(_owner) CCIPReceiver(_ccipRouter) { + i_ensoRouter = IEnsoRouter(_ensoRouter); + } + + /// @notice CCIP router callback: validates message, enforces replay protection, and dispatches. + /// @dev Flow: + /// 1) Check duplicate by messageId (fail fast). + /// 2) Check allowlisted source chain and sender (decoded from `message.sender`). + /// 3) Enforce exactly one ERC-20 delivered (and non-zero amount). + /// 4) Decode payload `(receiver, estimatedGas, shortcutData)`. + /// 5) Optional gas self-check (if `estimatedGas` > 0). + /// 6) Mark executed, attempt `execute(...)` via self-call; on failure, sweep token to `receiver`. + /// @param _message The CCIP Any2EVM message with metadata, payload, and delivered tokens. + function _ccipReceive(Client.Any2EVMMessage memory _message) internal override { + bytes32 messageId = _message.messageId; + if (s_executedMessage[messageId]) { + revert IEnsoCCIPReceiver.EnsoCCIPReceiver_AlreadyExecuted(messageId); + } + + uint64 sourceChainSelector = _message.sourceChainSelector; + if (!s_allowedSourceChain[sourceChainSelector]) { + revert IEnsoCCIPReceiver.EnsoCCIPReceiver_SourceChainNotAllowed(sourceChainSelector); + } + + address sender = abi.decode(_message.sender, (address)); + if (!s_allowedSender[sourceChainSelector][sender]) { + revert IEnsoCCIPReceiver.EnsoCCIPReceiver_SenderNotAllowed(sourceChainSelector, sender); + } + + Client.EVMTokenAmount[] memory destTokenAmounts = _message.destTokenAmounts; + if (destTokenAmounts.length == 0) { + revert IEnsoCCIPReceiver.EnsoCCIPReceiver_NoTokens(); + } + + if (destTokenAmounts.length > 1) { + revert IEnsoCCIPReceiver.EnsoCCIPReceiver_TooManyTokens(); + } + + address token = destTokenAmounts[0].token; + uint256 amount = destTokenAmounts[0].amount; + + if (amount == 0) { + revert IEnsoCCIPReceiver.EnsoCCIPReceiver_NoTokenAmount(token); + } + + (address receiver, uint256 estimatedGas, bytes memory shortcutData) = + abi.decode(_message.data, (address, uint256, bytes)); + + uint256 availableGas = gasleft(); + if (estimatedGas != 0 && availableGas < estimatedGas) { + revert IEnsoCCIPReceiver.EnsoCCIPReceiver_InsufficientGas(availableGas, estimatedGas); + } + + s_executedMessage[messageId] = true; + + // Attempt Shortcuts execution; on failure, sweep funds to the fallback receiver. + try this.execute(token, amount, shortcutData) { + emit IEnsoCCIPReceiver.ShortcutExecutionSuccessful(messageId); + } catch (bytes memory err) { + emit IEnsoCCIPReceiver.ShortcutExecutionFailed(messageId, err); + IERC20(token).safeTransfer(receiver, amount); + } + } + + /// @inheritdoc IEnsoCCIPReceiver + function execute(address _token, uint256 _amount, bytes calldata _shortcutData) external { + if (msg.sender != address(this)) { + revert IEnsoCCIPReceiver.EnsoCCIPReceiver_OnlySelf(); + } + Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(_token, _amount) }); + IERC20(_token).forceApprove(address(i_ensoRouter), _amount); + + i_ensoRouter.routeSingle(tokenIn, _shortcutData); + } + + /// @inheritdoc IEnsoCCIPReceiver + function setAllowedSender(uint64 _sourceChainSelector, address _sender, bool _isAllowed) external onlyOwner { + s_allowedSender[_sourceChainSelector][_sender] = _isAllowed; + emit IEnsoCCIPReceiver.AllowedSenderSet(_sourceChainSelector, _sender, _isAllowed); + } + + /// @inheritdoc IEnsoCCIPReceiver + function setAllowedSourceChain(uint64 _sourceChainSelector, bool _isAllowed) external onlyOwner { + s_allowedSourceChain[_sourceChainSelector] = _isAllowed; + emit IEnsoCCIPReceiver.AllowedSourceChainSet(_sourceChainSelector, _isAllowed); + } + + /// @inheritdoc IEnsoCCIPReceiver + function getEnsoRouter() external view returns (address) { + return address(i_ensoRouter); + } + + /// @inheritdoc IEnsoCCIPReceiver + function isSenderAllowed(uint64 _sourceChainSelector, address _sender) external view returns (bool) { + return s_allowedSender[_sourceChainSelector][_sender]; + } + + /// @inheritdoc IEnsoCCIPReceiver + function isSourceChainAllowed(uint64 _sourceChainSelector) external view returns (bool) { + return s_allowedSourceChain[_sourceChainSelector]; + } + + /// @inheritdoc IEnsoCCIPReceiver + function wasMessageExecuted(bytes32 _messageId) external view returns (bool) { + return s_executedMessage[_messageId]; + } +} diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol new file mode 100644 index 0000000..7f2c6e7 --- /dev/null +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.0; + +/// @title IEnsoCCIPReceiver +/// @author Enso +/// @notice Interface for a CCIP destination receiver that validates source (chain/sender), +/// enforces replay protection, and forwards a single bridged ERC-20 into Enso Shortcuts. +/// @dev Exposes only the external/public surface of the implementing receiver: +/// - Admin setters for allowlists +/// - Self-call execution entry used by try/catch in `_ccipReceive` +/// - Views for router/allowlists/replay state +interface IEnsoCCIPReceiver { + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + /// @notice Emitted when an allowed sender is (de)authorized for a source chain. + /// @param sourceChainSelector Chain selector of the source network. + /// @param sender Address of the source application on that chain. + /// @param isAllowed True if allowed, false if disallowed. + event AllowedSenderSet(uint64 indexed sourceChainSelector, address indexed sender, bool isAllowed); + + /// @notice Emitted when a source chain is (de)authorized. + /// @param sourceChainSelector Chain selector of the source network. + /// @param isAllowed True if allowed, false if disallowed. + event AllowedSourceChainSet(uint64 indexed sourceChainSelector, bool isAllowed); + + /// @notice Emitted when Enso Shortcuts execution succeeds for a CCIP message. + /// @param messageId CCIP message identifier. + event ShortcutExecutionSuccessful(bytes32 indexed messageId); + + /// @notice Emitted when Enso Shortcuts execution reverts for a CCIP message. + /// @param messageId CCIP message identifier. + /// @param err ABI-encoded revert data from the failed call. + event ShortcutExecutionFailed(bytes32 indexed messageId, bytes err); + + // ------------------------------------------------------------------------- + // Errors + // ------------------------------------------------------------------------- + + /// @notice Revert when a CCIP message with the same ID was already processed. + /// @param messageId CCIP message identifier. + error EnsoCCIPReceiver_AlreadyExecuted(bytes32 messageId); + + /// @notice Revert when available gas is below the estimated threshold from payload. + /// @param availableGas Remaining gas at the check point. + /// @param estimatedGas Gas amount the sender expects to be available. + error EnsoCCIPReceiver_InsufficientGas(uint256 availableGas, uint256 estimatedGas); + + /// @notice Revert when the CCIP message carries no tokens. + error EnsoCCIPReceiver_NoTokens(); + + /// @notice Revert when the delivered single token amount is zero. + /// @param token ERC-20 token address. + error EnsoCCIPReceiver_NoTokenAmount(address token); + + /// @notice Revert when an external caller targets the internal executor. + error EnsoCCIPReceiver_OnlySelf(); + + /// @notice Revert when the source chain is not allowlisted. + /// @param sourceChainSelector Chain selector of the source network. + error EnsoCCIPReceiver_SourceChainNotAllowed(uint64 sourceChainSelector); + + /// @notice Revert when the source sender is not allowlisted for a given chain. + /// @param sourceChainSelector Chain selector of the source network. + /// @param sender Address of the source application. + error EnsoCCIPReceiver_SenderNotAllowed(uint64 sourceChainSelector, address sender); + + /// @notice Revert when more than one token is delivered (not supported). + error EnsoCCIPReceiver_TooManyTokens(); + + // ------------------------------------------------------------------------- + // External Functions + // ------------------------------------------------------------------------- + + /// @notice Executes Enso Shortcuts with a single ERC-20 that was previously received via CCIP. + /// @dev MUST be callable only by the contract itself (self-call), typically from `_ccipReceive` + /// using `try this.execute(...)`. Implementations should guard with + /// `if (msg.sender != address(this)) revert EnsoCCIPReceiver_OnlySelf();` + /// @param token ERC-20 token to route. + /// @param amount Amount of `token` to route. + /// @param shortcutData ABI-encoded call data for the Enso Shortcuts entrypoint. + function execute(address token, uint256 amount, bytes calldata shortcutData) external; + + /// @notice Adds or removes an allowed sender for a specific source chain. + /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). + /// @param sourceChainSelector Chain selector of the source network. + /// @param sender Address of the source application on that chain. + /// @param isAllowed True to allow, false to disallow. + function setAllowedSender(uint64 sourceChainSelector, address sender, bool isAllowed) external; + + /// @notice Adds or removes an allowed source chain. + /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). + /// @param sourceChainSelector Chain selector of the source network. + /// @param isAllowed True to allow, false to disallow. + function setAllowedSourceChain(uint64 sourceChainSelector, bool isAllowed) external; + + /// @notice Returns the Enso Router address used by this receiver. + /// @return router Address of the Enso Router. + function getEnsoRouter() external view returns (address router); + + /// @notice Returns whether a sender is allowlisted for a given source chain. + /// @param sourceChainSelector Chain selector of the source network. + /// @param sender Address of the source application on that chain. + /// @return allowed True if the sender is allowed. + function isSenderAllowed(uint64 sourceChainSelector, address sender) external view returns (bool allowed); + + /// @notice Returns whether a source chain is allowlisted. + /// @param sourceChainSelector Chain selector of the source network. + /// @return allowed True if the source chain is allowed. + function isSourceChainAllowed(uint64 sourceChainSelector) external view returns (bool allowed); + + /// @notice Returns whether a CCIP message was already executed. + /// @param messageId CCIP message identifier. + /// @return executed True if the message was marked as executed. + function wasMessageExecuted(bytes32 messageId) external view returns (bool executed); +} From 998c5d9c754aa136a427b817a048bdb92f1113b8 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Tue, 28 Oct 2025 16:10:52 +0100 Subject: [PATCH 03/21] chore: forge fmt & forge lint --- foundry.toml | 80 +++++++++++++++++++ src/bridge/EnsoCCIPReceiver.sol | 6 ++ src/helpers/HyperCoreHelpers.sol | 1 + src/libraries/DataTypes.sol | 1 + test/Bridge.t.sol | 28 ++++--- test/DelegateEnsoShortcuts.t.sol | 10 +-- ...t_SmartWallet_EntryPointV7_Fork_Test.t.sol | 22 ++--- test/mocks/MockMultiVault.sol | 12 +-- test/mocks/MockNFTVault.sol | 8 +- test/mocks/MockVault.sol | 4 +- test/mocks/TestPaymaster.sol | 3 +- test/shortcuts/ShortcutsEthereum.sol | 4 +- .../delegate/ensoReceiver/safeExecute.t.sol | 20 ++--- 13 files changed, 143 insertions(+), 56 deletions(-) diff --git a/foundry.toml b/foundry.toml index 913165d..a140dbe 100644 --- a/foundry.toml +++ b/foundry.toml @@ -19,6 +19,37 @@ bracket_spacing = true contract_new_lines = false ignore = [ # --- Already audited contracts START --- + 'src/bridge/LayerZeroReceiver.sol', + 'src/bridge/interfaces/layerzero/IPool.sol', + 'src/delegate/DelegateEnsoShortcuts.sol', + 'src/delegate/EIP7702EnsoShortcuts.sol', + 'src/delegate/EnsoReceiver.sol', + 'src/factory/interfaces/IEnsoRouter.sol', + 'src/factory/ERC4337CloneFactory.sol', + 'src/helpers/BalancerHelpers.sol', + 'src/helpers/DecimalHelpers.sol', + 'src/helpers/EnsoShortcutsHelpers.sol', + 'src/helpers/ERC20Helpers.sol', + 'src/helpers/MathHelpers.sol', + 'src/helpers/MaverickV2Helpers.sol', + 'src/helpers/PercentageMathHelpers.sol', + 'src/helpers/SignedMathHelpers.sol', + 'src/helpers/SommelierHelpers.sol', + 'src/helpers/SwapHelpers.sol', + 'src/helpers/TupleHelpers.sol', + 'src/helpers/UniswapV4Helpers.sol', + 'src/helpers/WeirollVerifier.sol', + 'src/interfaces/IEnsoRouter.sol', + 'src/libraries/DataTypes.sol', + 'src/paymaster/SignaturePaymaster.sol', + 'src/router/EnsoRouter.sol', + 'src/solvers/BaseSolver.sol', + 'src/solvers/BebopSolver.sol', + 'src/solvers/MinimalWallet.sol', + 'src/utils/Withdrawable.sol', + 'src/AbstractEnsoShortcuts.sol', + 'src/AbstractMultiSend.sol', + 'src/EnsoShortcuts.sol', # --- Already audited contracts FINISH --- ] int_types = "long" @@ -26,6 +57,7 @@ line_length = 120 multiline_func_header = "all" number_underscore = "thousands" quote_style = "double" +single_line_statement_blocks = "multi" sort_imports = true tab_width = 4 wrap_comments = true @@ -33,7 +65,55 @@ wrap_comments = true [lint] lint_on_build = false ignore = [ + 'script/BaseSolverDeployer.s.sol', + 'script/BebopSolverDeployer.s.sol', + 'script/ClientDeployer.s.sol', + 'script/DelegateDeployer.s.sol', + 'script/EIP7702EnsoShortcutsDeployer.s.sol', + 'script/ERC20HelpersDeployer.s.sol', + 'script/EnsoReceiverAndPaymasterDeployer.s.sol', + 'script/EnsoReceiverDeployer.s.sol', + 'script/EnsoRouterDeployer.s.sol', + 'script/FullDeployer.s.sol', + 'script/HelpersDeployer.s.sol', + 'script/HyperCoreHelpersDeployer.s.sol', + 'script/LayerZeroDeployer.s.sol', + 'script/MaverickV2HelpersDeployer.s.sol', + 'script/SocketDeployer.s.sol', + 'script/SwapHelpersDeployer.s.sol', + 'script/UniswapV4Deployer.s.sol', # --- Already audited contracts START --- + 'src/bridge/LayerZeroReceiver.sol', + 'src/bridge/interfaces/layerzero/IPool.sol', + 'src/delegate/DelegateEnsoShortcuts.sol', + 'src/delegate/EIP7702EnsoShortcuts.sol', + 'src/delegate/EnsoReceiver.sol', + 'src/factory/interfaces/IEnsoRouter.sol', + 'src/factory/ERC4337CloneFactory.sol', + 'src/helpers/BalancerHelpers.sol', + 'src/helpers/DecimalHelpers.sol', + 'src/helpers/EnsoShortcutsHelpers.sol', + 'src/helpers/ERC20Helpers.sol', + 'src/helpers/MathHelpers.sol', + 'src/helpers/MaverickV2Helpers.sol', + 'src/helpers/PercentageMathHelpers.sol', + 'src/helpers/SignedMathHelpers.sol', + 'src/helpers/SommelierHelpers.sol', + 'src/helpers/SwapHelpers.sol', + 'src/helpers/TupleHelpers.sol', + 'src/helpers/UniswapV4Helpers.sol', + 'src/helpers/WeirollVerifier.sol', + 'src/interfaces/IEnsoRouter.sol', + 'src/libraries/DataTypes.sol', + 'src/paymaster/SignaturePaymaster.sol', + 'src/router/EnsoRouter.sol', + 'src/solvers/BaseSolver.sol', + 'src/solvers/BebopSolver.sol', + 'src/solvers/MinimalWallet.sol', + 'src/utils/Withdrawable.sol', + 'src/AbstractEnsoShortcuts.sol', + 'src/AbstractMultiSend.sol', + 'src/EnsoShortcuts.sol', # --- Already audited contracts FINISH --- 'test/**/*.sol', ] diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index 287801b..c86f792 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -22,13 +22,19 @@ contract EnsoCCIPReceiver is Ownable2Step, CCIPReceiver, IEnsoCCIPReceiver { using SafeERC20 for IERC20; /// @dev Immutable Enso Router used to dispatch tokens + call Shortcuts. + /// forge-lint: disable-next-item(screaming-snake-case-immutable) IEnsoRouter private immutable i_ensoRouter; /// @dev Allowlist by source chain selector. + /// forge-lint: disable-next-item(mixed-case-variable) mapping(uint64 sourceChainSelector => bool isAllowed) private s_allowedSourceChain; + /// @dev Allowlist of source senders per chain selector. + /// forge-lint: disable-next-item(mixed-case-variable) mapping(uint64 sourceChainSelector => mapping(address sender => bool isAllowed)) private s_allowedSender; + /// @dev Replay protection: tracks CCIP message IDs that were executed successfully (or handled). + /// forge-lint: disable-next-item(mixed-case-variable) mapping(bytes32 messageId => bool wasExecuted) private s_executedMessage; /// @notice Initializes the receiver with the CCIP router and Enso Router. diff --git a/src/helpers/HyperCoreHelpers.sol b/src/helpers/HyperCoreHelpers.sol index a9e6bfc..afda591 100644 --- a/src/helpers/HyperCoreHelpers.sol +++ b/src/helpers/HyperCoreHelpers.sol @@ -15,6 +15,7 @@ contract HyperCoreHelpers { pure returns (bytes memory payload) { + /// forge-lint: disable-next-item(unsafe-typecast) payload = abi.encodePacked( ENCODING_VERSION, ACTION_6, abi.encode(_receiver, uint64(_tokenIndex), uint64(_amountInCoreWei)) ); diff --git a/src/libraries/DataTypes.sol b/src/libraries/DataTypes.sol index cb0f362..663ff4e 100644 --- a/src/libraries/DataTypes.sol +++ b/src/libraries/DataTypes.sol @@ -14,6 +14,7 @@ library ChainId { uint256 public constant HYPER = 999; uint256 public constant SONEIUM = 1868; uint256 public constant BASE = 8453; + uint256 public constant PLASMA = 9745; uint256 public constant ARBITRUM = 42_161; uint256 public constant AVALANCHE = 43_114; uint256 public constant INK = 57_073; diff --git a/test/Bridge.t.sol b/test/Bridge.t.sol index 06b7782..144c2c3 100644 --- a/test/Bridge.t.sol +++ b/test/Bridge.t.sol @@ -64,7 +64,9 @@ contract BridgeTest is Test { // transfer funds (bool success,) = address(lzReceiver).call{ value: ETH_AMOUNT }(""); - if (!success) revert TransferFailed(); + if (!success) { + revert TransferFailed(); + } // trigger compose lzReceiver.lzCompose(ethPool, bytes32(0), message, address(0), ""); uint256 balanceAfter = weth.balanceOf(address(this)); @@ -82,7 +84,9 @@ contract BridgeTest is Test { // transfer funds (bool success,) = address(lzReceiver).call{ value: ETH_AMOUNT }(""); - if (!success) revert TransferFailed(); + if (!success) { + revert TransferFailed(); + } // confirm funds have left this address assertGt(balanceBefore, address(this).balance); // trigger compose @@ -97,15 +101,21 @@ contract BridgeTest is Test { (bytes32[] memory commands, bytes[] memory state) = _buildWethDeposit(ETH_AMOUNT); // exact gas amount needed for execution uint256 estimatedGas = 75_272; - bytes memory message = _buildLzComposeMessage(ETH_AMOUNT, 0, estimatedGas, commands, state); + bytes memory message = _buildLzComposeMessage(ETH_AMOUNT, 0, estimatedGas, commands, state); // transfer funds (bool success,) = address(lzReceiver).call{ value: ETH_AMOUNT }(""); - if (!success) revert TransferFailed(); + if (!success) { + revert TransferFailed(); + } // trigger compose with insufficient gas - vm.expectRevert(abi.encodeWithSelector(LayerZeroReceiver.InsufficientGas.selector, bytes32(0), estimatedGas, estimatedGas - 1)); + vm.expectRevert( + abi.encodeWithSelector( + LayerZeroReceiver.InsufficientGas.selector, bytes32(0), estimatedGas, estimatedGas - 1 + ) + ); // exactly 1 less gas than needed for lz compose - lzReceiver.lzCompose{ gas: 85_663 }(ethPool, bytes32(0), message, address(0), ""); + lzReceiver.lzCompose{ gas: 85_663 }(ethPool, bytes32(0), message, address(0), ""); } function testUsdcBridge() public { @@ -226,11 +236,7 @@ contract BridgeTest is Test { ); } - function _buildWethDeposit(uint256 amount) - internal - view - returns (bytes32[] memory commands, bytes[] memory state) - { + function _buildWethDeposit(uint256 amount) internal view returns (bytes32[] memory commands, bytes[] memory state) { // Setup script to deposit and transfer weth commands = new bytes32[](2); state = new bytes[](2); diff --git a/test/DelegateEnsoShortcuts.t.sol b/test/DelegateEnsoShortcuts.t.sol index b1226ee..a0d7780 100644 --- a/test/DelegateEnsoShortcuts.t.sol +++ b/test/DelegateEnsoShortcuts.t.sol @@ -56,10 +56,7 @@ contract DelegateEnsoShortcutsTest is Test, SafeTestTools { assertEq(weth.balanceOf(alice), 0); safeInstance.execTransaction({ - to: address(shortcuts), - value: 0 ether, - data: data, - operation: Enum.Operation.DelegateCall + to: address(shortcuts), value: 0 ether, data: data, operation: Enum.Operation.DelegateCall }); assertEq(weth.balanceOf(address(safeInstance.safe)), 0); @@ -86,10 +83,7 @@ contract DelegateEnsoShortcutsTest is Test, SafeTestTools { assertEq(weth.balanceOf(address(safeInstance.safe)), 0); safeInstance.execTransaction({ - to: address(shortcuts), - value: 0 ether, - data: data, - operation: Enum.Operation.DelegateCall + to: address(shortcuts), value: 0 ether, data: data, operation: Enum.Operation.DelegateCall }); assertEq(weth.balanceOf(address(safeInstance.safe)), 10 ether); diff --git a/test/fork/enso-checkout/Checkout_SmartWallet_EntryPointV7_Fork_Test.t.sol b/test/fork/enso-checkout/Checkout_SmartWallet_EntryPointV7_Fork_Test.t.sol index 76b0cde..f52d93b 100644 --- a/test/fork/enso-checkout/Checkout_SmartWallet_EntryPointV7_Fork_Test.t.sol +++ b/test/fork/enso-checkout/Checkout_SmartWallet_EntryPointV7_Fork_Test.t.sol @@ -110,21 +110,21 @@ contract Checkout_SmartWallet_EntryPointV7_Fork_Test is Test, TokenBalanceHelper uint256 safeThreshold = 2; address safeTo = address(0); // Contract address for optional delegate call. bytes memory safeData = ""; // Data payload for optional delegate call. - // address safeFallbackHandler = address(0); // Handler for fallback calls to this contract + // address safeFallbackHandler = address(0); // Handler for fallback calls to this contract address safePaymentToken = address(0); // Token that should be used for the payment (0 is ETH) uint256 safePayment = 0; // Value that should be paid address payable safePaymentReceiver = payable(address(0)); // Address that should receive the payment (or 0 if // tx.origin) - // s_safe.setup( - // safeOwners, - // safeThreshold, - // safeTo, - // safeData, - // safeFallbackHandler, - // safePaymentToken, - // safePayment, - // safePaymentReceiver - // ); + // s_safe.setup( + // safeOwners, + // safeThreshold, + // safeTo, + // safeData, + // safeFallbackHandler, + // safePaymentToken, + // safePayment, + // safePaymentReceiver + // ); bytes memory safeSetupData = abi.encodeCall( Safe.setup, ( diff --git a/test/mocks/MockMultiVault.sol b/test/mocks/MockMultiVault.sol index fc97b65..bce5e88 100644 --- a/test/mocks/MockMultiVault.sol +++ b/test/mocks/MockMultiVault.sol @@ -17,18 +17,14 @@ contract MockMultiVault is ERC1155, ERC1155Holder { } function redeem(uint256 tokenId, uint256 amount) public { - if (amount > balanceOf(msg.sender, tokenId)) revert(); + if (amount > balanceOf(msg.sender, tokenId)) { + revert(); + } _burn(msg.sender, tokenId, amount); token.safeTransferFrom(address(this), msg.sender, tokenId, amount, "0x"); } - function supportsInterface(bytes4 interfaceId) - public - view - virtual - override(ERC1155, ERC1155Holder) - returns (bool) - { + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155, ERC1155Holder) returns (bool) { return interfaceId == type(IERC1155Receiver).interfaceId || interfaceId == type(IERC1155).interfaceId || interfaceId == type(IERC1155MetadataURI).interfaceId || super.supportsInterface(interfaceId); } diff --git a/test/mocks/MockNFTVault.sol b/test/mocks/MockNFTVault.sol index 2e84117..ff8bda5 100644 --- a/test/mocks/MockNFTVault.sol +++ b/test/mocks/MockNFTVault.sol @@ -17,8 +17,12 @@ contract MockNFTVault is ERC721, ERC721Holder { } function redeem(uint256 tokenId) public { - if (ownerOf(tokenId) != msg.sender) revert(); - if (nft.ownerOf(tokenId) != address(this)) revert(); + if (ownerOf(tokenId) != msg.sender) { + revert(); + } + if (nft.ownerOf(tokenId) != address(this)) { + revert(); + } _burn(tokenId); nft.safeTransferFrom(address(this), msg.sender, tokenId); } diff --git a/test/mocks/MockVault.sol b/test/mocks/MockVault.sol index 4ab79dd..d1700f2 100644 --- a/test/mocks/MockVault.sol +++ b/test/mocks/MockVault.sol @@ -16,7 +16,9 @@ contract MockVault is ERC20 { } function redeem(uint256 amount) public { - if (amount > balanceOf(msg.sender)) revert(); + if (amount > balanceOf(msg.sender)) { + revert(); + } _burn(msg.sender, amount); token.transfer(msg.sender, amount); } diff --git a/test/mocks/TestPaymaster.sol b/test/mocks/TestPaymaster.sol index 7dcda76..db69127 100644 --- a/test/mocks/TestPaymaster.sol +++ b/test/mocks/TestPaymaster.sol @@ -30,8 +30,7 @@ contract TestPaymaster is IPaymaster { uint256, // actualGasCost uint256 // actualUserOpFeePerGas ) - external - { + external { // allow all } diff --git a/test/shortcuts/ShortcutsEthereum.sol b/test/shortcuts/ShortcutsEthereum.sol index f9f37d2..f7f4d89 100644 --- a/test/shortcuts/ShortcutsEthereum.sol +++ b/test/shortcuts/ShortcutsEthereum.sol @@ -19,7 +19,9 @@ library ShortcutsEthereum { pure returns (ExecuteShortcutParams memory executeShortcutParams) { - if (txData.length < 4) revert ShortcutsEthereum__InvalidShortcutTxData(); + if (txData.length < 4) { + revert ShortcutsEthereum__InvalidShortcutTxData(); + } // Decode it from starting at the function selector (first 4 bytes) (bytes32 accountId, bytes32 requestId, bytes32[] memory commands, bytes[] memory state) = diff --git a/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol b/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol index 0a34334..05ad5ea 100644 --- a/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol +++ b/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol @@ -150,9 +150,8 @@ contract EnsoReceiver_SafeExecute_SenderIsEntryPoint_Unit_Concrete_Test is // it should emit ShortcutExecutionFailed vm.expectEmit(address(s_ensoReceiver)); - emit EnsoReceiver.ShortcutExecutionFailed( - hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000" - ); + emit EnsoReceiver + .ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); s_ensoReceiver.safeExecute(IERC20(shortcut.tokensIn[0]), shortcut.amountsIn[0], shortcut.txData); // Get balances after execution @@ -232,9 +231,8 @@ contract EnsoReceiver_SafeExecute_SenderIsEntryPoint_Unit_Concrete_Test is // it should emit ShortcutExecutionFailed vm.expectEmit(address(s_ensoReceiver)); - emit EnsoReceiver.ShortcutExecutionFailed( - hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000" - ); + emit EnsoReceiver + .ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); s_ensoReceiver.safeExecute(IERC20(shortcut.tokensIn[0]), shortcut.amountsIn[0], shortcut.txData); // Get balances after execution @@ -401,9 +399,8 @@ contract EnsoReceiver_SafeExecute_SenderIsOwner_Unit_Concrete_Test is // it should emit ShortcutExecutionFailed vm.expectEmit(address(s_ensoReceiver)); - emit EnsoReceiver.ShortcutExecutionFailed( - hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000" - ); + emit EnsoReceiver + .ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); s_ensoReceiver.safeExecute(IERC20(shortcut.tokensIn[0]), shortcut.amountsIn[0], shortcut.txData); // Get balances after execution @@ -483,9 +480,8 @@ contract EnsoReceiver_SafeExecute_SenderIsOwner_Unit_Concrete_Test is // it should emit ShortcutExecutionFailed vm.expectEmit(address(s_ensoReceiver)); - emit EnsoReceiver.ShortcutExecutionFailed( - hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000" - ); + emit EnsoReceiver + .ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); s_ensoReceiver.safeExecute(IERC20(shortcut.tokensIn[0]), shortcut.amountsIn[0], shortcut.txData); // Get balances after execution From 17b38f96c197b5a271979917b60c9b0a1287acae Mon Sep 17 00:00:00 2001 From: vnavascues Date: Tue, 28 Oct 2025 16:50:42 +0100 Subject: [PATCH 04/21] chore: ci.yml, moved bash scripts --- deploy.sh => .bash/deploy.sh | 0 .github/workflows/ci.yml | 57 +++++++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) rename deploy.sh => .bash/deploy.sh (100%) diff --git a/deploy.sh b/.bash/deploy.sh similarity index 100% rename from deploy.sh rename to .bash/deploy.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d04cb..35ab9e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - "*" # Trigger on any push to any branch + - '*' # Trigger on any push to any branch pull_request: branches: @@ -24,12 +24,19 @@ env: ETHEREUM_RPC_URL: ${{ secrets.ETHEREUM_RPC_URL }} GNOSIS_RPC_URL: ${{ secrets.GNOSIS_RPC_URL }} HYPER_RPC_URL: ${{ secrets.HYPER_RPC_URL }} + INK_RPC_URL: ${{ secrets.INK_RPC_URL }} + KATANA_RPC_URL: ${{ secrets.KATANA_RPC_URL }} LINEA_RPC_URL: ${{ secrets.LINEA_RPC_URL }} OPTIMISM_RPC_URL: ${{ secrets.OPTIMISM_RPC_URL }} + PLASMA_RPC_URL: ${{ secrets.PLASMA_RPC_URL }} + PLUME_RPC_URL: ${{ secrets.PLUME_RPC_URL }} POLYGON_RPC_URL: ${{ secrets.POLYGON_RPC_URL }} SONIC_RPC_URL: ${{ secrets.SONIC_RPC_URL }} + SONEIUM_RPC_URL: ${{ secrets.SONEIUM_RPC_URL }} + UNICHAIN_RPC_URL: ${{ secrets.UNICHAIN_RPC_URL }} + WORLD_RPC_URL: ${{ secrets.WORLD_RPC_URL }} ZKSYNC_RPC_URL: ${{ secrets.ZKSYNC_RPC_URL }} - PLUME_RPC_URL: ${{ secrets.PLUME_RPC_URL }} + SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }} jobs: check: @@ -39,19 +46,53 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Install pnpm 10.11.0 + uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + + - name: Install Node.js 22.x + uses: actions/setup-node@v3 + with: + node-version: 22.x + registry-url: 'https://registry.npmjs.org' + + - name: Install Node dependencies + run: | + pnpm install --frozen-lockfile --ignore-scripts + id: pnpm + - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: version: stable - name: Show Forge version - run: forge --version + run: | + forge --version + id: version - name: Install Forge dependencies - run: forge soldeer update + run: | + forge soldeer update + id: soldeer + + - name: Run Forge fmt + run: | + forge fmt --check + id: fmt + + - name: Run Forge lint + run: | + bash .bash/forge-lint.sh + id: lint - - name: Build contracts - run: forge build --sizes + - name: Run Forge build + run: | + forge build --sizes + id: build - - name: Run tests - run: forge test -vvv + - name: Run Forge tests + run: | + forge test -vvv --no-match-test "invariant_.*" + id: test From cb05907363539355601e20a397113152419c0669 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Wed, 29 Oct 2025 11:01:32 +0100 Subject: [PATCH 05/21] feat: added pausable capabilities --- script/HyperCoreHelpersDeployer.s.sol | 4 ++-- src/bridge/EnsoCCIPReceiver.sol | 15 +++++++++++++-- src/interfaces/IEnsoCCIPReceiver.sol | 9 +++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/script/HyperCoreHelpersDeployer.s.sol b/script/HyperCoreHelpersDeployer.s.sol index 2142621..d50ea98 100644 --- a/script/HyperCoreHelpersDeployer.s.sol +++ b/script/HyperCoreHelpersDeployer.s.sol @@ -7,9 +7,9 @@ import "forge-std/Script.sol"; contract HyperCoreHelpersDeployer is Script { function run() public returns (HyperCoreHelpers hyperCoreHelpers) { uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); - string memory version = "1"; // NOTE: replace version in `salt` - vm.startBroadcast(deployerPrivateKey); + vm.startBroadcast(deployerPrivateKey); + // NOTE: replace version in `salt` hyperCoreHelpers = new HyperCoreHelpers{ salt: "HyperCoreHelpers_v1" }(); vm.stopBroadcast(); diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index c86f792..03b4b2b 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -6,6 +6,7 @@ import { IEnsoRouter, Token, TokenType } from "../interfaces/IEnsoRouter.sol"; import { CCIPReceiver, Client } from "chainlink-ccip/applications/CCIPReceiver.sol"; import { Ownable, Ownable2Step } from "openzeppelin-contracts/access/Ownable2Step.sol"; import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; /// @title EnsoCCIPReceiver /// @author Enso @@ -18,7 +19,7 @@ import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/Safe /// - Expects exactly one ERC-20 in `destTokenAmounts`; amount must be non-zero. /// - Executes Shortcuts through a self-call pattern (`try this.execute(...)`) so we can /// catch and handle reverts and sweep funds to a fallback receiver in the payload. -contract EnsoCCIPReceiver is Ownable2Step, CCIPReceiver, IEnsoCCIPReceiver { +contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Pausable { using SafeERC20 for IERC20; /// @dev Immutable Enso Router used to dispatch tokens + call Shortcuts. @@ -55,7 +56,7 @@ contract EnsoCCIPReceiver is Ownable2Step, CCIPReceiver, IEnsoCCIPReceiver { /// 5) Optional gas self-check (if `estimatedGas` > 0). /// 6) Mark executed, attempt `execute(...)` via self-call; on failure, sweep token to `receiver`. /// @param _message The CCIP Any2EVM message with metadata, payload, and delivered tokens. - function _ccipReceive(Client.Any2EVMMessage memory _message) internal override { + function _ccipReceive(Client.Any2EVMMessage memory _message) internal override whenNotPaused { bytes32 messageId = _message.messageId; if (s_executedMessage[messageId]) { revert IEnsoCCIPReceiver.EnsoCCIPReceiver_AlreadyExecuted(messageId); @@ -117,6 +118,11 @@ contract EnsoCCIPReceiver is Ownable2Step, CCIPReceiver, IEnsoCCIPReceiver { i_ensoRouter.routeSingle(tokenIn, _shortcutData); } + /// @inheritdoc IEnsoCCIPReceiver + function pause() external onlyOwner { + _pause(); + } + /// @inheritdoc IEnsoCCIPReceiver function setAllowedSender(uint64 _sourceChainSelector, address _sender, bool _isAllowed) external onlyOwner { s_allowedSender[_sourceChainSelector][_sender] = _isAllowed; @@ -129,6 +135,11 @@ contract EnsoCCIPReceiver is Ownable2Step, CCIPReceiver, IEnsoCCIPReceiver { emit IEnsoCCIPReceiver.AllowedSourceChainSet(_sourceChainSelector, _isAllowed); } + /// @inheritdoc IEnsoCCIPReceiver + function unpause() external onlyOwner { + _unpause(); + } + /// @inheritdoc IEnsoCCIPReceiver function getEnsoRouter() external view returns (address) { return address(i_ensoRouter); diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol index 7f2c6e7..0b20392 100644 --- a/src/interfaces/IEnsoCCIPReceiver.sol +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -82,6 +82,11 @@ interface IEnsoCCIPReceiver { /// @param shortcutData ABI-encoded call data for the Enso Shortcuts entrypoint. function execute(address token, uint256 amount, bytes calldata shortcutData) external; + /// @notice Pauses the CCIP receiver, disabling new incoming messages until unpaused. + /// @dev Only callable by the contract owner. While paused, `_ccipReceive` should + /// revert or ignore messages to prevent execution. + function pause() external; + /// @notice Adds or removes an allowed sender for a specific source chain. /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). /// @param sourceChainSelector Chain selector of the source network. @@ -95,6 +100,10 @@ interface IEnsoCCIPReceiver { /// @param isAllowed True to allow, false to disallow. function setAllowedSourceChain(uint64 sourceChainSelector, bool isAllowed) external; + /// @notice Unpauses the CCIP receiver, re-enabling message processing. + /// @dev Only callable by the contract owner. Resumes normal operation after a pause. + function unpause() external; + /// @notice Returns the Enso Router address used by this receiver. /// @return router Address of the Enso Router. function getEnsoRouter() external view returns (address router); From 51d1b9c1c730b6930ca2d556f2c8b2b05182b0b2 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Wed, 29 Oct 2025 12:51:02 +0100 Subject: [PATCH 06/21] feat: gas savings --- src/bridge/EnsoCCIPReceiver.sol | 37 ++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index 03b4b2b..b53067e 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -30,9 +30,11 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus /// forge-lint: disable-next-item(mixed-case-variable) mapping(uint64 sourceChainSelector => bool isAllowed) private s_allowedSourceChain; - /// @dev Allowlist of source senders per chain selector. + /// @dev Per-(chain selector, sender) allowlist. + /// Key is computed as: keccak256(abi.encode(sourceChainSelector, sender)), + /// where `sender` is the EVM address decoded from `Any2EVMMessage.sender` bytes. /// forge-lint: disable-next-item(mixed-case-variable) - mapping(uint64 sourceChainSelector => mapping(address sender => bool isAllowed)) private s_allowedSender; + mapping(bytes32 key => bool isAllowed) private s_allowedSender; /// @dev Replay protection: tracks CCIP message IDs that were executed successfully (or handled). /// forge-lint: disable-next-item(mixed-case-variable) @@ -68,7 +70,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus } address sender = abi.decode(_message.sender, (address)); - if (!s_allowedSender[sourceChainSelector][sender]) { + if (!s_allowedSender[_getAllowedSenderKey(sourceChainSelector, sender)]) { revert IEnsoCCIPReceiver.EnsoCCIPReceiver_SenderNotAllowed(sourceChainSelector, sender); } @@ -125,7 +127,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus /// @inheritdoc IEnsoCCIPReceiver function setAllowedSender(uint64 _sourceChainSelector, address _sender, bool _isAllowed) external onlyOwner { - s_allowedSender[_sourceChainSelector][_sender] = _isAllowed; + s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)] = _isAllowed; emit IEnsoCCIPReceiver.AllowedSenderSet(_sourceChainSelector, _sender, _isAllowed); } @@ -147,7 +149,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus /// @inheritdoc IEnsoCCIPReceiver function isSenderAllowed(uint64 _sourceChainSelector, address _sender) external view returns (bool) { - return s_allowedSender[_sourceChainSelector][_sender]; + return s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)]; } /// @inheritdoc IEnsoCCIPReceiver @@ -159,4 +161,29 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus function wasMessageExecuted(bytes32 _messageId) external view returns (bool) { return s_executedMessage[_messageId]; } + + /// @dev Computes the composite allowlist key for (chainSelector, sender). + /// ABI-equivalent to: + /// keccak256(abi.encode(chainSelector, sender)) + /// and implemented in Yul to avoid an extra temporary allocation. + /// Semantics are identical to the high-level version. + /// + /// Canonicality (no masking required): + /// - `sender` is a canonical Solidity `address`, either decoded via + /// `abi.decode(...,(address))` from `Any2EVMMessage.sender` or received + /// as a public/external ABI parameter. In both cases the VM zero-extends + /// it to a full 32-byte word when written to memory. + /// - `chainSelector` is a `uint64` and is zero-extended to 32 bytes by the ABI/VM. + /// + /// @param _chainSelector The CCIP source chain selector (uint64). + /// @param _sender The source application address decoded from `Any2EVMMessage.sender`. + /// @return allowKey keccak256(abi.encode(_chainSelector, _sender)). + function _getAllowedSenderKey(uint64 _chainSelector, address _sender) private pure returns (bytes32 allowKey) { + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(ptr, _chainSelector) + mstore(add(ptr, 0x20), _sender) + allowKey := keccak256(ptr, 0x40) + } + } } From afd501c67a07f5e5fe5b66377a5ce3c6e6ac2422 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Wed, 29 Oct 2025 19:33:57 +0100 Subject: [PATCH 07/21] feat: WIP - EnsoCCIPReceiverDefensive - no reverts --- src/bridge/EnsoCCIPReceiverDefensive.sol | 314 ++++++++++++++++++ src/interfaces/IEnsoCCIPReceiverDefensive.sol | 153 +++++++++ 2 files changed, 467 insertions(+) create mode 100644 src/bridge/EnsoCCIPReceiverDefensive.sol create mode 100644 src/interfaces/IEnsoCCIPReceiverDefensive.sol diff --git a/src/bridge/EnsoCCIPReceiverDefensive.sol b/src/bridge/EnsoCCIPReceiverDefensive.sol new file mode 100644 index 0000000..583ecc5 --- /dev/null +++ b/src/bridge/EnsoCCIPReceiverDefensive.sol @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.24; + +import { IEnsoCCIPReceiverDefensive } from "../interfaces/IEnsoCCIPReceiverDefensive.sol"; +import { IEnsoRouter, Token, TokenType } from "../interfaces/IEnsoRouter.sol"; +import { CCIPReceiver, Client } from "chainlink-ccip/applications/CCIPReceiver.sol"; +import { Ownable, Ownable2Step } from "openzeppelin-contracts/access/Ownable2Step.sol"; +import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; + +/// @title EnsoCCIPReceiverDefensive +/// @author Enso +/// @notice Destination-side CCIP receiver that validates source chain/sender, enforces replay +/// protection, and forwards a single bridged ERC-20 to Enso Shortcuts via the Enso Router. +/// @dev The contract: +/// - Relies on Chainlink CCIP’s router gating via {CCIPReceiver}. +/// - Adds allowlists for source chain selectors and source senders (per chain). +/// - Guards against duplicate delivery with a messageId map. +/// - Expects exactly one ERC-20 in `destTokenAmounts`; amount must be non-zero. +/// - Executes Shortcuts through a self-call pattern (`try this.execute(...)`) so we can +/// catch and handle reverts and sweep funds to a fallback receiver in the payload. +contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, Ownable2Step, Pausable { + using SafeERC20 for IERC20; + + uint256 private constant VERSION = 1; + + /// @dev Immutable Enso Router used to dispatch tokens + call Shortcuts. + /// forge-lint: disable-next-item(screaming-snake-case-immutable) + IEnsoRouter private immutable i_ensoRouter; + + /// @dev Allowlist by source chain selector. + /// forge-lint: disable-next-item(mixed-case-variable) + mapping(uint64 sourceChainSelector => bool isAllowed) private s_allowedSourceChain; + + /// @dev Per-(chain selector, sender) allowlist. + /// Key is computed as: keccak256(abi.encode(sourceChainSelector, sender)), + /// where `sender` is the EVM address decoded from `Any2EVMMessage.sender` bytes. + /// forge-lint: disable-next-item(mixed-case-variable) + mapping(bytes32 key => bool isAllowed) private s_allowedSender; + + mapping(bytes32 messageId => bool isEscrow) private s_escrowMessage; + + /// @dev Replay protection: tracks CCIP message IDs that were executed successfully (or handled). + /// forge-lint: disable-next-item(mixed-case-variable) + mapping(bytes32 messageId => bool wasExecuted) private s_executedMessage; + + /// @notice Initializes the receiver with the CCIP router and Enso Router. + /// @dev The owner is set via {Ownable} base (passed in to support 2-step ownership if desired). + /// @param _owner Address to set as initial owner. + /// @param _ccipRouter Address of the CCIP Router on the destination chain. + /// @param _ensoRouter Address of the Enso Router that will execute Shortcuts. + constructor(address _owner, address _ccipRouter, address _ensoRouter) Ownable(_owner) CCIPReceiver(_ccipRouter) { + i_ensoRouter = IEnsoRouter(_ensoRouter); + } + + /// @notice CCIP router callback: validates message, enforces replay protection, and dispatches. + /// @dev Flow: + /// 1) Check duplicate by messageId (fail fast). + /// 2) Check allowlisted source chain and sender (decoded from `message.sender`). + /// 3) Enforce exactly one ERC-20 delivered (and non-zero amount). + /// 4) Decode payload `(receiver, estimatedGas, shortcutData)`. + /// 5) Optional gas self-check (if `estimatedGas` > 0). + /// 6) Mark executed, attempt `execute(...)` via self-call; on failure, sweep token to `receiver`. + /// @param _message The CCIP Any2EVM message with metadata, payload, and delivered tokens. + function _ccipReceive(Client.Any2EVMMessage memory _message) internal override { + ( + address token, + uint256 amount, + address receiver, + bytes memory shortcutData, + ErrorCode errorCode, + bytes memory errorData + ) = _validateMessage(_message); + + if (errorCode != ErrorCode.NO_ERROR) { + emit IEnsoCCIPReceiverDefensive.MessageValidationFailed(_message.messageId, errorCode, errorData); + + RefundKind refundKind = _getRefundPolicy(errorCode); + if (refundKind == RefundKind.NONE) { + // NOTE: ErrorCode.ALREADY_EXECUTED β†’ no-op; + return; + } + if (refundKind == RefundKind.TO_RECEIVER) { + s_executedMessage[_message.messageId] = true; + IERC20(token).safeTransfer(receiver, amount); + return; + } + if (refundKind == RefundKind.TO_ESCROW) { + s_executedMessage[_message.messageId] = true; + emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); + s_escrowMessage[_message.messageId] = true; + } + + // NOTE: make sure this is caught in development + revert EnsoCCIPReceiver_UnsupportedRefundKind(refundKind); + } + + s_executedMessage[_message.messageId] = true; + + // Attempt Shortcuts execution; on failure, sweep funds to the fallback receiver. + try this.execute(token, amount, shortcutData) { + emit IEnsoCCIPReceiverDefensive.ShortcutExecutionSuccessful(_message.messageId); + } catch (bytes memory err) { + emit IEnsoCCIPReceiverDefensive.ShortcutExecutionFailed(_message.messageId, err); + IERC20(token).safeTransfer(receiver, amount); + } + } + + function decodeMessageData(bytes calldata _data) external view returns (address, uint256, bytes memory) { + if (msg.sender != address(this)) { + revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_OnlySelf(); + } + + return abi.decode(_data, (address, uint256, bytes)); + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function execute(address _token, uint256 _amount, bytes calldata _shortcutData) external { + if (msg.sender != address(this)) { + revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_OnlySelf(); + } + Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(_token, _amount) }); + IERC20(_token).forceApprove(address(i_ensoRouter), _amount); + + i_ensoRouter.routeSingle(tokenIn, _shortcutData); + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function pause() external onlyOwner { + _pause(); + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function setAllowedSender(uint64 _sourceChainSelector, address _sender, bool _isAllowed) external onlyOwner { + s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)] = _isAllowed; + emit IEnsoCCIPReceiverDefensive.AllowedSenderSet(_sourceChainSelector, _sender, _isAllowed); + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function setAllowedSourceChain(uint64 _sourceChainSelector, bool _isAllowed) external onlyOwner { + s_allowedSourceChain[_sourceChainSelector] = _isAllowed; + emit IEnsoCCIPReceiverDefensive.AllowedSourceChainSet(_sourceChainSelector, _isAllowed); + } + + /// @dev currently only for malformed messages, as multiple tokens are not supported by CCIP + function sweepMessageInEscrow( + bytes32 _messageId, + address _token, + uint256 _amount, + address _to + ) + external + onlyOwner + { + if (!s_escrowMessage[_messageId]) { + revert EnsoCCIPReceiver_MissingEscrow(_messageId); + } + delete s_escrowMessage[_messageId]; + + IERC20(_token).safeTransfer(_to, _amount); + emit EscrowSwept(_messageId, _token, _amount, _to); + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function unpause() external onlyOwner { + _unpause(); + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function getEnsoRouter() external view returns (address) { + return address(i_ensoRouter); + } + + function isMessageInEscrow(bytes32 _messageId) external view returns (bool) { + return s_escrowMessage[_messageId]; + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function isSenderAllowed(uint64 _sourceChainSelector, address _sender) external view returns (bool) { + return s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)]; + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function isSourceChainAllowed(uint64 _sourceChainSelector) external view returns (bool) { + return s_allowedSourceChain[_sourceChainSelector]; + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function version() external pure returns (uint256) { + return VERSION; + } + + /// @inheritdoc IEnsoCCIPReceiverDefensive + function wasMessageExecuted(bytes32 _messageId) external view returns (bool) { + return s_executedMessage[_messageId]; + } + + function _getRefundPolicy(ErrorCode _errorCode) private pure returns (RefundKind) { + if (_errorCode == ErrorCode.NO_ERROR || _errorCode == ErrorCode.ALREADY_EXECUTED) { + return RefundKind.NONE; + } + if ( + _errorCode == ErrorCode.PAUSED || _errorCode == ErrorCode.SOURCE_CHAIN_NOT_ALLOWED + || _errorCode == ErrorCode.SENDER_NOT_ALLOWED || _errorCode == ErrorCode.INSUFFICIENT_GAS + ) { + return RefundKind.TO_RECEIVER; + } + if ( + _errorCode == ErrorCode.MALFORMED_MESSAGE_DATA || _errorCode == ErrorCode.NO_TOKENS + || _errorCode == ErrorCode.NO_TOKEN_AMOUNT || _errorCode == ErrorCode.TOO_MANY_TOKENS + ) { + return RefundKind.TO_ESCROW; + } + + // NOTE: make sure this is caught in development + revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_UnsupportedErrorCode(_errorCode); + } + + function _validateMessage(Client.Any2EVMMessage memory _message) + private + view + returns ( + address token, + uint256 amount, + address receiver, + bytes memory shortcutData, + ErrorCode errorCode, + bytes memory errorData + ) + { + bytes32 messageId = _message.messageId; + if (s_executedMessage[messageId]) { + errorData = abi.encode(messageId); + return (token, amount, receiver, shortcutData, ErrorCode.ALREADY_EXECUTED, errorData); + } + + Client.EVMTokenAmount[] memory destTokenAmounts = _message.destTokenAmounts; + if (destTokenAmounts.length == 0) { + return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKENS, errorData); + } + + if (destTokenAmounts.length > 1) { + return (token, amount, receiver, shortcutData, ErrorCode.TOO_MANY_TOKENS, errorData); + } + + token = destTokenAmounts[0].token; + amount = destTokenAmounts[0].amount; + + if (amount == 0) { + return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKEN_AMOUNT, errorData); + } + + uint256 estimatedGas; + // TODO: find an assembly alternative... + try this.decodeMessageData(_message.data) returns ( + address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData + ) { + receiver = decodedReceiver; + estimatedGas = decodedEstimatedGas; + shortcutData = decodedShortcutData; + } catch { + return (token, amount, receiver, shortcutData, ErrorCode.MALFORMED_MESSAGE_DATA, errorData); + } + + if (paused()) { + return (token, amount, receiver, shortcutData, ErrorCode.PAUSED, errorData); + } + + uint64 sourceChainSelector = _message.sourceChainSelector; + if (!s_allowedSourceChain[sourceChainSelector]) { + errorData = abi.encode(sourceChainSelector); + return (token, amount, receiver, shortcutData, ErrorCode.SOURCE_CHAIN_NOT_ALLOWED, errorData); + } + + address sender = abi.decode(_message.sender, (address)); + if (!s_allowedSender[_getAllowedSenderKey(sourceChainSelector, sender)]) { + errorData = abi.encode(sourceChainSelector, sender); + return (token, amount, receiver, shortcutData, ErrorCode.SENDER_NOT_ALLOWED, errorData); + } + + uint256 availableGas = gasleft(); + if (estimatedGas != 0 && availableGas < estimatedGas) { + errorData = abi.encode(availableGas, estimatedGas); + return (token, amount, receiver, shortcutData, ErrorCode.INSUFFICIENT_GAS, errorData); + } + + return (token, amount, receiver, shortcutData, ErrorCode.NO_ERROR, errorData); + } + + /// @dev Computes the composite allowlist key for (chainSelector, sender). + /// ABI-equivalent to: + /// keccak256(abi.encode(chainSelector, sender)) + /// and implemented in Yul to avoid an extra temporary allocation. + /// Semantics are identical to the high-level version. + /// + /// Canonicality (no masking required): + /// - `sender` is a canonical Solidity `address`, either decoded via + /// `abi.decode(...,(address))` from `Any2EVMMessage.sender` or received + /// as a public/external ABI parameter. In both cases the VM zero-extends + /// it to a full 32-byte word when written to memory. + /// - `chainSelector` is a `uint64` and is zero-extended to 32 bytes by the ABI/VM. + /// + /// @param _chainSelector The CCIP source chain selector (uint64). + /// @param _sender The source application address decoded from `Any2EVMMessage.sender`. + /// @return allowKey keccak256(abi.encode(_chainSelector, _sender)). + function _getAllowedSenderKey(uint64 _chainSelector, address _sender) private pure returns (bytes32 allowKey) { + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(ptr, _chainSelector) + mstore(add(ptr, 0x20), _sender) + allowKey := keccak256(ptr, 0x40) + } + } +} diff --git a/src/interfaces/IEnsoCCIPReceiverDefensive.sol b/src/interfaces/IEnsoCCIPReceiverDefensive.sol new file mode 100644 index 0000000..3541d59 --- /dev/null +++ b/src/interfaces/IEnsoCCIPReceiverDefensive.sol @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.0; + +/// @title IEnsoCCIPReceiverDefensive +/// @author Enso +/// @notice Interface for a CCIP destination receiver that validates source (chain/sender), +/// enforces replay protection, and forwards a single bridged ERC-20 into Enso Shortcuts. +/// @dev Exposes only the external/public surface of the implementing receiver: +/// - Admin setters for allowlists +/// - Self-call execution entry used by try/catch in `_ccipReceive` +/// - Views for router/allowlists/replay state +interface IEnsoCCIPReceiverDefensive { + enum ErrorCode { + NO_ERROR, + ALREADY_EXECUTED, + NO_TOKENS, + TOO_MANY_TOKENS, + NO_TOKEN_AMOUNT, + MALFORMED_MESSAGE_DATA, + PAUSED, + SOURCE_CHAIN_NOT_ALLOWED, + SENDER_NOT_ALLOWED, + INSUFFICIENT_GAS + } + + enum RefundKind { + NONE, + TO_RECEIVER, + TO_ESCROW + } + + struct Escrow { + address token; // ERC20 token being held + uint256 amount; // amount held + address receiver; // payload receiver for reference; may be 0 + bool isEscrow; // presence flag + } + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + /// @notice Emitted when an allowed sender is (de)authorized for a source chain. + /// @param sourceChainSelector Chain selector of the source network. + /// @param sender Address of the source application on that chain. + /// @param isAllowed True if allowed, false if disallowed. + event AllowedSenderSet(uint64 indexed sourceChainSelector, address indexed sender, bool isAllowed); + + /// @notice Emitted when a source chain is (de)authorized. + /// @param sourceChainSelector Chain selector of the source network. + /// @param isAllowed True if allowed, false if disallowed. + event AllowedSourceChainSet(uint64 indexed sourceChainSelector, bool isAllowed); + + /// @notice Emitted when validation fails. See `errorCode` for class. + /// @dev errorData encodings: + /// - ALREADY_EXECUTED: (bytes32 messageId) + /// - SOURCE_CHAIN_NOT_ALLOWED: (uint64 sourceChainSelector) + /// - SENDER_NOT_ALLOWED: (uint64 sourceChainSelector, address sender) + /// - INSUFFICIENT_GAS: (uint256 availableGas, uint256 estimatedGas) + /// - Others: empty bytes unless specified. + event MessageValidationFailed(bytes32 indexed messageId, ErrorCode errorCode, bytes errorData); + + /// @notice Emitted when Enso Shortcuts execution succeeds for a CCIP message. + /// @param messageId CCIP message identifier. + event ShortcutExecutionSuccessful(bytes32 indexed messageId); + + /// @notice Emitted when Enso Shortcuts execution reverts for a CCIP message. + /// @param messageId CCIP message identifier. + /// @param err ABI-encoded revert data from the failed call. + event ShortcutExecutionFailed(bytes32 indexed messageId, bytes err); + + /// @notice Funds were quarantined to escrow instead of delivered to the payload receiver. + /// @param messageId The CCIP message id. + /// @param code The validation error that triggered quarantine. + /// @param token ERC-20 token moved to escrow. + /// @param amount Token amount quarantined. + /// @param receiver Original payload receiver (informational; may be zero if not decoded). + event MessageQuarantined( + bytes32 indexed messageId, ErrorCode code, address token, uint256 amount, address receiver + ); + event EscrowSwept(bytes32 indexed messageId, address token, uint256 amount, address to); + + // ------------------------------------------------------------------------- + // Errors + // ------------------------------------------------------------------------- + + /// @notice Revert when an external caller targets the internal executor. + error EnsoCCIPReceiver_OnlySelf(); + error EnsoCCIPReceiver_MissingEscrow(bytes32 messageId); + error EnsoCCIPReceiver_UnsupportedErrorCode(ErrorCode errorCode); + error EnsoCCIPReceiver_UnsupportedRefundKind(RefundKind refundKind); + + // ------------------------------------------------------------------------- + // External Functions + // ------------------------------------------------------------------------- + + /// @notice Executes Enso Shortcuts with a single ERC-20 that was previously received via CCIP. + /// @dev MUST be callable only by the contract itself (self-call), typically from `_ccipReceive` + /// using `try this.execute(...)`. Implementations should guard with + /// `if (msg.sender != address(this)) revert EnsoCCIPReceiver_OnlySelf();` + /// @param token ERC-20 token to route. + /// @param amount Amount of `token` to route. + /// @param shortcutData ABI-encoded call data for the Enso Shortcuts entrypoint. + function execute(address token, uint256 amount, bytes calldata shortcutData) external; + + /// @notice Pauses the CCIP receiver, disabling new incoming messages until unpaused. + /// @dev Only callable by the contract owner. While paused, `_ccipReceive` should + /// revert or ignore messages to prevent execution. + function pause() external; + + /// @notice Adds or removes an allowed sender for a specific source chain. + /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). + /// @param sourceChainSelector Chain selector of the source network. + /// @param sender Address of the source application on that chain. + /// @param isAllowed True to allow, false to disallow. + function setAllowedSender(uint64 sourceChainSelector, address sender, bool isAllowed) external; + + /// @notice Adds or removes an allowed source chain. + /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). + /// @param sourceChainSelector Chain selector of the source network. + /// @param isAllowed True to allow, false to disallow. + function setAllowedSourceChain(uint64 sourceChainSelector, bool isAllowed) external; + + function sweepMessageInEscrow(bytes32 messageId, address token, uint256 amount, address to) external; + + /// @notice Unpauses the CCIP receiver, re-enabling message processing. + /// @dev Only callable by the contract owner. Resumes normal operation after a pause. + function unpause() external; + + /// @notice Returns the Enso Router address used by this receiver. + /// @return router Address of the Enso Router. + function getEnsoRouter() external view returns (address router); + + function isMessageInEscrow(bytes32 messageId) external view returns (bool); + + /// @notice Returns whether a sender is allowlisted for a given source chain. + /// @param sourceChainSelector Chain selector of the source network. + /// @param sender Address of the source application on that chain. + /// @return allowed True if the sender is allowed. + function isSenderAllowed(uint64 sourceChainSelector, address sender) external view returns (bool allowed); + + /// @notice Returns whether a source chain is allowlisted. + /// @param sourceChainSelector Chain selector of the source network. + /// @return allowed True if the source chain is allowed. + function isSourceChainAllowed(uint64 sourceChainSelector) external view returns (bool allowed); + + function version() external returns (uint256 version); + + /// @notice Returns whether a CCIP message was already executed. + /// @param messageId CCIP message identifier. + /// @return executed True if the message was marked as executed. + function wasMessageExecuted(bytes32 messageId) external view returns (bool executed); +} From 0c6860d767481c24f7a4f5952ca6b0cca249afbb Mon Sep 17 00:00:00 2001 From: vnavascues Date: Thu, 30 Oct 2025 16:53:27 +0100 Subject: [PATCH 08/21] feat: EnsoCCIPReceiverDefensive changes --- src/bridge/EnsoCCIPReceiverDefensive.sol | 176 ++++++------------ src/interfaces/IEnsoCCIPReceiverDefensive.sol | 123 ++++++------ 2 files changed, 105 insertions(+), 194 deletions(-) diff --git a/src/bridge/EnsoCCIPReceiverDefensive.sol b/src/bridge/EnsoCCIPReceiverDefensive.sol index 583ecc5..5cd3a4a 100644 --- a/src/bridge/EnsoCCIPReceiverDefensive.sol +++ b/src/bridge/EnsoCCIPReceiverDefensive.sol @@ -10,15 +10,17 @@ import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; /// @title EnsoCCIPReceiverDefensive /// @author Enso -/// @notice Destination-side CCIP receiver that validates source chain/sender, enforces replay -/// protection, and forwards a single bridged ERC-20 to Enso Shortcuts via the Enso Router. -/// @dev The contract: -/// - Relies on Chainlink CCIP’s router gating via {CCIPReceiver}. -/// - Adds allowlists for source chain selectors and source senders (per chain). -/// - Guards against duplicate delivery with a messageId map. -/// - Expects exactly one ERC-20 in `destTokenAmounts`; amount must be non-zero. -/// - Executes Shortcuts through a self-call pattern (`try this.execute(...)`) so we can -/// catch and handle reverts and sweep funds to a fallback receiver in the payload. +/// @notice Destination-side CCIP receiver that enforces replay protection, validates the delivered +/// token shape (exactly one non-zero ERC-20), decodes a payload, and either forwards funds +/// to Enso Shortcuts via the Enso Router or performs defensive refund/quarantine without reverting. +/// @dev Key properties: +/// - Relies on Chainlink CCIP Router gating via {CCIPReceiver}. +/// - Maintains idempotency with a messageId β†’ handled flag. +/// - Validates `destTokenAmounts` has exactly one ERC-20 with non-zero amount. +/// - Decodes `(receiver, estimatedGas, shortcutData)` from the message payload (temp external helper). +/// - For environment issues (PAUSED / INSUFFICIENT_GAS), refunds to `receiver` for better UX. +/// - For malformed messages (no/too many tokens, zero amount, bad payload), quarantines funds in this contract. +/// - Executes Shortcuts using a self-call (`try this.execute(...)`) to catch and handle reverts. contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, Ownable2Step, Pausable { using SafeERC20 for IERC20; @@ -28,19 +30,7 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, /// forge-lint: disable-next-item(screaming-snake-case-immutable) IEnsoRouter private immutable i_ensoRouter; - /// @dev Allowlist by source chain selector. - /// forge-lint: disable-next-item(mixed-case-variable) - mapping(uint64 sourceChainSelector => bool isAllowed) private s_allowedSourceChain; - - /// @dev Per-(chain selector, sender) allowlist. - /// Key is computed as: keccak256(abi.encode(sourceChainSelector, sender)), - /// where `sender` is the EVM address decoded from `Any2EVMMessage.sender` bytes. - /// forge-lint: disable-next-item(mixed-case-variable) - mapping(bytes32 key => bool isAllowed) private s_allowedSender; - - mapping(bytes32 messageId => bool isEscrow) private s_escrowMessage; - - /// @dev Replay protection: tracks CCIP message IDs that were executed successfully (or handled). + /// @dev Replay protection: tracks CCIP message IDs that were executed/refunded/quarantined. /// forge-lint: disable-next-item(mixed-case-variable) mapping(bytes32 messageId => bool wasExecuted) private s_executedMessage; @@ -53,14 +43,17 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, i_ensoRouter = IEnsoRouter(_ensoRouter); } - /// @notice CCIP router callback: validates message, enforces replay protection, and dispatches. + /// @notice CCIP router callback: validate, classify (refund/quarantine/execute), and avoid reverting. /// @dev Flow: - /// 1) Check duplicate by messageId (fail fast). - /// 2) Check allowlisted source chain and sender (decoded from `message.sender`). - /// 3) Enforce exactly one ERC-20 delivered (and non-zero amount). - /// 4) Decode payload `(receiver, estimatedGas, shortcutData)`. - /// 5) Optional gas self-check (if `estimatedGas` > 0). - /// 6) Mark executed, attempt `execute(...)` via self-call; on failure, sweep token to `receiver`. + /// 1) Replay check by `messageId` (idempotent no-op if already handled). + /// 2) Validate token shape (exactly one ERC-20, non-zero amount). + /// 3) Decode payload `(receiver, estimatedGas, shortcutData)` using a temporary external helper. + /// 4) Environment checks: `paused()` and `estimatedGas` hint vs `gasleft()`. + /// 5) If non-OK β†’ select refund policy: + /// - TO_RECEIVER for environment issues (PAUSED / INSUFFICIENT_GAS), + /// - TO_ESCROW for malformed token/payload (funds remain in this contract), + /// - NONE for ALREADY_EXECUTED (no-op). + /// 6) If OK β†’ mark executed and `try this.execute(...)`; on revert, refund to `receiver`. /// @param _message The CCIP Any2EVM message with metadata, payload, and delivered tokens. function _ccipReceive(Client.Any2EVMMessage memory _message) internal override { ( @@ -73,11 +66,11 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, ) = _validateMessage(_message); if (errorCode != ErrorCode.NO_ERROR) { - emit IEnsoCCIPReceiverDefensive.MessageValidationFailed(_message.messageId, errorCode, errorData); + emit MessageValidationFailed(_message.messageId, errorCode, errorData); RefundKind refundKind = _getRefundPolicy(errorCode); if (refundKind == RefundKind.NONE) { - // NOTE: ErrorCode.ALREADY_EXECUTED β†’ no-op; + // ALREADY_EXECUTED β†’ idempotent no-op (do not flip the flag again) return; } if (refundKind == RefundKind.TO_RECEIVER) { @@ -87,41 +80,42 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, } if (refundKind == RefundKind.TO_ESCROW) { s_executedMessage[_message.messageId] = true; + // Quarantine-in-place: funds remain in this contract; ops can recover via `recoverTokens`. emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); - s_escrowMessage[_message.messageId] = true; + return; } - // NOTE: make sure this is caught in development + // Should not happen; guarded to surface during development. revert EnsoCCIPReceiver_UnsupportedRefundKind(refundKind); } + // Happy path: mark handled and attempt Shortcuts execution. s_executedMessage[_message.messageId] = true; - // Attempt Shortcuts execution; on failure, sweep funds to the fallback receiver. try this.execute(token, amount, shortcutData) { - emit IEnsoCCIPReceiverDefensive.ShortcutExecutionSuccessful(_message.messageId); + emit ShortcutExecutionSuccessful(_message.messageId); } catch (bytes memory err) { - emit IEnsoCCIPReceiverDefensive.ShortcutExecutionFailed(_message.messageId, err); + emit ShortcutExecutionFailed(_message.messageId, err); IERC20(token).safeTransfer(receiver, amount); } } + /// @inheritdoc IEnsoCCIPReceiverDefensive function decodeMessageData(bytes calldata _data) external view returns (address, uint256, bytes memory) { if (msg.sender != address(this)) { revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_OnlySelf(); } - + // Temporary approach; will be replaced by a safe inline decoder. return abi.decode(_data, (address, uint256, bytes)); } /// @inheritdoc IEnsoCCIPReceiverDefensive function execute(address _token, uint256 _amount, bytes calldata _shortcutData) external { if (msg.sender != address(this)) { - revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_OnlySelf(); + revert EnsoCCIPReceiver_OnlySelf(); } Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(_token, _amount) }); IERC20(_token).forceApprove(address(i_ensoRouter), _amount); - i_ensoRouter.routeSingle(tokenIn, _shortcutData); } @@ -131,34 +125,9 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, } /// @inheritdoc IEnsoCCIPReceiverDefensive - function setAllowedSender(uint64 _sourceChainSelector, address _sender, bool _isAllowed) external onlyOwner { - s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)] = _isAllowed; - emit IEnsoCCIPReceiverDefensive.AllowedSenderSet(_sourceChainSelector, _sender, _isAllowed); - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function setAllowedSourceChain(uint64 _sourceChainSelector, bool _isAllowed) external onlyOwner { - s_allowedSourceChain[_sourceChainSelector] = _isAllowed; - emit IEnsoCCIPReceiverDefensive.AllowedSourceChainSet(_sourceChainSelector, _isAllowed); - } - - /// @dev currently only for malformed messages, as multiple tokens are not supported by CCIP - function sweepMessageInEscrow( - bytes32 _messageId, - address _token, - uint256 _amount, - address _to - ) - external - onlyOwner - { - if (!s_escrowMessage[_messageId]) { - revert EnsoCCIPReceiver_MissingEscrow(_messageId); - } - delete s_escrowMessage[_messageId]; - + function recoverTokens(address _token, address _to, uint256 _amount) external onlyOwner { IERC20(_token).safeTransfer(_to, _amount); - emit EscrowSwept(_messageId, _token, _amount, _to); + emit TokensRecovered(_token, _to, _amount); } /// @inheritdoc IEnsoCCIPReceiverDefensive @@ -171,20 +140,6 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, return address(i_ensoRouter); } - function isMessageInEscrow(bytes32 _messageId) external view returns (bool) { - return s_escrowMessage[_messageId]; - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function isSenderAllowed(uint64 _sourceChainSelector, address _sender) external view returns (bool) { - return s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)]; - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function isSourceChainAllowed(uint64 _sourceChainSelector) external view returns (bool) { - return s_allowedSourceChain[_sourceChainSelector]; - } - /// @inheritdoc IEnsoCCIPReceiverDefensive function version() external pure returns (uint256) { return VERSION; @@ -195,14 +150,12 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, return s_executedMessage[_messageId]; } + /// @dev Maps an ErrorCode to a refund policy. NONE means no action (e.g., ALREADY_EXECUTED). function _getRefundPolicy(ErrorCode _errorCode) private pure returns (RefundKind) { if (_errorCode == ErrorCode.NO_ERROR || _errorCode == ErrorCode.ALREADY_EXECUTED) { return RefundKind.NONE; } - if ( - _errorCode == ErrorCode.PAUSED || _errorCode == ErrorCode.SOURCE_CHAIN_NOT_ALLOWED - || _errorCode == ErrorCode.SENDER_NOT_ALLOWED || _errorCode == ErrorCode.INSUFFICIENT_GAS - ) { + if (_errorCode == ErrorCode.PAUSED || _errorCode == ErrorCode.INSUFFICIENT_GAS) { return RefundKind.TO_RECEIVER; } if ( @@ -212,10 +165,17 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, return RefundKind.TO_ESCROW; } - // NOTE: make sure this is caught in development - revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_UnsupportedErrorCode(_errorCode); + // Should not happen; guarded to surface during development. + revert EnsoCCIPReceiver_UnsupportedErrorCode(_errorCode); } + /// @dev Validates message shape and environment; does not mutate state. + /// @return token The delivered ERC-20 token (must be non-zero if NO_ERROR). + /// @return amount The delivered token amount (must be > 0 if NO_ERROR). + /// @return receiver Decoded receiver from payload (valid if NO_ERROR/PAUSED/INSUFFICIENT_GAS). + /// @return shortcutData Decoded Enso Shortcuts calldata. + /// @return errorCode Classification of the validation result. + /// @return errorData Optional details (see `MessageValidationFailed` doc). function _validateMessage(Client.Any2EVMMessage memory _message) private view @@ -228,18 +188,22 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, bytes memory errorData ) { + // Replay protection bytes32 messageId = _message.messageId; if (s_executedMessage[messageId]) { errorData = abi.encode(messageId); return (token, amount, receiver, shortcutData, ErrorCode.ALREADY_EXECUTED, errorData); } + // Token shape Client.EVMTokenAmount[] memory destTokenAmounts = _message.destTokenAmounts; if (destTokenAmounts.length == 0) { return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKENS, errorData); } if (destTokenAmounts.length > 1) { + // CCIP currently delivers at most ONE token per message. Multiple-token deliveries are not supported by the + // protocol today, so treat any length > 1 as invalid and quarantine/refuse. return (token, amount, receiver, shortcutData, ErrorCode.TOO_MANY_TOKENS, errorData); } @@ -250,8 +214,8 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKEN_AMOUNT, errorData); } + // Decode payload (temporary external helper; to be replaced by safe inline decoder) uint256 estimatedGas; - // TODO: find an assembly alternative... try this.decodeMessageData(_message.data) returns ( address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData ) { @@ -262,22 +226,11 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, return (token, amount, receiver, shortcutData, ErrorCode.MALFORMED_MESSAGE_DATA, errorData); } + // Environment checks (refundable to receiver) if (paused()) { return (token, amount, receiver, shortcutData, ErrorCode.PAUSED, errorData); } - uint64 sourceChainSelector = _message.sourceChainSelector; - if (!s_allowedSourceChain[sourceChainSelector]) { - errorData = abi.encode(sourceChainSelector); - return (token, amount, receiver, shortcutData, ErrorCode.SOURCE_CHAIN_NOT_ALLOWED, errorData); - } - - address sender = abi.decode(_message.sender, (address)); - if (!s_allowedSender[_getAllowedSenderKey(sourceChainSelector, sender)]) { - errorData = abi.encode(sourceChainSelector, sender); - return (token, amount, receiver, shortcutData, ErrorCode.SENDER_NOT_ALLOWED, errorData); - } - uint256 availableGas = gasleft(); if (estimatedGas != 0 && availableGas < estimatedGas) { errorData = abi.encode(availableGas, estimatedGas); @@ -286,29 +239,4 @@ contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, return (token, amount, receiver, shortcutData, ErrorCode.NO_ERROR, errorData); } - - /// @dev Computes the composite allowlist key for (chainSelector, sender). - /// ABI-equivalent to: - /// keccak256(abi.encode(chainSelector, sender)) - /// and implemented in Yul to avoid an extra temporary allocation. - /// Semantics are identical to the high-level version. - /// - /// Canonicality (no masking required): - /// - `sender` is a canonical Solidity `address`, either decoded via - /// `abi.decode(...,(address))` from `Any2EVMMessage.sender` or received - /// as a public/external ABI parameter. In both cases the VM zero-extends - /// it to a full 32-byte word when written to memory. - /// - `chainSelector` is a `uint64` and is zero-extended to 32 bytes by the ABI/VM. - /// - /// @param _chainSelector The CCIP source chain selector (uint64). - /// @param _sender The source application address decoded from `Any2EVMMessage.sender`. - /// @return allowKey keccak256(abi.encode(_chainSelector, _sender)). - function _getAllowedSenderKey(uint64 _chainSelector, address _sender) private pure returns (bytes32 allowKey) { - assembly ("memory-safe") { - let ptr := mload(0x40) - mstore(ptr, _chainSelector) - mstore(add(ptr, 0x20), _sender) - allowKey := keccak256(ptr, 0x40) - } - } } diff --git a/src/interfaces/IEnsoCCIPReceiverDefensive.sol b/src/interfaces/IEnsoCCIPReceiverDefensive.sol index 3541d59..65cb960 100644 --- a/src/interfaces/IEnsoCCIPReceiverDefensive.sol +++ b/src/interfaces/IEnsoCCIPReceiverDefensive.sol @@ -10,6 +10,14 @@ pragma solidity ^0.8.0; /// - Self-call execution entry used by try/catch in `_ccipReceive` /// - Views for router/allowlists/replay state interface IEnsoCCIPReceiverDefensive { + /// @notice High-level validation/flow outcomes produced by `_validateMessage`. + /// @dev Meanings: + /// - NO_ERROR: message is well-formed; proceed to execution. + /// - ALREADY_EXECUTED: messageId was previously handled (idempotent no-op). + /// - NO_TOKENS / TOO_MANY_TOKENS / NO_TOKEN_AMOUNT: token shape invalid. + /// - MALFORMED_MESSAGE_DATA: payload (address,uint256,bytes) could not be decoded. + /// - PAUSED: contract is paused; environment block on execution. + /// - INSUFFICIENT_GAS: current gas < estimatedGas hint from payload. enum ErrorCode { NO_ERROR, ALREADY_EXECUTED, @@ -18,48 +26,39 @@ interface IEnsoCCIPReceiverDefensive { NO_TOKEN_AMOUNT, MALFORMED_MESSAGE_DATA, PAUSED, - SOURCE_CHAIN_NOT_ALLOWED, - SENDER_NOT_ALLOWED, INSUFFICIENT_GAS } + /// @notice Refund policy selected by the receiver for a given ErrorCode. + /// @dev TO_RECEIVER is used for environment errors (e.g., PAUSED/INSUFFICIENT_GAS) after successful payload decode. + /// TO_ESCROW is used for malformed token/payload cases. enum RefundKind { NONE, TO_RECEIVER, TO_ESCROW } - struct Escrow { - address token; // ERC20 token being held - uint256 amount; // amount held - address receiver; // payload receiver for reference; may be 0 - bool isEscrow; // presence flag - } - // ------------------------------------------------------------------------- // Events // ------------------------------------------------------------------------- - /// @notice Emitted when an allowed sender is (de)authorized for a source chain. - /// @param sourceChainSelector Chain selector of the source network. - /// @param sender Address of the source application on that chain. - /// @param isAllowed True if allowed, false if disallowed. - event AllowedSenderSet(uint64 indexed sourceChainSelector, address indexed sender, bool isAllowed); - - /// @notice Emitted when a source chain is (de)authorized. - /// @param sourceChainSelector Chain selector of the source network. - /// @param isAllowed True if allowed, false if disallowed. - event AllowedSourceChainSet(uint64 indexed sourceChainSelector, bool isAllowed); - - /// @notice Emitted when validation fails. See `errorCode` for class. + /// @notice Emitted when validation fails. See `errorCode` for the reason. /// @dev errorData encodings: /// - ALREADY_EXECUTED: (bytes32 messageId) - /// - SOURCE_CHAIN_NOT_ALLOWED: (uint64 sourceChainSelector) - /// - SENDER_NOT_ALLOWED: (uint64 sourceChainSelector, address sender) /// - INSUFFICIENT_GAS: (uint256 availableGas, uint256 estimatedGas) - /// - Others: empty bytes unless specified. + /// - Others: empty bytes unless specified by the implementation. event MessageValidationFailed(bytes32 indexed messageId, ErrorCode errorCode, bytes errorData); + /// @notice Funds were quarantined in the receiver instead of delivered to the payload receiver. + /// @param messageId The CCIP message id. + /// @param code The validation error that triggered quarantine. + /// @param token ERC-20 token retained. + /// @param amount Token amount retained. + /// @param receiver Original payload receiver (informational; may be zero if not decoded). + event MessageQuarantined( + bytes32 indexed messageId, ErrorCode code, address token, uint256 amount, address receiver + ); + /// @notice Emitted when Enso Shortcuts execution succeeds for a CCIP message. /// @param messageId CCIP message identifier. event ShortcutExecutionSuccessful(bytes32 indexed messageId); @@ -69,16 +68,8 @@ interface IEnsoCCIPReceiverDefensive { /// @param err ABI-encoded revert data from the failed call. event ShortcutExecutionFailed(bytes32 indexed messageId, bytes err); - /// @notice Funds were quarantined to escrow instead of delivered to the payload receiver. - /// @param messageId The CCIP message id. - /// @param code The validation error that triggered quarantine. - /// @param token ERC-20 token moved to escrow. - /// @param amount Token amount quarantined. - /// @param receiver Original payload receiver (informational; may be zero if not decoded). - event MessageQuarantined( - bytes32 indexed messageId, ErrorCode code, address token, uint256 amount, address receiver - ); - event EscrowSwept(bytes32 indexed messageId, address token, uint256 amount, address to); + /// @notice Emitted when the owner recovers tokens from the receiver. + event TokensRecovered(address token, address to, uint256 amount); // ------------------------------------------------------------------------- // Errors @@ -86,8 +77,11 @@ interface IEnsoCCIPReceiverDefensive { /// @notice Revert when an external caller targets the internal executor. error EnsoCCIPReceiver_OnlySelf(); - error EnsoCCIPReceiver_MissingEscrow(bytes32 messageId); + + /// @notice Revert if an unexpected ErrorCode is encountered in refund policy logic. error EnsoCCIPReceiver_UnsupportedErrorCode(ErrorCode errorCode); + + /// @notice Revert if an unexpected RefundKind is encountered in refund policy logic. error EnsoCCIPReceiver_UnsupportedRefundKind(RefundKind refundKind); // ------------------------------------------------------------------------- @@ -103,51 +97,40 @@ interface IEnsoCCIPReceiverDefensive { /// @param shortcutData ABI-encoded call data for the Enso Shortcuts entrypoint. function execute(address token, uint256 amount, bytes calldata shortcutData) external; - /// @notice Pauses the CCIP receiver, disabling new incoming messages until unpaused. - /// @dev Only callable by the contract owner. While paused, `_ccipReceive` should - /// revert or ignore messages to prevent execution. + /// @notice Temporary helper to decode `(address receiver, uint256 estimatedGas, bytes shortcutData)` from + /// a CCIP message payload. Implementations may replace this with safe inline decoding later. + /// @dev SHOULD revert when called externally by non-self to avoid surfacing internals. + /// Typical guard: `if (msg.sender != address(this)) revert EnsoCCIPReceiver_OnlySelf();` + function decodeMessageData(bytes calldata data) + external + view + returns (address receiver, uint256 estimatedGas, bytes memory shortcutData); + + /// @notice Pauses the CCIP receiver, disabling new incoming message execution until unpaused. + /// @dev Only callable by the contract owner. function pause() external; - /// @notice Adds or removes an allowed sender for a specific source chain. - /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). - /// @param sourceChainSelector Chain selector of the source network. - /// @param sender Address of the source application on that chain. - /// @param isAllowed True to allow, false to disallow. - function setAllowedSender(uint64 sourceChainSelector, address sender, bool isAllowed) external; - - /// @notice Adds or removes an allowed source chain. - /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). - /// @param sourceChainSelector Chain selector of the source network. - /// @param isAllowed True to allow, false to disallow. - function setAllowedSourceChain(uint64 sourceChainSelector, bool isAllowed) external; - - function sweepMessageInEscrow(bytes32 messageId, address token, uint256 amount, address to) external; + /// @notice Provides the ability for the owner to recover any ERC-20 tokens held by this contract + /// (for example, after quarantine or accidental sends). + /// @param token ERC20-token to recover. + /// @param to Destination address to send the tokens to. + /// @param amount The amount of tokens to send. + function recoverTokens(address token, address to, uint256 amount) external; - /// @notice Unpauses the CCIP receiver, re-enabling message processing. - /// @dev Only callable by the contract owner. Resumes normal operation after a pause. + /// @notice Unpauses the CCIP receiver, re-enabling normal message processing. + /// @dev Only callable by the contract owner. function unpause() external; /// @notice Returns the Enso Router address used by this receiver. /// @return router Address of the Enso Router. function getEnsoRouter() external view returns (address router); - function isMessageInEscrow(bytes32 messageId) external view returns (bool); - - /// @notice Returns whether a sender is allowlisted for a given source chain. - /// @param sourceChainSelector Chain selector of the source network. - /// @param sender Address of the source application on that chain. - /// @return allowed True if the sender is allowed. - function isSenderAllowed(uint64 sourceChainSelector, address sender) external view returns (bool allowed); - - /// @notice Returns whether a source chain is allowlisted. - /// @param sourceChainSelector Chain selector of the source network. - /// @return allowed True if the source chain is allowed. - function isSourceChainAllowed(uint64 sourceChainSelector) external view returns (bool allowed); - - function version() external returns (uint256 version); + /// @notice Returns a human-readable version/format indicator for off-chain tooling and tests. + /// @return version The version number of this receiver implementation. + function version() external view returns (uint256 version); - /// @notice Returns whether a CCIP message was already executed. + /// @notice Returns whether a CCIP message was already handled (executed/refunded/quarantined). /// @param messageId CCIP message identifier. - /// @return executed True if the message was marked as executed. + /// @return executed True if the messageId is marked as executed/handled. function wasMessageExecuted(bytes32 messageId) external view returns (bool executed); } From 84ae1afdf2e161f2ef1072a0f13c36e07a8de08d Mon Sep 17 00:00:00 2001 From: vnavascues Date: Thu, 30 Oct 2025 16:55:47 +0100 Subject: [PATCH 09/21] chore: removed non-defensive EnsoCCIPReceiver --- src/bridge/EnsoCCIPReceiver.sol | 271 +++++++++++------- src/bridge/EnsoCCIPReceiverDefensive.sol | 242 ---------------- src/interfaces/IEnsoCCIPReceiver.sol | 151 +++++----- src/interfaces/IEnsoCCIPReceiverDefensive.sol | 136 --------- 4 files changed, 243 insertions(+), 557 deletions(-) delete mode 100644 src/bridge/EnsoCCIPReceiverDefensive.sol delete mode 100644 src/interfaces/IEnsoCCIPReceiverDefensive.sol diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index b53067e..4069d72 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -10,33 +10,27 @@ import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; /// @title EnsoCCIPReceiver /// @author Enso -/// @notice Destination-side CCIP receiver that validates source chain/sender, enforces replay -/// protection, and forwards a single bridged ERC-20 to Enso Shortcuts via the Enso Router. -/// @dev The contract: -/// - Relies on Chainlink CCIP’s router gating via {CCIPReceiver}. -/// - Adds allowlists for source chain selectors and source senders (per chain). -/// - Guards against duplicate delivery with a messageId map. -/// - Expects exactly one ERC-20 in `destTokenAmounts`; amount must be non-zero. -/// - Executes Shortcuts through a self-call pattern (`try this.execute(...)`) so we can -/// catch and handle reverts and sweep funds to a fallback receiver in the payload. +/// @notice Destination-side CCIP receiver that enforces replay protection, validates the delivered +/// token shape (exactly one non-zero ERC-20), decodes a payload, and either forwards funds +/// to Enso Shortcuts via the Enso Router or performs defensive refund/quarantine without reverting. +/// @dev Key properties: +/// - Relies on Chainlink CCIP Router gating via {CCIPReceiver}. +/// - Maintains idempotency with a messageId β†’ handled flag. +/// - Validates `destTokenAmounts` has exactly one ERC-20 with non-zero amount. +/// - Decodes `(receiver, estimatedGas, shortcutData)` from the message payload (temp external helper). +/// - For environment issues (PAUSED / INSUFFICIENT_GAS), refunds to `receiver` for better UX. +/// - For malformed messages (no/too many tokens, zero amount, bad payload), quarantines funds in this contract. +/// - Executes Shortcuts using a self-call (`try this.execute(...)`) to catch and handle reverts. contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Pausable { using SafeERC20 for IERC20; + uint256 private constant VERSION = 1; + /// @dev Immutable Enso Router used to dispatch tokens + call Shortcuts. /// forge-lint: disable-next-item(screaming-snake-case-immutable) IEnsoRouter private immutable i_ensoRouter; - /// @dev Allowlist by source chain selector. - /// forge-lint: disable-next-item(mixed-case-variable) - mapping(uint64 sourceChainSelector => bool isAllowed) private s_allowedSourceChain; - - /// @dev Per-(chain selector, sender) allowlist. - /// Key is computed as: keccak256(abi.encode(sourceChainSelector, sender)), - /// where `sender` is the EVM address decoded from `Any2EVMMessage.sender` bytes. - /// forge-lint: disable-next-item(mixed-case-variable) - mapping(bytes32 key => bool isAllowed) private s_allowedSender; - - /// @dev Replay protection: tracks CCIP message IDs that were executed successfully (or handled). + /// @dev Replay protection: tracks CCIP message IDs that were executed/refunded/quarantined. /// forge-lint: disable-next-item(mixed-case-variable) mapping(bytes32 messageId => bool wasExecuted) private s_executedMessage; @@ -49,74 +43,79 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus i_ensoRouter = IEnsoRouter(_ensoRouter); } - /// @notice CCIP router callback: validates message, enforces replay protection, and dispatches. + /// @notice CCIP router callback: validate, classify (refund/quarantine/execute), and avoid reverting. /// @dev Flow: - /// 1) Check duplicate by messageId (fail fast). - /// 2) Check allowlisted source chain and sender (decoded from `message.sender`). - /// 3) Enforce exactly one ERC-20 delivered (and non-zero amount). - /// 4) Decode payload `(receiver, estimatedGas, shortcutData)`. - /// 5) Optional gas self-check (if `estimatedGas` > 0). - /// 6) Mark executed, attempt `execute(...)` via self-call; on failure, sweep token to `receiver`. + /// 1) Replay check by `messageId` (idempotent no-op if already handled). + /// 2) Validate token shape (exactly one ERC-20, non-zero amount). + /// 3) Decode payload `(receiver, estimatedGas, shortcutData)` using a temporary external helper. + /// 4) Environment checks: `paused()` and `estimatedGas` hint vs `gasleft()`. + /// 5) If non-OK β†’ select refund policy: + /// - TO_RECEIVER for environment issues (PAUSED / INSUFFICIENT_GAS), + /// - TO_ESCROW for malformed token/payload (funds remain in this contract), + /// - NONE for ALREADY_EXECUTED (no-op). + /// 6) If OK β†’ mark executed and `try this.execute(...)`; on revert, refund to `receiver`. /// @param _message The CCIP Any2EVM message with metadata, payload, and delivered tokens. - function _ccipReceive(Client.Any2EVMMessage memory _message) internal override whenNotPaused { - bytes32 messageId = _message.messageId; - if (s_executedMessage[messageId]) { - revert IEnsoCCIPReceiver.EnsoCCIPReceiver_AlreadyExecuted(messageId); - } - - uint64 sourceChainSelector = _message.sourceChainSelector; - if (!s_allowedSourceChain[sourceChainSelector]) { - revert IEnsoCCIPReceiver.EnsoCCIPReceiver_SourceChainNotAllowed(sourceChainSelector); - } - - address sender = abi.decode(_message.sender, (address)); - if (!s_allowedSender[_getAllowedSenderKey(sourceChainSelector, sender)]) { - revert IEnsoCCIPReceiver.EnsoCCIPReceiver_SenderNotAllowed(sourceChainSelector, sender); - } - - Client.EVMTokenAmount[] memory destTokenAmounts = _message.destTokenAmounts; - if (destTokenAmounts.length == 0) { - revert IEnsoCCIPReceiver.EnsoCCIPReceiver_NoTokens(); - } - - if (destTokenAmounts.length > 1) { - revert IEnsoCCIPReceiver.EnsoCCIPReceiver_TooManyTokens(); + function _ccipReceive(Client.Any2EVMMessage memory _message) internal override { + ( + address token, + uint256 amount, + address receiver, + bytes memory shortcutData, + ErrorCode errorCode, + bytes memory errorData + ) = _validateMessage(_message); + + if (errorCode != ErrorCode.NO_ERROR) { + emit MessageValidationFailed(_message.messageId, errorCode, errorData); + + RefundKind refundKind = _getRefundPolicy(errorCode); + if (refundKind == RefundKind.NONE) { + // ALREADY_EXECUTED β†’ idempotent no-op (do not flip the flag again) + return; + } + if (refundKind == RefundKind.TO_RECEIVER) { + s_executedMessage[_message.messageId] = true; + IERC20(token).safeTransfer(receiver, amount); + return; + } + if (refundKind == RefundKind.TO_ESCROW) { + s_executedMessage[_message.messageId] = true; + // Quarantine-in-place: funds remain in this contract; ops can recover via `recoverTokens`. + emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); + return; + } + + // Should not happen; guarded to surface during development. + revert EnsoCCIPReceiver_UnsupportedRefundKind(refundKind); } - address token = destTokenAmounts[0].token; - uint256 amount = destTokenAmounts[0].amount; + // Happy path: mark handled and attempt Shortcuts execution. + s_executedMessage[_message.messageId] = true; - if (amount == 0) { - revert IEnsoCCIPReceiver.EnsoCCIPReceiver_NoTokenAmount(token); - } - - (address receiver, uint256 estimatedGas, bytes memory shortcutData) = - abi.decode(_message.data, (address, uint256, bytes)); - - uint256 availableGas = gasleft(); - if (estimatedGas != 0 && availableGas < estimatedGas) { - revert IEnsoCCIPReceiver.EnsoCCIPReceiver_InsufficientGas(availableGas, estimatedGas); - } - - s_executedMessage[messageId] = true; - - // Attempt Shortcuts execution; on failure, sweep funds to the fallback receiver. try this.execute(token, amount, shortcutData) { - emit IEnsoCCIPReceiver.ShortcutExecutionSuccessful(messageId); + emit ShortcutExecutionSuccessful(_message.messageId); } catch (bytes memory err) { - emit IEnsoCCIPReceiver.ShortcutExecutionFailed(messageId, err); + emit ShortcutExecutionFailed(_message.messageId, err); IERC20(token).safeTransfer(receiver, amount); } } /// @inheritdoc IEnsoCCIPReceiver - function execute(address _token, uint256 _amount, bytes calldata _shortcutData) external { + function decodeMessageData(bytes calldata _data) external view returns (address, uint256, bytes memory) { if (msg.sender != address(this)) { revert IEnsoCCIPReceiver.EnsoCCIPReceiver_OnlySelf(); } + // Temporary approach; will be replaced by a safe inline decoder. + return abi.decode(_data, (address, uint256, bytes)); + } + + /// @inheritdoc IEnsoCCIPReceiver + function execute(address _token, uint256 _amount, bytes calldata _shortcutData) external { + if (msg.sender != address(this)) { + revert EnsoCCIPReceiver_OnlySelf(); + } Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(_token, _amount) }); IERC20(_token).forceApprove(address(i_ensoRouter), _amount); - i_ensoRouter.routeSingle(tokenIn, _shortcutData); } @@ -126,15 +125,9 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus } /// @inheritdoc IEnsoCCIPReceiver - function setAllowedSender(uint64 _sourceChainSelector, address _sender, bool _isAllowed) external onlyOwner { - s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)] = _isAllowed; - emit IEnsoCCIPReceiver.AllowedSenderSet(_sourceChainSelector, _sender, _isAllowed); - } - - /// @inheritdoc IEnsoCCIPReceiver - function setAllowedSourceChain(uint64 _sourceChainSelector, bool _isAllowed) external onlyOwner { - s_allowedSourceChain[_sourceChainSelector] = _isAllowed; - emit IEnsoCCIPReceiver.AllowedSourceChainSet(_sourceChainSelector, _isAllowed); + function recoverTokens(address _token, address _to, uint256 _amount) external onlyOwner { + IERC20(_token).safeTransfer(_to, _amount); + emit TokensRecovered(_token, _to, _amount); } /// @inheritdoc IEnsoCCIPReceiver @@ -148,13 +141,8 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus } /// @inheritdoc IEnsoCCIPReceiver - function isSenderAllowed(uint64 _sourceChainSelector, address _sender) external view returns (bool) { - return s_allowedSender[_getAllowedSenderKey(_sourceChainSelector, _sender)]; - } - - /// @inheritdoc IEnsoCCIPReceiver - function isSourceChainAllowed(uint64 _sourceChainSelector) external view returns (bool) { - return s_allowedSourceChain[_sourceChainSelector]; + function version() external pure returns (uint256) { + return VERSION; } /// @inheritdoc IEnsoCCIPReceiver @@ -162,28 +150,93 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus return s_executedMessage[_messageId]; } - /// @dev Computes the composite allowlist key for (chainSelector, sender). - /// ABI-equivalent to: - /// keccak256(abi.encode(chainSelector, sender)) - /// and implemented in Yul to avoid an extra temporary allocation. - /// Semantics are identical to the high-level version. - /// - /// Canonicality (no masking required): - /// - `sender` is a canonical Solidity `address`, either decoded via - /// `abi.decode(...,(address))` from `Any2EVMMessage.sender` or received - /// as a public/external ABI parameter. In both cases the VM zero-extends - /// it to a full 32-byte word when written to memory. - /// - `chainSelector` is a `uint64` and is zero-extended to 32 bytes by the ABI/VM. - /// - /// @param _chainSelector The CCIP source chain selector (uint64). - /// @param _sender The source application address decoded from `Any2EVMMessage.sender`. - /// @return allowKey keccak256(abi.encode(_chainSelector, _sender)). - function _getAllowedSenderKey(uint64 _chainSelector, address _sender) private pure returns (bytes32 allowKey) { - assembly ("memory-safe") { - let ptr := mload(0x40) - mstore(ptr, _chainSelector) - mstore(add(ptr, 0x20), _sender) - allowKey := keccak256(ptr, 0x40) + /// @dev Maps an ErrorCode to a refund policy. NONE means no action (e.g., ALREADY_EXECUTED). + function _getRefundPolicy(ErrorCode _errorCode) private pure returns (RefundKind) { + if (_errorCode == ErrorCode.NO_ERROR || _errorCode == ErrorCode.ALREADY_EXECUTED) { + return RefundKind.NONE; + } + if (_errorCode == ErrorCode.PAUSED || _errorCode == ErrorCode.INSUFFICIENT_GAS) { + return RefundKind.TO_RECEIVER; + } + if ( + _errorCode == ErrorCode.MALFORMED_MESSAGE_DATA || _errorCode == ErrorCode.NO_TOKENS + || _errorCode == ErrorCode.NO_TOKEN_AMOUNT || _errorCode == ErrorCode.TOO_MANY_TOKENS + ) { + return RefundKind.TO_ESCROW; + } + + // Should not happen; guarded to surface during development. + revert EnsoCCIPReceiver_UnsupportedErrorCode(_errorCode); + } + + /// @dev Validates message shape and environment; does not mutate state. + /// @return token The delivered ERC-20 token (must be non-zero if NO_ERROR). + /// @return amount The delivered token amount (must be > 0 if NO_ERROR). + /// @return receiver Decoded receiver from payload (valid if NO_ERROR/PAUSED/INSUFFICIENT_GAS). + /// @return shortcutData Decoded Enso Shortcuts calldata. + /// @return errorCode Classification of the validation result. + /// @return errorData Optional details (see `MessageValidationFailed` doc). + function _validateMessage(Client.Any2EVMMessage memory _message) + private + view + returns ( + address token, + uint256 amount, + address receiver, + bytes memory shortcutData, + ErrorCode errorCode, + bytes memory errorData + ) + { + // Replay protection + bytes32 messageId = _message.messageId; + if (s_executedMessage[messageId]) { + errorData = abi.encode(messageId); + return (token, amount, receiver, shortcutData, ErrorCode.ALREADY_EXECUTED, errorData); + } + + // Token shape + Client.EVMTokenAmount[] memory destTokenAmounts = _message.destTokenAmounts; + if (destTokenAmounts.length == 0) { + return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKENS, errorData); + } + + if (destTokenAmounts.length > 1) { + // CCIP currently delivers at most ONE token per message. Multiple-token deliveries are not supported by the + // protocol today, so treat any length > 1 as invalid and quarantine/refuse. + return (token, amount, receiver, shortcutData, ErrorCode.TOO_MANY_TOKENS, errorData); } + + token = destTokenAmounts[0].token; + amount = destTokenAmounts[0].amount; + + if (amount == 0) { + return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKEN_AMOUNT, errorData); + } + + // Decode payload (temporary external helper; to be replaced by safe inline decoder) + uint256 estimatedGas; + try this.decodeMessageData(_message.data) returns ( + address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData + ) { + receiver = decodedReceiver; + estimatedGas = decodedEstimatedGas; + shortcutData = decodedShortcutData; + } catch { + return (token, amount, receiver, shortcutData, ErrorCode.MALFORMED_MESSAGE_DATA, errorData); + } + + // Environment checks (refundable to receiver) + if (paused()) { + return (token, amount, receiver, shortcutData, ErrorCode.PAUSED, errorData); + } + + uint256 availableGas = gasleft(); + if (estimatedGas != 0 && availableGas < estimatedGas) { + errorData = abi.encode(availableGas, estimatedGas); + return (token, amount, receiver, shortcutData, ErrorCode.INSUFFICIENT_GAS, errorData); + } + + return (token, amount, receiver, shortcutData, ErrorCode.NO_ERROR, errorData); } } diff --git a/src/bridge/EnsoCCIPReceiverDefensive.sol b/src/bridge/EnsoCCIPReceiverDefensive.sol deleted file mode 100644 index 5cd3a4a..0000000 --- a/src/bridge/EnsoCCIPReceiverDefensive.sol +++ /dev/null @@ -1,242 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity ^0.8.24; - -import { IEnsoCCIPReceiverDefensive } from "../interfaces/IEnsoCCIPReceiverDefensive.sol"; -import { IEnsoRouter, Token, TokenType } from "../interfaces/IEnsoRouter.sol"; -import { CCIPReceiver, Client } from "chainlink-ccip/applications/CCIPReceiver.sol"; -import { Ownable, Ownable2Step } from "openzeppelin-contracts/access/Ownable2Step.sol"; -import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; -import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; - -/// @title EnsoCCIPReceiverDefensive -/// @author Enso -/// @notice Destination-side CCIP receiver that enforces replay protection, validates the delivered -/// token shape (exactly one non-zero ERC-20), decodes a payload, and either forwards funds -/// to Enso Shortcuts via the Enso Router or performs defensive refund/quarantine without reverting. -/// @dev Key properties: -/// - Relies on Chainlink CCIP Router gating via {CCIPReceiver}. -/// - Maintains idempotency with a messageId β†’ handled flag. -/// - Validates `destTokenAmounts` has exactly one ERC-20 with non-zero amount. -/// - Decodes `(receiver, estimatedGas, shortcutData)` from the message payload (temp external helper). -/// - For environment issues (PAUSED / INSUFFICIENT_GAS), refunds to `receiver` for better UX. -/// - For malformed messages (no/too many tokens, zero amount, bad payload), quarantines funds in this contract. -/// - Executes Shortcuts using a self-call (`try this.execute(...)`) to catch and handle reverts. -contract EnsoCCIPReceiverDefensive is IEnsoCCIPReceiverDefensive, CCIPReceiver, Ownable2Step, Pausable { - using SafeERC20 for IERC20; - - uint256 private constant VERSION = 1; - - /// @dev Immutable Enso Router used to dispatch tokens + call Shortcuts. - /// forge-lint: disable-next-item(screaming-snake-case-immutable) - IEnsoRouter private immutable i_ensoRouter; - - /// @dev Replay protection: tracks CCIP message IDs that were executed/refunded/quarantined. - /// forge-lint: disable-next-item(mixed-case-variable) - mapping(bytes32 messageId => bool wasExecuted) private s_executedMessage; - - /// @notice Initializes the receiver with the CCIP router and Enso Router. - /// @dev The owner is set via {Ownable} base (passed in to support 2-step ownership if desired). - /// @param _owner Address to set as initial owner. - /// @param _ccipRouter Address of the CCIP Router on the destination chain. - /// @param _ensoRouter Address of the Enso Router that will execute Shortcuts. - constructor(address _owner, address _ccipRouter, address _ensoRouter) Ownable(_owner) CCIPReceiver(_ccipRouter) { - i_ensoRouter = IEnsoRouter(_ensoRouter); - } - - /// @notice CCIP router callback: validate, classify (refund/quarantine/execute), and avoid reverting. - /// @dev Flow: - /// 1) Replay check by `messageId` (idempotent no-op if already handled). - /// 2) Validate token shape (exactly one ERC-20, non-zero amount). - /// 3) Decode payload `(receiver, estimatedGas, shortcutData)` using a temporary external helper. - /// 4) Environment checks: `paused()` and `estimatedGas` hint vs `gasleft()`. - /// 5) If non-OK β†’ select refund policy: - /// - TO_RECEIVER for environment issues (PAUSED / INSUFFICIENT_GAS), - /// - TO_ESCROW for malformed token/payload (funds remain in this contract), - /// - NONE for ALREADY_EXECUTED (no-op). - /// 6) If OK β†’ mark executed and `try this.execute(...)`; on revert, refund to `receiver`. - /// @param _message The CCIP Any2EVM message with metadata, payload, and delivered tokens. - function _ccipReceive(Client.Any2EVMMessage memory _message) internal override { - ( - address token, - uint256 amount, - address receiver, - bytes memory shortcutData, - ErrorCode errorCode, - bytes memory errorData - ) = _validateMessage(_message); - - if (errorCode != ErrorCode.NO_ERROR) { - emit MessageValidationFailed(_message.messageId, errorCode, errorData); - - RefundKind refundKind = _getRefundPolicy(errorCode); - if (refundKind == RefundKind.NONE) { - // ALREADY_EXECUTED β†’ idempotent no-op (do not flip the flag again) - return; - } - if (refundKind == RefundKind.TO_RECEIVER) { - s_executedMessage[_message.messageId] = true; - IERC20(token).safeTransfer(receiver, amount); - return; - } - if (refundKind == RefundKind.TO_ESCROW) { - s_executedMessage[_message.messageId] = true; - // Quarantine-in-place: funds remain in this contract; ops can recover via `recoverTokens`. - emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); - return; - } - - // Should not happen; guarded to surface during development. - revert EnsoCCIPReceiver_UnsupportedRefundKind(refundKind); - } - - // Happy path: mark handled and attempt Shortcuts execution. - s_executedMessage[_message.messageId] = true; - - try this.execute(token, amount, shortcutData) { - emit ShortcutExecutionSuccessful(_message.messageId); - } catch (bytes memory err) { - emit ShortcutExecutionFailed(_message.messageId, err); - IERC20(token).safeTransfer(receiver, amount); - } - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function decodeMessageData(bytes calldata _data) external view returns (address, uint256, bytes memory) { - if (msg.sender != address(this)) { - revert IEnsoCCIPReceiverDefensive.EnsoCCIPReceiver_OnlySelf(); - } - // Temporary approach; will be replaced by a safe inline decoder. - return abi.decode(_data, (address, uint256, bytes)); - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function execute(address _token, uint256 _amount, bytes calldata _shortcutData) external { - if (msg.sender != address(this)) { - revert EnsoCCIPReceiver_OnlySelf(); - } - Token memory tokenIn = Token({ tokenType: TokenType.ERC20, data: abi.encode(_token, _amount) }); - IERC20(_token).forceApprove(address(i_ensoRouter), _amount); - i_ensoRouter.routeSingle(tokenIn, _shortcutData); - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function pause() external onlyOwner { - _pause(); - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function recoverTokens(address _token, address _to, uint256 _amount) external onlyOwner { - IERC20(_token).safeTransfer(_to, _amount); - emit TokensRecovered(_token, _to, _amount); - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function unpause() external onlyOwner { - _unpause(); - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function getEnsoRouter() external view returns (address) { - return address(i_ensoRouter); - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function version() external pure returns (uint256) { - return VERSION; - } - - /// @inheritdoc IEnsoCCIPReceiverDefensive - function wasMessageExecuted(bytes32 _messageId) external view returns (bool) { - return s_executedMessage[_messageId]; - } - - /// @dev Maps an ErrorCode to a refund policy. NONE means no action (e.g., ALREADY_EXECUTED). - function _getRefundPolicy(ErrorCode _errorCode) private pure returns (RefundKind) { - if (_errorCode == ErrorCode.NO_ERROR || _errorCode == ErrorCode.ALREADY_EXECUTED) { - return RefundKind.NONE; - } - if (_errorCode == ErrorCode.PAUSED || _errorCode == ErrorCode.INSUFFICIENT_GAS) { - return RefundKind.TO_RECEIVER; - } - if ( - _errorCode == ErrorCode.MALFORMED_MESSAGE_DATA || _errorCode == ErrorCode.NO_TOKENS - || _errorCode == ErrorCode.NO_TOKEN_AMOUNT || _errorCode == ErrorCode.TOO_MANY_TOKENS - ) { - return RefundKind.TO_ESCROW; - } - - // Should not happen; guarded to surface during development. - revert EnsoCCIPReceiver_UnsupportedErrorCode(_errorCode); - } - - /// @dev Validates message shape and environment; does not mutate state. - /// @return token The delivered ERC-20 token (must be non-zero if NO_ERROR). - /// @return amount The delivered token amount (must be > 0 if NO_ERROR). - /// @return receiver Decoded receiver from payload (valid if NO_ERROR/PAUSED/INSUFFICIENT_GAS). - /// @return shortcutData Decoded Enso Shortcuts calldata. - /// @return errorCode Classification of the validation result. - /// @return errorData Optional details (see `MessageValidationFailed` doc). - function _validateMessage(Client.Any2EVMMessage memory _message) - private - view - returns ( - address token, - uint256 amount, - address receiver, - bytes memory shortcutData, - ErrorCode errorCode, - bytes memory errorData - ) - { - // Replay protection - bytes32 messageId = _message.messageId; - if (s_executedMessage[messageId]) { - errorData = abi.encode(messageId); - return (token, amount, receiver, shortcutData, ErrorCode.ALREADY_EXECUTED, errorData); - } - - // Token shape - Client.EVMTokenAmount[] memory destTokenAmounts = _message.destTokenAmounts; - if (destTokenAmounts.length == 0) { - return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKENS, errorData); - } - - if (destTokenAmounts.length > 1) { - // CCIP currently delivers at most ONE token per message. Multiple-token deliveries are not supported by the - // protocol today, so treat any length > 1 as invalid and quarantine/refuse. - return (token, amount, receiver, shortcutData, ErrorCode.TOO_MANY_TOKENS, errorData); - } - - token = destTokenAmounts[0].token; - amount = destTokenAmounts[0].amount; - - if (amount == 0) { - return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKEN_AMOUNT, errorData); - } - - // Decode payload (temporary external helper; to be replaced by safe inline decoder) - uint256 estimatedGas; - try this.decodeMessageData(_message.data) returns ( - address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData - ) { - receiver = decodedReceiver; - estimatedGas = decodedEstimatedGas; - shortcutData = decodedShortcutData; - } catch { - return (token, amount, receiver, shortcutData, ErrorCode.MALFORMED_MESSAGE_DATA, errorData); - } - - // Environment checks (refundable to receiver) - if (paused()) { - return (token, amount, receiver, shortcutData, ErrorCode.PAUSED, errorData); - } - - uint256 availableGas = gasleft(); - if (estimatedGas != 0 && availableGas < estimatedGas) { - errorData = abi.encode(availableGas, estimatedGas); - return (token, amount, receiver, shortcutData, ErrorCode.INSUFFICIENT_GAS, errorData); - } - - return (token, amount, receiver, shortcutData, ErrorCode.NO_ERROR, errorData); - } -} diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol index 0b20392..694e325 100644 --- a/src/interfaces/IEnsoCCIPReceiver.sol +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -3,27 +3,62 @@ pragma solidity ^0.8.0; /// @title IEnsoCCIPReceiver /// @author Enso -/// @notice Interface for a CCIP destination receiver that validates source (chain/sender), -/// enforces replay protection, and forwards a single bridged ERC-20 into Enso Shortcuts. +/// @notice Interface for a CCIP destination receiver that enforces replay protection, +/// validates delivered token shape (exactly one non-zero ERC-20), decodes a payload, +/// and either forwards funds into Enso Shortcuts or performs defensive refunds/quarantine. /// @dev Exposes only the external/public surface of the implementing receiver: -/// - Admin setters for allowlists /// - Self-call execution entry used by try/catch in `_ccipReceive` -/// - Views for router/allowlists/replay state +/// - Pause/unpause and token recovery +/// - Views for router/version/replay state interface IEnsoCCIPReceiver { + /// @notice High-level validation/flow outcomes produced by `_validateMessage`. + /// @dev Meanings: + /// - NO_ERROR: message is well-formed; proceed to execution. + /// - ALREADY_EXECUTED: messageId was previously handled (idempotent no-op). + /// - NO_TOKENS / TOO_MANY_TOKENS / NO_TOKEN_AMOUNT: token shape invalid. + /// - MALFORMED_MESSAGE_DATA: payload (address,uint256,bytes) could not be decoded. + /// - PAUSED: contract is paused; environment block on execution. + /// - INSUFFICIENT_GAS: current gas < estimatedGas hint from payload. + enum ErrorCode { + NO_ERROR, + ALREADY_EXECUTED, + NO_TOKENS, + TOO_MANY_TOKENS, + NO_TOKEN_AMOUNT, + MALFORMED_MESSAGE_DATA, + PAUSED, + INSUFFICIENT_GAS + } + + /// @notice Refund policy selected by the receiver for a given ErrorCode. + /// @dev TO_RECEIVER is used for environment errors (e.g., PAUSED/INSUFFICIENT_GAS) after successful payload decode. + /// TO_ESCROW is used for malformed token/payload cases. + enum RefundKind { + NONE, + TO_RECEIVER, + TO_ESCROW + } + // ------------------------------------------------------------------------- // Events // ------------------------------------------------------------------------- - /// @notice Emitted when an allowed sender is (de)authorized for a source chain. - /// @param sourceChainSelector Chain selector of the source network. - /// @param sender Address of the source application on that chain. - /// @param isAllowed True if allowed, false if disallowed. - event AllowedSenderSet(uint64 indexed sourceChainSelector, address indexed sender, bool isAllowed); - - /// @notice Emitted when a source chain is (de)authorized. - /// @param sourceChainSelector Chain selector of the source network. - /// @param isAllowed True if allowed, false if disallowed. - event AllowedSourceChainSet(uint64 indexed sourceChainSelector, bool isAllowed); + /// @notice Emitted when validation fails. See `errorCode` for the reason. + /// @dev errorData encodings: + /// - ALREADY_EXECUTED: (bytes32 messageId) + /// - INSUFFICIENT_GAS: (uint256 availableGas, uint256 estimatedGas) + /// - Others: empty bytes unless specified by the implementation. + event MessageValidationFailed(bytes32 indexed messageId, ErrorCode errorCode, bytes errorData); + + /// @notice Funds were quarantined in the receiver instead of delivered to the payload receiver. + /// @param messageId The CCIP message id. + /// @param code The validation error that triggered quarantine. + /// @param token ERC-20 token retained. + /// @param amount Token amount retained. + /// @param receiver Original payload receiver (informational; may be zero if not decoded). + event MessageQuarantined( + bytes32 indexed messageId, ErrorCode code, address token, uint256 amount, address receiver + ); /// @notice Emitted when Enso Shortcuts execution succeeds for a CCIP message. /// @param messageId CCIP message identifier. @@ -34,40 +69,21 @@ interface IEnsoCCIPReceiver { /// @param err ABI-encoded revert data from the failed call. event ShortcutExecutionFailed(bytes32 indexed messageId, bytes err); + /// @notice Emitted when the owner recovers tokens from the receiver. + event TokensRecovered(address token, address to, uint256 amount); + // ------------------------------------------------------------------------- // Errors // ------------------------------------------------------------------------- - /// @notice Revert when a CCIP message with the same ID was already processed. - /// @param messageId CCIP message identifier. - error EnsoCCIPReceiver_AlreadyExecuted(bytes32 messageId); - - /// @notice Revert when available gas is below the estimated threshold from payload. - /// @param availableGas Remaining gas at the check point. - /// @param estimatedGas Gas amount the sender expects to be available. - error EnsoCCIPReceiver_InsufficientGas(uint256 availableGas, uint256 estimatedGas); - - /// @notice Revert when the CCIP message carries no tokens. - error EnsoCCIPReceiver_NoTokens(); - - /// @notice Revert when the delivered single token amount is zero. - /// @param token ERC-20 token address. - error EnsoCCIPReceiver_NoTokenAmount(address token); - /// @notice Revert when an external caller targets the internal executor. error EnsoCCIPReceiver_OnlySelf(); - /// @notice Revert when the source chain is not allowlisted. - /// @param sourceChainSelector Chain selector of the source network. - error EnsoCCIPReceiver_SourceChainNotAllowed(uint64 sourceChainSelector); - - /// @notice Revert when the source sender is not allowlisted for a given chain. - /// @param sourceChainSelector Chain selector of the source network. - /// @param sender Address of the source application. - error EnsoCCIPReceiver_SenderNotAllowed(uint64 sourceChainSelector, address sender); + /// @notice Revert if an unexpected ErrorCode is encountered in refund policy logic. + error EnsoCCIPReceiver_UnsupportedErrorCode(ErrorCode errorCode); - /// @notice Revert when more than one token is delivered (not supported). - error EnsoCCIPReceiver_TooManyTokens(); + /// @notice Revert if an unexpected RefundKind is encountered in refund policy logic. + error EnsoCCIPReceiver_UnsupportedRefundKind(RefundKind refundKind); // ------------------------------------------------------------------------- // External Functions @@ -82,45 +98,40 @@ interface IEnsoCCIPReceiver { /// @param shortcutData ABI-encoded call data for the Enso Shortcuts entrypoint. function execute(address token, uint256 amount, bytes calldata shortcutData) external; - /// @notice Pauses the CCIP receiver, disabling new incoming messages until unpaused. - /// @dev Only callable by the contract owner. While paused, `_ccipReceive` should - /// revert or ignore messages to prevent execution. + /// @notice Temporary helper to decode `(address receiver, uint256 estimatedGas, bytes shortcutData)` from + /// a CCIP message payload. Implementations may replace this with safe inline decoding later. + /// @dev SHOULD revert when called externally by non-self to avoid surfacing internals. + /// Typical guard: `if (msg.sender != address(this)) revert EnsoCCIPReceiver_OnlySelf();` + function decodeMessageData(bytes calldata data) + external + view + returns (address receiver, uint256 estimatedGas, bytes memory shortcutData); + + /// @notice Pauses the CCIP receiver, disabling new incoming message execution until unpaused. + /// @dev Only callable by the contract owner. function pause() external; - /// @notice Adds or removes an allowed sender for a specific source chain. - /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). - /// @param sourceChainSelector Chain selector of the source network. - /// @param sender Address of the source application on that chain. - /// @param isAllowed True to allow, false to disallow. - function setAllowedSender(uint64 sourceChainSelector, address sender, bool isAllowed) external; - - /// @notice Adds or removes an allowed source chain. - /// @dev Typically `onlyOwner`. Idempotent (setting an already-set value is allowed). - /// @param sourceChainSelector Chain selector of the source network. - /// @param isAllowed True to allow, false to disallow. - function setAllowedSourceChain(uint64 sourceChainSelector, bool isAllowed) external; - - /// @notice Unpauses the CCIP receiver, re-enabling message processing. - /// @dev Only callable by the contract owner. Resumes normal operation after a pause. + /// @notice Provides the ability for the owner to recover any ERC-20 tokens held by this contract + /// (for example, after quarantine or accidental sends). + /// @param token ERC20-token to recover. + /// @param to Destination address to send the tokens to. + /// @param amount The amount of tokens to send. + function recoverTokens(address token, address to, uint256 amount) external; + + /// @notice Unpauses the CCIP receiver, re-enabling normal message processing. + /// @dev Only callable by the contract owner. function unpause() external; /// @notice Returns the Enso Router address used by this receiver. /// @return router Address of the Enso Router. function getEnsoRouter() external view returns (address router); - /// @notice Returns whether a sender is allowlisted for a given source chain. - /// @param sourceChainSelector Chain selector of the source network. - /// @param sender Address of the source application on that chain. - /// @return allowed True if the sender is allowed. - function isSenderAllowed(uint64 sourceChainSelector, address sender) external view returns (bool allowed); - - /// @notice Returns whether a source chain is allowlisted. - /// @param sourceChainSelector Chain selector of the source network. - /// @return allowed True if the source chain is allowed. - function isSourceChainAllowed(uint64 sourceChainSelector) external view returns (bool allowed); + /// @notice Returns a human-readable version/format indicator for off-chain tooling and tests. + /// @return version The version number of this receiver implementation. + function version() external view returns (uint256 version); - /// @notice Returns whether a CCIP message was already executed. + /// @notice Returns whether a CCIP message was already handled (executed/refunded/quarantined). /// @param messageId CCIP message identifier. - /// @return executed True if the message was marked as executed. + /// @return executed True if the messageId is marked as executed/handled. function wasMessageExecuted(bytes32 messageId) external view returns (bool executed); } diff --git a/src/interfaces/IEnsoCCIPReceiverDefensive.sol b/src/interfaces/IEnsoCCIPReceiverDefensive.sol deleted file mode 100644 index 65cb960..0000000 --- a/src/interfaces/IEnsoCCIPReceiverDefensive.sol +++ /dev/null @@ -1,136 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-only -pragma solidity ^0.8.0; - -/// @title IEnsoCCIPReceiverDefensive -/// @author Enso -/// @notice Interface for a CCIP destination receiver that validates source (chain/sender), -/// enforces replay protection, and forwards a single bridged ERC-20 into Enso Shortcuts. -/// @dev Exposes only the external/public surface of the implementing receiver: -/// - Admin setters for allowlists -/// - Self-call execution entry used by try/catch in `_ccipReceive` -/// - Views for router/allowlists/replay state -interface IEnsoCCIPReceiverDefensive { - /// @notice High-level validation/flow outcomes produced by `_validateMessage`. - /// @dev Meanings: - /// - NO_ERROR: message is well-formed; proceed to execution. - /// - ALREADY_EXECUTED: messageId was previously handled (idempotent no-op). - /// - NO_TOKENS / TOO_MANY_TOKENS / NO_TOKEN_AMOUNT: token shape invalid. - /// - MALFORMED_MESSAGE_DATA: payload (address,uint256,bytes) could not be decoded. - /// - PAUSED: contract is paused; environment block on execution. - /// - INSUFFICIENT_GAS: current gas < estimatedGas hint from payload. - enum ErrorCode { - NO_ERROR, - ALREADY_EXECUTED, - NO_TOKENS, - TOO_MANY_TOKENS, - NO_TOKEN_AMOUNT, - MALFORMED_MESSAGE_DATA, - PAUSED, - INSUFFICIENT_GAS - } - - /// @notice Refund policy selected by the receiver for a given ErrorCode. - /// @dev TO_RECEIVER is used for environment errors (e.g., PAUSED/INSUFFICIENT_GAS) after successful payload decode. - /// TO_ESCROW is used for malformed token/payload cases. - enum RefundKind { - NONE, - TO_RECEIVER, - TO_ESCROW - } - - // ------------------------------------------------------------------------- - // Events - // ------------------------------------------------------------------------- - - /// @notice Emitted when validation fails. See `errorCode` for the reason. - /// @dev errorData encodings: - /// - ALREADY_EXECUTED: (bytes32 messageId) - /// - INSUFFICIENT_GAS: (uint256 availableGas, uint256 estimatedGas) - /// - Others: empty bytes unless specified by the implementation. - event MessageValidationFailed(bytes32 indexed messageId, ErrorCode errorCode, bytes errorData); - - /// @notice Funds were quarantined in the receiver instead of delivered to the payload receiver. - /// @param messageId The CCIP message id. - /// @param code The validation error that triggered quarantine. - /// @param token ERC-20 token retained. - /// @param amount Token amount retained. - /// @param receiver Original payload receiver (informational; may be zero if not decoded). - event MessageQuarantined( - bytes32 indexed messageId, ErrorCode code, address token, uint256 amount, address receiver - ); - - /// @notice Emitted when Enso Shortcuts execution succeeds for a CCIP message. - /// @param messageId CCIP message identifier. - event ShortcutExecutionSuccessful(bytes32 indexed messageId); - - /// @notice Emitted when Enso Shortcuts execution reverts for a CCIP message. - /// @param messageId CCIP message identifier. - /// @param err ABI-encoded revert data from the failed call. - event ShortcutExecutionFailed(bytes32 indexed messageId, bytes err); - - /// @notice Emitted when the owner recovers tokens from the receiver. - event TokensRecovered(address token, address to, uint256 amount); - - // ------------------------------------------------------------------------- - // Errors - // ------------------------------------------------------------------------- - - /// @notice Revert when an external caller targets the internal executor. - error EnsoCCIPReceiver_OnlySelf(); - - /// @notice Revert if an unexpected ErrorCode is encountered in refund policy logic. - error EnsoCCIPReceiver_UnsupportedErrorCode(ErrorCode errorCode); - - /// @notice Revert if an unexpected RefundKind is encountered in refund policy logic. - error EnsoCCIPReceiver_UnsupportedRefundKind(RefundKind refundKind); - - // ------------------------------------------------------------------------- - // External Functions - // ------------------------------------------------------------------------- - - /// @notice Executes Enso Shortcuts with a single ERC-20 that was previously received via CCIP. - /// @dev MUST be callable only by the contract itself (self-call), typically from `_ccipReceive` - /// using `try this.execute(...)`. Implementations should guard with - /// `if (msg.sender != address(this)) revert EnsoCCIPReceiver_OnlySelf();` - /// @param token ERC-20 token to route. - /// @param amount Amount of `token` to route. - /// @param shortcutData ABI-encoded call data for the Enso Shortcuts entrypoint. - function execute(address token, uint256 amount, bytes calldata shortcutData) external; - - /// @notice Temporary helper to decode `(address receiver, uint256 estimatedGas, bytes shortcutData)` from - /// a CCIP message payload. Implementations may replace this with safe inline decoding later. - /// @dev SHOULD revert when called externally by non-self to avoid surfacing internals. - /// Typical guard: `if (msg.sender != address(this)) revert EnsoCCIPReceiver_OnlySelf();` - function decodeMessageData(bytes calldata data) - external - view - returns (address receiver, uint256 estimatedGas, bytes memory shortcutData); - - /// @notice Pauses the CCIP receiver, disabling new incoming message execution until unpaused. - /// @dev Only callable by the contract owner. - function pause() external; - - /// @notice Provides the ability for the owner to recover any ERC-20 tokens held by this contract - /// (for example, after quarantine or accidental sends). - /// @param token ERC20-token to recover. - /// @param to Destination address to send the tokens to. - /// @param amount The amount of tokens to send. - function recoverTokens(address token, address to, uint256 amount) external; - - /// @notice Unpauses the CCIP receiver, re-enabling normal message processing. - /// @dev Only callable by the contract owner. - function unpause() external; - - /// @notice Returns the Enso Router address used by this receiver. - /// @return router Address of the Enso Router. - function getEnsoRouter() external view returns (address router); - - /// @notice Returns a human-readable version/format indicator for off-chain tooling and tests. - /// @return version The version number of this receiver implementation. - function version() external view returns (uint256 version); - - /// @notice Returns whether a CCIP message was already handled (executed/refunded/quarantined). - /// @param messageId CCIP message identifier. - /// @return executed True if the messageId is marked as executed/handled. - function wasMessageExecuted(bytes32 messageId) external view returns (bool executed); -} From 4d5e9431b7357ae952d0d82a658db67b59e9e961 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Thu, 30 Oct 2025 17:20:28 +0100 Subject: [PATCH 10/21] feat: prevent refunding to zero address version 1 --- src/bridge/EnsoCCIPReceiver.sol | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index 4069d72..044dcb3 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -75,7 +75,12 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus } if (refundKind == RefundKind.TO_RECEIVER) { s_executedMessage[_message.messageId] = true; - IERC20(token).safeTransfer(receiver, amount); + if (receiver != address(0)) { + IERC20(token).safeTransfer(receiver, amount); + } else { + // Quarantine-in-place: funds remain in this contract; ops can recover via `recoverTokens`. + emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); + } return; } if (refundKind == RefundKind.TO_ESCROW) { @@ -96,7 +101,12 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus emit ShortcutExecutionSuccessful(_message.messageId); } catch (bytes memory err) { emit ShortcutExecutionFailed(_message.messageId, err); - IERC20(token).safeTransfer(receiver, amount); + if (receiver != address(0)) { + IERC20(token).safeTransfer(receiver, amount); + } else { + // Quarantine-in-place: funds remain in this contract; ops can recover via `recoverTokens`. + emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); + } } } From 4919f57939068bb56b8e391352560c5fc22b69cf Mon Sep 17 00:00:00 2001 From: vnavascues Date: Thu, 30 Oct 2025 17:33:07 +0100 Subject: [PATCH 11/21] feat: prevent zero address receiver, version 2 --- src/bridge/EnsoCCIPReceiver.sol | 27 ++++++++++++--------------- src/interfaces/IEnsoCCIPReceiver.sol | 2 ++ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index 044dcb3..9895713 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -19,7 +19,8 @@ import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; /// - Validates `destTokenAmounts` has exactly one ERC-20 with non-zero amount. /// - Decodes `(receiver, estimatedGas, shortcutData)` from the message payload (temp external helper). /// - For environment issues (PAUSED / INSUFFICIENT_GAS), refunds to `receiver` for better UX. -/// - For malformed messages (no/too many tokens, zero amount, bad payload), quarantines funds in this contract. +/// - For malformed messages (no/too many tokens, zero amount, bad payload, zero address receiver), quarantines +/// funds in this contract. /// - Executes Shortcuts using a self-call (`try this.execute(...)`) to catch and handle reverts. contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Pausable { using SafeERC20 for IERC20; @@ -75,12 +76,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus } if (refundKind == RefundKind.TO_RECEIVER) { s_executedMessage[_message.messageId] = true; - if (receiver != address(0)) { - IERC20(token).safeTransfer(receiver, amount); - } else { - // Quarantine-in-place: funds remain in this contract; ops can recover via `recoverTokens`. - emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); - } + IERC20(token).safeTransfer(receiver, amount); return; } if (refundKind == RefundKind.TO_ESCROW) { @@ -101,12 +97,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus emit ShortcutExecutionSuccessful(_message.messageId); } catch (bytes memory err) { emit ShortcutExecutionFailed(_message.messageId, err); - if (receiver != address(0)) { - IERC20(token).safeTransfer(receiver, amount); - } else { - // Quarantine-in-place: funds remain in this contract; ops can recover via `recoverTokens`. - emit MessageQuarantined(_message.messageId, errorCode, token, amount, receiver); - } + IERC20(token).safeTransfer(receiver, amount); } } @@ -169,8 +160,9 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus return RefundKind.TO_RECEIVER; } if ( - _errorCode == ErrorCode.MALFORMED_MESSAGE_DATA || _errorCode == ErrorCode.NO_TOKENS - || _errorCode == ErrorCode.NO_TOKEN_AMOUNT || _errorCode == ErrorCode.TOO_MANY_TOKENS + _errorCode == ErrorCode.NO_TOKENS || _errorCode == ErrorCode.TOO_MANY_TOKENS + || _errorCode == ErrorCode.NO_TOKEN_AMOUNT || _errorCode == ErrorCode.MALFORMED_MESSAGE_DATA + || _errorCode == ErrorCode.ZERO_ADDRESS_RECEIVER ) { return RefundKind.TO_ESCROW; } @@ -236,6 +228,11 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus return (token, amount, receiver, shortcutData, ErrorCode.MALFORMED_MESSAGE_DATA, errorData); } + // Check receiver + if (receiver == address(0)) { + return (token, amount, receiver, shortcutData, ErrorCode.ZERO_ADDRESS_RECEIVER, errorData); + } + // Environment checks (refundable to receiver) if (paused()) { return (token, amount, receiver, shortcutData, ErrorCode.PAUSED, errorData); diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol index 694e325..5738a86 100644 --- a/src/interfaces/IEnsoCCIPReceiver.sol +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -17,6 +17,7 @@ interface IEnsoCCIPReceiver { /// - ALREADY_EXECUTED: messageId was previously handled (idempotent no-op). /// - NO_TOKENS / TOO_MANY_TOKENS / NO_TOKEN_AMOUNT: token shape invalid. /// - MALFORMED_MESSAGE_DATA: payload (address,uint256,bytes) could not be decoded. + /// - ZERO_ADDRESS_RECEIVER: payload receiver is the zero address. /// - PAUSED: contract is paused; environment block on execution. /// - INSUFFICIENT_GAS: current gas < estimatedGas hint from payload. enum ErrorCode { @@ -26,6 +27,7 @@ interface IEnsoCCIPReceiver { TOO_MANY_TOKENS, NO_TOKEN_AMOUNT, MALFORMED_MESSAGE_DATA, + ZERO_ADDRESS_RECEIVER, PAUSED, INSUFFICIENT_GAS } From b6f90ecfce37b5c17eb7e1e14172fce40f92b976 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Thu, 30 Oct 2025 18:21:07 +0100 Subject: [PATCH 12/21] feat: WIP - unit tests --- .gitignore | 1 + dependencies/@openzeppelin-contracts-5.2.0 | 1 - dependencies/account-abstraction-v7-0.7.0 | 1 - dependencies/chainlink-ccip-1.6.2 | 1 - dependencies/devtools-0.0.1 | 1 - dependencies/enso-weiroll-1.4.1 | 1 - dependencies/forge-std-1.9.7 | 1 - dependencies/layerzero-v2-2.0.2 | 1 - dependencies/safe-smart-account-1.5.0 | 1 - dependencies/safe-tools-0.2.0 | 1 - dependencies/solady-0.1.22 | 1 - dependencies/v4-core-4.0.0 | 1 - dependencies/v4-periphery-4.0.0 | 1 - foundry.toml | 1 + remappings.txt | 6 ++ soldeer.lock | 6 ++ .../ensoCCIPReceiver/EnsoCCIPReceiver.t.sol | 56 +++++++++++++++++++ 17 files changed, 70 insertions(+), 12 deletions(-) delete mode 160000 dependencies/@openzeppelin-contracts-5.2.0 delete mode 160000 dependencies/account-abstraction-v7-0.7.0 delete mode 160000 dependencies/chainlink-ccip-1.6.2 delete mode 160000 dependencies/devtools-0.0.1 delete mode 160000 dependencies/enso-weiroll-1.4.1 delete mode 160000 dependencies/forge-std-1.9.7 delete mode 160000 dependencies/layerzero-v2-2.0.2 delete mode 160000 dependencies/safe-smart-account-1.5.0 delete mode 160000 dependencies/safe-tools-0.2.0 delete mode 160000 dependencies/solady-0.1.22 delete mode 160000 dependencies/v4-core-4.0.0 delete mode 160000 dependencies/v4-periphery-4.0.0 create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.t.sol diff --git a/.gitignore b/.gitignore index 24bee41..fa865f1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ zkout/ # Dependencies node_modules/ +dependencies/ # Deprecated submodule folder lib/ diff --git a/dependencies/@openzeppelin-contracts-5.2.0 b/dependencies/@openzeppelin-contracts-5.2.0 deleted file mode 160000 index acd4ff7..0000000 --- a/dependencies/@openzeppelin-contracts-5.2.0 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit acd4ff74de833399287ed6b31b4debf6b2b35527 diff --git a/dependencies/account-abstraction-v7-0.7.0 b/dependencies/account-abstraction-v7-0.7.0 deleted file mode 160000 index 7af70c8..0000000 --- a/dependencies/account-abstraction-v7-0.7.0 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7af70c8993a6f42973f520ae0752386a5032abe7 diff --git a/dependencies/chainlink-ccip-1.6.2 b/dependencies/chainlink-ccip-1.6.2 deleted file mode 160000 index 0e3e0fc..0000000 --- a/dependencies/chainlink-ccip-1.6.2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0e3e0fc5c0f70f0d50dca66b139142ddf3009294 diff --git a/dependencies/devtools-0.0.1 b/dependencies/devtools-0.0.1 deleted file mode 160000 index ac89128..0000000 --- a/dependencies/devtools-0.0.1 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ac8912867862f6dd737b0febabd8d3cb8f142df7 diff --git a/dependencies/enso-weiroll-1.4.1 b/dependencies/enso-weiroll-1.4.1 deleted file mode 160000 index 9002501..0000000 --- a/dependencies/enso-weiroll-1.4.1 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 900250114203727ff236d3f6313673c17c2d90dd diff --git a/dependencies/forge-std-1.9.7 b/dependencies/forge-std-1.9.7 deleted file mode 160000 index 77041d2..0000000 --- a/dependencies/forge-std-1.9.7 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 77041d2ce690e692d6e03cc812b57d1ddaa4d505 diff --git a/dependencies/layerzero-v2-2.0.2 b/dependencies/layerzero-v2-2.0.2 deleted file mode 160000 index 9a4049a..0000000 --- a/dependencies/layerzero-v2-2.0.2 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9a4049ae3a374e1c0ef01ac9fb53dd83f4257a68 diff --git a/dependencies/safe-smart-account-1.5.0 b/dependencies/safe-smart-account-1.5.0 deleted file mode 160000 index dc437e8..0000000 --- a/dependencies/safe-smart-account-1.5.0 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit dc437e8fba8b4805d76bcbd1c668c9fd3d1e83be diff --git a/dependencies/safe-tools-0.2.0 b/dependencies/safe-tools-0.2.0 deleted file mode 160000 index ce6c654..0000000 --- a/dependencies/safe-tools-0.2.0 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ce6c654a76d91b619ab7778c77d1a76b3ced6666 diff --git a/dependencies/solady-0.1.22 b/dependencies/solady-0.1.22 deleted file mode 160000 index 65e87c7..0000000 --- a/dependencies/solady-0.1.22 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 65e87c72a5ee4a6708946b25611ec5f980ceba70 diff --git a/dependencies/v4-core-4.0.0 b/dependencies/v4-core-4.0.0 deleted file mode 160000 index e50237c..0000000 --- a/dependencies/v4-core-4.0.0 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e50237c43811bd9b526eff40f26772152a42daba diff --git a/dependencies/v4-periphery-4.0.0 b/dependencies/v4-periphery-4.0.0 deleted file mode 160000 index 9628c36..0000000 --- a/dependencies/v4-periphery-4.0.0 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9628c36b4f5083d19606e63224e4041fe748edae diff --git a/foundry.toml b/foundry.toml index a140dbe..918a4c8 100644 --- a/foundry.toml +++ b/foundry.toml @@ -129,6 +129,7 @@ remappings_location = "txt" [dependencies] account-abstraction-v7 = { version = "0.7.0", git = "https://github.com/eth-infinitism/account-abstraction.git", rev = "7af70c8993a6f42973f520ae0752386a5032abe7" } chainlink-ccip = { version = "1.6.2", git = "https://github.com/smartcontractkit/chainlink-ccip.git", rev = "0e3e0fc5c0f70f0d50dca66b139142ddf3009294"} +chainlink-evm = { version = "1.5.0", git = "https://github.com/smartcontractkit/chainlink-evm.git", rev = "86aa5a1d34b20eda8d18fe6eb0e4882948e545ba"} devtools = { version = "0.0.1", git = "https://github.com/LayerZero-Labs/devtools.git", rev = "ac8912867862f6dd737b0febabd8d3cb8f142df7" } enso-weiroll = { version = "1.4.1", git = "https://github.com/EnsoBuild/enso-weiroll.git", rev = "900250114203727ff236d3f6313673c17c2d90dd" } forge-std = { version = "1.9.7", git = "https://github.com/foundry-rs/forge-std.git", tag = "v1.9.7" } diff --git a/remappings.txt b/remappings.txt index 07b223c..41bff27 100644 --- a/remappings.txt +++ b/remappings.txt @@ -35,4 +35,10 @@ v4-core-4.0.0/=dependencies/v4-core-4.0.0/src/ v4-periphery-4.0.0/=dependencies/v4-periphery-4.0.0/src/ chainlink-ccip=dependencies/chainlink-ccip-1.6.2/chains/evm/contracts/ chainlink-ccip-1.6.2/=dependencies/chainlink-ccip-1.6.2/chains/evm/contracts/ +chainlink-evm=dependencies/chainlink-evm-1.5.0/contracts/src/v0.8/ +chainlink-evm-1.5.0/=dependencies/chainlink-evm-1.5.0/contracts/src/v0.8/ +@chainlink/=dependencies/chainlink-evm-1.5.0/ @openzeppelin/contracts@5.0.2/utils/introspection/IERC165.sol=dependencies/@openzeppelin-contracts-5.2.0/contracts/utils/introspection/IERC165.sol +@openzeppelin/contracts@5.0.2/utils/introspection/ERC165Checker.sol=dependencies/@openzeppelin-contracts-5.2.0/contracts/utils/introspection/ERC165Checker.sol +@openzeppelin/contracts@4.8.3/token/ERC20/utils/SafeERC20.sol=dependencies/@openzeppelin-contracts-5.2.0/contracts/token/ERC20/utils/SafeERC20.sol +@openzeppelin/contracts@4.8.3/token/ERC20/IERC20.sol=dependencies/@openzeppelin-contracts-5.2.0/contracts/token/ERC20/IERC20.sol diff --git a/soldeer.lock b/soldeer.lock index fcf1d28..0675570 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -16,6 +16,12 @@ version = "1.6.2" git = "https://github.com/smartcontractkit/chainlink-ccip.git" rev = "0e3e0fc5c0f70f0d50dca66b139142ddf3009294" +[[dependencies]] +name = "chainlink-evm" +version = "1.5.0" +git = "https://github.com/smartcontractkit/chainlink-evm.git" +rev = "86aa5a1d34b20eda8d18fe6eb0e4882948e545ba" + [[dependencies]] name = "devtools" version = "0.0.1" diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.t.sol new file mode 100644 index 0000000..e5250cf --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.t.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; +import { EnsoShortcutsHelpers } from "../../../../../src/helpers/EnsoShortcutsHelpers.sol"; +import { EnsoRouter } from "../../../../../src/router/EnsoRouter.sol"; +import { WETH9 } from "../../../../mocks/WETH9.sol"; +import { MockCCIPRouter } from "chainlink-ccip/test/mocks/MockRouter.sol"; +import { Test } from "forge-std-1.9.7/Test.sol"; + +abstract contract EnsoCCIPReceiver_Unit_Concrete_Test is Test { + address payable internal s_deployer; + address payable internal s_owner; + address payable internal s_account1; + address payable internal s_account2; + EnsoRouter internal s_ensoRouter; + EnsoShortcutsHelpers internal s_ensoShortcutsHelpers; + MockCCIPRouter internal s_ccipRouter; + EnsoCCIPReceiver internal s_ensoCcipReceiver; + WETH9 internal s_weth; + + function setUp() public virtual { + s_deployer = payable(vm.addr(1)); + vm.deal(s_deployer, 1000 ether); + vm.label(s_deployer, "Deployer"); + + s_owner = payable(vm.addr(2)); + vm.deal(s_owner, 1000 ether); + vm.label(s_owner, "Owner"); + + s_account1 = payable(vm.addr(3)); + vm.deal(s_account1, 1000 ether); + vm.label(s_account1, "Account_1"); + + s_account2 = payable(vm.addr(4)); + vm.deal(s_account2, 1000 ether); + vm.label(s_account2, "Account_2"); + + vm.startPrank(s_deployer); + s_ensoRouter = new EnsoRouter(); + vm.label(address(s_ensoRouter), "EnsoRouter"); + + s_ensoShortcutsHelpers = new EnsoShortcutsHelpers(); + vm.label(address(s_ensoShortcutsHelpers), "EnsoShortcutsHelpers"); + + s_ccipRouter = new MockCCIPRouter(); + vm.label(address(s_ccipRouter), "MockCCIPRouter"); + + s_ensoCcipReceiver = new EnsoCCIPReceiver(s_owner, address(s_ccipRouter), address(s_ensoRouter)); + vm.label(address(s_ensoCcipReceiver), "EnsoCCIPReceiver"); + + s_weth = new WETH9(); + vm.label(address(s_weth), "WETH9"); + vm.stopPrank(); + } +} From daffe3d3957bfbbaa78d59e75715d3305ebf7c02 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Thu, 30 Oct 2025 19:22:50 +0100 Subject: [PATCH 13/21] feat: WIP - tests --- .../ensoCCIPReceiver/EnsoCCIPReceiver.tree | 8 +++ .../ensoCCIPReceiver/decodeMessageData.t.sol | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/decodeMessageData.t.sol diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree new file mode 100644 index 0000000..2226e8c --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree @@ -0,0 +1,8 @@ +EnsoCCIPReceiver::decodeMessageData +β”œβ”€β”€ when msg sender is not self +β”‚ └── it should revert +└── when msg sender is self + β”œβ”€β”€ when data is malformed + β”‚ └── it should panic + └── when data is not malformed + └── it should return payload decoded diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/decodeMessageData.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/decodeMessageData.t.sol new file mode 100644 index 0000000..71930c6 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/decodeMessageData.t.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.28; + +import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; +import { EnsoShortcutsHelpers } from "../../../../../src/helpers/EnsoShortcutsHelpers.sol"; +import { IEnsoCCIPReceiver } from "../../../../../src/interfaces/IEnsoCCIPReceiver.sol"; +import { EnsoRouter } from "../../../../../src/router/EnsoRouter.sol"; +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; + +contract EnsoCCIPReceiver_DecodeMessageData_Unit_Concrete is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_RevertWhen_MsgSenderIsNotSelf() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(IEnsoCCIPReceiver.EnsoCCIPReceiver_OnlySelf.selector)); + vm.prank(s_account1); + s_ensoCcipReceiver.decodeMessageData(""); + } + + modifier whenMsgSenderIsCcipSelf() { + vm.startPrank(address(s_ensoCcipReceiver)); + _; + vm.stopPrank(); + } + + function test_WhenDataIsMalformed() external whenMsgSenderIsCcipSelf { + // Act & Assert + // it should panic + vm.expectRevert(); + s_ensoCcipReceiver.decodeMessageData(""); + } + + function test_WhenDataIsNotMalformed() external whenMsgSenderIsCcipSelf { + // Arrange + address receiver = address(777); + uint256 estimatedGas = 1_000_000; + bytes memory shortcutData = ""; + bytes memory data = abi.encode(receiver, estimatedGas, shortcutData); + + // Act + (address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData) = + s_ensoCcipReceiver.decodeMessageData(data); + + // Assert + // it should return payload decoded + assertEq(decodedReceiver, receiver); + assertEq(decodedEstimatedGas, estimatedGas); + assertEq(decodedShortcutData, shortcutData); + } +} From 3fed9341f3a9f611a955e939ab2d3230a68e94aa Mon Sep 17 00:00:00 2001 From: vnavascues Date: Fri, 31 Oct 2025 14:29:14 +0100 Subject: [PATCH 14/21] feat: replaced abi.decode with CCIPMessageDecoder lib --- foundry.toml | 4 + src/bridge/EnsoCCIPReceiver.sol | 25 +- src/interfaces/IEnsoCCIPReceiver.sol | 9 - src/libraries/CCIPMessageDecoder.sol | 83 +++++++ .../ensoCCIPReceiver/decodeMessageData.t.sol | 49 ---- .../ensoReceiver/executeMultiSend.t.sol | 1 - .../tryDecodeMessageData.t.sol | 215 ++++++++++++++++++ .../tryDecodeMessageData.tree | 34 +++ .../tryDecodeMessageData.t.sol | 194 ++++++++++++++++ 9 files changed, 538 insertions(+), 76 deletions(-) create mode 100644 src/libraries/CCIPMessageDecoder.sol delete mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/decodeMessageData.t.sol create mode 100644 test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol create mode 100644 test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.tree create mode 100644 test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol diff --git a/foundry.toml b/foundry.toml index 918a4c8..1dfd26c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -14,6 +14,10 @@ src = "src" test = "test" via_ir = true +[profile.default.fuzz] +max_test_rejects = 1_000_000 # Number of times `vm.assume` can fail +runs = 1_000 + [fmt] bracket_spacing = true contract_new_lines = false diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index 9895713..78c6974 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import { IEnsoCCIPReceiver } from "../interfaces/IEnsoCCIPReceiver.sol"; import { IEnsoRouter, Token, TokenType } from "../interfaces/IEnsoRouter.sol"; +import { CCIPMessageDecoder } from "../libraries/CCIPMessageDecoder.sol"; import { CCIPReceiver, Client } from "chainlink-ccip/applications/CCIPReceiver.sol"; import { Ownable, Ownable2Step } from "openzeppelin-contracts/access/Ownable2Step.sol"; import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; @@ -101,15 +102,6 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus } } - /// @inheritdoc IEnsoCCIPReceiver - function decodeMessageData(bytes calldata _data) external view returns (address, uint256, bytes memory) { - if (msg.sender != address(this)) { - revert IEnsoCCIPReceiver.EnsoCCIPReceiver_OnlySelf(); - } - // Temporary approach; will be replaced by a safe inline decoder. - return abi.decode(_data, (address, uint256, bytes)); - } - /// @inheritdoc IEnsoCCIPReceiver function execute(address _token, uint256 _amount, bytes calldata _shortcutData) external { if (msg.sender != address(this)) { @@ -159,6 +151,9 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus if (_errorCode == ErrorCode.PAUSED || _errorCode == ErrorCode.INSUFFICIENT_GAS) { return RefundKind.TO_RECEIVER; } + // Only refund directly to the receiver when the payload decodes successfully. + // If decoding fails (MALFORMED_MESSAGE_DATA), all fields (including `receiver`) must be treated as untrusted, + // since a malformed payload could spoof a plausible receiver address. if ( _errorCode == ErrorCode.NO_TOKENS || _errorCode == ErrorCode.TOO_MANY_TOKENS || _errorCode == ErrorCode.NO_TOKEN_AMOUNT || _errorCode == ErrorCode.MALFORMED_MESSAGE_DATA @@ -216,15 +211,11 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus return (token, amount, receiver, shortcutData, ErrorCode.NO_TOKEN_AMOUNT, errorData); } - // Decode payload (temporary external helper; to be replaced by safe inline decoder) + // Decode payload + bool decodeSuccess; uint256 estimatedGas; - try this.decodeMessageData(_message.data) returns ( - address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData - ) { - receiver = decodedReceiver; - estimatedGas = decodedEstimatedGas; - shortcutData = decodedShortcutData; - } catch { + (decodeSuccess, receiver, estimatedGas, shortcutData) = CCIPMessageDecoder.tryDecodeMessageData(_message.data); + if (!decodeSuccess) { return (token, amount, receiver, shortcutData, ErrorCode.MALFORMED_MESSAGE_DATA, errorData); } diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol index 5738a86..7b8a7c4 100644 --- a/src/interfaces/IEnsoCCIPReceiver.sol +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -100,15 +100,6 @@ interface IEnsoCCIPReceiver { /// @param shortcutData ABI-encoded call data for the Enso Shortcuts entrypoint. function execute(address token, uint256 amount, bytes calldata shortcutData) external; - /// @notice Temporary helper to decode `(address receiver, uint256 estimatedGas, bytes shortcutData)` from - /// a CCIP message payload. Implementations may replace this with safe inline decoding later. - /// @dev SHOULD revert when called externally by non-self to avoid surfacing internals. - /// Typical guard: `if (msg.sender != address(this)) revert EnsoCCIPReceiver_OnlySelf();` - function decodeMessageData(bytes calldata data) - external - view - returns (address receiver, uint256 estimatedGas, bytes memory shortcutData); - /// @notice Pauses the CCIP receiver, disabling new incoming message execution until unpaused. /// @dev Only callable by the contract owner. function pause() external; diff --git a/src/libraries/CCIPMessageDecoder.sol b/src/libraries/CCIPMessageDecoder.sol new file mode 100644 index 0000000..81dfecd --- /dev/null +++ b/src/libraries/CCIPMessageDecoder.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.24; + +library CCIPMessageDecoder { + /// @dev Safe, non-reverting decoder for abi.encode(address,uint256,bytes) in MEMORY. + /// Returns (ok, receiver, estimatedGas, shortcutData). On malformed input, ok=false. + /// Layout: HEAD (96 bytes) = [receiver|estimatedGas|offset], TAIL at (base+off) = [len|bytes...]. + function tryDecodeMessageData(bytes memory _data) + internal + pure + returns (bool success, address receiver, uint256 estimatedGas, bytes memory shortcutData) + { + // Need 3 head words (96) + 1 length word (32) + if (_data.length < 128) { + return (false, address(0), 0, bytes("")); + } + + // Pointer to first head word + uint256 base; + assembly { base := add(_data, 32) } + + uint256 off; + assembly ("memory-safe") { + // Address is right-aligned in the word β†’ keep low 20 bytes + receiver := and(mload(base), 0x000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) + estimatedGas := mload(add(base, 32)) + off := mload(add(base, 64)) + } + + // Word-aligned offset? + if ((off & 31) != 0) { + return (false, address(0), 0, bytes("")); + } + + uint256 baseLen = _data.length; + + // Off must be at/after 3-word head and leave room for tail length word + // i.e. off >= 96 && off <= baseLen - 32 (avoid off+32 overflow) + if (off < 96 || off > baseLen - 32) { + return (false, address(0), 0, bytes("")); + } + + // Safe now to compute tail start (no overflow) + uint256 tailStart = off + 32; + + // Read tail len + uint256 len; + assembly ("memory-safe") { + len := mload(add(base, off)) + } + + unchecked { + // Available bytes remaining after the tail length word + uint256 avail = baseLen - tailStart; + + // Require len itself to fit in the available tail + if (len > avail) { + return (false, address(0), 0, bytes("")); + } + + // Ceil32(len) and ensure padded bytes also fit (defensive; usually implied by len<=avail) + uint256 padded = (len + 31) & ~uint256(31); + if (padded > avail) { + return (false, address(0), 0, bytes("")); + } + + // Allocate and copy exactly `len` bytes (ignore padding) + shortcutData = new bytes(len); + if (len != 0) { + assembly ("memory-safe") { + let src := add(add(base, off), 32) // start of tail payload + let dst := add(shortcutData, 32) // start of new bytes payload + // Copy in 32-byte chunks up to padded boundary + for { let i := 0 } lt(i, padded) { i := add(i, 32) } { + mstore(add(dst, i), mload(add(src, i))) + } + } + } + } + + return (true, receiver, estimatedGas, shortcutData); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/decodeMessageData.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/decodeMessageData.t.sol deleted file mode 100644 index 71930c6..0000000 --- a/test/unit/concrete/bridge/ensoCCIPReceiver/decodeMessageData.t.sol +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.28; - -import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; -import { EnsoShortcutsHelpers } from "../../../../../src/helpers/EnsoShortcutsHelpers.sol"; -import { IEnsoCCIPReceiver } from "../../../../../src/interfaces/IEnsoCCIPReceiver.sol"; -import { EnsoRouter } from "../../../../../src/router/EnsoRouter.sol"; -import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; - -contract EnsoCCIPReceiver_DecodeMessageData_Unit_Concrete is EnsoCCIPReceiver_Unit_Concrete_Test { - function test_RevertWhen_MsgSenderIsNotSelf() external { - // Act & Assert - // it should revert - vm.expectRevert(abi.encodeWithSelector(IEnsoCCIPReceiver.EnsoCCIPReceiver_OnlySelf.selector)); - vm.prank(s_account1); - s_ensoCcipReceiver.decodeMessageData(""); - } - - modifier whenMsgSenderIsCcipSelf() { - vm.startPrank(address(s_ensoCcipReceiver)); - _; - vm.stopPrank(); - } - - function test_WhenDataIsMalformed() external whenMsgSenderIsCcipSelf { - // Act & Assert - // it should panic - vm.expectRevert(); - s_ensoCcipReceiver.decodeMessageData(""); - } - - function test_WhenDataIsNotMalformed() external whenMsgSenderIsCcipSelf { - // Arrange - address receiver = address(777); - uint256 estimatedGas = 1_000_000; - bytes memory shortcutData = ""; - bytes memory data = abi.encode(receiver, estimatedGas, shortcutData); - - // Act - (address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData) = - s_ensoCcipReceiver.decodeMessageData(data); - - // Assert - // it should return payload decoded - assertEq(decodedReceiver, receiver); - assertEq(decodedEstimatedGas, estimatedGas); - assertEq(decodedShortcutData, shortcutData); - } -} diff --git a/test/unit/concrete/delegate/ensoReceiver/executeMultiSend.t.sol b/test/unit/concrete/delegate/ensoReceiver/executeMultiSend.t.sol index a84024e..2673ba7 100644 --- a/test/unit/concrete/delegate/ensoReceiver/executeMultiSend.t.sol +++ b/test/unit/concrete/delegate/ensoReceiver/executeMultiSend.t.sol @@ -6,7 +6,6 @@ import { EnsoReceiver } from "../../../../../src/delegate/EnsoReceiver.sol"; import { TokenBalanceHelper } from "../../../../utils/TokenBalanceHelper.sol"; import { EnsoReceiver_Unit_Concrete_Test } from "./EnsoReceiver.t.sol"; import { console2 } from "forge-std-1.9.7/Test.sol"; -import { ReentrancyGuardTransient } from "openzeppelin-contracts/utils/ReentrancyGuardTransient.sol"; contract EnsoReceiver_ExecuteMultiSend_SenderIsEnsoReceiver_Unit_Concrete_Test is EnsoReceiver_Unit_Concrete_Test, diff --git a/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol new file mode 100644 index 0000000..ba1118a --- /dev/null +++ b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.28; + +import { CCIPMessageDecoder } from "../../../../../src/libraries/CCIPMessageDecoder.sol"; +import { Test } from "forge-std-1.9.7/Test.sol"; + +contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + // Head layout in memory (after the 32-byte length word): + // [ receiver (32) | estimatedGas (32) | offset (32) ] -> 96 bytes + uint256 private constant HEAD_SIZE = 96; + + function _encodeValid( + address receiver, + uint256 estimatedGas, + bytes memory payload + ) + internal + pure + returns (bytes memory) + { + return abi.encode(receiver, estimatedGas, payload); + } + + function _setOffset(bytes memory data, uint256 off) internal pure { + // write at head[2] (offset word) + assembly { + mstore(add(data, add(32, 64)), off) + } + } + + function _peekOffsetMem(bytes memory data) internal pure returns (uint256 off) { + assembly { off := mload(add(data, 96)) } + } + + function _setTailLenAtOffset(bytes memory data, uint256 off, uint256 len) internal pure { + // writes the tail length word at base+off + // NOTE: caller must ensure base+off is within the allocated buffer + assembly { + mstore(add(add(data, 32), off), len) + } + } + + /*////////////////////////////////////////////////////////////// + TESTS: < 128 bytes + //////////////////////////////////////////////////////////////*/ + + function test_WhenDataLengthLt128Bytes() external { + // Arrange + bytes memory data = + hex"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(data); + + // Assert + // it should return unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + /*////////////////////////////////////////////////////////////// + MOD: β‰₯ 128 bytes (valid base) + //////////////////////////////////////////////////////////////*/ + + modifier whenDataLengthGte128Bytes() { + _; + } + + function test_WhenOffsetIsNotWordAligned() external whenDataLengthGte128Bytes { + // Arrange + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); + // Make offset = 97 (not multiple of 32) + _setOffset(msgData, 97); + assertEq(_peekOffsetMem(msgData), 97, "offset mutation failed"); + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(msgData); + + // Assert + // it should return an unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + /*////////////////////////////////////////////////////////////// + MOD: offset is word-aligned (but not valid) + //////////////////////////////////////////////////////////////*/ + + modifier whenOffsetIsWordAligned() { + _; + } + + function test_WhenOffsetIsBefore3WordHead() external whenDataLengthGte128Bytes whenOffsetIsWordAligned { + // Arrange + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); + _setOffset(msgData, 64); // word-aligned but < 96 (invalid for our layout) + assertEq(_peekOffsetMem(msgData), 64, "offset mutation failed"); + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(msgData); + + // Assert + // it should return an unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + /*////////////////////////////////////////////////////////////// + MOD: offset is after the 3-word head (β‰₯ 96) + //////////////////////////////////////////////////////////////*/ + + modifier whenOffsetIsAfter3WordHead() { + _; + } + + function test_WhenThereIsNotEnoughRoomForTailLengthWord() + external + whenDataLengthGte128Bytes + whenOffsetIsWordAligned + whenOffsetIsAfter3WordHead + { + // Arrange + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); + // Start from valid base and set offset to the canonical value (96) + _setOffset(msgData, HEAD_SIZE); // 96 + // Make offset so large that data.length < off + 32. + // Current s_messageData.length == 128 and word-aligned, so set off = 128. + _setOffset(msgData, msgData.length); + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(msgData); + + // Assert + // it should return an unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + /*////////////////////////////////////////////////////////////// + MOD: enough room for tail length, but tail may/may not fit + //////////////////////////////////////////////////////////////*/ + + modifier whenThereIsEnoughRoomForTailLengthWord() { + _; + } + + function test_WhenTailDoesNotFullyFit() + external + whenDataLengthGte128Bytes + whenOffsetIsWordAligned + whenOffsetIsAfter3WordHead + whenThereIsEnoughRoomForTailLengthWord + { + // Arrange + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); + // Set offset = 96 so the length word is within bounds (128 >= 96+32) + _setOffset(msgData, HEAD_SIZE); // 96 + // With base length=128 and off=96, writing len=64 requires: + // off + 32 + ceil32(64) = 96 + 32 + 64 = 192 > 128 β†’ should fail + _setTailLenAtOffset(msgData, HEAD_SIZE, 64); + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(msgData); + + // Assert + // it should return an unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + function test_WhenTailFullyFits() + external + whenDataLengthGte128Bytes + whenOffsetIsWordAligned + whenOffsetIsAfter3WordHead + whenThereIsEnoughRoomForTailLengthWord + { + // Arrange + // Build a valid payload where tail fully fits: len=3 β†’ ceil32=32 + // Total = 96 + 32 + 32 = 160 bytes + bytes memory payload = hex"010203"; + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, type(uint256).max, payload); + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(msgData); + + // Assert + // it should return a successful result + assertTrue(decodeSuccess); + assertEq(receiver, 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); + assertEq(estimatedGas, type(uint256).max); + assertEq(shortcutData, payload); + assertEq(msgData.length, 160); + } +} diff --git a/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.tree b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.tree new file mode 100644 index 0000000..08e979e --- /dev/null +++ b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.tree @@ -0,0 +1,34 @@ +// CCIPMessageDecoder::tryDecodeMessageData +// # when data length lt 128 bytes +// ## it should return unsuccessful result +// # when data length gte 128 bytes +// ## when offset is not word aligned +// ### it should return unsuccessful result +// ## when offset is word aligned +// ### when offset is before 3 word head +// #### it should return unsuccessful result +// ### when offset is after 3 word head +// #### when there is not enough room for tail length word +// ##### it should return unsuccessful result +// #### when there is enough room for tail length word +// ##### when tail does not fully fit +// ###### it should return unsuccessful result +// ##### when tail fully fits +// ###### it should return successful result +CCIPMessageDecoder::tryDecodeMessageData +β”œβ”€β”€ when data length lt 128 bytes +β”‚ └── it should return unsuccessful result +└── when data length gte 128 bytes + β”œβ”€β”€ when offset is not word aligned + β”‚ └── it should return unsuccessful result + └── when offset is word aligned + β”œβ”€β”€ when offset is before 3 word head + β”‚ └── it should return unsuccessful result + └── when offset is after 3 word head + β”œβ”€β”€ when there is not enough room for tail length word + β”‚ └── it should return unsuccessful result + └── when there is enough room for tail length word + β”œβ”€β”€ when tail does not fully fit + β”‚ └── it should return unsuccessful result + └── when tail fully fits + └── it should return successful result diff --git a/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol b/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol new file mode 100644 index 0000000..e492303 --- /dev/null +++ b/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity 0.8.28; + +import { CCIPMessageDecoder } from "../../../../../src/libraries/CCIPMessageDecoder.sol"; +import { Test } from "forge-std-1.9.7/Test.sol"; + +contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { + function testFuzz_unsuccessfulResult_lengthLt128Bytes(bytes memory _data) external { + // Arrange + vm.assume(_data.length < 128); + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(_data); + + // Assert + // it should return a unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + function testFuzz_unsuccessfulResult_misalignedOffset( + address _receiver, + uint256 _estimatedGas, + bytes memory _tail, + uint8 _wiggle + ) + external + { + // Arrange + // Build a valid payload first + bytes memory data = abi.encode(_receiver, _estimatedGas, _tail); + + // Choose an offset β‰₯96 but misaligned: 96 + [1..31] + uint256 off = 96 + (uint256(_wiggle) % 31 + 1); + + // Overwrite offset at head[2] + assembly { mstore(add(data, 96), off) } + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(data); + + // Assert + // it should return a unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + function testFuzz_unsuccessful_offsetBeforeHead( + address _receiver, + uint256 _estimatedGas, + bytes memory _tail, + uint8 _k + ) + external + { + // Arrange + bytes memory data = abi.encode(_receiver, _estimatedGas, _tail); + + // Pick aligned off ∈ {0,32,64} + uint256 aligned = (uint256(_k) % 3) * 32; + assembly { mstore(add(data, 96), aligned) } + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(data); + + // Assert + // it should return a unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + function testFuzz_unsuccessful_offsetBeyondEnd( + address _receiver, + uint256 _estimatedGas, + bytes memory _tail + ) + external + { + // Arrange + bytes memory data = abi.encode(_receiver, _estimatedGas, _tail); + + // Set off = data.length (aligned since abi.encode makes length % 32 == 0 when tail present/empty) + uint256 off = data.length; + assembly { mstore(add(data, 96), off) } + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(data); + + // Assert + // it should return a unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + function testFuzz_unsuccessful_tailDoesNotFit(address _receiver, uint256 _estimatedGas) external { + // Arrange + // Start with empty tail β†’ total 128 bytes + bytes memory data = abi.encode(_receiver, _estimatedGas, bytes("")); + // off=96 (canonical) + assembly { mstore(add(data, 96), 96) } + // Write len=64 β†’ requires 96 + 32 + 64 = 192 > 128 + assembly { mstore(add(data, 128), 64) } + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(data); + + // Assert + // it should return a unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + function testFuzz_unsuccessful_hugeLenOverflowGuard(address _receiver, uint256 _estimatedGas) external { + // Start with empty tail β†’ total 128 bytes + bytes memory data = abi.encode(_receiver, _estimatedGas, bytes("")); + // off = 96 + assembly { mstore(add(data, 96), 96) } + // len = max => ceil32(len) definitely exceeds remaining buffer + assembly { mstore(add(data, 128), not(0)) } + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(data); + + // Assert + // it should return a unsuccessful result + assertFalse(decodeSuccess); + assertEq(receiver, address(0)); + assertEq(estimatedGas, 0); + assertEq(shortcutData, ""); + } + + function testFuzz_success_receiverMasking(bytes20 _low20, bytes12 _garbageHigh) external { + // Arrange + address expectedReceiver = address(uint160(uint256(bytes32(_low20)))); + // Build a valid encoding + bytes memory data = abi.encode(address(0), uint256(0), bytes("")); + // Overwrite receiver head word with high-12 garbage | low-20 address + bytes32 word = bytes32(_garbageHigh) | bytes32(_low20); + assembly { mstore(add(data, 32), word) } + + // Act + (bool decodeSuccess, address receiver,,) = CCIPMessageDecoder.tryDecodeMessageData(data); + + // Assert + assertTrue(decodeSuccess); + assertEq(receiver, expectedReceiver); + } + + function testFuzz_successfulResult( + address _receiver, + uint256 _estimatedGas, + bytes memory _shortcutData + ) + external + { + // Arrange + bytes memory data = abi.encode(_receiver, _estimatedGas, _shortcutData); + + (address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData) = + abi.decode(data, (address, uint256, bytes)); + + // Act + (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = + CCIPMessageDecoder.tryDecodeMessageData(data); + + // Assert + // it should return a successful result + assertTrue(decodeSuccess); + assertEq(receiver, _receiver); + assertEq(estimatedGas, _estimatedGas); + assertEq(shortcutData, _shortcutData); + + // Differential + assertEq(receiver, decodedReceiver); + assertEq(estimatedGas, decodedEstimatedGas); + assertEq(shortcutData, decodedShortcutData); + } +} From 3975f88f8846cff648ffe62b4b3c31a19e055010 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Wed, 12 Nov 2025 14:35:29 +0100 Subject: [PATCH 15/21] feat: added EnsoCCIPReceiver tests --- .bash/deploy.sh | 22 +++-- package.json | 1 + script/EnsoCCIPReceiverDeployer.s.sol | 97 +++++++++++++++++++ src/libraries/CCIPMessageDecoder.sol | 2 +- test/EnsoRouter.t.sol | 4 +- ...t_SmartWallet_EntryPointV7_Fork_Test.t.sol | 24 ++--- test/mocks/MockEnsoRouter.sol | 68 +++++++++++++ .../ensoCCIPReceiver/EnsoCCIPReceiver.tree | 43 ++++++-- .../ensoCCIPReceiver/acceptOwnership.t.sol | 32 ++++++ .../bridge/ensoCCIPReceiver/constructor.t.sol | 22 +++++ .../bridge/ensoCCIPReceiver/pause.t.sol | 25 +++++ .../ensoCCIPReceiver/renounceOwnership.t.sol | 25 +++++ .../ensoCCIPReceiver/transferOwnership.t.sol | 26 +++++ .../bridge/ensoCCIPReceiver/unpause.t.sol | 32 ++++++ .../delegate/ensoReceiver/safeExecute.t.sol | 12 +-- .../tryDecodeMessageData.t.sol | 17 ++-- .../tryDecodeMessageData.t.sol | 12 ++- 17 files changed, 412 insertions(+), 52 deletions(-) create mode 100644 script/EnsoCCIPReceiverDeployer.s.sol create mode 100644 test/mocks/MockEnsoRouter.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/acceptOwnership.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/constructor.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/pause.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/renounceOwnership.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/transferOwnership.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/unpause.t.sol diff --git a/.bash/deploy.sh b/.bash/deploy.sh index d7cacc5..79a09d8 100755 --- a/.bash/deploy.sh +++ b/.bash/deploy.sh @@ -24,22 +24,22 @@ fi if [[ $network_upper == "POLYGON" ]]; then params+=(--gas-estimate-multiplier 300) fi -if [ $broadcast == "broadcast" ]; then +if [ "$broadcast" == "broadcast" ]; then params+=(--broadcast) if [ -n "$verifier" ]; then params+=(--verify) params+=(--verifier "${verifier}") - if [ $verifier == "etherscan" ]; then - params+=(--etherscan-api-key ${!blockscan_key}) - elif [ $verifier == "routescan" ]; then + if [ "$verifier" == "etherscan" ]; then + params+=(--etherscan-api-key "${!blockscan_key}") + elif [ "$verifier" == "routescan" ]; then params+=(--verifier-url "https://api.routescan.io/v2/network/mainnet/evm/80094/etherscan") params+=(--etherscan-api-key "verifyContract") - elif [ $verifier == "blockscout" ]; then - if [ $network_upper == "INK"]; then + elif [ "$verifier" == "blockscout" ]; then + if [ "$network_upper" == "INK" ]; then params+=(--verifier-url "https://explorer.inkonchain.com/api") - elif [ $network_upper == "PLUME"]; then + elif [ "$network_upper" == "PLUME" ]; then params+=(--verifier-url "https://explorer.plume.org/api") - elif [ $network_upper == "KATANA"]; then + elif [ "$network_upper" == "KATANA" ]; then params+=(--verifier-url "https://explorer.katanarpc.com/api") else params+=(--verifier-url "https://${network}.blockscout.com/api") @@ -49,5 +49,7 @@ if [ $broadcast == "broadcast" ]; then params+=(-vvvv) fi -set -x -forge script script/${script} --private-key $PRIVATE_KEY --rpc-url ${!rpc} "${params[@]}" +{ set +x; } 2>/dev/null + +PRIVATE_KEY="$PRIVATE_KEY" \ +forge script "script/${script}" --rpc-url "${!rpc}" "${params[@]}" diff --git a/package.json b/package.json index 9f81c59..93b5c8d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "format": "prettier --check . && forge fmt --check", "format:fix": "prettier --write . && forge fmt", "lint:fix": "prettier --write . && ./.bash/forge-lint.sh", + "test:enso_ccip:unit": "forge test --match-path 'test/unit/concrete/{bridge/ensoCCIPReceiver,libraries/ccipMessageDecoder,fuzz/libraries/ccipMessageDecoder}/*.t.sol'", "test:enso_checkout:fork": "forge test --match-path 'test/fork/enso-checkout/*.t.sol'", "test:enso_checkout:unit": "forge test --match-path 'test/unit/concrete/{delegate/ensoReceiver,factory/erc4337CloneFactory,paymaster/signaturePaymaster}/*.t.sol'", "test:enso_checkout:mutation": "node scripts/runEnsoCheckoutMutationTests.mjs" diff --git a/script/EnsoCCIPReceiverDeployer.s.sol b/script/EnsoCCIPReceiverDeployer.s.sol new file mode 100644 index 0000000..6277655 --- /dev/null +++ b/script/EnsoCCIPReceiverDeployer.s.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "../src/bridge/EnsoCCIPReceiver.sol"; +import "../src/libraries/DataTypes.sol"; +import "forge-std/Script.sol"; + +contract EnsoCCIPReceiverDeployer is Script { + error EnsoRouterIsNotSet(); + error CCIPRouterIsNotSet(); + error OwnerIsNotSet(); + error UnsupportedChainId(uint256 chainId); + + function run() public returns (address ensoCcipReceiver, address owner, address ccipRouter, address ensoRouter) { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + + uint256 chainId = block.chainid; + + // TODO: set owner address + if (chainId == ChainId.ETHEREUM) { + ccipRouter = 0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.OPTIMISM) { + ccipRouter = 0x3206695CaE29952f4b0c22a169725a865bc8Ce0f; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.BINANCE) { + ccipRouter = 0x34B03Cb9086d7D758AC55af71584F81A598759FE; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.GNOSIS) { + ccipRouter = 0x4aAD6071085df840abD9Baf1697d5D5992bDadce; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.UNICHAIN) { + ccipRouter = 0x68891f5F96695ECd7dEdBE2289D1b73426ae7864; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.POLYGON) { + ccipRouter = 0x849c5ED5a80F5B408Dd4969b78c2C8fdf0565Bfe; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.SONIC) { + ccipRouter = 0xB4e1Ff7882474BB93042be9AD5E1fA387949B860; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.ZKSYNC) { + ccipRouter = 0x748Fd769d81F5D94752bf8B0875E9301d0ba71bB; + ensoRouter = 0x1BD8CefD703CF6b8fF886AD2E32653C32bc62b5C; // NOTE: different router for zksync + } else if (chainId == ChainId.WORLD) { + ccipRouter = 0x5fd9E4986187c56826A3064954Cfa2Cf250cfA0f; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.HYPER) { + ccipRouter = 0x13b3332b66389B1467CA6eBd6fa79775CCeF65ec; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.BASE) { + ccipRouter = 0x881e3A65B4d4a04dD529061dd0071cf975F58bCD; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.PLASMA) { + ccipRouter = 0xcDca5D374e46A6DDDab50bD2D9acB8c796eC35C3; + ensoRouter = 0xCfBAa9Cfce952Ca4F4069874fF1Df8c05e37a3c7; // NOTE: different router for plasma + } else if (chainId == ChainId.ARBITRUM) { + ccipRouter = 0x141fa059441E0ca23ce184B6A78bafD2A517DdE8; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.AVALANCHE) { + ccipRouter = 0xF4c7E640EdA248ef95972845a62bdC74237805dB; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.INK) { + ccipRouter = 0xca7c90A52B44E301AC01Cb5EB99b2fD99339433A; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.LINEA) { + ccipRouter = 0x549FEB73F2348F6cD99b9fc8c69252034897f06C; + ensoRouter = 0xA146d46823f3F594B785200102Be5385CAfCE9B5; // NOTE: different router for linea + } else if (chainId == ChainId.BERACHAIN) { + ccipRouter = 0x71a275704c283486fBa26dad3dd0DB78804426eF; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else if (chainId == ChainId.PLUME) { + ccipRouter = 0x5C4f4622AD0EC4a47e04840db7E9EcA8354109af; + ensoRouter = 0x3067BDBa0e6628497d527bEF511c22DA8b32cA3F; // NOTE: different router for plume + } else if (chainId == ChainId.KATANA) { + ccipRouter = 0x7c19b79D2a054114Ab36ad758A36e92376e267DA; + ensoRouter = 0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf; + } else { + revert UnsupportedChainId(chainId); + } + + if (owner == address(0)) { + revert OwnerIsNotSet(); + } + if (ccipRouter == address(0)) { + revert CCIPRouterIsNotSet(); + } + if (ensoRouter == address(0)) { + revert EnsoRouterIsNotSet(); + } + + vm.startBroadcast(deployerPrivateKey); + + ensoCcipReceiver = address(new EnsoCCIPReceiver{ salt: "EnsoCCIPReceiver" }(owner, ccipRouter, ensoRouter)); + + vm.stopBroadcast(); + } +} diff --git a/src/libraries/CCIPMessageDecoder.sol b/src/libraries/CCIPMessageDecoder.sol index 81dfecd..c23f625 100644 --- a/src/libraries/CCIPMessageDecoder.sol +++ b/src/libraries/CCIPMessageDecoder.sol @@ -70,7 +70,7 @@ library CCIPMessageDecoder { assembly ("memory-safe") { let src := add(add(base, off), 32) // start of tail payload let dst := add(shortcutData, 32) // start of new bytes payload - // Copy in 32-byte chunks up to padded boundary + // Copy in 32-byte chunks up to padded boundary for { let i := 0 } lt(i, padded) { i := add(i, 32) } { mstore(add(dst, i), mload(add(src, i))) } diff --git a/test/EnsoRouter.t.sol b/test/EnsoRouter.t.sol index fe3b210..0fca8b6 100644 --- a/test/EnsoRouter.t.sol +++ b/test/EnsoRouter.t.sol @@ -356,8 +356,8 @@ contract EnsoRouterTest is Test, ERC721Holder, ERC1155Holder { Token memory tokenIn = Token(TokenType.ERC721, abi.encode(address(nft), TOKENID)); Token memory tokenOut = Token(TokenType.ERC721, abi.encode(address(nftVault), 1)); // token out is checking for - // balance, which - // should increase by 1 + // balance, which + // should increase by 1 router.safeRouteSingle(tokenIn, tokenOut, address(this), data); assertEq(1, nftVault.balanceOf(address(this))); diff --git a/test/fork/enso-checkout/Checkout_SmartWallet_EntryPointV7_Fork_Test.t.sol b/test/fork/enso-checkout/Checkout_SmartWallet_EntryPointV7_Fork_Test.t.sol index f52d93b..e5cefe9 100644 --- a/test/fork/enso-checkout/Checkout_SmartWallet_EntryPointV7_Fork_Test.t.sol +++ b/test/fork/enso-checkout/Checkout_SmartWallet_EntryPointV7_Fork_Test.t.sol @@ -110,21 +110,21 @@ contract Checkout_SmartWallet_EntryPointV7_Fork_Test is Test, TokenBalanceHelper uint256 safeThreshold = 2; address safeTo = address(0); // Contract address for optional delegate call. bytes memory safeData = ""; // Data payload for optional delegate call. - // address safeFallbackHandler = address(0); // Handler for fallback calls to this contract + // address safeFallbackHandler = address(0); // Handler for fallback calls to this contract address safePaymentToken = address(0); // Token that should be used for the payment (0 is ETH) uint256 safePayment = 0; // Value that should be paid address payable safePaymentReceiver = payable(address(0)); // Address that should receive the payment (or 0 if - // tx.origin) - // s_safe.setup( - // safeOwners, - // safeThreshold, - // safeTo, - // safeData, - // safeFallbackHandler, - // safePaymentToken, - // safePayment, - // safePaymentReceiver - // ); + // tx.origin) + // s_safe.setup( + // safeOwners, + // safeThreshold, + // safeTo, + // safeData, + // safeFallbackHandler, + // safePaymentToken, + // safePayment, + // safePaymentReceiver + // ); bytes memory safeSetupData = abi.encodeCall( Safe.setup, ( diff --git a/test/mocks/MockEnsoRouter.sol b/test/mocks/MockEnsoRouter.sol new file mode 100644 index 0000000..d90f36c --- /dev/null +++ b/test/mocks/MockEnsoRouter.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.20; + +import { Token, TokenType } from "../../../../src/interfaces/IEnsoRouter.sol"; +import { IERC1155 } from "openzeppelin-contracts/token/ERC1155/IERC1155.sol"; +import { IERC20, SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC721 } from "openzeppelin-contracts/token/ERC721/IERC721.sol"; + +contract MockEnsoShortcuts { + receive() external payable { } +} + +contract MockEnsoRouter { + using SafeERC20 for IERC20; + + address public immutable shortcuts; + bool private s_success; + bytes private s_response; + + error WrongMsgValue(uint256 value, uint256 expectedAmount); + error UnsupportedTokenType(TokenType tokenType); + + constructor() { + shortcuts = address(new MockEnsoShortcuts()); + } + + function routeSingle(Token calldata tokenIn, bytes calldata) public payable returns (bytes memory response) { + bool isNativeAsset = _transfer(tokenIn); + if (!isNativeAsset && msg.value != 0) { + revert WrongMsgValue(msg.value, 0); + } + response = s_response; + + if (!s_success) { + assembly { + revert(add(response, 32), mload(response)) + } + } + if (isNativeAsset) { + shortcuts.call{ value: msg.value }(""); + } + } + + function setRouteSingleResponse(bool _success, bytes memory _response) external { + s_success = _success; + s_response = _response; + } + + function _transfer(Token calldata token) internal returns (bool isNativeAsset) { + TokenType tokenType = token.tokenType; + + if (tokenType == TokenType.ERC20) { + (IERC20 erc20, uint256 amount) = abi.decode(token.data, (IERC20, uint256)); + erc20.safeTransferFrom(msg.sender, shortcuts, amount); + } else if (tokenType == TokenType.Native) { + // no need to get amount, it will come from msg.value + isNativeAsset = true; + } else if (tokenType == TokenType.ERC721) { + (IERC721 erc721, uint256 tokenId) = abi.decode(token.data, (IERC721, uint256)); + erc721.safeTransferFrom(msg.sender, shortcuts, tokenId); + } else if (tokenType == TokenType.ERC1155) { + (IERC1155 erc1155, uint256 tokenId, uint256 amount) = abi.decode(token.data, (IERC1155, uint256, uint256)); + erc1155.safeTransferFrom(msg.sender, shortcuts, tokenId, amount, "0x"); + } else { + revert UnsupportedTokenType(tokenType); + } + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree index 2226e8c..3354652 100644 --- a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree @@ -1,8 +1,35 @@ -EnsoCCIPReceiver::decodeMessageData -β”œβ”€β”€ when msg sender is not self -β”‚ └── it should revert -└── when msg sender is self - β”œβ”€β”€ when data is malformed - β”‚ └── it should panic - └── when data is not malformed - └── it should return payload decoded +// EnsoCCIPReceiver::AcceptOwnership +// β”œβ”€β”€ when caller is not pending owner +// β”‚ └── it should revert +// └── when caller is pending owner +// └── it should transfer ownership + +EnsoCCIPReceiver::Constructor +└── when deployed + β”œβ”€β”€ it should set owner + β”œβ”€β”€ it should set ccipRouter + └── it should set ensoRouter + +// EnsoCCIPReceiver::Pause +// β”œβ”€β”€ when caller is not owner +// β”‚ └── it should revert +// └── when caller is owner +// └── it should pause the contract + +// EnsoCCIPReceiver::Unpause +// β”œβ”€β”€ when caller is not owner +// β”‚ └── it should revert +// └── when caller is owner +// └── it should unpause the contract + +// EnsoCCIPReceiver::TransferOwnership +// β”œβ”€β”€ when caller is not owner +// β”‚ └── it should revert +// └── when caller is owner +// └── it should start ownership transfer + +// EnsoCCIPReceiver::RenounceOwnership +// β”œβ”€β”€ when caller is not owner +// β”‚ └── it should revert +// └── when caller is owner +// └── it should transfer ownership to zero address diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/acceptOwnership.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/acceptOwnership.t.sol new file mode 100644 index 0000000..96e2fda --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/acceptOwnership.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { Ownable } from "openzeppelin-contracts/access/Ownable.sol"; + +contract EnsoCCIPReceiver_AcceptOwnership_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function setUp() public virtual override { + super.setUp(); + + vm.prank(s_owner); + s_ensoCcipReceiver.transferOwnership(s_account2); + } + + function test_RevertWhen_CallerIsNotPendingOwner() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, s_account1)); + vm.prank(s_account1); + s_ensoCcipReceiver.acceptOwnership(); + } + + function test_WhenCallerIsPendingOwner() external { + // Act + vm.prank(s_account2); + s_ensoCcipReceiver.acceptOwnership(); + + // Assert + // it should transfer ownership + assertEq(s_ensoCcipReceiver.owner(), s_account2); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/constructor.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/constructor.t.sol new file mode 100644 index 0000000..f0bdb94 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/constructor.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; + +contract EnsoCCIPReceiver_Constructor_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_WhenDeployed() external { + // Act & Assert + vm.prank(s_deployer); + EnsoCCIPReceiver ensoCcipReceiver = new EnsoCCIPReceiver(s_owner, address(s_ccipRouter), address(s_ensoRouter)); + + // it should set owner + assertTrue(ensoCcipReceiver.owner() == s_owner); + + // it should set ccipRouter + assertTrue(ensoCcipReceiver.getRouter() == address(s_ccipRouter)); + + // it should set ensoRouter + assertTrue(ensoCcipReceiver.getEnsoRouter() == address(s_ensoRouter)); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/pause.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/pause.t.sol new file mode 100644 index 0000000..cabc515 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/pause.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { Ownable } from "openzeppelin-contracts/access/Ownable.sol"; + +contract EnsoCCIPReceiver_Pause_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_RevertWhen_CallerIsNotOwner() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, s_account1)); + vm.prank(s_account1); + s_ensoCcipReceiver.pause(); + } + + function test_WhenCallerIsOwner() external { + // Act + vm.prank(s_owner); + s_ensoCcipReceiver.pause(); + + // Assert + // it should pause the contract + assertTrue(s_ensoCcipReceiver.paused()); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/renounceOwnership.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/renounceOwnership.t.sol new file mode 100644 index 0000000..05f85f0 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/renounceOwnership.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { Ownable } from "openzeppelin-contracts/access/Ownable.sol"; + +contract EnsoCCIPReceiver_RenounceOwnership_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_RevertWhen_CallerIsNotOwner() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, s_account2)); + vm.prank(s_account2); + s_ensoCcipReceiver.renounceOwnership(); + } + + function test_WhenCallerIsOwner() external { + // Act + vm.prank(s_owner); + s_ensoCcipReceiver.renounceOwnership(); + + // Assert + // it should transfer ownership to zero address + assertEq(s_ensoCcipReceiver.owner(), address(0)); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/transferOwnership.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/transferOwnership.t.sol new file mode 100644 index 0000000..6293b3e --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/transferOwnership.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { Ownable } from "openzeppelin-contracts/access/Ownable.sol"; + +contract EnsoCCIPReceiver_TransferOwnership_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_RevertWhen_CallerIsNotOwner() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, s_account2)); + vm.prank(s_account2); + s_ensoCcipReceiver.transferOwnership(s_account2); + } + + function test_WhenCallerIsOwner() external { + // Act + vm.prank(s_owner); + s_ensoCcipReceiver.transferOwnership(s_account2); + + // Assert + // it should start ownership transfer + assertEq(s_ensoCcipReceiver.owner(), s_owner); + assertEq(s_ensoCcipReceiver.pendingOwner(), s_account2); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/unpause.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/unpause.t.sol new file mode 100644 index 0000000..ce983a9 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/unpause.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { Ownable } from "openzeppelin-contracts/access/Ownable.sol"; + +contract EnsoCCIPReceiver_Unpause_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function setUp() public virtual override { + super.setUp(); + + vm.prank(s_owner); + s_ensoCcipReceiver.pause(); + } + + function test_RevertWhen_CallerIsNotOwner() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, s_account1)); + vm.prank(s_account1); + s_ensoCcipReceiver.unpause(); + } + + function test_WhenCallerIsOwner() external { + // Act + vm.prank(s_owner); + s_ensoCcipReceiver.unpause(); + + // Assert + // it should unpause the contract + assertFalse(s_ensoCcipReceiver.paused()); + } +} diff --git a/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol b/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol index 05ad5ea..dcf33bc 100644 --- a/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol +++ b/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol @@ -150,8 +150,7 @@ contract EnsoReceiver_SafeExecute_SenderIsEntryPoint_Unit_Concrete_Test is // it should emit ShortcutExecutionFailed vm.expectEmit(address(s_ensoReceiver)); - emit EnsoReceiver - .ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); + emit EnsoReceiver.ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); s_ensoReceiver.safeExecute(IERC20(shortcut.tokensIn[0]), shortcut.amountsIn[0], shortcut.txData); // Get balances after execution @@ -231,8 +230,7 @@ contract EnsoReceiver_SafeExecute_SenderIsEntryPoint_Unit_Concrete_Test is // it should emit ShortcutExecutionFailed vm.expectEmit(address(s_ensoReceiver)); - emit EnsoReceiver - .ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); + emit EnsoReceiver.ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); s_ensoReceiver.safeExecute(IERC20(shortcut.tokensIn[0]), shortcut.amountsIn[0], shortcut.txData); // Get balances after execution @@ -399,8 +397,7 @@ contract EnsoReceiver_SafeExecute_SenderIsOwner_Unit_Concrete_Test is // it should emit ShortcutExecutionFailed vm.expectEmit(address(s_ensoReceiver)); - emit EnsoReceiver - .ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); + emit EnsoReceiver.ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); s_ensoReceiver.safeExecute(IERC20(shortcut.tokensIn[0]), shortcut.amountsIn[0], shortcut.txData); // Get balances after execution @@ -480,8 +477,7 @@ contract EnsoReceiver_SafeExecute_SenderIsOwner_Unit_Concrete_Test is // it should emit ShortcutExecutionFailed vm.expectEmit(address(s_ensoReceiver)); - emit EnsoReceiver - .ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); + emit EnsoReceiver.ShortcutExecutionFailed(hex"08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000284f6e6c79206f6e652072657475726e2076616c7565207065726d6974746564202873746174696329000000000000000000000000000000000000000000000000"); s_ensoReceiver.safeExecute(IERC20(shortcut.tokensIn[0]), shortcut.amountsIn[0], shortcut.txData); // Get balances after execution diff --git a/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol index ba1118a..e8726ec 100644 --- a/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol +++ b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol @@ -48,7 +48,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { TESTS: < 128 bytes //////////////////////////////////////////////////////////////*/ - function test_WhenDataLengthLt128Bytes() external { + function test_WhenDataLengthLt128Bytes() external pure { // Arrange bytes memory data = hex"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; @@ -73,7 +73,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { _; } - function test_WhenOffsetIsNotWordAligned() external whenDataLengthGte128Bytes { + function test_WhenOffsetIsNotWordAligned() external pure whenDataLengthGte128Bytes { // Arrange bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); // Make offset = 97 (not multiple of 32) @@ -100,7 +100,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { _; } - function test_WhenOffsetIsBefore3WordHead() external whenDataLengthGte128Bytes whenOffsetIsWordAligned { + function test_WhenOffsetIsBefore3WordHead() external pure whenDataLengthGte128Bytes whenOffsetIsWordAligned { // Arrange bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); _setOffset(msgData, 64); // word-aligned but < 96 (invalid for our layout) @@ -128,6 +128,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { function test_WhenThereIsNotEnoughRoomForTailLengthWord() external + pure whenDataLengthGte128Bytes whenOffsetIsWordAligned whenOffsetIsAfter3WordHead @@ -136,8 +137,8 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); // Start from valid base and set offset to the canonical value (96) _setOffset(msgData, HEAD_SIZE); // 96 - // Make offset so large that data.length < off + 32. - // Current s_messageData.length == 128 and word-aligned, so set off = 128. + // Make offset so large that data.length < off + 32. + // Current s_messageData.length == 128 and word-aligned, so set off = 128. _setOffset(msgData, msgData.length); // Act @@ -162,6 +163,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { function test_WhenTailDoesNotFullyFit() external + pure whenDataLengthGte128Bytes whenOffsetIsWordAligned whenOffsetIsAfter3WordHead @@ -171,8 +173,8 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); // Set offset = 96 so the length word is within bounds (128 >= 96+32) _setOffset(msgData, HEAD_SIZE); // 96 - // With base length=128 and off=96, writing len=64 requires: - // off + 32 + ceil32(64) = 96 + 32 + 64 = 192 > 128 β†’ should fail + // With base length=128 and off=96, writing len=64 requires: + // off + 32 + ceil32(64) = 96 + 32 + 64 = 192 > 128 β†’ should fail _setTailLenAtOffset(msgData, HEAD_SIZE, 64); // Act @@ -189,6 +191,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { function test_WhenTailFullyFits() external + pure whenDataLengthGte128Bytes whenOffsetIsWordAligned whenOffsetIsAfter3WordHead diff --git a/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol b/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol index e492303..5c03c17 100644 --- a/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol +++ b/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol @@ -5,7 +5,7 @@ import { CCIPMessageDecoder } from "../../../../../src/libraries/CCIPMessageDeco import { Test } from "forge-std-1.9.7/Test.sol"; contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { - function testFuzz_unsuccessfulResult_lengthLt128Bytes(bytes memory _data) external { + function testFuzz_unsuccessfulResult_lengthLt128Bytes(bytes memory _data) external pure { // Arrange vm.assume(_data.length < 128); @@ -28,6 +28,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { uint8 _wiggle ) external + pure { // Arrange // Build a valid payload first @@ -58,6 +59,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { uint8 _k ) external + pure { // Arrange bytes memory data = abi.encode(_receiver, _estimatedGas, _tail); @@ -84,6 +86,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { bytes memory _tail ) external + pure { // Arrange bytes memory data = abi.encode(_receiver, _estimatedGas, _tail); @@ -104,7 +107,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { assertEq(shortcutData, ""); } - function testFuzz_unsuccessful_tailDoesNotFit(address _receiver, uint256 _estimatedGas) external { + function testFuzz_unsuccessful_tailDoesNotFit(address _receiver, uint256 _estimatedGas) external pure { // Arrange // Start with empty tail β†’ total 128 bytes bytes memory data = abi.encode(_receiver, _estimatedGas, bytes("")); @@ -125,7 +128,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { assertEq(shortcutData, ""); } - function testFuzz_unsuccessful_hugeLenOverflowGuard(address _receiver, uint256 _estimatedGas) external { + function testFuzz_unsuccessful_hugeLenOverflowGuard(address _receiver, uint256 _estimatedGas) external pure { // Start with empty tail β†’ total 128 bytes bytes memory data = abi.encode(_receiver, _estimatedGas, bytes("")); // off = 96 @@ -145,7 +148,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { assertEq(shortcutData, ""); } - function testFuzz_success_receiverMasking(bytes20 _low20, bytes12 _garbageHigh) external { + function testFuzz_success_receiverMasking(bytes20 _low20, bytes12 _garbageHigh) external pure { // Arrange address expectedReceiver = address(uint160(uint256(bytes32(_low20)))); // Build a valid encoding @@ -168,6 +171,7 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { bytes memory _shortcutData ) external + pure { // Arrange bytes memory data = abi.encode(_receiver, _estimatedGas, _shortcutData); From 0442ae5a4accbd59dd82fcbeffcb10598aa696c9 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Wed, 12 Nov 2025 18:54:48 +0100 Subject: [PATCH 16/21] WIP - tree for complex tests --- .../ensoCCIPReceiver/EnsoCCIPReceiver.tree | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree index 3354652..8dde9c0 100644 --- a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree @@ -4,11 +4,56 @@ // └── when caller is pending owner // └── it should transfer ownership -EnsoCCIPReceiver::Constructor -└── when deployed - β”œβ”€β”€ it should set owner - β”œβ”€β”€ it should set ccipRouter - └── it should set ensoRouter +EnsoCCIPReceiver::CcipReceive +# when caller is not ccipRouter +## it should revert +# when caller is ccipRouter +## when message was already executed +### it should emit MessageValidationFailed +## when message was not executed +### when message has no tokens +#### it should emit MessageValidationFailed +### when message has tokens +#### when message has more than one token +##### it should emit MessageValidationFailed +#### when message has single token +##### when message token amount is zero +###### it should emit MessageValidationFailed +##### when message token amount is gt zero +###### when message data is malformed +####### it should emit MessageValidationFailed +###### when message data is well formed +####### when message data receiver is zero address +######## it should emit MessageValidationFailed +######## it shoud set executedMessage to true +######## it should emit MessageQuarantined +######## it should escrow message token amount +####### when message data receiver is not zero address +######## when contract is paused +######### it should emit MessageValidationFailed +######### it shoud set executedMessage to true +######### it should safe transfer token amount to receiver +######## when contract is not paused +######### when shortcut execution was was successful +########## it shoud set executedMessage to true +########## it should emit ShortcutExecutionSuccessful +######### when shortcut execution failed +########## it should emit ShortcutExecutionFailed +########## it should safe transfer token amount to receiver + + +// EnsoCCIPReceiver::Constructor +// └── when deployed +// β”œβ”€β”€ it should set owner +// β”œβ”€β”€ it should set ccipRouter +// └── it should set ensoRouter + +EnsoCCIPReceiver::RecoverTokens +# when caller is not self +## it should revert +# when caller is self +## it should force approve amount to ensoRouter +## it should call ensoRouter routeSingle // EnsoCCIPReceiver::Pause // β”œβ”€β”€ when caller is not owner @@ -28,6 +73,13 @@ EnsoCCIPReceiver::Constructor // └── when caller is owner // └── it should start ownership transfer +EnsoCCIPReceiver::RecoverTokens +# when caller is not owner +## it should revert +# when caller is owner +## it should safe transfer amount to recipient +## it should emit TOkensRecovered + // EnsoCCIPReceiver::RenounceOwnership // β”œβ”€β”€ when caller is not owner // β”‚ └── it should revert From 80688207324bd99b202a91ee686c7d5df7a77d7f Mon Sep 17 00:00:00 2001 From: vnavascues Date: Wed, 12 Nov 2025 19:21:06 +0100 Subject: [PATCH 17/21] feat: removed estimatedGas from message --- src/bridge/EnsoCCIPReceiver.sol | 17 +-- src/interfaces/IEnsoCCIPReceiver.sol | 7 +- src/libraries/CCIPMessageDecoder.sol | 35 +++-- .../ensoCCIPReceiver/EnsoCCIPReceiver.tree | 1 - .../tryDecodeMessageData.t.sol | 125 +++++++-------- .../tryDecodeMessageData.tree | 16 +- .../tryDecodeMessageData.t.sol | 143 +++++++----------- 7 files changed, 143 insertions(+), 201 deletions(-) diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index 78c6974..44a9578 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -19,7 +19,7 @@ import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; /// - Maintains idempotency with a messageId β†’ handled flag. /// - Validates `destTokenAmounts` has exactly one ERC-20 with non-zero amount. /// - Decodes `(receiver, estimatedGas, shortcutData)` from the message payload (temp external helper). -/// - For environment issues (PAUSED / INSUFFICIENT_GAS), refunds to `receiver` for better UX. +/// - For environment issues (PAUSED), refunds to `receiver` for better UX. /// - For malformed messages (no/too many tokens, zero amount, bad payload, zero address receiver), quarantines /// funds in this contract. /// - Executes Shortcuts using a self-call (`try this.execute(...)`) to catch and handle reverts. @@ -52,7 +52,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus /// 3) Decode payload `(receiver, estimatedGas, shortcutData)` using a temporary external helper. /// 4) Environment checks: `paused()` and `estimatedGas` hint vs `gasleft()`. /// 5) If non-OK β†’ select refund policy: - /// - TO_RECEIVER for environment issues (PAUSED / INSUFFICIENT_GAS), + /// - TO_RECEIVER for environment issues (PAUSED), /// - TO_ESCROW for malformed token/payload (funds remain in this contract), /// - NONE for ALREADY_EXECUTED (no-op). /// 6) If OK β†’ mark executed and `try this.execute(...)`; on revert, refund to `receiver`. @@ -148,7 +148,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus if (_errorCode == ErrorCode.NO_ERROR || _errorCode == ErrorCode.ALREADY_EXECUTED) { return RefundKind.NONE; } - if (_errorCode == ErrorCode.PAUSED || _errorCode == ErrorCode.INSUFFICIENT_GAS) { + if (_errorCode == ErrorCode.PAUSED) { return RefundKind.TO_RECEIVER; } // Only refund directly to the receiver when the payload decodes successfully. @@ -169,7 +169,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus /// @dev Validates message shape and environment; does not mutate state. /// @return token The delivered ERC-20 token (must be non-zero if NO_ERROR). /// @return amount The delivered token amount (must be > 0 if NO_ERROR). - /// @return receiver Decoded receiver from payload (valid if NO_ERROR/PAUSED/INSUFFICIENT_GAS). + /// @return receiver Decoded receiver from payload (valid if NO_ERROR/PAUSED). /// @return shortcutData Decoded Enso Shortcuts calldata. /// @return errorCode Classification of the validation result. /// @return errorData Optional details (see `MessageValidationFailed` doc). @@ -213,8 +213,7 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus // Decode payload bool decodeSuccess; - uint256 estimatedGas; - (decodeSuccess, receiver, estimatedGas, shortcutData) = CCIPMessageDecoder.tryDecodeMessageData(_message.data); + (decodeSuccess, receiver, shortcutData) = CCIPMessageDecoder._tryDecodeMessageData(_message.data); if (!decodeSuccess) { return (token, amount, receiver, shortcutData, ErrorCode.MALFORMED_MESSAGE_DATA, errorData); } @@ -229,12 +228,6 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus return (token, amount, receiver, shortcutData, ErrorCode.PAUSED, errorData); } - uint256 availableGas = gasleft(); - if (estimatedGas != 0 && availableGas < estimatedGas) { - errorData = abi.encode(availableGas, estimatedGas); - return (token, amount, receiver, shortcutData, ErrorCode.INSUFFICIENT_GAS, errorData); - } - return (token, amount, receiver, shortcutData, ErrorCode.NO_ERROR, errorData); } } diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol index 7b8a7c4..f6aac82 100644 --- a/src/interfaces/IEnsoCCIPReceiver.sol +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -19,7 +19,6 @@ interface IEnsoCCIPReceiver { /// - MALFORMED_MESSAGE_DATA: payload (address,uint256,bytes) could not be decoded. /// - ZERO_ADDRESS_RECEIVER: payload receiver is the zero address. /// - PAUSED: contract is paused; environment block on execution. - /// - INSUFFICIENT_GAS: current gas < estimatedGas hint from payload. enum ErrorCode { NO_ERROR, ALREADY_EXECUTED, @@ -28,12 +27,11 @@ interface IEnsoCCIPReceiver { NO_TOKEN_AMOUNT, MALFORMED_MESSAGE_DATA, ZERO_ADDRESS_RECEIVER, - PAUSED, - INSUFFICIENT_GAS + PAUSED } /// @notice Refund policy selected by the receiver for a given ErrorCode. - /// @dev TO_RECEIVER is used for environment errors (e.g., PAUSED/INSUFFICIENT_GAS) after successful payload decode. + /// @dev TO_RECEIVER is used for environment errors (e.g., PAUSED) after successful payload decode. /// TO_ESCROW is used for malformed token/payload cases. enum RefundKind { NONE, @@ -48,7 +46,6 @@ interface IEnsoCCIPReceiver { /// @notice Emitted when validation fails. See `errorCode` for the reason. /// @dev errorData encodings: /// - ALREADY_EXECUTED: (bytes32 messageId) - /// - INSUFFICIENT_GAS: (uint256 availableGas, uint256 estimatedGas) /// - Others: empty bytes unless specified by the implementation. event MessageValidationFailed(bytes32 indexed messageId, ErrorCode errorCode, bytes errorData); diff --git a/src/libraries/CCIPMessageDecoder.sol b/src/libraries/CCIPMessageDecoder.sol index c23f625..49b9c28 100644 --- a/src/libraries/CCIPMessageDecoder.sol +++ b/src/libraries/CCIPMessageDecoder.sol @@ -2,17 +2,17 @@ pragma solidity ^0.8.24; library CCIPMessageDecoder { - /// @dev Safe, non-reverting decoder for abi.encode(address,uint256,bytes) in MEMORY. - /// Returns (ok, receiver, estimatedGas, shortcutData). On malformed input, ok=false. - /// Layout: HEAD (96 bytes) = [receiver|estimatedGas|offset], TAIL at (base+off) = [len|bytes...]. - function tryDecodeMessageData(bytes memory _data) + /// @dev Safe, non-reverting decoder for abi.encode(address,bytes) in MEMORY. + /// Returns (ok, receiver, shortcutData). On malformed input, ok=false. + /// Layout: HEAD (64 bytes) = [receiver|offset], TAIL at (base+off) = [len|bytes...]. + function _tryDecodeMessageData(bytes memory _data) internal pure - returns (bool success, address receiver, uint256 estimatedGas, bytes memory shortcutData) + returns (bool success, address receiver, bytes memory shortcutData) { - // Need 3 head words (96) + 1 length word (32) - if (_data.length < 128) { - return (false, address(0), 0, bytes("")); + // Need 2 head words (64) + 1 length word (32) = 96 bytes minimum + if (_data.length < 96) { + return (false, address(0), bytes("")); } // Pointer to first head word @@ -23,21 +23,20 @@ library CCIPMessageDecoder { assembly ("memory-safe") { // Address is right-aligned in the word β†’ keep low 20 bytes receiver := and(mload(base), 0x000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF) - estimatedGas := mload(add(base, 32)) - off := mload(add(base, 64)) + off := mload(add(base, 32)) } // Word-aligned offset? if ((off & 31) != 0) { - return (false, address(0), 0, bytes("")); + return (false, address(0), bytes("")); } uint256 baseLen = _data.length; - // Off must be at/after 3-word head and leave room for tail length word - // i.e. off >= 96 && off <= baseLen - 32 (avoid off+32 overflow) - if (off < 96 || off > baseLen - 32) { - return (false, address(0), 0, bytes("")); + // Off must be at/after 2-word head and leave room for tail length word + // i.e. off >= 64 && off <= baseLen - 32 (avoid off+32 overflow) + if (off < 64 || off > baseLen - 32) { + return (false, address(0), bytes("")); } // Safe now to compute tail start (no overflow) @@ -55,13 +54,13 @@ library CCIPMessageDecoder { // Require len itself to fit in the available tail if (len > avail) { - return (false, address(0), 0, bytes("")); + return (false, address(0), bytes("")); } // Ceil32(len) and ensure padded bytes also fit (defensive; usually implied by len<=avail) uint256 padded = (len + 31) & ~uint256(31); if (padded > avail) { - return (false, address(0), 0, bytes("")); + return (false, address(0), bytes("")); } // Allocate and copy exactly `len` bytes (ignore padding) @@ -78,6 +77,6 @@ library CCIPMessageDecoder { } } - return (true, receiver, estimatedGas, shortcutData); + return (true, receiver, shortcutData); } } diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree index 8dde9c0..4f363cc 100644 --- a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree @@ -41,7 +41,6 @@ EnsoCCIPReceiver::CcipReceive ########## it should emit ShortcutExecutionFailed ########## it should safe transfer token amount to receiver - // EnsoCCIPReceiver::Constructor // └── when deployed // β”œβ”€β”€ it should set owner diff --git a/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol index e8726ec..423eb84 100644 --- a/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol +++ b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol @@ -9,31 +9,23 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { HELPERS //////////////////////////////////////////////////////////////*/ - // Head layout in memory (after the 32-byte length word): - // [ receiver (32) | estimatedGas (32) | offset (32) ] -> 96 bytes - uint256 private constant HEAD_SIZE = 96; - - function _encodeValid( - address receiver, - uint256 estimatedGas, - bytes memory payload - ) - internal - pure - returns (bytes memory) - { - return abi.encode(receiver, estimatedGas, payload); + // New head layout in memory (after the 32-byte length word): + // [ receiver (32) | offset (32) ] -> 64 bytes + uint256 private constant HEAD_SIZE = 64; + + function _encodeValid(address receiver, bytes memory payload) internal pure returns (bytes memory) { + return abi.encode(receiver, payload); } function _setOffset(bytes memory data, uint256 off) internal pure { - // write at head[2] (offset word) + // write at head[1] (offset word) assembly { - mstore(add(data, add(32, 64)), off) + mstore(add(data, add(32, 32)), off) } } function _peekOffsetMem(bytes memory data) internal pure returns (uint256 off) { - assembly { off := mload(add(data, 96)) } + assembly { off := mload(add(data, 64)) } } function _setTailLenAtOffset(bytes memory data, uint256 off, uint256 len) internal pure { @@ -45,50 +37,47 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { } /*////////////////////////////////////////////////////////////// - TESTS: < 128 bytes + TESTS: < 96 bytes //////////////////////////////////////////////////////////////*/ - function test_WhenDataLengthLt128Bytes() external pure { - // Arrange - bytes memory data = - hex"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + function test_WhenDataLengthLt96Bytes() external pure { + // Arrange: make any buffer shorter than 96 bytes + bytes memory data = new bytes(95); // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(data); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(data); // Assert // it should return unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } /*////////////////////////////////////////////////////////////// - MOD: β‰₯ 128 bytes (valid base) + MOD: β‰₯ 96 bytes (valid base) //////////////////////////////////////////////////////////////*/ - modifier whenDataLengthGte128Bytes() { + modifier whenDataLengthGte96Bytes() { _; } - function test_WhenOffsetIsNotWordAligned() external pure whenDataLengthGte128Bytes { - // Arrange - bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); + function test_WhenOffsetIsNotWordAligned() external pure whenDataLengthGte96Bytes { + // Arrange: abi.encode(address, bytes("")) β†’ length = 96 (64 head + 32 length word) + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, bytes("")); // Make offset = 97 (not multiple of 32) _setOffset(msgData, 97); assertEq(_peekOffsetMem(msgData), 97, "offset mutation failed"); // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(msgData); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(msgData); // Assert // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } @@ -100,56 +89,54 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { _; } - function test_WhenOffsetIsBefore3WordHead() external pure whenDataLengthGte128Bytes whenOffsetIsWordAligned { + function test_WhenOffsetIsBefore2WordHead() external pure whenDataLengthGte96Bytes whenOffsetIsWordAligned { // Arrange - bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); - _setOffset(msgData, 64); // word-aligned but < 96 (invalid for our layout) - assertEq(_peekOffsetMem(msgData), 64, "offset mutation failed"); + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, bytes("")); + _setOffset(msgData, 32); // word-aligned but < 64 (invalid for our layout) + assertEq(_peekOffsetMem(msgData), 32, "offset mutation failed"); // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(msgData); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(msgData); // Assert // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } /*////////////////////////////////////////////////////////////// - MOD: offset is after the 3-word head (β‰₯ 96) + MOD: offset is after the 2-word head (β‰₯ 64) //////////////////////////////////////////////////////////////*/ - modifier whenOffsetIsAfter3WordHead() { + modifier whenOffsetIsAfter2WordHead() { _; } function test_WhenThereIsNotEnoughRoomForTailLengthWord() external pure - whenDataLengthGte128Bytes + whenDataLengthGte96Bytes whenOffsetIsWordAligned - whenOffsetIsAfter3WordHead + whenOffsetIsAfter2WordHead { // Arrange - bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); - // Start from valid base and set offset to the canonical value (96) - _setOffset(msgData, HEAD_SIZE); // 96 + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, bytes("")); + // Start from valid base and set offset to the canonical value (64) + _setOffset(msgData, HEAD_SIZE); // 64 // Make offset so large that data.length < off + 32. - // Current s_messageData.length == 128 and word-aligned, so set off = 128. + // Current msgData.length == 96 and word-aligned, so set off = 96. _setOffset(msgData, msgData.length); // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(msgData); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(msgData); // Assert // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } @@ -164,55 +151,53 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Concrete_Test is Test { function test_WhenTailDoesNotFullyFit() external pure - whenDataLengthGte128Bytes + whenDataLengthGte96Bytes whenOffsetIsWordAligned - whenOffsetIsAfter3WordHead + whenOffsetIsAfter2WordHead whenThereIsEnoughRoomForTailLengthWord { // Arrange - bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 123, bytes("")); - // Set offset = 96 so the length word is within bounds (128 >= 96+32) - _setOffset(msgData, HEAD_SIZE); // 96 - // With base length=128 and off=96, writing len=64 requires: - // off + 32 + ceil32(64) = 96 + 32 + 64 = 192 > 128 β†’ should fail - _setTailLenAtOffset(msgData, HEAD_SIZE, 64); + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, bytes("")); + // Set offset = 64 so the length word is within bounds (96 >= 64+32) + _setOffset(msgData, HEAD_SIZE); // 64 + // With base length=96 and off=64, avail = 96 - (64+32) = 0 + // Writing len=1 makes it overflow the available tail β†’ should fail + _setTailLenAtOffset(msgData, HEAD_SIZE, 1); // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(msgData); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(msgData); // Assert // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } function test_WhenTailFullyFits() external pure - whenDataLengthGte128Bytes + whenDataLengthGte96Bytes whenOffsetIsWordAligned - whenOffsetIsAfter3WordHead + whenOffsetIsAfter2WordHead whenThereIsEnoughRoomForTailLengthWord { // Arrange // Build a valid payload where tail fully fits: len=3 β†’ ceil32=32 - // Total = 96 + 32 + 32 = 160 bytes + // Total = 64 + 32 + 32 = 128 bytes bytes memory payload = hex"010203"; - bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, type(uint256).max, payload); + bytes memory msgData = _encodeValid(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, payload); // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(msgData); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(msgData); // Assert // it should return a successful result assertTrue(decodeSuccess); assertEq(receiver, 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); - assertEq(estimatedGas, type(uint256).max); assertEq(shortcutData, payload); - assertEq(msgData.length, 160); + assertEq(msgData.length, 128); } } diff --git a/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.tree b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.tree index 08e979e..67e4c3c 100644 --- a/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.tree +++ b/test/unit/concrete/libraries/ccipMessageDecoder/tryDecodeMessageData.tree @@ -1,13 +1,13 @@ // CCIPMessageDecoder::tryDecodeMessageData -// # when data length lt 128 bytes +// # when data length lt 96 bytes // ## it should return unsuccessful result -// # when data length gte 128 bytes +// # when data length gte 96 bytes // ## when offset is not word aligned // ### it should return unsuccessful result // ## when offset is word aligned -// ### when offset is before 3 word head +// ### when offset is before 2 word head // #### it should return unsuccessful result -// ### when offset is after 3 word head +// ### when offset is after 2 word head // #### when there is not enough room for tail length word // ##### it should return unsuccessful result // #### when there is enough room for tail length word @@ -16,15 +16,15 @@ // ##### when tail fully fits // ###### it should return successful result CCIPMessageDecoder::tryDecodeMessageData -β”œβ”€β”€ when data length lt 128 bytes +β”œβ”€β”€ when data length lt 96 bytes β”‚ └── it should return unsuccessful result -└── when data length gte 128 bytes +└── when data length gte 96 bytes β”œβ”€β”€ when offset is not word aligned β”‚ └── it should return unsuccessful result └── when offset is word aligned - β”œβ”€β”€ when offset is before 3 word head + β”œβ”€β”€ when offset is before 2 word head β”‚ └── it should return unsuccessful result - └── when offset is after 3 word head + └── when offset is after 2 word head β”œβ”€β”€ when there is not enough room for tail length word β”‚ └── it should return unsuccessful result └── when there is enough room for tail length word diff --git a/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol b/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol index 5c03c17..c9effd8 100644 --- a/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol +++ b/test/unit/fuzz/libraries/ccipMessageDecoder/tryDecodeMessageData.t.sol @@ -5,25 +5,23 @@ import { CCIPMessageDecoder } from "../../../../../src/libraries/CCIPMessageDeco import { Test } from "forge-std-1.9.7/Test.sol"; contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { - function testFuzz_unsuccessfulResult_lengthLt128Bytes(bytes memory _data) external pure { + function testFuzz_unsuccessfulResult_lengthLt96Bytes(bytes memory _data) external pure { // Arrange - vm.assume(_data.length < 128); + vm.assume(_data.length < 96); // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(_data); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(_data); // Assert - // it should return a unsuccessful result + // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } function testFuzz_unsuccessfulResult_misalignedOffset( address _receiver, - uint256 _estimatedGas, bytes memory _tail, uint8 _wiggle ) @@ -32,167 +30,138 @@ contract CCIPMessageDecoder_TryDecodeMessageData_Unit_Fuzz_Test is Test { { // Arrange // Build a valid payload first - bytes memory data = abi.encode(_receiver, _estimatedGas, _tail); + bytes memory data = abi.encode(_receiver, _tail); - // Choose an offset β‰₯96 but misaligned: 96 + [1..31] - uint256 off = 96 + (uint256(_wiggle) % 31 + 1); + // Choose an offset β‰₯64 but misaligned: 64 + [1..31] + uint256 off = 64 + (uint256(_wiggle) % 31 + 1); - // Overwrite offset at head[2] - assembly { mstore(add(data, 96), off) } + // Overwrite offset at head[1] (base+32 => absolute +64 from bytes start) + assembly { mstore(add(data, 64), off) } // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(data); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(data); // Assert - // it should return a unsuccessful result + // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } - function testFuzz_unsuccessful_offsetBeforeHead( - address _receiver, - uint256 _estimatedGas, - bytes memory _tail, - uint8 _k - ) - external - pure - { + function testFuzz_unsuccessful_offsetBeforeHead(address _receiver, bytes memory _tail, uint8 _k) external pure { // Arrange - bytes memory data = abi.encode(_receiver, _estimatedGas, _tail); + bytes memory data = abi.encode(_receiver, _tail); - // Pick aligned off ∈ {0,32,64} - uint256 aligned = (uint256(_k) % 3) * 32; - assembly { mstore(add(data, 96), aligned) } + // Pick aligned off ∈ {0,32} (both < 64 β†’ before 2-word head) + uint256 aligned = (uint256(_k) % 2) * 32; + assembly { mstore(add(data, 64), aligned) } // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(data); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(data); // Assert - // it should return a unsuccessful result + // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } - function testFuzz_unsuccessful_offsetBeyondEnd( - address _receiver, - uint256 _estimatedGas, - bytes memory _tail - ) - external - pure - { + function testFuzz_unsuccessful_offsetBeyondEnd(address _receiver, bytes memory _tail) external pure { // Arrange - bytes memory data = abi.encode(_receiver, _estimatedGas, _tail); + bytes memory data = abi.encode(_receiver, _tail); - // Set off = data.length (aligned since abi.encode makes length % 32 == 0 when tail present/empty) + // Set off = data.length (aligned since abi.encode pads to /32) uint256 off = data.length; - assembly { mstore(add(data, 96), off) } + assembly { mstore(add(data, 64), off) } // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(data); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(data); // Assert - // it should return a unsuccessful result + // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } - function testFuzz_unsuccessful_tailDoesNotFit(address _receiver, uint256 _estimatedGas) external pure { + function testFuzz_unsuccessful_tailDoesNotFit(address _receiver) external pure { // Arrange - // Start with empty tail β†’ total 128 bytes - bytes memory data = abi.encode(_receiver, _estimatedGas, bytes("")); - // off=96 (canonical) - assembly { mstore(add(data, 96), 96) } - // Write len=64 β†’ requires 96 + 32 + 64 = 192 > 128 - assembly { mstore(add(data, 128), 64) } + // Start with empty tail β†’ total 96 bytes (64 head + 32 length word) + bytes memory data = abi.encode(_receiver, bytes("")); + // off = 64 (canonical) + assembly { mstore(add(data, 64), 64) } + // Write len=1 β†’ requires 64 + 32 + ceil32(1)=64+32+32=128 bytes total, + // but current buffer is only 96 β†’ should fail + assembly { mstore(add(data, 96), 1) } // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(data); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(data); // Assert - // it should return a unsuccessful result + // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } - function testFuzz_unsuccessful_hugeLenOverflowGuard(address _receiver, uint256 _estimatedGas) external pure { - // Start with empty tail β†’ total 128 bytes - bytes memory data = abi.encode(_receiver, _estimatedGas, bytes("")); - // off = 96 - assembly { mstore(add(data, 96), 96) } + function testFuzz_unsuccessful_hugeLenOverflowGuard(address _receiver) external pure { + // Arrange + // Empty tail β†’ total 96 bytes; off = 64 + bytes memory data = abi.encode(_receiver, bytes("")); + assembly { mstore(add(data, 64), 64) } // len = max => ceil32(len) definitely exceeds remaining buffer - assembly { mstore(add(data, 128), not(0)) } + assembly { mstore(add(data, 96), not(0)) } // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(data); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(data); // Assert - // it should return a unsuccessful result + // it should return an unsuccessful result assertFalse(decodeSuccess); assertEq(receiver, address(0)); - assertEq(estimatedGas, 0); assertEq(shortcutData, ""); } function testFuzz_success_receiverMasking(bytes20 _low20, bytes12 _garbageHigh) external pure { // Arrange address expectedReceiver = address(uint160(uint256(bytes32(_low20)))); - // Build a valid encoding - bytes memory data = abi.encode(address(0), uint256(0), bytes("")); + // Build a valid encoding with zeroed head, empty tail + bytes memory data = abi.encode(address(0), bytes("")); // Overwrite receiver head word with high-12 garbage | low-20 address bytes32 word = bytes32(_garbageHigh) | bytes32(_low20); assembly { mstore(add(data, 32), word) } // Act - (bool decodeSuccess, address receiver,,) = CCIPMessageDecoder.tryDecodeMessageData(data); + (bool decodeSuccess, address receiver,) = CCIPMessageDecoder._tryDecodeMessageData(data); // Assert assertTrue(decodeSuccess); assertEq(receiver, expectedReceiver); } - function testFuzz_successfulResult( - address _receiver, - uint256 _estimatedGas, - bytes memory _shortcutData - ) - external - pure - { + function testFuzz_successfulResult(address _receiver, bytes memory _shortcutData) external pure { // Arrange - bytes memory data = abi.encode(_receiver, _estimatedGas, _shortcutData); + bytes memory data = abi.encode(_receiver, _shortcutData); - (address decodedReceiver, uint256 decodedEstimatedGas, bytes memory decodedShortcutData) = - abi.decode(data, (address, uint256, bytes)); + (address decodedReceiver, bytes memory decodedShortcutData) = abi.decode(data, (address, bytes)); // Act - (bool decodeSuccess, address receiver, uint256 estimatedGas, bytes memory shortcutData) = - CCIPMessageDecoder.tryDecodeMessageData(data); + (bool decodeSuccess, address receiver, bytes memory shortcutData) = + CCIPMessageDecoder._tryDecodeMessageData(data); // Assert // it should return a successful result assertTrue(decodeSuccess); assertEq(receiver, _receiver); - assertEq(estimatedGas, _estimatedGas); assertEq(shortcutData, _shortcutData); // Differential assertEq(receiver, decodedReceiver); - assertEq(estimatedGas, decodedEstimatedGas); assertEq(shortcutData, decodedShortcutData); } } From cec55b2f319f2f1d79b598d129ee62fb410d1cd3 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Thu, 13 Nov 2025 15:47:42 +0100 Subject: [PATCH 18/21] feat: added EnsoCCIPReceiver tests --- src/bridge/EnsoCCIPReceiver.sol | 10 +- src/interfaces/IEnsoCCIPReceiver.sol | 10 +- src/interfaces/ITypeAndVersion.sol | 10 + test/mocks/MockEnsoRouter.sol | 4 +- test/shortcuts/ShortcutsEthereum.sol | 23 +- .../ensoCCIPReceiver/EnsoCCIPReceiver.t.sol | 17 + .../ensoCCIPReceiver/EnsoCCIPReceiver.tree | 136 +++-- .../bridge/ensoCCIPReceiver/ccipReceive.t.sol | 519 ++++++++++++++++++ .../bridge/ensoCCIPReceiver/execute.t.sol | 68 +++ .../ensoCCIPReceiver/getEnsoRouter.t.sol | 13 + .../ensoCCIPReceiver/recoverTokens.t.sol | 51 ++ .../ensoCCIPReceiver/supportsInterface.t.sol | 34 ++ .../ensoCCIPReceiver/typeAndVersion.t.sol | 13 + .../ensoCCIPReceiver/wasMessageExecuted.t.sol | 13 + .../delegate/ensoReceiver/safeExecute.t.sol | 18 +- 15 files changed, 857 insertions(+), 82 deletions(-) create mode 100644 src/interfaces/ITypeAndVersion.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/ccipReceive.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/execute.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/getEnsoRouter.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/recoverTokens.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/supportsInterface.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/typeAndVersion.t.sol create mode 100644 test/unit/concrete/bridge/ensoCCIPReceiver/wasMessageExecuted.t.sol diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index 44a9578..3301c94 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.24; import { IEnsoCCIPReceiver } from "../interfaces/IEnsoCCIPReceiver.sol"; import { IEnsoRouter, Token, TokenType } from "../interfaces/IEnsoRouter.sol"; +import { ITypeAndVersion } from "../interfaces/ITypeAndVersion.sol"; import { CCIPMessageDecoder } from "../libraries/CCIPMessageDecoder.sol"; import { CCIPReceiver, Client } from "chainlink-ccip/applications/CCIPReceiver.sol"; import { Ownable, Ownable2Step } from "openzeppelin-contracts/access/Ownable2Step.sol"; @@ -23,10 +24,10 @@ import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; /// - For malformed messages (no/too many tokens, zero amount, bad payload, zero address receiver), quarantines /// funds in this contract. /// - Executes Shortcuts using a self-call (`try this.execute(...)`) to catch and handle reverts. -contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Pausable { +contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Pausable, ITypeAndVersion { using SafeERC20 for IERC20; - uint256 private constant VERSION = 1; + string public constant override typeAndVersion = "EnsoCCIPReceiver 1.0.0"; /// @dev Immutable Enso Router used to dispatch tokens + call Shortcuts. /// forge-lint: disable-next-item(screaming-snake-case-immutable) @@ -133,11 +134,6 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus return address(i_ensoRouter); } - /// @inheritdoc IEnsoCCIPReceiver - function version() external pure returns (uint256) { - return VERSION; - } - /// @inheritdoc IEnsoCCIPReceiver function wasMessageExecuted(bytes32 _messageId) external view returns (bool) { return s_executedMessage[_messageId]; diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol index f6aac82..0f0126a 100644 --- a/src/interfaces/IEnsoCCIPReceiver.sol +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -47,7 +47,7 @@ interface IEnsoCCIPReceiver { /// @dev errorData encodings: /// - ALREADY_EXECUTED: (bytes32 messageId) /// - Others: empty bytes unless specified by the implementation. - event MessageValidationFailed(bytes32 indexed messageId, ErrorCode errorCode, bytes errorData); + event MessageValidationFailed(bytes32 indexed messageId, ErrorCode indexed errorCode, bytes errorData); /// @notice Funds were quarantined in the receiver instead of delivered to the payload receiver. /// @param messageId The CCIP message id. @@ -56,7 +56,7 @@ interface IEnsoCCIPReceiver { /// @param amount Token amount retained. /// @param receiver Original payload receiver (informational; may be zero if not decoded). event MessageQuarantined( - bytes32 indexed messageId, ErrorCode code, address token, uint256 amount, address receiver + bytes32 indexed messageId, ErrorCode indexed code, address token, uint256 amount, address receiver ); /// @notice Emitted when Enso Shortcuts execution succeeds for a CCIP message. @@ -69,7 +69,7 @@ interface IEnsoCCIPReceiver { event ShortcutExecutionFailed(bytes32 indexed messageId, bytes err); /// @notice Emitted when the owner recovers tokens from the receiver. - event TokensRecovered(address token, address to, uint256 amount); + event TokensRecovered(address indexed token, address indexed to, uint256 amount); // ------------------------------------------------------------------------- // Errors @@ -116,10 +116,6 @@ interface IEnsoCCIPReceiver { /// @return router Address of the Enso Router. function getEnsoRouter() external view returns (address router); - /// @notice Returns a human-readable version/format indicator for off-chain tooling and tests. - /// @return version The version number of this receiver implementation. - function version() external view returns (uint256 version); - /// @notice Returns whether a CCIP message was already handled (executed/refunded/quarantined). /// @param messageId CCIP message identifier. /// @return executed True if the messageId is marked as executed/handled. diff --git a/src/interfaces/ITypeAndVersion.sol b/src/interfaces/ITypeAndVersion.sol new file mode 100644 index 0000000..c2742db --- /dev/null +++ b/src/interfaces/ITypeAndVersion.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.0; + +/// @title ITypeAndVersion +/// @author Enso +/// @notice Provides a human-readable identifier for the contract type and its version. +interface ITypeAndVersion { + /// @return A string containing the contract type and semantic version. + function typeAndVersion() external pure returns (string memory); +} diff --git a/test/mocks/MockEnsoRouter.sol b/test/mocks/MockEnsoRouter.sol index d90f36c..e309996 100644 --- a/test/mocks/MockEnsoRouter.sol +++ b/test/mocks/MockEnsoRouter.sol @@ -37,7 +37,9 @@ contract MockEnsoRouter { } } if (isNativeAsset) { - shortcuts.call{ value: msg.value }(""); + (bool success, bytes memory result) = shortcuts.call{ value: msg.value }(""); + (success); + (result); } } diff --git a/test/shortcuts/ShortcutsEthereum.sol b/test/shortcuts/ShortcutsEthereum.sol index f7f4d89..38f94e6 100644 --- a/test/shortcuts/ShortcutsEthereum.sol +++ b/test/shortcuts/ShortcutsEthereum.sol @@ -144,9 +144,9 @@ library ShortcutsEthereum { shortcut.txGas = 92_661; shortcut.txData = abi.encodePacked( - hex"95352c9fad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a51b21602de47c41deb22fa12edf3f9188303132333435363738394142434445460000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000042e1a7d4d0100ffffffffffffc02aaa39b223fe8d0a0e5c4f27ead9083c756cc219198595a30081ffffffffff", + hex"95352c9fad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a523e16585efc24d639f861691ad38481130313233343536373839414243444546000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000005a9059cbb010001ffffffffffc02aaa39b223fe8d0a0e5c4f27ead9083c756cc22e1a7d4d0102ffffffffffffc02aaa39b223fe8d0a0e5c4f27ead9083c756cc219198595a30283ffffffffff", receiver, - hex"6e7a43a3010002ffffffff027e7d64d987cab6eed08a191c4c2459daf2f8ed0b241c59120102ffffffffffff7e7d64d987cab6eed08a191c4c2459daf2f8ed0b0000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000dcef33a6f838000" + hex"6e7a43a3010204ffffffff047e7d64d987cab6eed08a191c4c2459daf2f8ed0b241c59120104ffffffffffff7e7d64d987cab6eed08a191c4c2459daf2f8ed0b000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000000000000000000006aa68c46ed86161eb318b1396f7b79e386e886760000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000dbd2fc137a30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001" ); shortcut.referralCode = "0123456789ABCDEF"; } @@ -158,7 +158,8 @@ library ShortcutsEthereum { function getShortcut2( address weth, address ensoShortcutsHelpers, - address receiver + address receiver, + address feeReceiver ) public pure @@ -182,19 +183,23 @@ library ShortcutsEthereum { shortcut.tokensOut = tokensOut; shortcut.fee = 0.01 ether; - shortcut.feeReceiver = 0x6AA68C46eD86161eB318b1396F7b79E386e88676; + shortcut.feeReceiver = feeReceiver; shortcut.txGas = 92_661; shortcut.txData = abi.encodePacked( - hex"95352c9fad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a51b21602de47c41deb22fa12edf3f9188303132333435363738394142434445460000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000042e1a7d4d0100ffffffffffff", + hex"95352c9fad7c5bef027816a800da1736444fb58a807ef4c9603b7848673f7e3a68eb14a523e16585efc24d639f861691ad38481130313233343536373839414243444546000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000005a9059cbb010001ffffffffff", + weth, + hex"2e1a7d4d0102ffffffffffff", weth, - hex"19198595a30081ffffffffff", + hex"19198595a30283ffffffffff", receiver, - hex"6e7a43a3010002ffffffff02", + hex"6e7a43a3010204ffffffff04", ensoShortcutsHelpers, - hex"241c59120102ffffffffffff", + hex"241c59120104ffffffffffff", ensoShortcutsHelpers, - hex"0000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000dcef33a6f838000" + hex"000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000", + feeReceiver, + hex"0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000dbd2fc137a30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001" ); shortcut.referralCode = "0123456789ABCDEF"; } diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.t.sol index e5250cf..37951e2 100644 --- a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.t.sol +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.t.sol @@ -1,9 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-only pragma solidity ^0.8.28; +import { EnsoShortcuts } from "../../../../../src/EnsoShortcuts.sol"; import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; import { EnsoShortcutsHelpers } from "../../../../../src/helpers/EnsoShortcutsHelpers.sol"; import { EnsoRouter } from "../../../../../src/router/EnsoRouter.sol"; +import { MockERC20 } from "../../../../mocks/MockERC20.sol"; import { WETH9 } from "../../../../mocks/WETH9.sol"; import { MockCCIPRouter } from "chainlink-ccip/test/mocks/MockRouter.sol"; import { Test } from "forge-std-1.9.7/Test.sol"; @@ -14,10 +16,13 @@ abstract contract EnsoCCIPReceiver_Unit_Concrete_Test is Test { address payable internal s_account1; address payable internal s_account2; EnsoRouter internal s_ensoRouter; + EnsoShortcuts internal s_ensoShortcuts; EnsoShortcutsHelpers internal s_ensoShortcutsHelpers; MockCCIPRouter internal s_ccipRouter; EnsoCCIPReceiver internal s_ensoCcipReceiver; WETH9 internal s_weth; + MockERC20 internal s_tokenA; + MockERC20 internal s_tokenB; function setUp() public virtual { s_deployer = payable(vm.addr(1)); @@ -40,6 +45,9 @@ abstract contract EnsoCCIPReceiver_Unit_Concrete_Test is Test { s_ensoRouter = new EnsoRouter(); vm.label(address(s_ensoRouter), "EnsoRouter"); + s_ensoShortcuts = EnsoShortcuts(payable(s_ensoRouter.shortcuts())); + vm.label(address(s_ensoShortcuts), "EnsoShortcuts"); + s_ensoShortcutsHelpers = new EnsoShortcutsHelpers(); vm.label(address(s_ensoShortcutsHelpers), "EnsoShortcutsHelpers"); @@ -51,6 +59,15 @@ abstract contract EnsoCCIPReceiver_Unit_Concrete_Test is Test { s_weth = new WETH9(); vm.label(address(s_weth), "WETH9"); + + s_tokenA = new MockERC20("Token A", "TKNA"); + vm.label(address(s_tokenA), "TKNA"); + s_tokenA.mint(s_deployer, 1000 ether); + + s_tokenB = new MockERC20("Token B", "TKNB"); + vm.label(address(s_tokenB), "TKNB"); + s_tokenB.mint(s_deployer, 1000 ether); + vm.stopPrank(); } } diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree index 4f363cc..a7ef06a 100644 --- a/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/EnsoCCIPReceiver.tree @@ -4,42 +4,54 @@ // └── when caller is pending owner // └── it should transfer ownership -EnsoCCIPReceiver::CcipReceive -# when caller is not ccipRouter -## it should revert -# when caller is ccipRouter -## when message was already executed -### it should emit MessageValidationFailed -## when message was not executed -### when message has no tokens -#### it should emit MessageValidationFailed -### when message has tokens -#### when message has more than one token -##### it should emit MessageValidationFailed -#### when message has single token -##### when message token amount is zero -###### it should emit MessageValidationFailed -##### when message token amount is gt zero -###### when message data is malformed -####### it should emit MessageValidationFailed -###### when message data is well formed -####### when message data receiver is zero address -######## it should emit MessageValidationFailed -######## it shoud set executedMessage to true -######## it should emit MessageQuarantined -######## it should escrow message token amount -####### when message data receiver is not zero address -######## when contract is paused -######### it should emit MessageValidationFailed -######### it shoud set executedMessage to true -######### it should safe transfer token amount to receiver -######## when contract is not paused -######### when shortcut execution was was successful -########## it shoud set executedMessage to true -########## it should emit ShortcutExecutionSuccessful -######### when shortcut execution failed -########## it should emit ShortcutExecutionFailed -########## it should safe transfer token amount to receiver +// EnsoCCIPReceiver::CcipReceive +// β”œβ”€β”€ when caller is not ccipRouter +// β”‚ └── it should revert +// └── when caller is ccipRouter +// β”œβ”€β”€ when message was already executed +// β”‚ β”œβ”€β”€ it should emit MessageValidationFailed +// β”‚ └── it should update executedMessage +// └── when message was not executed +// β”œβ”€β”€ when message has no tokens +// β”‚ β”œβ”€β”€ it should emit MessageValidationFailed +// β”‚ └── it should not update executedMessage +// └── when message has tokens +// β”œβ”€β”€ when message has more than one token +// β”‚ β”œβ”€β”€ it should emit MessageValidationFailed +// β”‚ β”œβ”€β”€ it should not update executedMessage +// β”‚ β”œβ”€β”€ it should emit MessageQuarantined +// β”‚ └── it should escrow message tokens +// └── when message has single token +// β”œβ”€β”€ when message token amount is zero +// β”‚ β”œβ”€β”€ it should emit MessageValidationFailed +// β”‚ β”œβ”€β”€ it should not update executedMessage +// β”‚ └── it should emit MessageQuarantined +// └── when message token amount is gt zero +// β”œβ”€β”€ when message data is malformed +// β”‚ β”œβ”€β”€ it should emit MessageValidationFailed +// β”‚ β”œβ”€β”€ it should not update executedMessage +// β”‚ β”œβ”€β”€ it should emit MessageQuarantined +// β”‚ └── it should escrow message token +// └── when message data is well formed +// β”œβ”€β”€ when message data receiver is zero address +// β”‚ β”œβ”€β”€ it should emit MessageValidationFailed +// β”‚ β”œβ”€β”€ it shoud update executedMessage +// β”‚ β”œβ”€β”€ it should escrow message token +// β”‚ └── it should emit MessageQuarantined +// └── when message data receiver is not zero address +// β”œβ”€β”€ when contract is paused +// β”‚ β”œβ”€β”€ it should emit MessageValidationFailed +// β”‚ β”œβ”€β”€ it shoud update executedMessage +// β”‚ └── it should safe transfer token amount to receiver +// └── when contract is not paused +// β”œβ”€β”€ when shortcut execution succeeded +// β”‚ β”œβ”€β”€ it shoud update executedMessage +// β”‚ β”œβ”€β”€ it shoud apply shortcut state changes +// β”‚ └── it should emit ShortcutExecutionSuccessful +// └── when shortcut execution failed +// β”œβ”€β”€ it shoud update executedMessage +// β”œβ”€β”€ it should emit ShortcutExecutionFailed +// └── it should safe transfer token amount to receiver // EnsoCCIPReceiver::Constructor // └── when deployed @@ -47,12 +59,17 @@ EnsoCCIPReceiver::CcipReceive // β”œβ”€β”€ it should set ccipRouter // └── it should set ensoRouter -EnsoCCIPReceiver::RecoverTokens -# when caller is not self -## it should revert -# when caller is self -## it should force approve amount to ensoRouter -## it should call ensoRouter routeSingle +// EnsoCCIPReceiver::Execute +// β”œβ”€β”€ when caller is not self +// β”‚ └── it should revert +// └── when caller is self +// β”œβ”€β”€ when shortcut execution failed +// β”‚ └── it should revert +// └── when shortcut execution succeeded +// └── it should apply shorcut state changes + +// EnsoCCIPReceiver::GetEnsoRouter +// └── it should return EnsoRouter address // EnsoCCIPReceiver::Pause // β”œβ”€β”€ when caller is not owner @@ -60,11 +77,27 @@ EnsoCCIPReceiver::RecoverTokens // └── when caller is owner // └── it should pause the contract -// EnsoCCIPReceiver::Unpause +// EnsoCCIPReceiver::RecoverTokens // β”œβ”€β”€ when caller is not owner // β”‚ └── it should revert // └── when caller is owner -// └── it should unpause the contract +// β”œβ”€β”€ it should safe transfer amount to recipient +// └── it should emit TokensRecovered + +// EnsoCCIPReceiver::RenounceOwnership +// β”œβ”€β”€ when caller is not owner +// β”‚ └── it should revert +// └── when caller is owner +// └── it should transfer ownership to zero address + +// EnsoCCIPReceiver::SupportsInterface +// β”œβ”€β”€ when interfaceId is type IAny2EVMMessageReceiver +// β”‚ └── it should return true +// └── when interfaceId is not type IAny2EVMMessageReceiver +// β”œβ”€β”€ when interfaceId is type IERC165 +// β”‚ └── it should return true +// └── when interfaceId is not type IERC165 +// └── it should return false // EnsoCCIPReceiver::TransferOwnership // β”œβ”€β”€ when caller is not owner @@ -72,15 +105,14 @@ EnsoCCIPReceiver::RecoverTokens // └── when caller is owner // └── it should start ownership transfer -EnsoCCIPReceiver::RecoverTokens -# when caller is not owner -## it should revert -# when caller is owner -## it should safe transfer amount to recipient -## it should emit TOkensRecovered +// EnsoCCIPReceiver::TypeAndVersion +// └── it should return current type and semantic version -// EnsoCCIPReceiver::RenounceOwnership +// EnsoCCIPReceiver::Unpause // β”œβ”€β”€ when caller is not owner // β”‚ └── it should revert // └── when caller is owner -// └── it should transfer ownership to zero address +// └── it should unpause the contract + +EnsoCCIPReceiver::WasMessageExecuted +└── it should return whether message was executed diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/ccipReceive.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/ccipReceive.t.sol new file mode 100644 index 0000000..ab9dd11 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/ccipReceive.t.sol @@ -0,0 +1,519 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; +import { IEnsoCCIPReceiver } from "../../../../../src/interfaces/IEnsoCCIPReceiver.sol"; +import { Shortcut } from "../../../../shortcuts/ShortcutDataTypes.sol"; +import { ShortcutsEthereum } from "../../../../shortcuts/ShortcutsEthereum.sol"; +import { TokenBalanceHelper } from "../../../../utils/TokenBalanceHelper.sol"; +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { CCIPReceiver, Client } from "chainlink-ccip/applications/CCIPReceiver.sol"; + +contract EnsoCCIPReceiver_CcipReceive_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test, TokenBalanceHelper { + address private s_caller; + Client.Any2EVMMessage private s_message; + + function test_RevertWhen_CallerIsNotCcipRouter() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(CCIPReceiver.InvalidRouter.selector, s_account1)); + vm.prank(s_account1); + s_ensoCcipReceiver.ccipReceive(s_message); + } + + modifier whenCallerIsCcipRouter() { + s_caller = address(s_ccipRouter); + _; + } + + function test_WhenMessageWasAlreadyExecuted() external whenCallerIsCcipRouter { + // Arrange + Client.EVMTokenAmount[] memory destTokenAmounts = new Client.EVMTokenAmount[](1); + destTokenAmounts[0] = Client.EVMTokenAmount({ token: address(s_tokenA), amount: 16 ether }); + + s_message.destTokenAmounts = destTokenAmounts; + s_message.data = abi.encode(s_account1, ""); // NOTE: no shortcut should succeed + + // Execute message for the first time + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), s_message.destTokenAmounts[0].amount); + vm.stopPrank(); + + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + bytes32 messageId = s_message.messageId; + IEnsoCCIPReceiver.ErrorCode errorCode = IEnsoCCIPReceiver.ErrorCode.ALREADY_EXECUTED; + bytes memory errorData; + + // Act & Assert + vm.skip(true); + vm.expectEmit(true, false, false, true); + // it should emit MessageValidationFailed + emit IEnsoCCIPReceiver.MessageValidationFailed(messageId, errorCode, errorData); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it should not update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + } + + modifier whenMessageWasNotExecuted() { + _; + } + + function test_WhenMessageHasNoTokens() external whenCallerIsCcipRouter whenMessageWasNotExecuted { + // Arrange + bytes32 messageId = s_message.messageId; + IEnsoCCIPReceiver.ErrorCode errorCode = IEnsoCCIPReceiver.ErrorCode.NO_TOKENS; + bytes memory errorData; + + address token; + uint256 amount; + address receiver; + + // Act & Assert + // it should emit MessageValidationFailed + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageValidationFailed(messageId, errorCode, errorData); + // it should emit MessageQuarantined + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageQuarantined(messageId, errorCode, token, amount, receiver); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it should update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + } + + modifier whenMessageHasTokens() { + uint256 amountA = 16 ether; + uint256 amountB = 42 ether; + + Client.EVMTokenAmount[] memory destTokenAmounts = new Client.EVMTokenAmount[](2); + destTokenAmounts[0] = Client.EVMTokenAmount({ token: address(s_tokenA), amount: amountA }); + destTokenAmounts[1] = Client.EVMTokenAmount({ token: address(s_tokenB), amount: amountB }); + + s_message.destTokenAmounts = destTokenAmounts; + _; + } + + function test_WhenMessageHasMoreThanOneToken() + external + whenCallerIsCcipRouter + whenMessageWasNotExecuted + whenMessageHasTokens + { + // Arrange + bytes32 messageId = s_message.messageId; + IEnsoCCIPReceiver.ErrorCode errorCode = IEnsoCCIPReceiver.ErrorCode.TOO_MANY_TOKENS; + bytes memory errorData; + + address token; + uint256 amount; + address receiver; + + uint256 ccipReceiverBalanceTokenABefore = balance(address(s_tokenA), address(s_ensoCcipReceiver)); + uint256 ccipReceiverBalanceTokenBBefore = balance(address(s_tokenB), address(s_ensoCcipReceiver)); + + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), s_message.destTokenAmounts[0].amount); + s_tokenB.transfer(address(s_ensoCcipReceiver), s_message.destTokenAmounts[1].amount); + vm.stopPrank(); + + // Act & Assert + // it should emit MessageValidationFailed + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageValidationFailed(messageId, errorCode, errorData); + // it should emit MessageQuarantined + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageQuarantined(messageId, errorCode, token, amount, receiver); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it should update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + + // it should escrow message tokens + uint256 ccipReceiverBalanceTokenAAfter = balance(address(s_tokenA), address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenABefore, + ccipReceiverBalanceTokenAAfter, + int256(s_message.destTokenAmounts[0].amount), + "EnsoCCIPReceiver tokenOut (TKNB)" + ); + uint256 ccipReceiverBalanceTokenBAfter = balance(address(s_tokenB), address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenBBefore, + ccipReceiverBalanceTokenBAfter, + int256(s_message.destTokenAmounts[1].amount), + "EnsoCCIPReceiver tokenOut (TKNB)" + ); + } + + modifier whenMessageHasSingleToken() { + uint256 amountA = 0 ether; + + Client.EVMTokenAmount[] memory destTokenAmounts = new Client.EVMTokenAmount[](1); + destTokenAmounts[0] = Client.EVMTokenAmount({ token: address(s_tokenA), amount: amountA }); + + s_message.destTokenAmounts = destTokenAmounts; + _; + } + + function test_WhenMessageTokenAmountIsZero() + external + whenCallerIsCcipRouter + whenMessageWasNotExecuted + whenMessageHasTokens + whenMessageHasSingleToken + { + // Arrange + bytes32 messageId = s_message.messageId; + IEnsoCCIPReceiver.ErrorCode errorCode = IEnsoCCIPReceiver.ErrorCode.NO_TOKEN_AMOUNT; + bytes memory errorData; + + address token = s_message.destTokenAmounts[0].token; + uint256 amount; + address receiver; + + uint256 ccipReceiverBalanceTokenABefore = balance(address(s_tokenA), address(s_ensoCcipReceiver)); + + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), s_message.destTokenAmounts[0].amount); + vm.stopPrank(); + + // Act & Assert + // it should emit MessageValidationFailed + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageValidationFailed(messageId, errorCode, errorData); + // it should emit MessageQuarantined + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageQuarantined(messageId, errorCode, token, amount, receiver); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it should update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + + // it should escrow message tokens + uint256 ccipReceiverBalanceTokenAAfter = balance(address(s_tokenA), address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenABefore, + ccipReceiverBalanceTokenAAfter, + int256(s_message.destTokenAmounts[0].amount), + "EnsoCCIPReceiver tokenOut (TKNA)" + ); + } + + modifier whenMessageTokenAmountIsGtZero() { + uint256 amountA = 16 ether; + + Client.EVMTokenAmount[] memory destTokenAmounts = new Client.EVMTokenAmount[](1); + destTokenAmounts[0] = Client.EVMTokenAmount({ token: address(s_tokenA), amount: amountA }); + + s_message.destTokenAmounts = destTokenAmounts; + _; + } + + function test_WhenMessageDataIsMalformed() + external + whenCallerIsCcipRouter + whenMessageWasNotExecuted + whenMessageHasTokens + whenMessageHasSingleToken + whenMessageTokenAmountIsGtZero + { + // Arrange + bytes32 messageId = s_message.messageId; + IEnsoCCIPReceiver.ErrorCode errorCode = IEnsoCCIPReceiver.ErrorCode.MALFORMED_MESSAGE_DATA; + bytes memory errorData; + + address token = s_message.destTokenAmounts[0].token; + uint256 amount = s_message.destTokenAmounts[0].amount; + address receiver; + + uint256 ccipReceiverBalanceTokenABefore = balance(address(s_tokenA), address(s_ensoCcipReceiver)); + + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), amount); + vm.stopPrank(); + + // Act & Assert + // it should emit MessageValidationFailed + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageValidationFailed(messageId, errorCode, errorData); + // it should emit MessageQuarantined + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageQuarantined(messageId, errorCode, token, amount, receiver); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it should update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + + // it should escrow message tokens + uint256 ccipReceiverBalanceTokenAAfter = balance(address(s_tokenA), address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenABefore, + ccipReceiverBalanceTokenAAfter, + int256(amount), + "EnsoCCIPReceiver tokenOut (TKNA)" + ); + } + + modifier whenMessageDataIsWellFormed() { + s_message.data = abi.encode(address(0), ""); + _; + } + + function test_WhenMessageDataReceiverIsZeroAddress() + external + whenCallerIsCcipRouter + whenMessageWasNotExecuted + whenMessageHasTokens + whenMessageHasSingleToken + whenMessageTokenAmountIsGtZero + whenMessageDataIsWellFormed + { + // Arrange + bytes32 messageId = s_message.messageId; + IEnsoCCIPReceiver.ErrorCode errorCode = IEnsoCCIPReceiver.ErrorCode.ZERO_ADDRESS_RECEIVER; + bytes memory errorData; + + address token = s_message.destTokenAmounts[0].token; + uint256 amount = s_message.destTokenAmounts[0].amount; + address receiver; + + uint256 ccipReceiverBalanceTokenABefore = balance(address(s_tokenA), address(s_ensoCcipReceiver)); + + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), amount); + vm.stopPrank(); + + // Act & Assert + // it should emit MessageValidationFailed + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageValidationFailed(messageId, errorCode, errorData); + // it should emit MessageQuarantined + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageQuarantined(messageId, errorCode, token, amount, receiver); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it should update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + + // it should escrow message tokens + uint256 ccipReceiverBalanceTokenAAfter = balance(address(s_tokenA), address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenABefore, + ccipReceiverBalanceTokenAAfter, + int256(amount), + "EnsoCCIPReceiver tokenOut (TKNA)" + ); + } + + modifier whenMessageDataReceiverIsNotZeroAddress() { + s_message.data = abi.encode(s_account1, ""); + _; + } + + function test_WhenContractIsPaused() + external + whenCallerIsCcipRouter + whenMessageWasNotExecuted + whenMessageHasTokens + whenMessageHasSingleToken + whenMessageTokenAmountIsGtZero + whenMessageDataIsWellFormed + whenMessageDataReceiverIsNotZeroAddress + { + // Arrange + bytes32 messageId = s_message.messageId; + IEnsoCCIPReceiver.ErrorCode errorCode = IEnsoCCIPReceiver.ErrorCode.PAUSED; + bytes memory errorData; + + address token = s_message.destTokenAmounts[0].token; + uint256 amount = s_message.destTokenAmounts[0].amount; + (address receiver,) = abi.decode(s_message.data, (address, bytes)); + + uint256 ccipReceiverBalanceTokenABefore = balance(token, address(s_ensoCcipReceiver)); + uint256 receiverBalanceTokenABefore = balance(token, receiver); + + vm.prank(s_owner); + s_ensoCcipReceiver.pause(); + + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), amount); + vm.stopPrank(); + + // Act & Assert + // it should emit MessageValidationFailed + vm.expectEmit(true, true, false, true); + emit IEnsoCCIPReceiver.MessageValidationFailed(messageId, errorCode, errorData); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it should update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + + // it should safe transfer token amount to receiver + uint256 ccipReceiverBalanceTokenAAfter = balance(token, address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenABefore, ccipReceiverBalanceTokenAAfter, 0, "EnsoCCIPReceiver tokenOut (TKNA)" + ); + uint256 receiverBalanceTokenAAfter = balance(token, receiver); + assertBalanceDiff( + receiverBalanceTokenABefore, receiverBalanceTokenAAfter, int256(amount), "Receiver tokenOut (TKNA)" + ); + } + + modifier whenContractIsNotPaused() { + // noop + _; + } + + function test_WhenShortcutExecutionSucceeded() + external + whenCallerIsCcipRouter + whenMessageWasNotExecuted + whenMessageHasTokens + whenMessageHasSingleToken + whenMessageTokenAmountIsGtZero + whenMessageDataIsWellFormed + whenMessageDataReceiverIsNotZeroAddress + whenContractIsNotPaused + { + // Arrange + // NOTE: this test uses a shortcut that unwraps 1 WETH and sends it to receiver as ETH (0.99 ETH). There is also + // a 0.01 WETH fee sent to feeReceiver. + address receiver = s_account1; + address feeReceiver = s_account2; + + Shortcut memory shortcut = + ShortcutsEthereum.getShortcut2(address(s_weth), address(s_ensoShortcutsHelpers), receiver, feeReceiver); + s_message.data = abi.encode(s_account1, shortcut.txData); + + bytes32 messageId = s_message.messageId; + + Client.EVMTokenAmount[] memory destTokenAmounts = new Client.EVMTokenAmount[](1); + destTokenAmounts[0] = Client.EVMTokenAmount({ token: address(s_weth), amount: shortcut.amountsIn[0] }); + + s_message.destTokenAmounts = destTokenAmounts; + + uint256 ccipReceiverBalanceEthBefore = balance(NATIVE_ASSET, address(s_ensoCcipReceiver)); + uint256 ccipReceiverBalanceWethBefore = balance(address(s_weth), address(s_ensoCcipReceiver)); + uint256 ensoShortcutsBalanceEthBefore = balance(NATIVE_ASSET, address(s_ensoShortcuts)); + uint256 ensoShortcutsBalanceWethBefore = balance(address(s_weth), address(s_ensoShortcuts)); + uint256 receiverBalanceEthBefore = balance(NATIVE_ASSET, receiver); + uint256 receiverBalanceWethBefore = balance(address(s_weth), receiver); + uint256 feeReceiverBalanceEthBefore = balance(NATIVE_ASSET, feeReceiver); + uint256 feeReceiverBalanceWethBefore = balance(address(s_weth), feeReceiver); + + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_weth.deposit{ value: shortcut.amountsIn[0] }(); + s_weth.transfer(address(s_ensoCcipReceiver), shortcut.amountsIn[0]); + vm.stopPrank(); + + // Act & Assert + // it should emit ShortcutExecutionSuccessful + vm.expectEmit(true, false, false, true); + emit IEnsoCCIPReceiver.ShortcutExecutionSuccessful(messageId); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it shoud update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + + // it should apply shortcut state changes + uint256 ccipReceiverBalanceEthAfter = balance(NATIVE_ASSET, address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceEthBefore, ccipReceiverBalanceEthAfter, 0, "EnsoCCIPReceiver tokenOut (ETH)" + ); + uint256 ccipReceiverBalanceWethAfter = balance(address(s_weth), address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceWethBefore, ccipReceiverBalanceWethAfter, 0, "EnsoCCIPReceiver tokenIn (WETH)" + ); + uint256 ensoShortcutsBalanceEthAfter = balance(NATIVE_ASSET, address(s_ensoShortcuts)); + assertBalanceDiff( + ensoShortcutsBalanceEthBefore, ensoShortcutsBalanceEthAfter, 0, "EnsoShortcuts tokenOut (ETH)" + ); + uint256 ensoShortcutsBalanceWethAfter = balance(address(s_weth), address(s_ensoShortcuts)); + assertBalanceDiff( + ensoShortcutsBalanceWethBefore, ensoShortcutsBalanceWethAfter, 0, "EnsoShortcuts tokenIn (WETH)" + ); + uint256 receiverBalanceEthAfter = balance(NATIVE_ASSET, receiver); + assertBalanceDiff( + receiverBalanceEthBefore, + receiverBalanceEthAfter, + int256(shortcut.amountsIn[0] - shortcut.fee), + "Receiver tokenOut (ETH)" + ); + uint256 receiverBalanceWethAfter = balance(address(s_weth), receiver); + assertBalanceDiff(receiverBalanceWethBefore, receiverBalanceWethAfter, 0, "Receiver tokenIn (WETH)"); + uint256 feeReceiverBalanceEthAfter = balance(NATIVE_ASSET, feeReceiver); + assertBalanceDiff(feeReceiverBalanceEthBefore, feeReceiverBalanceEthAfter, 0, "FeeReceiver tokenOut(ETH)"); + uint256 feeReceiverBalanceWethAfter = balance(address(s_weth), feeReceiver); + assertBalanceDiff( + feeReceiverBalanceWethBefore, feeReceiverBalanceWethAfter, int256(shortcut.fee), "FeeReceiver tokenIn(WETH)" + ); + } + + function test_WhenShortcutExecutionFailed() + external + whenCallerIsCcipRouter + whenMessageWasNotExecuted + whenMessageHasTokens + whenMessageHasSingleToken + whenMessageTokenAmountIsGtZero + whenMessageDataIsWellFormed + whenMessageDataReceiverIsNotZeroAddress + whenContractIsNotPaused + { + // Arrange + s_message.data = abi.encode(s_account1, "0xdeadbeef"); + + bytes32 messageId = s_message.messageId; + bytes memory errorData; + + address token = s_message.destTokenAmounts[0].token; + uint256 amount = s_message.destTokenAmounts[0].amount; + (address receiver,) = abi.decode(s_message.data, (address, bytes)); + + uint256 ccipReceiverBalanceTokenABefore = balance(token, address(s_ensoCcipReceiver)); + uint256 receiverBalanceTokenABefore = balance(token, receiver); + + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), amount); + vm.stopPrank(); + + // Act & Assert + // it should emit ShortcutExecutionFailed + vm.expectEmit(true, false, false, true); + emit IEnsoCCIPReceiver.ShortcutExecutionFailed(messageId, errorData); + vm.prank(s_caller); + s_ensoCcipReceiver.ccipReceive(s_message); + + // it shoud update executedMessage + assertTrue(s_ensoCcipReceiver.wasMessageExecuted(messageId)); + + // it should safe transfer token amount to receiver + uint256 ccipReceiverBalanceTokenAAfter = balance(token, address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenABefore, ccipReceiverBalanceTokenAAfter, 0, "EnsoCCIPReceiver tokenOut (TKNA)" + ); + uint256 receiverBalanceTokenAAfter = balance(token, receiver); + assertBalanceDiff( + receiverBalanceTokenABefore, receiverBalanceTokenAAfter, int256(amount), "Receiver tokenOut (TKNA)" + ); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/execute.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/execute.t.sol new file mode 100644 index 0000000..8adfd75 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/execute.t.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; +import { IEnsoCCIPReceiver } from "../../../../../src/interfaces/IEnsoCCIPReceiver.sol"; +import { Shortcut } from "../../../../shortcuts/ShortcutDataTypes.sol"; +import { TokenBalanceHelper } from "../../../../utils/TokenBalanceHelper.sol"; +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { SafeERC20 } from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; + +contract EnsoCCIP_ReceiverExecute_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test, TokenBalanceHelper { + address private s_caller; + + function test_RevertWhen_CallerIsNotSelf() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(IEnsoCCIPReceiver.EnsoCCIPReceiver_OnlySelf.selector)); + vm.prank(s_caller); + s_ensoCcipReceiver.execute(address(0), 0, ""); + } + + modifier whenCallerIsSelf() { + s_caller = address(s_ensoCcipReceiver); + _; + } + + function test_RevertWhen_ShortcutExecutionFailed() external whenCallerIsSelf { + // NOTE: force a shortcut failure by not providing allowance + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(SafeERC20.SafeERC20FailedOperation.selector, address(0))); + vm.prank(s_caller); + s_ensoCcipReceiver.execute(address(0), 0, "0xdeadbeef"); + } + + function test_WhenShortcutExecutionSucceeded() external whenCallerIsSelf { + // Arrange + address token = address(s_tokenA); + uint256 amount = 16 ether; + + uint256 ccipReceiverBalanceTokenABefore = balance(token, address(s_ensoCcipReceiver)); + uint256 ensoShortcutsBalanceTokenABefore = balance(token, address(s_ensoShortcuts)); + + // NOTE: transfer tokens to EnsoCCIPReceiver contract to simulate CCIP Router behavior + vm.startPrank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), amount); + vm.stopPrank(); + + // Act + // NOTE: this shortcut just transfers the tokenIn to the EnsoShortcuts contract + // it should apply shorcut state changes + vm.prank(s_caller); + s_ensoCcipReceiver.execute(address(s_tokenA), amount, ""); + + // Assert + uint256 ccipReceiverBalanceTokenAAfter = balance(token, address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenABefore, ccipReceiverBalanceTokenAAfter, 0, "EnsoCCIPReceiver tokenIn (TKNA)" + ); + uint256 ensoShortcutsBalanceTokenAAfter = balance(token, address(s_ensoShortcuts)); + assertBalanceDiff( + ensoShortcutsBalanceTokenABefore, + ensoShortcutsBalanceTokenAAfter, + int256(amount), + "EnsoShortcuts tokenIn (TKNA)" + ); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/getEnsoRouter.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/getEnsoRouter.t.sol new file mode 100644 index 0000000..49212ad --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/getEnsoRouter.t.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; + +contract EnsoCCIPReceiver_GetEnsoRouter_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_ShouldReturnEnsoRouterAddress() external { + // Act & Assert + // it should return EnsoRouter address + vm.prank(s_account1); + assertEq(s_ensoCcipReceiver.getEnsoRouter(), address(s_ensoRouter)); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/recoverTokens.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/recoverTokens.t.sol new file mode 100644 index 0000000..26d3007 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/recoverTokens.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; +import { IEnsoCCIPReceiver } from "../../../../../src/interfaces/IEnsoCCIPReceiver.sol"; +import { TokenBalanceHelper } from "../../../../utils/TokenBalanceHelper.sol"; +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { Ownable } from "openzeppelin-contracts/access/Ownable.sol"; + +contract EnsoCCIPReceiver_RecoverTokens_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test, TokenBalanceHelper { + function test_RevertWhen_CallerIsNotOwner() external { + // Act & Assert + // it should revert + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, s_account1)); + vm.prank(s_account1); + s_ensoCcipReceiver.recoverTokens(address(0), address(0), 0); + } + + function test_WhenCallerIsOwner() external { + // Arrange + address token = address(s_tokenA); + address recipient = s_account2; + uint256 amount = 42 ether; + + vm.prank(s_deployer); + s_tokenA.transfer(address(s_ensoCcipReceiver), amount); + + uint256 ccipReceiverBalanceTokenABefore = balance(token, address(s_ensoCcipReceiver)); + uint256 recipientBalanceTokenABefore = balance(token, recipient); + + // Act & Assert + // it should emit TokensRecovered + vm.expectEmit(true, false, false, true); + emit IEnsoCCIPReceiver.TokensRecovered(token, recipient, amount); + vm.prank(s_owner); + s_ensoCcipReceiver.recoverTokens(token, recipient, amount); + + // it should safe transfer amount to recipient + uint256 ccipReceiverBalanceTokenAAfter = balance(token, address(s_ensoCcipReceiver)); + assertBalanceDiff( + ccipReceiverBalanceTokenABefore, + ccipReceiverBalanceTokenAAfter, + -int256(amount), + "EnsoCCIPReceiver token (TKNA)" + ); + uint256 recipientBalanceTokenAAfter = balance(token, recipient); + assertBalanceDiff( + recipientBalanceTokenABefore, recipientBalanceTokenAAfter, int256(amount), "Recipient token (TKNA)" + ); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/supportsInterface.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/supportsInterface.t.sol new file mode 100644 index 0000000..7e98415 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/supportsInterface.t.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver } from "../../../../../src/bridge/EnsoCCIPReceiver.sol"; +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; +import { IAny2EVMMessageReceiver } from "chainlink-ccip/interfaces/IAny2EVMMessageReceiver.sol"; +import { IERC165 } from "openzeppelin-contracts/utils/introspection/IERC165.sol"; + +contract EnsoCCIPReceiver_SupportsInterface_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_WhenInterfaceIdIsTypeIAny2EVMMessageReceiver() external { + // Act & Assert + // it should return true + vm.prank(s_account1); + assertTrue(s_ensoCcipReceiver.supportsInterface(s_ensoCcipReceiver.ccipReceive.selector)); + } + + modifier whenInterfaceIdIsNotTypeIAny2EVMMessageReceiver() { + _; + } + + function test_WhenInterfaceIdIsTypeIERC165() external whenInterfaceIdIsNotTypeIAny2EVMMessageReceiver { + // Act & Assert + // it should return true + vm.prank(s_account1); + assertTrue(s_ensoCcipReceiver.supportsInterface(s_ensoCcipReceiver.supportsInterface.selector)); + } + + function test_WhenInterfaceIdIsNotTypeIERC165() external whenInterfaceIdIsNotTypeIAny2EVMMessageReceiver { + // Act & Assert + // it should return false + vm.prank(s_account1); + assertFalse(s_ensoCcipReceiver.supportsInterface(bytes4(hex"FFFFFFFF"))); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/typeAndVersion.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/typeAndVersion.t.sol new file mode 100644 index 0000000..6139f49 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/typeAndVersion.t.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; + +contract EnsoCCIPReceiver_TypeAndVersion_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_ShouldReturnCurrentTypeAndSemanticVersion() external { + // Act & Assert + // it should return current type and semantic version + vm.prank(s_account1); + assertEq(keccak256(bytes(s_ensoCcipReceiver.typeAndVersion())), keccak256(bytes("EnsoCCIPReceiver 1.0.0"))); + } +} diff --git a/test/unit/concrete/bridge/ensoCCIPReceiver/wasMessageExecuted.t.sol b/test/unit/concrete/bridge/ensoCCIPReceiver/wasMessageExecuted.t.sol new file mode 100644 index 0000000..a19c650 --- /dev/null +++ b/test/unit/concrete/bridge/ensoCCIPReceiver/wasMessageExecuted.t.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.28; + +import { EnsoCCIPReceiver_Unit_Concrete_Test } from "./EnsoCCIPReceiver.t.sol"; + +contract EnsoCCIPReceiver_WasMessageExecuted_Unit_Concrete_Test is EnsoCCIPReceiver_Unit_Concrete_Test { + function test_ShouldReturnWhetherMessageWasExecuted() external { + // Act & Assert + // it should return whether message was executed + vm.prank(s_account1); + assertFalse(s_ensoCcipReceiver.wasMessageExecuted(bytes32(uint256(1)))); + } +} diff --git a/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol b/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol index dcf33bc..05d9c3a 100644 --- a/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol +++ b/test/unit/concrete/delegate/ensoReceiver/safeExecute.t.sol @@ -191,8 +191,9 @@ contract EnsoReceiver_SafeExecute_SenderIsEntryPoint_Unit_Concrete_Test is whenTokenInIsNotNativeToken { // Get shortcut - Shortcut memory shortcut = - ShortcutsEthereum.getShortcut2(address(s_weth), address(s_ensoShortcutsHelpers), s_owner); + Shortcut memory shortcut = ShortcutsEthereum.getShortcut2( + address(s_weth), address(s_ensoShortcutsHelpers), s_owner, 0x6AA68C46eD86161eB318b1396F7b79E386e88676 + ); // Top-up EnsoReceiver with WETH (as EntryPoint) s_weth.deposit{ value: shortcut.amountsIn[0] }(); // NOTE: as EntryPoint @@ -211,7 +212,9 @@ contract EnsoReceiver_SafeExecute_SenderIsEntryPoint_Unit_Concrete_Test is { // Get shortcut // NOTE: force shortcut failure by replacing `EnsoShortcutsHelpers` address with Zero address - Shortcut memory shortcut = ShortcutsEthereum.getShortcut2(address(s_weth), address(0), s_owner); + Shortcut memory shortcut = ShortcutsEthereum.getShortcut2( + address(s_weth), address(0), s_owner, 0x6AA68C46eD86161eB318b1396F7b79E386e88676 + ); // Top-up EnsoReceiver with WETH (as EntryPoint) s_weth.deposit{ value: shortcut.amountsIn[0] }(); // NOTE: as EntryPoint @@ -438,8 +441,9 @@ contract EnsoReceiver_SafeExecute_SenderIsOwner_Unit_Concrete_Test is whenTokenInIsNotNativeToken { // Get shortcut - Shortcut memory shortcut = - ShortcutsEthereum.getShortcut2(address(s_weth), address(s_ensoShortcutsHelpers), s_owner); + Shortcut memory shortcut = ShortcutsEthereum.getShortcut2( + address(s_weth), address(s_ensoShortcutsHelpers), s_owner, 0x6AA68C46eD86161eB318b1396F7b79E386e88676 + ); // Top-up EnsoReceiver with WETH (as owner) s_weth.deposit{ value: shortcut.amountsIn[0] }(); // NOTE: as owner @@ -458,7 +462,9 @@ contract EnsoReceiver_SafeExecute_SenderIsOwner_Unit_Concrete_Test is { // Get shortcut // NOTE: force shortcut failure by replacing `EnsoShortcutsHelpers` address with Zero address - Shortcut memory shortcut = ShortcutsEthereum.getShortcut2(address(s_weth), address(0), s_owner); + Shortcut memory shortcut = ShortcutsEthereum.getShortcut2( + address(s_weth), address(0), s_owner, 0x6AA68C46eD86161eB318b1396F7b79E386e88676 + ); // Top-up EnsoReceiver with WETH (as owner) s_weth.deposit{ value: shortcut.amountsIn[0] }(); // NOTE: as owner From 0526e61e6e1a5a09d2e1b18c34c135d04673b0dd Mon Sep 17 00:00:00 2001 From: vnavascues Date: Fri, 14 Nov 2025 16:01:41 +0100 Subject: [PATCH 19/21] chore: make sure MessageQuarantined is informational --- src/interfaces/IEnsoCCIPReceiver.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol index 0f0126a..3ceafea 100644 --- a/src/interfaces/IEnsoCCIPReceiver.sol +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -50,6 +50,8 @@ interface IEnsoCCIPReceiver { event MessageValidationFailed(bytes32 indexed messageId, ErrorCode indexed errorCode, bytes errorData); /// @notice Funds were quarantined in the receiver instead of delivered to the payload receiver. + /// @dev CCIP currently delivers at most one ERC-20 per message, so we log the single token/amount + /// pair (best-effort, informational) rather than arrays. /// @param messageId The CCIP message id. /// @param code The validation error that triggered quarantine. /// @param token ERC-20 token retained. From a419b1cab6a54b91ce8bd28ccf80327d8a767f21 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Mon, 1 Dec 2025 21:46:12 +0100 Subject: [PATCH 20/21] docs: pre-audit doc & amendments --- audits/README.audit.md | 902 +++++++++++++++++++++++++++ src/bridge/EnsoCCIPReceiver.sol | 6 +- src/interfaces/IEnsoCCIPReceiver.sol | 2 +- 3 files changed, 906 insertions(+), 4 deletions(-) create mode 100644 audits/README.audit.md diff --git a/audits/README.audit.md b/audits/README.audit.md new file mode 100644 index 0000000..b18a4d0 --- /dev/null +++ b/audits/README.audit.md @@ -0,0 +1,902 @@ +# Audit README: Enso CCIP Receiver + +**Project:** Enso Shortcuts Client Contracts +**Component:** EnsoCCIPReceiver & Chainlink CCIP Integration +**Version:** 1.0.0 +**Audit Date:** [TBD] +**PR:** [#60](https://github.com/EnsoBuild/shortcuts-client-contracts/pull/60) +**Commit:** [0526e61](https://github.com/EnsoBuild/shortcuts-client-contracts/commit/0526e61e6e1a5a09d2e1b18c34c135d04673b0dd) + +--- + +## Project Overview + +The Enso CCIP Receiver is a destination-side contract that integrates Enso +Shortcuts with Chainlink's Cross-Chain Interoperability Protocol (CCIP). It +serves as a bridge endpoint that receives cross-chain messages containing ERC-20 +tokens and executes Enso Shortcuts operations on the destination chain. + +### Purpose + +Enso Shortcuts is a composable DeFi routing system that enables complex +multi-protocol operations in a single transaction. The CCIP Receiver allows +users to initiate Shortcuts operations cross-chain by: + +1. Sending tokens via CCIP from a source chain +2. Receiving tokens and execution data on the destination chain +3. Automatically routing tokens through Enso Shortcuts to execute complex DeFi + operations + +### Key Features + +- **Replay Protection**: Message ID-based idempotency to prevent duplicate + executions +- **Defensive Error Handling**: Non-reverting error handling with + refund/quarantine mechanisms +- **Message Validation**: Strict validation of token shape (exactly one ERC-20 + with non-zero amount) +- **Safe Payload Decoding**: Non-reverting ABI decoding with comprehensive + validation +- **Graceful Degradation**: Funds are safely handled even when execution fails + +--- + +## Audit Scope + +### In-Scope Contracts + +| File | Lines | Description | +| --------------------------------------- | ----- | ------------------------------------------------------- | +| `src/bridge/EnsoCCIPReceiver.sol` | 230 | Main receiver contract implementing CCIP callback logic | +| `src/interfaces/IEnsoCCIPReceiver.sol` | 125 | Interface definition with error codes and events | +| `src/interfaces/ITypeAndVersion.sol` | 10 | Versioning interface | +| `src/libraries/CCIPMessageDecoder.sol` | 82 | Safe ABI decoder for CCIP message payloads | +| `script/EnsoCCIPReceiverDeployer.s.sol` | 97 | Deployment script with multi-chain configuration | + +**Total Lines of Code:** ~544 SLOC + +### Out-of-Scope + +- Enso Router implementation (`src/router/EnsoRouter.sol`) +- Enso Shortcuts core (`src/EnsoShortcuts.sol`) +- Chainlink CCIP Router contracts (external dependency) +- Deployment infrastructure beyond the deployer script +- Off-chain message construction and sending logic +- Upstream Enso Shortcuts functionality (assumed to work correctly) + +### Dependencies + +- **Chainlink CCIP**: `chainlink-ccip` package (v1.6.2) - CCIPReceiver base + contract +- **OpenZeppelin Contracts**: Access control (Ownable2Step), Pausable, SafeERC20 +- **Enso Router**: Interface only - execution engine for Shortcuts + +--- + +## Architecture & Components + +### High-Level Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Source Chain β”‚ +β”‚ β”‚ +β”‚ CCIP Router │──────CCIP Message──────┐ +β”‚ (Chainlink) β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Destination β”‚ + β”‚ Chain β”‚ + β”‚ β”‚ + β”‚ CCIP Router β”‚ + β”‚ (Chainlink) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ _ccipReceive() + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ EnsoCCIPReceiverβ”‚ + β”‚ β”‚ + β”‚ 1. Validate β”‚ + β”‚ 2. Decode β”‚ + β”‚ 3. Execute β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ routeSingle() + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Enso Router β”‚ + β”‚ β”‚ + β”‚ Executes β”‚ + β”‚ Shortcuts β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Component Breakdown + +#### 1. EnsoCCIPReceiver (`EnsoCCIPReceiver.sol`) + +The main contract that implements the CCIP receiver interface. It inherits from: + +- **CCIPReceiver**: Provides router gating and base CCIP functionality +- **Ownable2Step**: Two-step ownership transfer for security +- **Pausable**: Emergency pause mechanism +- **ITypeAndVersion**: Contract versioning + +**Key State Variables:** + +- `i_ensoRouter` (immutable): Enso Router address for executing Shortcuts +- `s_executedMessage` (mapping): Replay protection tracking + +**Core Functions:** + +- `_ccipReceive()`: Main entry point called by CCIP Router +- `execute()`: Self-callable function that routes tokens to Enso Router +- `pause()`/`unpause()`: Emergency controls +- `recoverTokens()`: Owner-only token recovery for quarantined funds + +#### 2. CCIPMessageDecoder (`CCIPMessageDecoder.sol`) + +Library that safely decodes CCIP message payloads without reverting. The payload +format is: + +``` +abi.encode(address receiver, bytes shortcutData) +``` + +**Key Properties:** + +- Non-reverting: Returns `(success, receiver, shortcutData)` tuple +- Memory-safe assembly: Uses memory-safe assembly for efficient decoding +- Comprehensive validation: Checks alignment, bounds, and format + +**Validation Checks:** + +- Minimum length (96 bytes) +- Word alignment +- Offset bounds checking +- Length validation +- Overflow protection + +#### 3. IEnsoCCIPReceiver (`IEnsoCCIPReceiver.sol`) + +Interface defining the contract's external API and error classification system. + +**Error Codes:** + +- `NO_ERROR`: Message is valid and ready for execution +- `ALREADY_EXECUTED`: Message ID was previously processed (idempotent) +- `NO_TOKENS`: No tokens delivered +- `TOO_MANY_TOKENS`: More than one token delivered (not supported by Chainlink + CCIP) +- `NO_TOKEN_AMOUNT`: Token amount is zero +- `MALFORMED_MESSAGE_DATA`: Payload cannot be decoded +- `ZERO_ADDRESS_RECEIVER`: Decoded receiver is zero address +- `PAUSED`: Contract is paused + +**Refund Policies:** + +- `NONE`: No action (for idempotent no-ops) +- `TO_RECEIVER`: Refund directly to decoded receiver +- `TO_ESCROW`: Quarantine funds in contract (for malformed messages) + +--- + +## Trust Model & Assumptions + +### Trusted Components + +1. **Chainlink CCIP Router**: + - Trusted to only call `_ccipReceive()` from authorized router address + - Trusted to deliver tokens as specified in `destTokenAmounts` + - Trusted message ID uniqueness and authenticity + +2. **Enso Router**: + - Trusted to execute Shortcuts correctly + - Trusted to handle token approvals safely + +### Trust Assumptions + +1. **Message Format**: Sender constructs valid `abi.encode(address, bytes)` + payload +2. **Token Standard**: Only ERC-20 tokens are delivered (no native ETH) +3. **Single Token**: CCIP currently delivers at most one token per message +4. **Router Immutability**: Enso Router address is immutable after deployment + +### Untrusted Inputs + +- **CCIP Message Payload** (`_message.data`): Must be validated and decoded + safely +- **Token Addresses**: Delivered tokens could be malicious or non-standard +- **Message IDs**: Uniqueness assumed but replay protection enforced +- **Receiver Address**: Decoded from untrusted payload + +--- + +## Roles & Permissions + +### Owner + +The contract owner has the following capabilities: + +1. **Pause/Unpause** (`pause()`, `unpause()`): + - Can pause contract to stop all new message processing + - When paused, messages are refunded to receiver instead of executing + - Two-step ownership transfer prevents accidental loss of control + +2. **Token Recovery** (`recoverTokens()`): + - Can recover any ERC-20 tokens held by the contract + - Primary mechanism for recovering quarantined funds + - Emits `TokensRecovered` event for transparency + +3. **Ownership Transfer** (`transferOwnership()`, `acceptOwnership()`, + `renounceOwnership()`): + - Two-step process for security + - Can renounce ownership (irreversible) + +**Security Considerations:** + +- Owner has significant power but cannot steal correctly routed funds +- Owner cannot modify router addresses (immutable) +- Two-step ownership prevents accidental transfers + +### CCIP Router + +- **Can Call**: `_ccipReceive()` (via CCIPReceiver base contract) +- **Access Control**: Enforced by CCIPReceiver's router check +- **Limitations**: Cannot call other functions directly + +### Self (Contract) + +- **Can Call**: `execute()` (self-call only) +- **Purpose**: Enables try/catch pattern for error handling (avoid reverting is + recommended by Chainlink CCIP) +- **Access Control**: `msg.sender == address(this)` check + +### Public + +- **View Functions**: `getEnsoRouter()`, `wasMessageExecuted()`, + `typeAndVersion()` +- **No State-Modifying Functions**: All state changes are permissioned + +--- + +## Core Flows / State Machines + +### Message Processing Flow + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ _ccipReceive() Entry Point β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ _validateMessage() β”‚ + β”‚ β”‚ + β”‚ 1. Replay Check β”‚ + β”‚ 2. Token Shape β”‚ + β”‚ 3. Payload Decode β”‚ + β”‚ 4. Receiver Check β”‚ + β”‚ 5. Pause Check β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ + β–Ό β–Ό + errorCode != errorCode == + NO_ERROR NO_ERROR + β”‚ β”‚ + β”‚ β”‚ + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Handle Error β”‚ β”‚ Execute Path β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ 1. Emit Event β”‚ β”‚ 1. Mark Done β”‚ +β”‚ 2. Refund β”‚ β”‚ 2. Try Exec β”‚ +β”‚ Policy β”‚ β”‚ 3. Handle β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ Revert β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Complete β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Error Handling State Machine + +**Simplified Flow Diagram:** + +``` + Message Received + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ _validateMessage() β”‚ + β”‚ (Sequential Checks) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό + ALREADY_EXECUTED NO_ERROR Error Codes + β”‚ β”‚ (See table) + β”‚ β”‚ β”‚ + β–Ό β”‚ β”‚ + NONE (no-op) β”‚ β”‚ + β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Refund Policy β”‚ + β”‚ Selection β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό + NONE TO_RECEIVER TO_ESCROW + (no-op) (Refund) (Quarantine) + β”‚ β”‚ β”‚ + β”‚ β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Complete β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Complete Error Code β†’ Refund Policy Mapping:** + +| Error Code | Validation Stage | Refund Policy | Action | Notes | +| ------------------------ | -------------------- | ------------- | ------------------ | ----------------------------------------------- | +| `NO_ERROR` | All checks pass | NONE | Execute Shortcut | Proceeds to execution path | +| `ALREADY_EXECUTED` | Replay check | NONE | Idempotent no-op | Prevents duplicate processing, no state change | +| `PAUSED` | Environment check | TO_RECEIVER | Refund to receiver | Receiver trusted (payload decoded successfully) | +| `NO_TOKENS` | Token shape (step 1) | TO_ESCROW | Quarantine | No tokens delivered, cannot refund | +| `TOO_MANY_TOKENS` | Token shape (step 2) | TO_ESCROW | Quarantine | Not supported by CCIP protocol | +| `NO_TOKEN_AMOUNT` | Token shape (step 3) | TO_ESCROW | Quarantine | Zero amount invalid | +| `MALFORMED_MESSAGE_DATA` | Payload decode | TO_ESCROW | Quarantine | Cannot decode, receiver untrusted | +| `ZERO_ADDRESS_RECEIVER` | Receiver validation | TO_ESCROW | Quarantine | Invalid receiver address | + +**Validation Sequence (as executed in `_validateMessage()`):** + +1. **Replay Protection** β†’ Check `s_executedMessage[messageId]` + - If true: Return `ALREADY_EXECUTED` (NONE refund) +2. **Token Shape Validation** β†’ Check `destTokenAmounts` + - If empty: Return `NO_TOKENS` (TO_ESCROW) + - If length > 1: Return `TOO_MANY_TOKENS` (TO_ESCROW) + - If amount == 0: Return `NO_TOKEN_AMOUNT` (TO_ESCROW) +3. **Payload Decoding** β†’ Decode `(address, bytes)` from `data` + - If decode fails: Return `MALFORMED_MESSAGE_DATA` (TO_ESCROW) +4. **Receiver Validation** β†’ Check decoded receiver address + - If zero address: Return `ZERO_ADDRESS_RECEIVER` (TO_ESCROW) +5. **Environment Check** β†’ Check pause status + - If paused: Return `PAUSED` (TO_RECEIVER) +6. **Success** β†’ All checks pass + - Return `NO_ERROR` β†’ Proceed to execution + +**Key Design Decisions:** + +- **PAUSED β†’ TO_RECEIVER**: Only error that refunds to receiver because payload + is successfully decoded, so receiver address is trusted +- **All others β†’ TO_ESCROW**: Either payload decoding failed (receiver + untrusted) or message shape is invalid (no valid receiver) +- **ALREADY_EXECUTED β†’ NONE**: Idempotent no-op; no refund needed as message was + already handled + +### Message Lifecycle States + +1. **Unprocessed**: Message ID not in `s_executedMessage` +2. **Processing**: Inside `_ccipReceive()` execution +3. **Executed**: Marked in `s_executedMessage` mapping + - **Sub-states**: + - Successfully executed + - Refunded to receiver + - Quarantined in contract + +**State Transitions:** + +- Unprocessed β†’ Processing: CCIP Router calls `_ccipReceive()` +- Processing β†’ Executed (Success): Shortcut execution succeeds +- Processing β†’ Executed (Refund): Error or revert triggers refund +- Processing β†’ Executed (Quarantine): Malformed message quarantined +- Processing β†’ Unprocessed: **Never** - replay protection prevents + +### Replay Protection Mechanism + +``` +Message ID β†’ s_executedMessage mapping + β”‚ + β”œβ”€ false: Process message + └─ true: Return immediately (idempotent no-op) +``` + +**Properties:** + +- Once a message ID is marked as executed, it can never be processed again +- Prevents duplicate executions from CCIP retries or reorgs +- Applied before any state changes or token transfers + +--- + +## Invariants & Safety Properties + +### Critical Invariants + +1. **Replay Protection Invariant**: + + ``` + For all messageId: if s_executedMessage[messageId] == true, + then that messageId will never execute again + ``` + +2. **No Token Loss Invariant**: + + ``` + For all messages: tokens are either: + - Successfully routed to Enso Router + - Refunded to receiver + - Quarantined in contract (recoverable by owner) + ``` + +3. **Immutable Router Invariant**: + + ``` + i_ensoRouter address cannot change after deployment + ``` + +4. **Immutable CCIP Router Invariant**: + + ``` + CCIP Router address (passed to CCIPReceiver base) cannot change after deployment + ``` + + The CCIP Router address is set in the constructor via + `CCIPReceiver(_ccipRouter)` and stored as immutable in the base contract. + This ensures the receiver only accepts messages from the authorized Chainlink + CCIP Router. + +5. **Non-Reverting Invariant** (Critical CCIP Requirement): + + ``` + _ccipReceive() must NEVER revert; all errors must be handled gracefully + ``` + + This is a fundamental requirement for CCIP receivers. If `_ccipReceive()` + reverts, it can cause critical issues with the CCIP protocol's message + delivery system. All error paths must: + - Use try/catch for external calls + - Handle errors with refund/quarantine policies + - Emit events instead of reverting + - Never revert on validation failures + + This invariant is why the contract uses: + - Non-reverting payload decoder + (`CCIPMessageDecoder._tryDecodeMessageData()`) + - Try/catch pattern for Shortcuts execution (`try this.execute(...)`) + - Defensive error handling with refund/quarantine + - No require/revert statements in `_ccipReceive()` error paths + +6. **Idempotency Invariant**: + + ``` + Processing the same messageId multiple times has the same effect + as processing it once (idempotent) + ``` + +7. **Pause Invariant**: + ``` + When paused, no new messages execute; all refunded to receiver + ``` + +### Safety Properties + +1. **Non-Reverting Error Handling** (Critical CCIP Requirement): + - `_ccipReceive()` **must never revert** - this is a fundamental CCIP + protocol requirement + - Reverting from `_ccipReceive()` can cause protocol-level issues with + message delivery + - All errors are handled gracefully with appropriate refund/quarantine + policies + - This is why the contract uses non-reverting decoders, try/catch patterns, + and defensive error handling + - The entire error handling architecture (ErrorCode enum, RefundKind + policies) exists to satisfy this invariant + +2. **Safe Token Transfers**: + - Uses `SafeERC20` for all token operations + - Handles non-standard ERC-20 implementations + +3. **Boundary Checks**: + - Payload length validation prevents buffer overflows + - Offset validation prevents out-of-bounds access + - Amount validation prevents zero transfers + +4. **Access Control**: + - Router-only execution enforced by CCIPReceiver + - Self-call only for `execute()` function + - Owner-only for administrative functions + +5. **Defensive Decoding**: + - Non-reverting decoder prevents malicious payload crashes + - Comprehensive validation of ABI structure + - Safe assembly with overflow checks + +### Failure Modes + +1. **Enso Router Reverts**: + - Caught by try/catch + - Tokens refunded to receiver + - Event emitted for monitoring + +2. **Token Transfer Fails**: + - SafeERC20 will revert (caught by try/catch) + - Funds remain in contract (recoverable) + +3. **Malformed Payload**: + - Decoder returns failure + - Funds quarantined in contract + - Owner can recover via `recoverTokens()` + +4. **Contract Paused**: + - Validation returns `PAUSED` error + - Funds refunded to receiver immediately + - No execution attempted + +--- + +## Upgradability & Admin Controls + +### Upgradeability Model + +The contract is NOT upgradeable. + +### Admin Controls + +1. **Pause Mechanism**: + - **Function**: `pause()`, `unpause()` + - **Access**: Owner only + - **Effect**: Stops all new message processing + - **Refund Policy**: Messages refunded to receiver when paused + +2. **Token Recovery**: + - **Function**: `recoverTokens(address, address, uint256)` + - **Access**: Owner only + - **Purpose**: Recover quarantined or accidentally sent tokens + - **Event**: `TokensRecovered` for transparency + +3. **Ownership Management**: + - **Two-Step Transfer**: Prevents accidental loss + - **Renounce**: Owner can renounce ownership (irreversible) + - **Security**: Prevents accidental transfers to wrong address + +### Admin Risk Assessment + +**Low Risk:** + +- Cannot modify execution logic +- Cannot change router address +- Cannot bypass validation + +**Medium Risk:** + +- Can pause contract (legitimate emergency action) +- Can recover tokens (requires off-chain coordination for quarantined funds) + +**Mitigations:** + +- Two-step ownership transfer +- Events for all admin actions +- Clear refund policies for paused state + +--- + +## External Integrations & Dependencies + +### Chainlink CCIP + +**Dependency**: `chainlink-ccip/applications/CCIPReceiver.sol` +**Version**: 1.6.2 **Source**: `dependencies/chainlink-ccip-1.6.2/` + +All dependency versions and paths can be verified in `remappings.txt`. + +**Integration Points:** + +- Inherits from `CCIPReceiver` base contract +- Implements `_ccipReceive()` callback +- Uses `Client.Any2EVMMessage` data structure +- Router gating enforced by base contract + +**Assumptions:** + +- CCIP Router correctly calls authorized receiver +- Message IDs are unique across all chains +- Token delivery matches `destTokenAmounts` specification +- Router address remains constant (or changes require redeployment) + +**Risk:** + +- CCIP protocol bugs could affect receiver +- Router compromise could send malicious messages +- Network congestion could delay message delivery + +### Enso Router + +**Dependency**: `src/router/EnsoRouter.sol` (in-repo, not external) +**Interface**: `src/interfaces/IEnsoRouter.sol` + +The Enso Router is part of this repository (`shortcuts-client-contracts`), not +an external dependency. The receiver only uses the `IEnsoRouter` interface to +interact with it. + +**Integration Points:** + +- Calls `routeSingle(Token, bytes)` function +- Uses `forceApprove()` before routing +- Shortcuts execution could revert unexpectedly, but router is permissionless + +**Note**: The Enso Router implementation itself is out of scope for this audit. +Only the interface usage and integration are relevant. + +### OpenZeppelin Contracts + +**Dependency**: `openzeppelin-contracts/` +**Version**: 5.2.0 **Source**: `dependencies/@openzeppelin-contracts-5.2.0/` + +**Contracts Used:** + +- `Ownable2Step`: Two-step ownership transfer +- `Pausable`: Emergency pause functionality +- `SafeERC20`: Safe token transfer wrapper + +**Risk**: Low - well-audited, battle-tested libraries + +### Verifying Dependencies + +All dependency versions, source locations, and remappings can be verified by +examining: + +- `remappings.txt` - Contains all import remappings and dependency paths +- `foundry.toml` (dependencies section) - Contains git sources and versions +- `dependencies/` directory - Local copies of all dependencies + +Key remappings for this audit: + +- `chainlink-ccip=dependencies/chainlink-ccip-1.6.2/chains/evm/contracts/` +- `openzeppelin-contracts=dependencies/@openzeppelin-contracts-5.2.0/contracts/` + +### Message Payload Format + +**Expected Format**: + +```solidity +abi.encode( + address receiver, // Destination for refunds + bytes shortcutData // Enso Shortcuts execution data +) +``` + +**Decoding**: Handled by `CCIPMessageDecoder._tryDecodeMessageData()` + +**Validation**: + +- Minimum 96 bytes (address + offset + length word) +- Word-aligned offsets +- Bounds checking +- Length validation + +--- + +## Known Issues, Non-Goals, and Out-of-Scope + +### Known Issues + +1. **Single Token Limitation**: + - Currently supports only one ERC-20 token per message + - Multiple tokens cause quarantine (by design) + - Future: Could be extended if CCIP adds multi-token support + +2. **No Gas Validation**: + - No gas estimation or validation is performed on-chain + - Gas estimation and limits must be handled off-chain when constructing + messages + - Execution may fail if insufficient gas is provided by CCIP Router + +3. **No Native ETH Support**: + - Only ERC-20 tokens are supported + - Native ETH deliveries would cause issues + +4. **Quarantine Recovery**: + - Quarantined funds require manual owner intervention + - No automatic recovery mechanism + - Owner must identify and recover funds off-chain + +### Non-Goals + +1. **Multi-Token Support**: + - Not supported by CCIP protocol currently + - Would require significant redesign + +2. **Cross-Chain State Verification**: + - No verification of source chain state + - Relies entirely on CCIP message delivery + +3. **Rate Limiting**: + - No built-in rate limiting + - Relies on CCIP Router and network congestion + +4. **Gas Optimization**: + - Not a primary concern for this receiver + - Focus is on safety and correctness + +### Out-of-Scope + +1. **Enso Router Security**: + - Assumed to be secure and correct + - Not part of this audit scope + +2. **Message Construction**: + - Off-chain message building not audited + - Sender responsibility to create valid payloads + +3. **CCIP Protocol Itself**: + - Chainlink's CCIP protocol not audited + - Assumed to work correctly + +4. **Frontend/UI**: + - User-facing interfaces out of scope + - Focus on smart contract security only + +--- + +## Testing & Verification + +### Test Coverage + +**Location**: `test/unit/concrete/bridge/ensoCCIPReceiver/` + +**Test Structure**: Branching Tree Technique (BTT) with Bulloak, and fuzz tests + +### Running Tests + +```bash +# Run all CCIP-related unit tests +pnpm test:enso_ccip:unit + +# Run specific test file +forge test --match-path 'test/unit/concrete/bridge/ensoCCIPReceiver/*.t.sol' + +# Run with verbose output +forge test -vvv +``` + +--- + +## Deployment & Network Info + +### Deployment Script + +**File**: `script/EnsoCCIPReceiverDeployer.s.sol` + +**Parameters**: + +- `owner`: Contract owner address +- `ccipRouter`: Chainlink CCIP Router address (chain-specific) +- `ensoRouter`: Enso Router address (chain-specific) + +### Supported Chains + +The deployer script supports the following chains (as of current commit): + +| Chain | Chain ID | CCIP Router | Enso Router | +| --------- | -------- | -------------------------------------------- | -------------------------------------------- | +| Ethereum | `1` | `0x80226fc0Ee2b096224EeAc085Bb9a8cba1146f7D` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Optimism | `10` | `0x3206695CaE29952f4b0c22a169725a865bc8Ce0f` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Binance | `56` | `0x34B03Cb9086d7D758AC55af71584F81A598759FE` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Gnosis | `100` | `0x4aAD6071085df840abD9Baf1697d5D5992bDadce` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Unichain | `130` | `0x68891f5F96695ECd7dEdBE2289D1b73426ae7864` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Polygon | `137` | `0x849c5ED5a80F5B408Dd4969b78c2C8fdf0565Bfe` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Sonic | `146` | `0xB4e1Ff7882474BB93042be9AD5E1fA387949B860` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| zkSync | `324` | `0x748Fd769d81F5D94752bf8B0875E9301d0ba71bB` | `0x1BD8CefD703CF6b8fF886AD2E32653C32bc62b5C` | +| World | `480` | `0x5fd9E4986187c56826A3064954Cfa2Cf250cfA0f` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Hyper | `999` | `0x13b3332b66389B1467CA6eBd6fa79775CCeF65ec` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Base | `8453` | `0x881e3A65B4d4a04dD529061dd0071cf975F58bCD` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Plasma | `9745` | `0xcDca5D374e46A6DDDab50bD2D9acB8c796eC35C3` | `0xCfBAa9Cfce952Ca4F4069874fF1Df8c05e37a3c7` | +| Arbitrum | `42161` | `0x141fa059441E0ca23ce184B6A78bafD2A517DdE8` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Avalanche | `43114` | `0xF4c7E640EdA248ef95972845a62bdC74237805dB` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Ink | `57073` | `0xca7c90A52B44E301AC01Cb5EB99b2fD99339433A` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Linea | `59144` | `0x549FEB73F2348F6cD99b9fc8c69252034897f06C` | `0xA146d46823f3F594B785200102Be5385CAfCE9B5` | +| Berachain | `80094` | `0x71a275704c283486fBa26dad3dd0DB78804426eF` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | +| Plume | `98866` | `0x5C4f4622AD0EC4a47e04840db7E9EcA8354109af` | `0x3067BDBa0e6628497d527bEF511c22DA8b32cA3F` | +| Katana | `747474` | `0x7c19b79D2a054114Ab36ad758A36e92376e267DA` | `0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf` | + +**Note**: Most chains use the standard Enso Router address +`0xF75584eF6673aD213a685a1B58Cc0330B8eA22Cf`. The following chains use different +Enso Router addresses: + +- **zkSync** (`324`): `0x1BD8CefD703CF6b8fF886AD2E32653C32bc62b5C` +- **Plasma** (`9745`): `0xCfBAa9Cfce952Ca4F4069874fF1Df8c05e37a3c7` +- **Linea** (`59144`): `0xA146d46823f3F594B785200102Be5385CAfCE9B5` +- **Plume** (`98866`): `0x3067BDBa0e6628497d527bEF511c22DA8b32cA3F` + +### Deployment Steps + +1. **Set Environment Variables**: + + ```bash + export PRIVATE_KEY= + ``` + +2. **Update Owner Address**: + - Modify deployer script or pass as parameter + - **TODO**: Currently hardcoded check fails - needs owner address set + +3. **Deploy**: + + ```bash + forge script EnsoCCIPReceiverDeployer --broadcast --fork-url + ``` + +4. **Verify Contract**: + ```bash + forge verify-contract \ + --chain \ + \ + src/bridge/EnsoCCIPReceiver.sol:EnsoCCIPReceiver \ + --constructor-args $(cast abi-encode "constructor(address,address,address)" ) + ``` + +### Deployment Checklist + +- [ ] Verify CCIP Router address for target chain +- [ ] Verify Enso Router address for target chain +- [ ] Set owner address (use multi-sig recommended) +- [ ] Deploy contract +- [ ] Verify contract on block explorer +- [ ] Test pause/unpause functionality +- [ ] Test with small test message +- [ ] Set up monitoring for events + +### Post-Deployment + +1. **Monitoring**: + - Monitor `MessageValidationFailed` events + - Monitor `MessageQuarantined` events + - Monitor `ShortcutExecutionFailed` events + - Track quarantined token balances + +2. **Emergency Procedures**: + - Pause contract if issues detected + - Recover quarantined funds if needed + - Coordinate with Chainlink if CCIP issues + +--- + +## Additional Resources + +### Documentation + +- [Enso Shortcuts Documentation](https://docs.enso.finance/) +- [Chainlink CCIP Documentation](https://docs.chain.link/ccip) +- [OpenZeppelin Contracts](https://docs.openzeppelin.com/contracts/) + +--- + +## Contact & Questions + +For questions about this audit scope or the contracts, please contact the Enso +team or refer to the PR discussion: +[#60](https://github.com/EnsoBuild/shortcuts-client-contracts/pull/60) + +--- + +**Document Version**: 1.0 +**Last Updated**: [TBD] +**Next Review**: Post-audit diff --git a/src/bridge/EnsoCCIPReceiver.sol b/src/bridge/EnsoCCIPReceiver.sol index 3301c94..afa7618 100644 --- a/src/bridge/EnsoCCIPReceiver.sol +++ b/src/bridge/EnsoCCIPReceiver.sol @@ -19,7 +19,7 @@ import { Pausable } from "openzeppelin-contracts/utils/Pausable.sol"; /// - Relies on Chainlink CCIP Router gating via {CCIPReceiver}. /// - Maintains idempotency with a messageId β†’ handled flag. /// - Validates `destTokenAmounts` has exactly one ERC-20 with non-zero amount. -/// - Decodes `(receiver, estimatedGas, shortcutData)` from the message payload (temp external helper). +/// - Decodes `(receiver, shortcutData)` from the message payload (temp external helper). /// - For environment issues (PAUSED), refunds to `receiver` for better UX. /// - For malformed messages (no/too many tokens, zero amount, bad payload, zero address receiver), quarantines /// funds in this contract. @@ -50,8 +50,8 @@ contract EnsoCCIPReceiver is IEnsoCCIPReceiver, CCIPReceiver, Ownable2Step, Paus /// @dev Flow: /// 1) Replay check by `messageId` (idempotent no-op if already handled). /// 2) Validate token shape (exactly one ERC-20, non-zero amount). - /// 3) Decode payload `(receiver, estimatedGas, shortcutData)` using a temporary external helper. - /// 4) Environment checks: `paused()` and `estimatedGas` hint vs `gasleft()`. + /// 3) Decode payload `(receiver, shortcutData)` using a temporary external helper. + /// 4) Environment checks: `paused()`. /// 5) If non-OK β†’ select refund policy: /// - TO_RECEIVER for environment issues (PAUSED), /// - TO_ESCROW for malformed token/payload (funds remain in this contract), diff --git a/src/interfaces/IEnsoCCIPReceiver.sol b/src/interfaces/IEnsoCCIPReceiver.sol index 3ceafea..3718e5a 100644 --- a/src/interfaces/IEnsoCCIPReceiver.sol +++ b/src/interfaces/IEnsoCCIPReceiver.sol @@ -16,7 +16,7 @@ interface IEnsoCCIPReceiver { /// - NO_ERROR: message is well-formed; proceed to execution. /// - ALREADY_EXECUTED: messageId was previously handled (idempotent no-op). /// - NO_TOKENS / TOO_MANY_TOKENS / NO_TOKEN_AMOUNT: token shape invalid. - /// - MALFORMED_MESSAGE_DATA: payload (address,uint256,bytes) could not be decoded. + /// - MALFORMED_MESSAGE_DATA: payload (address,bytes) could not be decoded. /// - ZERO_ADDRESS_RECEIVER: payload receiver is the zero address. /// - PAUSED: contract is paused; environment block on execution. enum ErrorCode { From 70d011062818f522708c551900fb3be1ef16f2d8 Mon Sep 17 00:00:00 2001 From: vnavascues Date: Mon, 1 Dec 2025 21:54:42 +0100 Subject: [PATCH 21/21] docs: fix commit --- audits/README.audit.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/audits/README.audit.md b/audits/README.audit.md index b18a4d0..1ad2302 100644 --- a/audits/README.audit.md +++ b/audits/README.audit.md @@ -5,7 +5,7 @@ **Version:** 1.0.0 **Audit Date:** [TBD] **PR:** [#60](https://github.com/EnsoBuild/shortcuts-client-contracts/pull/60) -**Commit:** [0526e61](https://github.com/EnsoBuild/shortcuts-client-contracts/commit/0526e61e6e1a5a09d2e1b18c34c135d04673b0dd) +**Commit:** [0526e61](https://github.com/EnsoBuild/shortcuts-client-contracts/commit/a419b1cab6a54b91ce8bd28ccf80327d8a767f21) ---