From a91409d64f6ffb50c31635ff3f33c52c856937e5 Mon Sep 17 00:00:00 2001 From: Paras Jain Date: Wed, 15 Apr 2026 14:42:09 -0500 Subject: [PATCH 1/2] feat: add 1inchswap module --- .gitignore | 4 +- scripts/DeployOneInchSafeImpl.s.sol | 33 + scripts/DeployOneInchSwapModule.s.sol | 36 + scripts/DeployOneInchSwapModuleDev.s.sol | 67 ++ .../ConfigureOneInchSwapModule.s.sol | 61 ++ .../gnosis-txs/UpgradeSafeImplOneInch.s.sol | 55 ++ src/interfaces/IEtherFiSafe.sol | 16 +- src/interfaces/IOneInch.sol | 28 + .../oneinch-swap/OneInchSwapModule.sol | 517 +++++++++++++ src/safe/EtherFiSafe.sol | 62 +- .../oneinch-swap/OneInchSwapModule.t.sol | 693 ++++++++++++++++++ 11 files changed, 1569 insertions(+), 3 deletions(-) create mode 100644 scripts/DeployOneInchSafeImpl.s.sol create mode 100644 scripts/DeployOneInchSwapModule.s.sol create mode 100644 scripts/DeployOneInchSwapModuleDev.s.sol create mode 100644 scripts/gnosis-txs/ConfigureOneInchSwapModule.s.sol create mode 100644 scripts/gnosis-txs/UpgradeSafeImplOneInch.s.sol create mode 100644 src/interfaces/IOneInch.sol create mode 100644 src/modules/oneinch-swap/OneInchSwapModule.sol create mode 100644 test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol diff --git a/.gitignore b/.gitignore index f2e5eac3..3cf6b955 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ broadcast **/*/.DS_Store # Certora -.certora_internal/ \ No newline at end of file +.certora_internal/ + +.DS_Store \ No newline at end of file diff --git a/scripts/DeployOneInchSafeImpl.s.sol b/scripts/DeployOneInchSafeImpl.s.sol new file mode 100644 index 00000000..b4a117ce --- /dev/null +++ b/scripts/DeployOneInchSafeImpl.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import { stdJson } from "forge-std/StdJson.sol"; +import { console } from "forge-std/console.sol"; + +import { EtherFiSafe } from "../src/safe/EtherFiSafe.sol"; +import { Utils } from "./utils/Utils.sol"; + +/** + * @title DeployOneInchSafeImpl + * @notice Deploys a new EtherFiSafe implementation with ERC-1271 support + * + * ENV=mainnet forge script scripts/DeployOneInchSafeImpl.s.sol --rpc-url --broadcast + */ +contract DeployOneInchSafeImpl is Utils { + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + + string memory deployments = readDeploymentFile(); + + address dataProvider = stdJson.readAddress(deployments, string.concat(".", "addresses", ".", "EtherFiDataProvider")); + + vm.startBroadcast(deployerPrivateKey); + + EtherFiSafe safeImpl = new EtherFiSafe(dataProvider); + + console.log("New EtherFiSafe implementation deployed at:", address(safeImpl)); + console.log("Set NEW_SAFE_IMPL=%s for the gnosis upgrade script", address(safeImpl)); + + vm.stopBroadcast(); + } +} diff --git a/scripts/DeployOneInchSwapModule.s.sol b/scripts/DeployOneInchSwapModule.s.sol new file mode 100644 index 00000000..8adba938 --- /dev/null +++ b/scripts/DeployOneInchSwapModule.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import { stdJson } from "forge-std/StdJson.sol"; +import { console } from "forge-std/console.sol"; + +import { OneInchSwapModule } from "../src/modules/oneinch-swap/OneInchSwapModule.sol"; +import { Utils } from "./utils/Utils.sol"; + +/** + * @title DeployOneInchSwapModule + * @notice Deploys the OneInchSwapModule contract + * + * ENV=mainnet forge script scripts/DeployOneInchSwapModule.s.sol --rpc-url --broadcast + */ +contract DeployOneInchSwapModule is Utils { + // 1inch v6 Aggregation Router — canonical address on all EVM chains + address constant AGGREGATION_ROUTER = 0x111111125421cA6dc452d289314280a0f8842A65; + + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + + string memory deployments = readDeploymentFile(); + + address dataProvider = stdJson.readAddress(deployments, string.concat(".", "addresses", ".", "EtherFiDataProvider")); + + vm.startBroadcast(deployerPrivateKey); + + OneInchSwapModule oneInchModule = new OneInchSwapModule(AGGREGATION_ROUTER, dataProvider); + + console.log("OneInchSwapModule deployed at:", address(oneInchModule)); + console.log("Set ONE_INCH_MODULE=%s for the gnosis config script", address(oneInchModule)); + + vm.stopBroadcast(); + } +} diff --git a/scripts/DeployOneInchSwapModuleDev.s.sol b/scripts/DeployOneInchSwapModuleDev.s.sol new file mode 100644 index 00000000..27327e6c --- /dev/null +++ b/scripts/DeployOneInchSwapModuleDev.s.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {stdJson} from "forge-std/StdJson.sol"; +import {console} from "forge-std/console.sol"; + +import {EtherFiSafe} from "../src/safe/EtherFiSafe.sol"; +import {EtherFiSafeFactory} from "../src/safe/EtherFiSafeFactory.sol"; +import {EtherFiDataProvider} from "../src/data-provider/EtherFiDataProvider.sol"; +import {OneInchSwapModule} from "../src/modules/oneinch-swap/OneInchSwapModule.sol"; +import {Utils} from "./utils/Utils.sol"; + +/** + * @title DeployOneInchSwapModuleDev + * @notice All-in-one dev script: deploys new Safe impl, upgrades beacon, deploys module, configures module + * @dev Runs everything via a single EOA. For dev environment only. + * + * Usage: + * ENV=dev PRIVATE_KEY=0x... forge script scripts/DeployOneInchSwapModuleDev.s.sol --rpc-url --broadcast + */ +contract DeployOneInchSwapModuleDev is Utils { + // 1inch v6 Aggregation Router — canonical address on all EVM chains + address constant AGGREGATION_ROUTER = 0x111111125421cA6dc452d289314280a0f8842A65; + + function run() public { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + + string memory deployments = readDeploymentFile(); + + address dataProvider = stdJson.readAddress( + deployments, + string.concat(".", "addresses", ".", "EtherFiDataProvider") + ); + + address safeFactoryAddr = stdJson.readAddress( + deployments, + string.concat(".", "addresses", ".", "EtherFiSafeFactory") + ); + + vm.startBroadcast(deployerPrivateKey); + + // 1. Deploy new EtherFiSafe implementation with ERC-1271 support + EtherFiSafe safeImpl = new EtherFiSafe(dataProvider); + console.log("New EtherFiSafe impl:", address(safeImpl)); + + // 2. Upgrade beacon to new implementation + EtherFiSafeFactory safeFactory = EtherFiSafeFactory(safeFactoryAddr); + safeFactory.upgradeBeaconImplementation(address(safeImpl)); + console.log("Beacon upgraded"); + + // 3. Deploy OneInchSwapModule + OneInchSwapModule oneInchModule = new OneInchSwapModule(AGGREGATION_ROUTER, dataProvider); + console.log("OneInchSwapModule:", address(oneInchModule)); + + // 4. Configure as default module on DataProvider + address[] memory modules = new address[](1); + modules[0] = address(oneInchModule); + + bool[] memory shouldWhitelist = new bool[](1); + shouldWhitelist[0] = true; + + EtherFiDataProvider(dataProvider).configureDefaultModules(modules, shouldWhitelist); + console.log("Module configured as default"); + + vm.stopBroadcast(); + } +} diff --git a/scripts/gnosis-txs/ConfigureOneInchSwapModule.s.sol b/scripts/gnosis-txs/ConfigureOneInchSwapModule.s.sol new file mode 100644 index 00000000..aff2e69b --- /dev/null +++ b/scripts/gnosis-txs/ConfigureOneInchSwapModule.s.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {stdJson} from "forge-std/StdJson.sol"; +import {console} from "forge-std/console.sol"; + +import {EtherFiDataProvider} from "../../src/data-provider/EtherFiDataProvider.sol"; +import {GnosisHelpers} from "../utils/GnosisHelpers.sol"; +import {Utils} from "../utils/Utils.sol"; + +/** + * @title ConfigureOneInchSwapModule + * @notice Generates a Gnosis Safe transaction to configure the OneInchSwapModule as a default module + * + * ENV=mainnet ONE_INCH_MODULE=0x... forge script scripts/gnosis-txs/ConfigureOneInchSwapModule.s.sol --rpc-url + */ +contract ConfigureOneInchSwapModule is GnosisHelpers, Utils { + // Prod multisig (DATA_PROVIDER_ADMIN_ROLE holder) + address constant multisig = 0xA6cf33124cb342D1c604cAC87986B965F428AAC4; + + function run() public { + address oneInchModule = vm.envAddress("ONE_INCH_MODULE"); + + string memory deployments = readDeploymentFile(); + string memory chainId = vm.toString(block.chainid); + + address dataProvider = stdJson.readAddress( + deployments, + string.concat(".", "addresses", ".", "EtherFiDataProvider") + ); + + // Build Gnosis transaction: dataProvider.configureDefaultModules([module], [true]) + string memory txs = _getGnosisHeader(chainId, addressToHex(multisig)); + + address[] memory modules = new address[](1); + modules[0] = oneInchModule; + + bool[] memory shouldWhitelist = new bool[](1); + shouldWhitelist[0] = true; + + string memory configData = iToHex( + abi.encodeWithSelector(EtherFiDataProvider.configureDefaultModules.selector, modules, shouldWhitelist) + ); + txs = string(abi.encodePacked( + txs, + _getGnosisTransaction(addressToHex(dataProvider), configData, "0", true) + )); + + vm.createDir("./output", true); + string memory path = "./output/ConfigureOneInchSwapModule.json"; + vm.writeFile(path, txs); + + console.log("Gnosis transaction written to:", path); + console.log("DataProvider:", dataProvider); + console.log("OneInchSwapModule:", oneInchModule); + + // Simulate execution + executeGnosisTransactionBundle(path); + console.log("Simulation passed"); + } +} diff --git a/scripts/gnosis-txs/UpgradeSafeImplOneInch.s.sol b/scripts/gnosis-txs/UpgradeSafeImplOneInch.s.sol new file mode 100644 index 00000000..dce0bd0b --- /dev/null +++ b/scripts/gnosis-txs/UpgradeSafeImplOneInch.s.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {stdJson} from "forge-std/StdJson.sol"; +import {console} from "forge-std/console.sol"; + +import {EtherFiSafeFactory} from "../../src/safe/EtherFiSafeFactory.sol"; +import {GnosisHelpers} from "../utils/GnosisHelpers.sol"; +import {Utils} from "../utils/Utils.sol"; + +/** + * @title UpgradeSafeImplOneInch + * @notice Generates a Gnosis Safe transaction to upgrade the EtherFiSafe beacon implementation + * + * ENV=mainnet NEW_SAFE_IMPL=0x... forge script scripts/gnosis-txs/UpgradeSafeImplOneInch.s.sol --rpc-url + */ +contract UpgradeSafeImplOneInch is GnosisHelpers, Utils { + // Prod multisig (roleRegistry owner + DATA_PROVIDER_ADMIN_ROLE holder) + address constant multisig = 0xA6cf33124cb342D1c604cAC87986B965F428AAC4; + + function run() public { + address newSafeImpl = vm.envAddress("NEW_SAFE_IMPL"); + + string memory deployments = readDeploymentFile(); + string memory chainId = vm.toString(block.chainid); + + address safeFactory = stdJson.readAddress( + deployments, + string.concat(".", "addresses", ".", "EtherFiSafeFactory") + ); + + // Build Gnosis transaction: safeFactory.upgradeBeaconImplementation(newSafeImpl) + string memory txs = _getGnosisHeader(chainId, addressToHex(multisig)); + + string memory upgradeData = iToHex( + abi.encodeWithSelector(EtherFiSafeFactory.upgradeBeaconImplementation.selector, newSafeImpl) + ); + txs = string(abi.encodePacked( + txs, + _getGnosisTransaction(addressToHex(safeFactory), upgradeData, "0", true) + )); + + vm.createDir("./output", true); + string memory path = "./output/UpgradeSafeImplOneInch.json"; + vm.writeFile(path, txs); + + console.log("Gnosis transaction written to:", path); + console.log("Safe Factory:", safeFactory); + console.log("New Safe Impl:", newSafeImpl); + + // Simulate execution + executeGnosisTransactionBundle(path); + console.log("Simulation passed"); + } +} diff --git a/src/interfaces/IEtherFiSafe.sol b/src/interfaces/IEtherFiSafe.sol index 8b1daed3..8ebee1b2 100644 --- a/src/interfaces/IEtherFiSafe.sol +++ b/src/interfaces/IEtherFiSafe.sol @@ -50,5 +50,19 @@ interface IEtherFiSafe { */ function useNonce() external returns (uint256); - function isAdmin(address account) external view returns (bool); + function isAdmin(address account) external view returns (bool); + + /** + * @notice Authorizes an order hash for ERC-1271 signature validation + * @dev Can only be called by enabled modules + * @param hash The order hash to authorize + */ + function authorizeOrderHash(bytes32 hash) external; + + /** + * @notice Revokes a previously authorized order hash + * @dev Can only be called by enabled modules + * @param hash The order hash to revoke + */ + function revokeOrderHash(bytes32 hash) external; } diff --git a/src/interfaces/IOneInch.sol b/src/interfaces/IOneInch.sol new file mode 100644 index 00000000..ac863e1f --- /dev/null +++ b/src/interfaces/IOneInch.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title 1inch interfaces for OneInchSwapModule +/// @dev Minimal interfaces for both Classic (DEX aggregation) and Fusion (RFQ/intent) swaps + +/// @notice EIP-1271 interface for smart contract signature validation +interface IERC1271 { + /// @notice Validates a signature for a given hash + /// @param hash The hash that was signed + /// @param signature The signature bytes + /// @return magicValue 0x1626ba7e if valid, any other value if invalid + function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4); +} + +/// @notice 1inch Aggregation Router SwapDescription for classic (DEX) swaps +/// @dev Matches the SwapDescription struct in the 1inch v6 router's swap() function +struct OneInchSwapDescription { + IERC20 srcToken; + IERC20 dstToken; + address payable srcReceiver; + address payable dstReceiver; + uint256 amount; + uint256 minReturnAmount; + uint256 flags; +} diff --git a/src/modules/oneinch-swap/OneInchSwapModule.sol b/src/modules/oneinch-swap/OneInchSwapModule.sol new file mode 100644 index 00000000..9a2145b9 --- /dev/null +++ b/src/modules/oneinch-swap/OneInchSwapModule.sol @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; + +import {OneInchSwapDescription} from "../../interfaces/IOneInch.sol"; +import {IEtherFiSafe} from "../../interfaces/IEtherFiSafe.sol"; +import {IBridgeModule} from "../../interfaces/IBridgeModule.sol"; +import {ModuleBase} from "../ModuleBase.sol"; +import {ModuleCheckBalance} from "../ModuleCheckBalance.sol"; + +/** + * @title OneInchSwapModule + * @author ether.fi + * @notice Module for executing token swaps through 1inch — supports both Classic (DEX) and Fusion (RFQ) modes + * + * @dev Classic (DEX aggregation): + * Single atomic transaction. Safe approves router, router swaps via DEXes, module verifies output. + * Tokens never leave the Safe. Same pattern as OpenOceanSwapModule. + * Entry point: swap() + * + * Fusion (intent-based / RFQ): + * Multi-step async flow. The Safe is the "maker" in a 1inch Fusion order. + * Tokens never leave the Safe — the Safe approves the 1inch router and implements ERC-1271 + * (via authorizeOrderHash) so the Limit Order Protocol can verify the order and the resolver + * can pull tokens directly from the Safe via transferFrom. + * Entry points: requestSwap() -> executeSwap() -> settleSwap() + * + * Both modes use the same 1inch Aggregation Router. + */ +contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient, IBridgeModule { + using MessageHashUtils for bytes32; + + // ────────────────────────────────────────────── + // Structs + // ────────────────────────────────────────────── + + /// @notice State of a pending Fusion swap for a Safe + struct PendingSwap { + address fromToken; + address toToken; + uint256 fromAmount; + uint256 minToAmount; + bytes32 orderHash; + uint256 fromBalanceBefore; + uint256 toBalanceBefore; + } + + // ────────────────────────────────────────────── + // Immutables + // ────────────────────────────────────────────── + + /// @notice 1inch Aggregation Router address (shared by Classic and Fusion) + address public immutable aggregationRouter; + + // ────────────────────────────────────────────── + // State (Fusion only) + // ────────────────────────────────────────────── + + /// @notice Pending Fusion swap per Safe (only one at a time) + mapping(address safe => PendingSwap) public pendingSwaps; + + // ────────────────────────────────────────────── + // Signature type hashes + // ────────────────────────────────────────────── + + /// @notice Classic swap signature type hash + bytes32 public constant SWAP_SIG = keccak256("swap"); + + /// @notice Fusion: request swap signature type hash + bytes32 public constant REQUEST_SWAP_SIG = keccak256("requestSwap"); + + /// @notice Fusion: execute swap signature type hash + bytes32 public constant EXECUTE_SWAP_SIG = keccak256("executeSwap"); + + /// @notice Fusion: settle swap signature type hash + bytes32 public constant SETTLE_SWAP_SIG = keccak256("settleSwap"); + + /// @notice Fusion: cancel swap signature type hash + bytes32 public constant CANCEL_SWAP_SIG = keccak256("cancelSwap"); + + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + /// @notice Emitted on a successful classic (DEX) swap + event ClassicSwap( + address indexed safe, + address indexed fromAsset, + address indexed toAsset, + uint256 fromAssetAmount, + uint256 minToAssetAmount, + uint256 returnAmount + ); + + /// @notice Emitted when a Fusion swap is requested (intent recorded) + event FusionSwapRequested( + address indexed safe, + address indexed fromToken, + address indexed toToken, + uint256 fromAmount, + uint256 minToAmount + ); + + /// @notice Emitted when a Fusion swap is executed (order authorized on Safe) + event FusionSwapExecuted(address indexed safe, bytes32 indexed orderHash); + + /// @notice Emitted when a Fusion swap is settled after fill + event FusionSwapSettled( + address indexed safe, + address indexed fromToken, + address indexed toToken, + uint256 fromAmount, + uint256 receivedAmount + ); + + /// @notice Emitted when a Fusion swap is cancelled + event FusionSwapCancelled(address indexed safe, address indexed fromToken); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + error SwappingToSameAsset(); + error InvalidSignatures(); + error OutputLessThanMinAmount(); + error SlippageTooHigh(); + error NoPendingSwap(); + error SwapAlreadyPending(); + error SwapAlreadyExecuted(); + error SwapNotExecuted(); + error InsufficientReceivedAmount(); + error OrderNotFilled(); + error NativeETHNotSupported(); + error Unauthorized(); + + // ────────────────────────────────────────────── + // Constructor + // ────────────────────────────────────────────── + + /** + * @param _aggregationRouter 1inch Aggregation Router address + * @param _dataProvider EtherFi data provider address + */ + constructor( + address _aggregationRouter, + address _dataProvider + ) ModuleBase(_dataProvider) ModuleCheckBalance(_dataProvider) { + if (_aggregationRouter == address(0)) revert InvalidInput(); + aggregationRouter = _aggregationRouter; + } + + // ══════════════════════════════════════════════ + // CLASSIC (DEX) SWAP + // ══════════════════════════════════════════════ + + /** + * @notice Executes an atomic token swap through 1inch DEX aggregation + * @dev Tokens never leave the Safe. The Safe approves the router, router executes the swap, + * module verifies the output, and approval is revoked. Same pattern as OpenOceanSwapModule. + * @param safe Address of the EtherFi Safe + * @param fromAsset Token to sell (or ETH address for native) + * @param toAsset Token to buy (or ETH address for native) + * @param fromAssetAmount Amount of fromAsset to sell + * @param minToAssetAmount Minimum amount of toAsset to receive + * @param data Raw 1inch router calldata (from 1inch Swap API) + * @param signers Safe owner addresses authorizing this swap + * @param signatures Signatures from the signers + */ + function swap( + address safe, + address fromAsset, + address toAsset, + uint256 fromAssetAmount, + uint256 minToAssetAmount, + bytes calldata data, + address[] calldata signers, + bytes[] calldata signatures + ) external nonReentrant onlyEtherFiSafe(safe) { + _checkSignatures(SWAP_SIG, safe, abi.encode(fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data), signers, signatures); + _classicSwap(safe, fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data); + } + + // ══════════════════════════════════════════════ + // FUSION (RFQ / INTENT) SWAP — Safe-as-Maker + // ══════════════════════════════════════════════ + + /** + * @notice Initiates a Fusion swap by recording the swap intent + * @dev No tokens are moved. The Safe's balance is checked for availability. + * The backend will later call executeSwap() with the 1inch order hash. + * @param safe Address of the EtherFi Safe + * @param fromToken Token to sell + * @param toToken Token to buy + * @param fromAmount Amount of fromToken to sell + * @param minToAmount Minimum amount of toToken expected after fill + * @param signers Safe owner addresses authorizing this swap + * @param signatures Signatures from the signers + */ + function requestSwap( + address safe, + address fromToken, + address toToken, + uint256 fromAmount, + uint256 minToAmount, + address[] calldata signers, + bytes[] calldata signatures + ) external onlyEtherFiSafe(safe) { + if (fromToken == ETH || toToken == ETH) revert NativeETHNotSupported(); + if (fromToken == toToken) revert SwappingToSameAsset(); + if (fromAmount == 0 || minToAmount == 0) revert InvalidInput(); + if (pendingSwaps[safe].fromAmount != 0) revert SwapAlreadyPending(); + + _checkSignatures(REQUEST_SWAP_SIG, safe, abi.encode(fromToken, toToken, fromAmount, minToAmount), signers, signatures); + _checkAmountAvailable(safe, fromToken, fromAmount); + + pendingSwaps[safe] = PendingSwap({ + fromToken: fromToken, + toToken: toToken, + fromAmount: fromAmount, + minToAmount: minToAmount, + orderHash: bytes32(0), + fromBalanceBefore: 0, + toBalanceBefore: 0 + }); + + emit FusionSwapRequested(safe, fromToken, toToken, fromAmount, minToAmount); + } + + /** + * @notice Activates the Fusion swap: Safe approves the router and authorizes the order hash + * @dev Called by backend once the 1inch order is constructed. + * The Safe approves the aggregation router for fromAmount and registers the orderHash + * on the Safe for ERC-1271 validation. The resolver can then fill via transferFrom on the Safe. + * @param safe Address of the EtherFi Safe + * @param orderHash The 1inch order hash (precomputed by backend) + * @param signers Safe owner addresses authorizing execution + * @param signatures Signatures from the signers + */ + function executeSwap( + address safe, + bytes32 orderHash, + address[] calldata signers, + bytes[] calldata signatures + ) external nonReentrant onlyEtherFiSafe(safe) { + PendingSwap storage pendingSwap = pendingSwaps[safe]; + if (pendingSwap.fromAmount == 0) revert NoPendingSwap(); + if (pendingSwap.orderHash != bytes32(0)) revert SwapAlreadyExecuted(); + if (orderHash == bytes32(0)) revert InvalidInput(); + + _checkSignatures(EXECUTE_SWAP_SIG, safe, abi.encode(orderHash), signers, signatures); + + // Snapshot balances before the fill + pendingSwap.fromBalanceBefore = IERC20(pendingSwap.fromToken).balanceOf(safe); + pendingSwap.toBalanceBefore = IERC20(pendingSwap.toToken).balanceOf(safe); + pendingSwap.orderHash = orderHash; + + // Safe approves router to pull fromTokens + address[] memory to = new address[](1); + uint256[] memory values = new uint256[](1); + bytes[] memory data = new bytes[](1); + to[0] = pendingSwap.fromToken; + data[0] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, pendingSwap.fromAmount); + IEtherFiSafe(safe).execTransactionFromModule(to, values, data); + + // Authorize order hash on Safe for ERC-1271 validation + IEtherFiSafe(safe).authorizeOrderHash(orderHash); + + emit FusionSwapExecuted(safe, orderHash); + } + + /** + * @notice Finalizes the Fusion swap after the 1inch order has been filled + * @dev Called by backend once the order fill is confirmed on-chain. + * Verifies that the Safe's fromToken balance decreased (order filled) and + * the Safe received at least minToAmount of toToken since executeSwap. + * @param safe Address of the EtherFi Safe + * @param signers Safe owner addresses authorizing settlement + * @param signatures Signatures from the signers + */ + function settleSwap( + address safe, + address[] calldata signers, + bytes[] calldata signatures + ) external nonReentrant onlyEtherFiSafe(safe) { + PendingSwap memory pendingSwap = pendingSwaps[safe]; + if (pendingSwap.fromAmount == 0) revert NoPendingSwap(); + if (pendingSwap.orderHash == bytes32(0)) revert SwapNotExecuted(); + + _checkSignatures(SETTLE_SWAP_SIG, safe, "", signers, signatures); + + // Verify the order was filled: Safe's fromToken balance must have decreased + uint256 currentFromBalance = IERC20(pendingSwap.fromToken).balanceOf(safe); + if (currentFromBalance >= pendingSwap.fromBalanceBefore) revert OrderNotFilled(); + + // Verify the Safe received enough toToken since executeSwap + uint256 currentToBalance = IERC20(pendingSwap.toToken).balanceOf(safe); + uint256 received = currentToBalance - pendingSwap.toBalanceBefore; + if (received < pendingSwap.minToAmount) revert InsufficientReceivedAmount(); + + // Revoke router approval and order hash on Safe + _revokeApprovalAndOrderHash(safe, pendingSwap.fromToken, pendingSwap.orderHash); + + delete pendingSwaps[safe]; + + emit FusionSwapSettled(safe, pendingSwap.fromToken, pendingSwap.toToken, pendingSwap.fromAmount, received); + } + + /** + * @notice Cancels a pending Fusion swap at any stage before settlement + * @dev If executeSwap was called, revokes the router approval and order hash on the Safe. + * Tokens remain on the Safe throughout — nothing to transfer back. + * @param safe Address of the EtherFi Safe + * @param signers Safe owner addresses authorizing cancellation + * @param signatures Signatures from the signers + */ + function cancelSwap( + address safe, + address[] calldata signers, + bytes[] calldata signatures + ) external nonReentrant onlyEtherFiSafe(safe) { + PendingSwap memory pendingSwap = pendingSwaps[safe]; + if (pendingSwap.fromAmount == 0) revert NoPendingSwap(); + + _checkSignatures(CANCEL_SWAP_SIG, safe, "", signers, signatures); + + if (pendingSwap.orderHash != bytes32(0)) { + _revokeApprovalAndOrderHash(safe, pendingSwap.fromToken, pendingSwap.orderHash); + } + + delete pendingSwaps[safe]; + + emit FusionSwapCancelled(safe, pendingSwap.fromToken); + } + + // ────────────────────────────────────────────── + // IBridgeModule (called by CashModule for force-cancellation) + // ────────────────────────────────────────────── + + /** + * @notice Called by CashModule to force-cancel a pending swap (e.g. during liquidation) + * @dev Revokes any active approval and order hash on the Safe, then cleans up state. + * @param safe Address of the EtherFi Safe + */ + function cancelBridgeByCashModule(address safe) external nonReentrant { + if (msg.sender != etherFiDataProvider.getCashModule()) revert Unauthorized(); + + PendingSwap memory pendingSwap = pendingSwaps[safe]; + if (pendingSwap.fromAmount == 0) return; + + if (pendingSwap.orderHash != bytes32(0)) { + _revokeApprovalAndOrderHash(safe, pendingSwap.fromToken, pendingSwap.orderHash); + } + + delete pendingSwaps[safe]; + emit FusionSwapCancelled(safe, pendingSwap.fromToken); + } + + // ────────────────────────────────────────────── + // View Functions + // ────────────────────────────────────────────── + + /** + * @notice Returns the pending Fusion swap details for a Safe + * @param safe Address of the EtherFi Safe + * @return The PendingSwap struct + */ + function getPendingSwap(address safe) external view returns (PendingSwap memory) { + return pendingSwaps[safe]; + } + + // ══════════════════════════════════════════════ + // INTERNAL: Classic Swap Logic + // ══════════════════════════════════════════════ + + function _classicSwap( + address safe, + address fromAsset, + address toAsset, + uint256 fromAssetAmount, + uint256 minToAssetAmount, + bytes calldata data + ) internal { + if (fromAsset == toAsset) revert SwappingToSameAsset(); + if (minToAssetAmount == 0) revert InvalidInput(); + + _checkAmountAvailable(safe, fromAsset, fromAssetAmount); + _validateClassicSwapData(safe, fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data); + + uint256 balBefore; + if (toAsset == ETH) balBefore = address(safe).balance; + else balBefore = IERC20(toAsset).balanceOf(safe); + + address[] memory to; + uint256[] memory value; + bytes[] memory callData; + if (fromAsset == ETH) (to, value, callData) = _classicSwapNative(fromAssetAmount, data); + else (to, value, callData) = _classicSwapERC20(fromAsset, fromAssetAmount, data); + + IEtherFiSafe(safe).execTransactionFromModule(to, value, callData); + + uint256 balAfter; + if (toAsset == ETH) balAfter = address(safe).balance; + else balAfter = IERC20(toAsset).balanceOf(safe); + + uint256 receivedAmt = balAfter - balBefore; + if (receivedAmt < minToAssetAmount) revert OutputLessThanMinAmount(); + + emit ClassicSwap(safe, fromAsset, toAsset, fromAssetAmount, minToAssetAmount, receivedAmt); + } + + function _classicSwapERC20( + address fromAsset, + uint256 fromAssetAmount, + bytes calldata data + ) internal view returns (address[] memory to, uint256[] memory value, bytes[] memory callData) { + to = new address[](3); + value = new uint256[](3); + callData = new bytes[](3); + + to[0] = fromAsset; + callData[0] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, fromAssetAmount); + + to[1] = aggregationRouter; + callData[1] = data; + + to[2] = fromAsset; + callData[2] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, 0); + } + + function _classicSwapNative( + uint256 fromAssetAmount, + bytes calldata data + ) internal view returns (address[] memory to, uint256[] memory value, bytes[] memory callData) { + to = new address[](1); + value = new uint256[](1); + callData = new bytes[](1); + + to[0] = aggregationRouter; + value[0] = fromAssetAmount; + callData[0] = data; + } + + /** + * @notice Validates the 1inch classic swap calldata when the selector matches swap() + * @dev Decodes the SwapDescription from the 1inch router's swap() function and validates + * that the parameters match what was signed by the Safe owners. + * Selector 0x12aa3caf = swap(address,(address,address,address,address,uint256,uint256,uint256),bytes,bytes) + * For other 1inch router functions (unoswapTo, etc.), the balance check in _classicSwap provides safety. + */ + function _validateClassicSwapData( + address safe, + address fromAsset, + address toAsset, + uint256 fromAssetAmount, + uint256 minToAssetAmount, + bytes calldata data + ) internal pure { + if (data.length >= 4 && bytes4(data[:4]) == bytes4(0x12aa3caf)) { + (, OneInchSwapDescription memory desc,,) = abi.decode(data[4:], (address, OneInchSwapDescription, bytes, bytes)); + + if ( + address(desc.srcToken) != fromAsset || + address(desc.dstToken) != toAsset || + desc.dstReceiver != payable(safe) || + desc.amount != fromAssetAmount + ) revert InvalidInput(); + + if (desc.minReturnAmount < minToAssetAmount) revert SlippageTooHigh(); + } + } + + // ══════════════════════════════════════════════ + // INTERNAL: Fusion Helpers + // ══════════════════════════════════════════════ + + /** + * @dev Revokes the router's approval and the order hash on the Safe + */ + function _revokeApprovalAndOrderHash(address safe, address fromToken, bytes32 orderHash) internal { + // Revoke router approval + address[] memory to = new address[](1); + uint256[] memory values = new uint256[](1); + bytes[] memory data = new bytes[](1); + to[0] = fromToken; + data[0] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, 0); + IEtherFiSafe(safe).execTransactionFromModule(to, values, data); + + // Revoke order hash on Safe + IEtherFiSafe(safe).revokeOrderHash(orderHash); + } + + // ══════════════════════════════════════════════ + // INTERNAL: Signature Verification + // ══════════════════════════════════════════════ + + function _checkSignatures( + bytes32 selector, + address safe, + bytes memory data, + address[] calldata signers, + bytes[] calldata signatures + ) internal { + bytes32 digestHash = keccak256(abi.encodePacked( + selector, + block.chainid, + address(this), + IEtherFiSafe(safe).useNonce(), + safe, + data + )).toEthSignedMessageHash(); + + if (!IEtherFiSafe(safe).checkSignatures(digestHash, signers, signatures)) revert InvalidSignatures(); + } +} diff --git a/src/safe/EtherFiSafe.sol b/src/safe/EtherFiSafe.sol index 9897cfb7..70b032ae 100644 --- a/src/safe/EtherFiSafe.sol +++ b/src/safe/EtherFiSafe.sol @@ -7,6 +7,7 @@ import { EnumerableSetLib } from "solady/utils/EnumerableSetLib.sol"; import { IEtherFiDataProvider } from "../interfaces/IEtherFiDataProvider.sol"; +import { IERC1271 } from "../interfaces/IOneInch.sol"; import { IEtherFiHook } from "../interfaces/IEtherFiHook.sol"; import { IRoleRegistry } from "../interfaces/IRoleRegistry.sol"; import { ArrayDeDupLib } from "../libraries/ArrayDeDupLib.sol"; @@ -23,11 +24,32 @@ import { EtherFiSafeBase } from "./EtherFiSafeBase.sol"; * @dev Combines ModuleManager and MultiSig functionality with EIP-712 signature verification * @author ether.fi */ -contract EtherFiSafe is EtherFiSafeBase, ModuleManager, RecoveryManager, MultiSig{ +contract EtherFiSafe is EtherFiSafeBase, ModuleManager, RecoveryManager, MultiSig, IERC1271 { using SignatureUtils for bytes32; using EnumerableSetLib for EnumerableSetLib.AddressSet; using ArrayDeDupLib for address[]; + /// @custom:storage-location erc7201:etherfi.storage.ERC1271 + struct ERC1271Storage { + /// @notice Order hashes authorized by modules for ERC-1271 validation + mapping(bytes32 orderHash => bool authorized) authorizedOrderHashes; + } + + // keccak256(abi.encode(uint256(keccak256("etherfi.storage.ERC1271")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC1271StorageLocation = 0x95f3323d4da52a4232223e5d284728adc716e07261fde28fdddfa1e90df38000; + + function _getERC1271Storage() internal pure returns (ERC1271Storage storage $) { + assembly { + $.slot := ERC1271StorageLocation + } + } + + /// @notice Emitted when a module authorizes an order hash + event OrderHashAuthorized(address indexed module, bytes32 indexed orderHash); + + /// @notice Emitted when a module revokes an order hash + event OrderHashRevoked(address indexed module, bytes32 indexed orderHash); + /** * @notice Contract constructor * @dev Sets the immutable data provider reference @@ -232,6 +254,44 @@ contract EtherFiSafe is EtherFiSafeBase, ModuleManager, RecoveryManager, MultiSi return _useNonce(); } + // ══════════════════════════════════════════════ + // ERC-1271: Smart Contract Signature Validation + // ══════════════════════════════════════════════ + + /** + * @notice Authorizes an order hash for ERC-1271 signature validation + * @param hash The order hash to authorize + * @custom:throws OnlyModules If the caller is not an enabled module + */ + function authorizeOrderHash(bytes32 hash) external { + if (!isModuleEnabled(msg.sender)) revert OnlyModules(); + _getERC1271Storage().authorizedOrderHashes[hash] = true; + emit OrderHashAuthorized(msg.sender, hash); + } + + /** + * @notice Revokes a previously authorized order hash + * @param hash The order hash to revoke + * @custom:throws OnlyModules If the caller is not an enabled module + */ + function revokeOrderHash(bytes32 hash) external { + if (!isModuleEnabled(msg.sender)) revert OnlyModules(); + delete _getERC1271Storage().authorizedOrderHashes[hash]; + emit OrderHashRevoked(msg.sender, hash); + } + + /** + * @notice ERC-1271 signature validation + * @param hash The hash to validate + * @return magicValue 0x1626ba7e if authorized, 0xffffffff otherwise + */ + function isValidSignature(bytes32 hash, bytes calldata) external view override returns (bytes4) { + if (_getERC1271Storage().authorizedOrderHashes[hash]) { + return 0x1626ba7e; + } + return 0xffffffff; + } + /** * @dev Implementation of abstract function from ModuleManager * @dev Checks if a module is whitelisted on the data provider diff --git a/test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol b/test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol new file mode 100644 index 00000000..aeb9b638 --- /dev/null +++ b/test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol @@ -0,0 +1,693 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; +import {Test} from "forge-std/Test.sol"; + +import {OneInchSwapModule, ModuleBase, ModuleCheckBalance} from "../../../../src/modules/oneinch-swap/OneInchSwapModule.sol"; +import {OneInchSwapDescription} from "../../../../src/interfaces/IOneInch.sol"; +import {ArrayDeDupLib, EtherFiDataProvider, EtherFiSafe, EtherFiSafeErrors, SafeTestSetup, IDebtManager} from "../../SafeTestSetup.t.sol"; +import {ICashModule} from "../../../../src/interfaces/ICashModule.sol"; +import {CashVerificationLib} from "../../../../src/libraries/CashVerificationLib.sol"; + +contract OneInchSwapModuleTest is SafeTestSetup { + using MessageHashUtils for bytes32; + + OneInchSwapModule public oneInchModule; + + // 1inch Aggregation Router on Optimism + address public constant AGGREGATION_ROUTER = 0x111111125421cA6dc452d289314280a0f8842A65; + + // Test swap parameters + uint256 public constant SWAP_AMOUNT = 100e6; // 100 USDC + uint256 public constant MIN_TO_AMOUNT = 1e16; // 0.01 weETH + bytes32 public constant TEST_ORDER_HASH = keccak256("test-order-hash"); + + function setUp() public override { + super.setUp(); + + oneInchModule = new OneInchSwapModule(AGGREGATION_ROUTER, address(dataProvider)); + + // Register module on data provider + address[] memory modules = new address[](1); + modules[0] = address(oneInchModule); + + bool[] memory shouldWhitelist = new bool[](1); + shouldWhitelist[0] = true; + + vm.prank(owner); + dataProvider.configureModules(modules, shouldWhitelist); + + // Enable module on safe + bytes[] memory setupData = new bytes[](1); + _configureModules(modules, shouldWhitelist, setupData); + } + + // ══════════════════════════════════════════════ + // CLASSIC SWAP TESTS + // ══════════════════════════════════════════════ + + function test_classic_swap_revertsWhenSameAsset() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + bytes memory data = ""; + + (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( + address(usdc), address(usdc), SWAP_AMOUNT, MIN_TO_AMOUNT, data + ); + + vm.expectRevert(OneInchSwapModule.SwappingToSameAsset.selector); + oneInchModule.swap(address(safe), address(usdc), address(usdc), SWAP_AMOUNT, MIN_TO_AMOUNT, data, signers, signatures); + } + + function test_classic_swap_revertsWhenZeroMinOutput() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + bytes memory data = ""; + + (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, 0, data + ); + + vm.expectRevert(ModuleBase.InvalidInput.selector); + oneInchModule.swap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, 0, data, signers, signatures); + } + + function test_classic_swap_revertsWhenInsufficientBalance() public { + // Don't give safe any balance + bytes memory data = ""; + + (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, data + ); + + vm.expectRevert(ModuleCheckBalance.InsufficientAvailableBalanceOnSafe.selector); + oneInchModule.swap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, data, signers, signatures); + } + + function test_classic_swap_revertsWithInvalidSignature() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + bytes memory data = ""; + + // Sign with different amount + (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT + 1, MIN_TO_AMOUNT, data + ); + + vm.expectRevert(OneInchSwapModule.InvalidSignatures.selector); + oneInchModule.swap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, data, signers, signatures); + } + + function test_classic_swap_revertsWithNotEtherFiSafe() public { + address fakeSafe = makeAddr("fakeSafe"); + bytes memory data = ""; + address[] memory signers = new address[](2); + bytes[] memory signatures = new bytes[](2); + + vm.expectRevert(ModuleBase.OnlyEtherFiSafe.selector); + oneInchModule.swap(fakeSafe, address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, data, signers, signatures); + } + + function test_classic_swap_validatesSwapDescription() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + + // Build calldata with 1inch swap() selector but wrong dstReceiver + bytes memory swapData = _buildOneInchSwapCalldata( + address(usdc), // srcToken + address(weETH), // dstToken + makeAddr("wrong"), // dstReceiver (wrong - should be safe) + SWAP_AMOUNT, + MIN_TO_AMOUNT + ); + + (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, swapData + ); + + vm.expectRevert(ModuleBase.InvalidInput.selector); + oneInchModule.swap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, swapData, signers, signatures); + } + + function test_classic_swap_validatesSlippage() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + + // Build calldata where minReturnAmount in desc < minToAssetAmount + bytes memory swapData = _buildOneInchSwapCalldata( + address(usdc), + address(weETH), + address(safe), + SWAP_AMOUNT, + MIN_TO_AMOUNT - 1 // Less than what we're requiring + ); + + (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, swapData + ); + + vm.expectRevert(OneInchSwapModule.SlippageTooHigh.selector); + oneInchModule.swap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, swapData, signers, signatures); + } + + function test_classic_swap_nonceNotConsumedOnRevert() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + uint256 nonceBefore = safe.nonce(); + + bytes memory data = ""; + (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, data + ); + + // Revert rolls back all state changes including nonce increment + vm.expectRevert(); + oneInchModule.swap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, data, signers, signatures); + + assertEq(safe.nonce(), nonceBefore); + } + + // ══════════════════════════════════════════════ + // FUSION SWAP TESTS — Safe-as-Maker + // ══════════════════════════════════════════════ + + // ────────────────────────────────────────────── + // requestSwap tests + // ────────────────────────────────────────────── + + function test_fusion_requestSwap_works() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + + OneInchSwapModule.PendingSwap memory pendingSwap = oneInchModule.getPendingSwap(address(safe)); + assertEq(pendingSwap.fromToken, address(usdc)); + assertEq(pendingSwap.toToken, address(weETH)); + assertEq(pendingSwap.fromAmount, SWAP_AMOUNT); + assertEq(pendingSwap.minToAmount, MIN_TO_AMOUNT); + assertEq(pendingSwap.orderHash, bytes32(0)); + + // Tokens stay on Safe + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + } + + function test_fusion_requestSwap_revertsWithNativeETH() public { + address ETH_ADDR = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + ETH_ADDR, address(weETH), 1 ether, MIN_TO_AMOUNT + ); + + vm.expectRevert(OneInchSwapModule.NativeETHNotSupported.selector); + oneInchModule.requestSwap(address(safe), ETH_ADDR, address(weETH), 1 ether, MIN_TO_AMOUNT, signers, signatures); + } + + function test_fusion_requestSwap_revertsWhenSameAsset() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(usdc), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + + vm.expectRevert(OneInchSwapModule.SwappingToSameAsset.selector); + oneInchModule.requestSwap(address(safe), address(usdc), address(usdc), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + } + + function test_fusion_requestSwap_revertsWhenZeroAmount() public { + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), 0, MIN_TO_AMOUNT + ); + + vm.expectRevert(ModuleBase.InvalidInput.selector); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), 0, MIN_TO_AMOUNT, signers, signatures); + } + + function test_fusion_requestSwap_revertsWhenInsufficientBalance() public { + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + + vm.expectRevert(ModuleCheckBalance.InsufficientAvailableBalanceOnSafe.selector); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + } + + function test_fusion_requestSwap_revertsWhenSwapAlreadyPending() public { + deal(address(usdc), address(safe), SWAP_AMOUNT * 2); + + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + + (address[] memory signers2, bytes[] memory signatures2) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + + vm.expectRevert(OneInchSwapModule.SwapAlreadyPending.selector); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers2, signatures2); + } + + function test_fusion_requestSwap_revertsWithInvalidSignatures() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT + 1, MIN_TO_AMOUNT + ); + + vm.expectRevert(OneInchSwapModule.InvalidSignatures.selector); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + } + + // ────────────────────────────────────────────── + // executeSwap tests + // ────────────────────────────────────────────── + + function test_fusion_executeSwap_works() public { + _setupRequestSwap(); + + (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(TEST_ORDER_HASH); + oneInchModule.executeSwap(address(safe), TEST_ORDER_HASH, signers, signatures); + + OneInchSwapModule.PendingSwap memory pendingSwap = oneInchModule.getPendingSwap(address(safe)); + assertEq(pendingSwap.orderHash, TEST_ORDER_HASH); + assertEq(pendingSwap.fromBalanceBefore, SWAP_AMOUNT); + assertEq(pendingSwap.toBalanceBefore, 0); + + // Tokens still on Safe + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + + // Safe authorized the order hash (ERC-1271) + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0x1626ba7e)); + + // Router has approval from Safe + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), SWAP_AMOUNT); + } + + function test_fusion_executeSwap_revertsWhenNoPendingSwap() public { + (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(TEST_ORDER_HASH); + + vm.expectRevert(OneInchSwapModule.NoPendingSwap.selector); + oneInchModule.executeSwap(address(safe), TEST_ORDER_HASH, signers, signatures); + } + + function test_fusion_executeSwap_revertsWhenZeroOrderHash() public { + _setupRequestSwap(); + + (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(bytes32(0)); + + vm.expectRevert(ModuleBase.InvalidInput.selector); + oneInchModule.executeSwap(address(safe), bytes32(0), signers, signatures); + } + + function test_fusion_executeSwap_revertsWhenAlreadyExecuted() public { + _setupRequestAndExecuteSwap(); + + bytes32 newHash = keccak256("new-hash"); + (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(newHash); + + vm.expectRevert(OneInchSwapModule.SwapAlreadyExecuted.selector); + oneInchModule.executeSwap(address(safe), newHash, signers, signatures); + } + + // ────────────────────────────────────────────── + // isValidSignature tests (on Safe, not module) + // ────────────────────────────────────────────── + + function test_fusion_isValidSignature_authorized() public { + _setupRequestAndExecuteSwap(); + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0x1626ba7e)); + } + + function test_fusion_isValidSignature_unauthorized() public view { + assertEq(safe.isValidSignature(keccak256("random"), ""), bytes4(0xffffffff)); + } + + // ────────────────────────────────────────────── + // settleSwap tests + // ────────────────────────────────────────────── + + function test_fusion_settleSwap_works() public { + _setupRequestAndExecuteSwap(); + + // Simulate fill: resolver pulls fromToken from Safe, sends toToken to Safe + vm.prank(AGGREGATION_ROUTER); + usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); + deal(address(weETH), address(safe), MIN_TO_AMOUNT); + + (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); + oneInchModule.settleSwap(address(safe), signers, signatures); + + // State cleaned up + OneInchSwapModule.PendingSwap memory pendingSwap = oneInchModule.getPendingSwap(address(safe)); + assertEq(pendingSwap.fromAmount, 0); + + // Order hash revoked + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); + + // Approval revoked + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); + } + + function test_fusion_settleSwap_revertsWhenNoPendingSwap() public { + (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); + + vm.expectRevert(OneInchSwapModule.NoPendingSwap.selector); + oneInchModule.settleSwap(address(safe), signers, signatures); + } + + function test_fusion_settleSwap_revertsWhenNotExecuted() public { + _setupRequestSwap(); + + (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); + + vm.expectRevert(OneInchSwapModule.SwapNotExecuted.selector); + oneInchModule.settleSwap(address(safe), signers, signatures); + } + + function test_fusion_settleSwap_revertsWhenOrderNotFilled() public { + _setupRequestAndExecuteSwap(); + + // Don't simulate fill — tokens still on Safe + deal(address(weETH), address(safe), MIN_TO_AMOUNT); + + (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); + + vm.expectRevert(OneInchSwapModule.OrderNotFilled.selector); + oneInchModule.settleSwap(address(safe), signers, signatures); + } + + function test_fusion_settleSwap_revertsWhenInsufficientReceived() public { + _setupRequestAndExecuteSwap(); + + // Simulate fill but safe doesn't receive enough toToken + vm.prank(AGGREGATION_ROUTER); + usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); + // Don't deal toToken to safe — balance is 0 + + (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); + + vm.expectRevert(OneInchSwapModule.InsufficientReceivedAmount.selector); + oneInchModule.settleSwap(address(safe), signers, signatures); + } + + function test_fusion_settleSwap_partialFill() public { + _setupRequestAndExecuteSwap(); + + // Simulate partial fill: router only takes half + uint256 halfAmount = SWAP_AMOUNT / 2; + vm.prank(AGGREGATION_ROUTER); + usdc.transferFrom(address(safe), makeAddr("resolver"), halfAmount); + deal(address(weETH), address(safe), MIN_TO_AMOUNT); + + (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); + oneInchModule.settleSwap(address(safe), signers, signatures); + + // Remaining half stays on Safe (never left) + assertEq(usdc.balanceOf(address(safe)), halfAmount); + + // State cleaned up + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + } + + // ────────────────────────────────────────────── + // cancelSwap tests + // ────────────────────────────────────────────── + + function test_fusion_cancelSwap_beforeExecute() public { + _setupRequestSwap(); + + (address[] memory signers, bytes[] memory signatures) = _createCancelSwapSignatures(); + oneInchModule.cancelSwap(address(safe), signers, signatures); + + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + // Tokens still on Safe + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + } + + function test_fusion_cancelSwap_afterExecute() public { + _setupRequestAndExecuteSwap(); + + (address[] memory signers, bytes[] memory signatures) = _createCancelSwapSignatures(); + oneInchModule.cancelSwap(address(safe), signers, signatures); + + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + // Tokens still on Safe + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + // Order hash revoked + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); + // Approval revoked + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); + } + + function test_fusion_cancelSwap_afterPartialFill() public { + _setupRequestAndExecuteSwap(); + + // Simulate partial fill — router took half + uint256 halfAmount = SWAP_AMOUNT / 2; + vm.prank(AGGREGATION_ROUTER); + usdc.transferFrom(address(safe), makeAddr("resolver"), halfAmount); + + (address[] memory signers, bytes[] memory signatures) = _createCancelSwapSignatures(); + oneInchModule.cancelSwap(address(safe), signers, signatures); + + // Half remains on Safe + assertEq(usdc.balanceOf(address(safe)), halfAmount); + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); + } + + function test_fusion_cancelSwap_revertsWhenNoPendingSwap() public { + (address[] memory signers, bytes[] memory signatures) = _createCancelSwapSignatures(); + + vm.expectRevert(OneInchSwapModule.NoPendingSwap.selector); + oneInchModule.cancelSwap(address(safe), signers, signatures); + } + + // ────────────────────────────────────────────── + // cancelBridgeByCashModule tests + // ────────────────────────────────────────────── + + function test_fusion_cancelBridgeByCashModule_beforeExecute() public { + _setupRequestSwap(); + + vm.prank(address(cashModule)); + oneInchModule.cancelBridgeByCashModule(address(safe)); + + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + // Tokens still on Safe + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + } + + function test_fusion_cancelBridgeByCashModule_afterExecute() public { + _setupRequestAndExecuteSwap(); + + vm.prank(address(cashModule)); + oneInchModule.cancelBridgeByCashModule(address(safe)); + + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + // Tokens still on Safe + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + // Order hash revoked + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); + // Approval revoked + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); + } + + function test_fusion_cancelBridgeByCashModule_revertsWhenNotCashModule() public { + vm.prank(makeAddr("random")); + vm.expectRevert(OneInchSwapModule.Unauthorized.selector); + oneInchModule.cancelBridgeByCashModule(address(safe)); + } + + function test_fusion_cancelBridgeByCashModule_noop() public { + vm.prank(address(cashModule)); + oneInchModule.cancelBridgeByCashModule(address(safe)); + } + + // ────────────────────────────────────────────── + // Nonce / replay tests + // ────────────────────────────────────────────── + + function test_fusion_nonce_increments() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + uint256 nonceBefore = safe.nonce(); + + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + + assertEq(safe.nonce(), nonceBefore + 1); + } + + function test_fusion_signatureReplay_reverts() public { + deal(address(usdc), address(safe), SWAP_AMOUNT * 2); + + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + + (address[] memory cancelSigners, bytes[] memory cancelSigs) = _createCancelSwapSignatures(); + oneInchModule.cancelSwap(address(safe), cancelSigners, cancelSigs); + + vm.expectRevert(OneInchSwapModule.InvalidSignatures.selector); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + } + + // ────────────────────────────────────────────── + // Full lifecycle test + // ────────────────────────────────────────────── + + function test_fusion_fullLifecycle() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + + // Request — intent recorded, tokens stay on Safe + (address[] memory reqSigners, bytes[] memory reqSigs) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, reqSigners, reqSigs); + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + + // Execute — Safe approves router, authorizes order hash + (address[] memory execSigners, bytes[] memory execSigs) = _createExecuteSwapSignatures(TEST_ORDER_HASH); + oneInchModule.executeSwap(address(safe), TEST_ORDER_HASH, execSigners, execSigs); + + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0x1626ba7e)); + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), SWAP_AMOUNT); + + // Simulate fill — resolver pulls from Safe, sends toToken to Safe + vm.prank(AGGREGATION_ROUTER); + usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); + deal(address(weETH), address(safe), MIN_TO_AMOUNT); + + // Settle — verify fill, clean up + (address[] memory settleSigners, bytes[] memory settleSigs) = _createSettleSwapSignatures(); + oneInchModule.settleSwap(address(safe), settleSigners, settleSigs); + + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); + } + + // ────────────────────────────────────────────── + // Concurrent swaps — different Safes, same token (no mutex needed) + // ────────────────────────────────────────────── + + // Note: Testing concurrent same-token swaps across different safes requires + // deploying a second safe, which depends on the test harness infrastructure. + // The key architectural point is: since tokens never leave each Safe, there is + // no shared custody and no fungible token attribution problem. + + // ══════════════════════════════════════════════ + // HELPERS + // ══════════════════════════════════════════════ + + function _setupRequestSwap() internal { + deal(address(usdc), address(safe), SWAP_AMOUNT); + + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + ); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + } + + function _setupRequestAndExecuteSwap() internal { + _setupRequestSwap(); + + (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(TEST_ORDER_HASH); + oneInchModule.executeSwap(address(safe), TEST_ORDER_HASH, signers, signatures); + } + + // Unified signature helper + function _createSignatures( + bytes32 selector, + bytes memory data + ) internal view returns (address[] memory, bytes[] memory) { + bytes32 digestHash = keccak256(abi.encodePacked( + selector, + block.chainid, + address(oneInchModule), + safe.nonce(), + address(safe), + data + )).toEthSignedMessageHash(); + + return _signWithOwners(digestHash); + } + + function _createClassicSwapSignatures( + address fromAsset, + address toAsset, + uint256 fromAssetAmount, + uint256 minToAssetAmount, + bytes memory data + ) internal view returns (address[] memory, bytes[] memory) { + return _createSignatures(oneInchModule.SWAP_SIG(), abi.encode(fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data)); + } + + function _createRequestSwapSignatures( + address fromToken, + address toToken, + uint256 fromAmount, + uint256 minToAmount + ) internal view returns (address[] memory, bytes[] memory) { + return _createSignatures(oneInchModule.REQUEST_SWAP_SIG(), abi.encode(fromToken, toToken, fromAmount, minToAmount)); + } + + function _createExecuteSwapSignatures( + bytes32 orderHash + ) internal view returns (address[] memory, bytes[] memory) { + return _createSignatures(oneInchModule.EXECUTE_SWAP_SIG(), abi.encode(orderHash)); + } + + function _createSettleSwapSignatures() internal view returns (address[] memory, bytes[] memory) { + return _createSignatures(oneInchModule.SETTLE_SWAP_SIG(), ""); + } + + function _createCancelSwapSignatures() internal view returns (address[] memory, bytes[] memory) { + return _createSignatures(oneInchModule.CANCEL_SWAP_SIG(), ""); + } + + function _signWithOwners(bytes32 digestHash) internal view returns (address[] memory, bytes[] memory) { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(owner1Pk, digestHash); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(owner2Pk, digestHash); + + address[] memory signers = new address[](2); + signers[0] = owner1; + signers[1] = owner2; + + bytes[] memory signatures = new bytes[](2); + signatures[0] = abi.encodePacked(r1, s1, v1); + signatures[1] = abi.encodePacked(r2, s2, v2); + + return (signers, signatures); + } + + /// @dev Builds mock 1inch swap() calldata with selector 0x12aa3caf + function _buildOneInchSwapCalldata( + address srcToken, + address dstToken, + address dstReceiver, + uint256 amount, + uint256 minReturnAmount + ) internal pure returns (bytes memory) { + OneInchSwapDescription memory desc = OneInchSwapDescription({ + srcToken: IERC20(srcToken), + dstToken: IERC20(dstToken), + srcReceiver: payable(address(0)), + dstReceiver: payable(dstReceiver), + amount: amount, + minReturnAmount: minReturnAmount, + flags: 0 + }); + + return abi.encodeWithSelector( + bytes4(0x12aa3caf), + address(0), // executor + desc, + "", // permit + "" // data + ); + } +} From 0485b49aa39e8123b265bf4b182fffb16d1fc1fb Mon Sep 17 00:00:00 2001 From: Paras Jain Date: Fri, 24 Apr 2026 11:59:12 -0500 Subject: [PATCH 2/2] feat: updated isValidSignature and implementation --- lib/LayerZero-v2 | 1 + lib/devtools | 1 + src/interfaces/IERC1271.sol | 11 + src/interfaces/IEtherFiSafe.sol | 17 +- src/interfaces/IOneInch.sol | 28 -- .../oneinch-swap/OneInchSwapModule.sol | 360 +++++--------- src/safe/EtherFiSafe.sol | 73 +-- src/safe/EtherFiSafeBase.sol | 2 +- src/safe/MultiSig.sol | 2 +- .../oneinch-swap/OneInchSwapModule.t.sol | 463 ++++++------------ 10 files changed, 321 insertions(+), 637 deletions(-) create mode 160000 lib/LayerZero-v2 create mode 160000 lib/devtools create mode 100644 src/interfaces/IERC1271.sol delete mode 100644 src/interfaces/IOneInch.sol diff --git a/lib/LayerZero-v2 b/lib/LayerZero-v2 new file mode 160000 index 00000000..9c741e7f --- /dev/null +++ b/lib/LayerZero-v2 @@ -0,0 +1 @@ +Subproject commit 9c741e7f9790639537b1710a203bcdfd73b0b9ac diff --git a/lib/devtools b/lib/devtools new file mode 160000 index 00000000..128b6978 --- /dev/null +++ b/lib/devtools @@ -0,0 +1 @@ +Subproject commit 128b697838f4b0fd53ae748093fd66cc409ae5c4 diff --git a/src/interfaces/IERC1271.sol b/src/interfaces/IERC1271.sol new file mode 100644 index 00000000..7900efe1 --- /dev/null +++ b/src/interfaces/IERC1271.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +/// @notice EIP-1271 interface for smart contract signature validation +interface IERC1271 { + /// @notice Validates a signature for a given hash + /// @param hash The hash that was signed + /// @param signature The signature bytes + /// @return magicValue 0x1626ba7e if valid, any other value if invalid + function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4); +} diff --git a/src/interfaces/IEtherFiSafe.sol b/src/interfaces/IEtherFiSafe.sol index 8ebee1b2..b8b2cda7 100644 --- a/src/interfaces/IEtherFiSafe.sol +++ b/src/interfaces/IEtherFiSafe.sol @@ -15,7 +15,7 @@ interface IEtherFiSafe { * @custom:throws DuplicateElementFound If the signers array contains duplicate addresses * @custom:throws InvalidSigner If a signer is the zero address or not an owner of the safe */ - function checkSignatures(bytes32 digestHash, address[] calldata signers, bytes[] calldata signatures) external view returns (bool); + function checkSignatures(bytes32 digestHash, address[] memory signers, bytes[] memory signatures) external view returns (bool); /** * @notice Executes a transaction from an authorized module @@ -53,16 +53,9 @@ interface IEtherFiSafe { function isAdmin(address account) external view returns (bool); /** - * @notice Authorizes an order hash for ERC-1271 signature validation - * @dev Can only be called by enabled modules - * @param hash The order hash to authorize - */ - function authorizeOrderHash(bytes32 hash) external; - - /** - * @notice Revokes a previously authorized order hash - * @dev Can only be called by enabled modules - * @param hash The order hash to revoke + * @notice Returns the EIP-712 domain separator used by the Safe + * @dev Modules that verify owner sigs via `checkSignatures` should build digests as + * `keccak256("\x19\x01" || domainSeparator || structHash)` under this domain. */ - function revokeOrderHash(bytes32 hash) external; + function getDomainSeparator() external view returns (bytes32); } diff --git a/src/interfaces/IOneInch.sol b/src/interfaces/IOneInch.sol deleted file mode 100644 index ac863e1f..00000000 --- a/src/interfaces/IOneInch.sol +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - -/// @title 1inch interfaces for OneInchSwapModule -/// @dev Minimal interfaces for both Classic (DEX aggregation) and Fusion (RFQ/intent) swaps - -/// @notice EIP-1271 interface for smart contract signature validation -interface IERC1271 { - /// @notice Validates a signature for a given hash - /// @param hash The hash that was signed - /// @param signature The signature bytes - /// @return magicValue 0x1626ba7e if valid, any other value if invalid - function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4); -} - -/// @notice 1inch Aggregation Router SwapDescription for classic (DEX) swaps -/// @dev Matches the SwapDescription struct in the 1inch v6 router's swap() function -struct OneInchSwapDescription { - IERC20 srcToken; - IERC20 dstToken; - address payable srcReceiver; - address payable dstReceiver; - uint256 amount; - uint256 minReturnAmount; - uint256 flags; -} diff --git a/src/modules/oneinch-swap/OneInchSwapModule.sol b/src/modules/oneinch-swap/OneInchSwapModule.sol index 9a2145b9..cb8139ef 100644 --- a/src/modules/oneinch-swap/OneInchSwapModule.sol +++ b/src/modules/oneinch-swap/OneInchSwapModule.sol @@ -1,15 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; -import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; -import {OneInchSwapDescription} from "../../interfaces/IOneInch.sol"; -import {IEtherFiSafe} from "../../interfaces/IEtherFiSafe.sol"; -import {IBridgeModule} from "../../interfaces/IBridgeModule.sol"; -import {ModuleBase} from "../ModuleBase.sol"; -import {ModuleCheckBalance} from "../ModuleCheckBalance.sol"; +import { IBridgeModule } from "../../interfaces/IBridgeModule.sol"; +import { IEtherFiSafe } from "../../interfaces/IEtherFiSafe.sol"; +import { ModuleBase } from "../ModuleBase.sol"; +import { ModuleCheckBalance } from "../ModuleCheckBalance.sol"; /** * @title OneInchSwapModule @@ -22,17 +20,17 @@ import {ModuleCheckBalance} from "../ModuleCheckBalance.sol"; * Entry point: swap() * * Fusion (intent-based / RFQ): - * Multi-step async flow. The Safe is the "maker" in a 1inch Fusion order. - * Tokens never leave the Safe — the Safe approves the 1inch router and implements ERC-1271 - * (via authorizeOrderHash) so the Limit Order Protocol can verify the order and the resolver - * can pull tokens directly from the Safe via transferFrom. - * Entry points: requestSwap() -> executeSwap() -> settleSwap() + * Two-step async flow. The Safe is the "maker" in a 1inch Fusion order. + * Tokens never leave the Safe — requestSwap() records the intent, registers a pending + * withdrawal with CashModule (preempted by card spend via cancelBridgeByCashModule), + * and approves the 1inch router. The 1inch Limit Order Protocol validates the order via + * EtherFiSafe.isValidSignature (direct multi-owner verification), then the resolver + * pulls fromToken via transferFrom. settleSwap() finalizes once the fill lands. + * Entry points: requestSwap() -> settleSwap() (or cancelSwap() to abort) * * Both modes use the same 1inch Aggregation Router. */ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTransient, IBridgeModule { - using MessageHashUtils for bytes32; - // ────────────────────────────────────────────── // Structs // ────────────────────────────────────────────── @@ -63,61 +61,33 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra mapping(address safe => PendingSwap) public pendingSwaps; // ────────────────────────────────────────────── - // Signature type hashes + // EIP-712 type hashes (unified with EtherFiSafe's signing scheme) // ────────────────────────────────────────────── - /// @notice Classic swap signature type hash - bytes32 public constant SWAP_SIG = keccak256("swap"); - - /// @notice Fusion: request swap signature type hash - bytes32 public constant REQUEST_SWAP_SIG = keccak256("requestSwap"); + /// @notice Classic (DEX aggregation) swap typehash + bytes32 public constant SWAP_TYPEHASH = keccak256("ClassicSwap(address safe,address fromAsset,address toAsset,uint256 fromAssetAmount,uint256 minToAssetAmount,bytes data,uint256 nonce)"); - /// @notice Fusion: execute swap signature type hash - bytes32 public constant EXECUTE_SWAP_SIG = keccak256("executeSwap"); + /// @notice Fusion: request swap typehash + bytes32 public constant REQUEST_SWAP_TYPEHASH = keccak256("RequestSwap(address safe,address fromToken,address toToken,uint256 fromAmount,uint256 minToAmount,bytes32 orderHash,uint256 nonce)"); - /// @notice Fusion: settle swap signature type hash - bytes32 public constant SETTLE_SWAP_SIG = keccak256("settleSwap"); - - /// @notice Fusion: cancel swap signature type hash - bytes32 public constant CANCEL_SWAP_SIG = keccak256("cancelSwap"); + /// @notice Fusion: cancel swap typehash + bytes32 public constant CANCEL_SWAP_TYPEHASH = keccak256("CancelSwap(address safe,uint256 nonce)"); // ────────────────────────────────────────────── // Events // ────────────────────────────────────────────── /// @notice Emitted on a successful classic (DEX) swap - event ClassicSwap( - address indexed safe, - address indexed fromAsset, - address indexed toAsset, - uint256 fromAssetAmount, - uint256 minToAssetAmount, - uint256 returnAmount - ); - - /// @notice Emitted when a Fusion swap is requested (intent recorded) - event FusionSwapRequested( - address indexed safe, - address indexed fromToken, - address indexed toToken, - uint256 fromAmount, - uint256 minToAmount - ); - - /// @notice Emitted when a Fusion swap is executed (order authorized on Safe) - event FusionSwapExecuted(address indexed safe, bytes32 indexed orderHash); + event ClassicSwap(address indexed safe, address indexed fromAsset, address indexed toAsset, uint256 fromAssetAmount, uint256 minToAssetAmount, uint256 returnAmount); + + /// @notice Emitted when a Fusion swap is requested (intent recorded, router approved) + event FusionSwapRequested(address indexed safe, address indexed fromToken, address indexed toToken, uint256 fromAmount, uint256 minToAmount, bytes32 orderHash); /// @notice Emitted when a Fusion swap is settled after fill - event FusionSwapSettled( - address indexed safe, - address indexed fromToken, - address indexed toToken, - uint256 fromAmount, - uint256 receivedAmount - ); + event FusionSwapSettled(address indexed safe, address indexed fromToken, address indexed toToken, uint256 fromAmount, uint256 receivedAmount); /// @notice Emitted when a Fusion swap is cancelled - event FusionSwapCancelled(address indexed safe, address indexed fromToken); + event FusionSwapCancelled(address indexed safe, address indexed fromToken, bytes32 indexed orderHash); // ────────────────────────────────────────────── // Errors @@ -126,13 +96,11 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra error SwappingToSameAsset(); error InvalidSignatures(); error OutputLessThanMinAmount(); - error SlippageTooHigh(); error NoPendingSwap(); error SwapAlreadyPending(); - error SwapAlreadyExecuted(); - error SwapNotExecuted(); error InsufficientReceivedAmount(); error OrderNotFilled(); + error UnexpectedFromBalance(); error NativeETHNotSupported(); error Unauthorized(); @@ -144,11 +112,8 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra * @param _aggregationRouter 1inch Aggregation Router address * @param _dataProvider EtherFi data provider address */ - constructor( - address _aggregationRouter, - address _dataProvider - ) ModuleBase(_dataProvider) ModuleCheckBalance(_dataProvider) { - if (_aggregationRouter == address(0)) revert InvalidInput(); + constructor(address _aggregationRouter, address _dataProvider) ModuleBase(_dataProvider) ModuleCheckBalance(_dataProvider) { + if (_aggregationRouter == address(0) || _aggregationRouter.code.length == 0) revert InvalidInput(); aggregationRouter = _aggregationRouter; } @@ -169,170 +134,135 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra * @param signers Safe owner addresses authorizing this swap * @param signatures Signatures from the signers */ - function swap( - address safe, - address fromAsset, - address toAsset, - uint256 fromAssetAmount, - uint256 minToAssetAmount, - bytes calldata data, - address[] calldata signers, - bytes[] calldata signatures - ) external nonReentrant onlyEtherFiSafe(safe) { - _checkSignatures(SWAP_SIG, safe, abi.encode(fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data), signers, signatures); + function swap(address safe, address fromAsset, address toAsset, uint256 fromAssetAmount, uint256 minToAssetAmount, bytes calldata data, address[] calldata signers, bytes[] calldata signatures) external nonReentrant onlyEtherFiSafe(safe) { + _verifyClassicSwap(safe, fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data, signers, signatures); _classicSwap(safe, fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data); } + /// @dev Extracted to break stack pressure in `requestSwap()`. + function _verifyRequestSwap(address safe, address fromToken, address toToken, uint256 fromAmount, uint256 minToAmount, bytes32 orderHash, address[] calldata signers, bytes[] calldata signatures) internal { + uint256 nonce_ = IEtherFiSafe(safe).useNonce(); + bytes32 structHash = keccak256(abi.encode(REQUEST_SWAP_TYPEHASH, safe, fromToken, toToken, fromAmount, minToAmount, orderHash, nonce_)); + _verifyStructHash(safe, structHash, signers, signatures); + } + + /// @dev Extracted to break stack pressure in `swap()`. + function _verifyClassicSwap(address safe, address fromAsset, address toAsset, uint256 fromAssetAmount, uint256 minToAssetAmount, bytes calldata data, address[] calldata signers, bytes[] calldata signatures) internal { + uint256 nonce_ = IEtherFiSafe(safe).useNonce(); + bytes32 dataHash = keccak256(data); + bytes32 structHash = keccak256(abi.encode(SWAP_TYPEHASH, safe, fromAsset, toAsset, fromAssetAmount, minToAssetAmount, dataHash, nonce_)); + _verifyStructHash(safe, structHash, signers, signatures); + } + // ══════════════════════════════════════════════ // FUSION (RFQ / INTENT) SWAP — Safe-as-Maker // ══════════════════════════════════════════════ /** - * @notice Initiates a Fusion swap by recording the swap intent - * @dev No tokens are moved. The Safe's balance is checked for availability. - * The backend will later call executeSwap() with the 1inch order hash. + * @notice Opens a Fusion swap: records intent, registers pending withdrawal, approves router + * @dev Atomically: + * 1. verifies Safe-owner quorum over (fromToken, toToken, fromAmount, minToAmount, orderHash) + * 2. calls CashModule.requestWithdrawalByModule — card spend can preempt via + * cancelByCashModule, which revokes the router approval and deletes state + * 3. snapshots balances and approves the 1inch router for fromAmount + * The 1inch Limit Order Protocol validates the order against the Safe's + * isValidSignature (direct multi-owner verification); no orderHash whitelisting is needed. * @param safe Address of the EtherFi Safe * @param fromToken Token to sell * @param toToken Token to buy * @param fromAmount Amount of fromToken to sell - * @param minToAmount Minimum amount of toToken expected after fill + * @param minToAmount Minimum amount of toToken expected after fill (net of 1inch fee) + * @param orderHash The 1inch order hash (precomputed by backend, covered by signatures) * @param signers Safe owner addresses authorizing this swap * @param signatures Signatures from the signers */ - function requestSwap( - address safe, - address fromToken, - address toToken, - uint256 fromAmount, - uint256 minToAmount, - address[] calldata signers, - bytes[] calldata signatures - ) external onlyEtherFiSafe(safe) { + function requestSwap(address safe, address fromToken, address toToken, uint256 fromAmount, uint256 minToAmount, bytes32 orderHash, address[] calldata signers, bytes[] calldata signatures) external nonReentrant onlyEtherFiSafe(safe) { if (fromToken == ETH || toToken == ETH) revert NativeETHNotSupported(); if (fromToken == toToken) revert SwappingToSameAsset(); - if (fromAmount == 0 || minToAmount == 0) revert InvalidInput(); + if (fromAmount == 0 || minToAmount == 0 || orderHash == bytes32(0)) revert InvalidInput(); if (pendingSwaps[safe].fromAmount != 0) revert SwapAlreadyPending(); - _checkSignatures(REQUEST_SWAP_SIG, safe, abi.encode(fromToken, toToken, fromAmount, minToAmount), signers, signatures); + _verifyRequestSwap(safe, fromToken, toToken, fromAmount, minToAmount, orderHash, signers, signatures); _checkAmountAvailable(safe, fromToken, fromAmount); - pendingSwaps[safe] = PendingSwap({ - fromToken: fromToken, - toToken: toToken, - fromAmount: fromAmount, - minToAmount: minToAmount, - orderHash: bytes32(0), - fromBalanceBefore: 0, - toBalanceBefore: 0 - }); - - emit FusionSwapRequested(safe, fromToken, toToken, fromAmount, minToAmount); - } - - /** - * @notice Activates the Fusion swap: Safe approves the router and authorizes the order hash - * @dev Called by backend once the 1inch order is constructed. - * The Safe approves the aggregation router for fromAmount and registers the orderHash - * on the Safe for ERC-1271 validation. The resolver can then fill via transferFrom on the Safe. - * @param safe Address of the EtherFi Safe - * @param orderHash The 1inch order hash (precomputed by backend) - * @param signers Safe owner addresses authorizing execution - * @param signatures Signatures from the signers - */ - function executeSwap( - address safe, - bytes32 orderHash, - address[] calldata signers, - bytes[] calldata signatures - ) external nonReentrant onlyEtherFiSafe(safe) { - PendingSwap storage pendingSwap = pendingSwaps[safe]; - if (pendingSwap.fromAmount == 0) revert NoPendingSwap(); - if (pendingSwap.orderHash != bytes32(0)) revert SwapAlreadyExecuted(); - if (orderHash == bytes32(0)) revert InvalidInput(); - - _checkSignatures(EXECUTE_SWAP_SIG, safe, abi.encode(orderHash), signers, signatures); + // Register a pending withdrawal on CashModule so card spend can preempt via cancelBridgeByCashModule + cashModule.requestWithdrawalByModule(safe, fromToken, fromAmount); - // Snapshot balances before the fill - pendingSwap.fromBalanceBefore = IERC20(pendingSwap.fromToken).balanceOf(safe); - pendingSwap.toBalanceBefore = IERC20(pendingSwap.toToken).balanceOf(safe); - pendingSwap.orderHash = orderHash; + pendingSwaps[safe] = PendingSwap({ fromToken: fromToken, toToken: toToken, fromAmount: fromAmount, minToAmount: minToAmount, orderHash: orderHash, fromBalanceBefore: IERC20(fromToken).balanceOf(safe), toBalanceBefore: IERC20(toToken).balanceOf(safe) }); // Safe approves router to pull fromTokens address[] memory to = new address[](1); uint256[] memory values = new uint256[](1); bytes[] memory data = new bytes[](1); - to[0] = pendingSwap.fromToken; - data[0] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, pendingSwap.fromAmount); + to[0] = fromToken; + data[0] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, fromAmount); IEtherFiSafe(safe).execTransactionFromModule(to, values, data); - // Authorize order hash on Safe for ERC-1271 validation - IEtherFiSafe(safe).authorizeOrderHash(orderHash); - - emit FusionSwapExecuted(safe, orderHash); + emit FusionSwapRequested(safe, fromToken, toToken, fromAmount, minToAmount, orderHash); } /** * @notice Finalizes the Fusion swap after the 1inch order has been filled - * @dev Called by backend once the order fill is confirmed on-chain. - * Verifies that the Safe's fromToken balance decreased (order filled) and - * the Safe received at least minToAmount of toToken since executeSwap. + * @dev Callable only by Safe admins (single admin suffices — no quorum). The correctness + * gates are post-hoc: fromToken balance must have decreased (OrderNotFilled check) + * and toToken balance must have increased by at least minToAmount (set at request + * time under full owner quorum). No partial fills per design, so a premature settle + * cannot truncate remaining fills. + * Releases the pending withdrawal on CashModule and revokes the router approval. * @param safe Address of the EtherFi Safe - * @param signers Safe owner addresses authorizing settlement - * @param signatures Signatures from the signers */ - function settleSwap( - address safe, - address[] calldata signers, - bytes[] calldata signatures - ) external nonReentrant onlyEtherFiSafe(safe) { + function settleSwap(address safe) external nonReentrant onlyEtherFiSafe(safe) { + if (!IEtherFiSafe(safe).isAdmin(msg.sender)) revert Unauthorized(); + PendingSwap memory pendingSwap = pendingSwaps[safe]; if (pendingSwap.fromAmount == 0) revert NoPendingSwap(); - if (pendingSwap.orderHash == bytes32(0)) revert SwapNotExecuted(); - _checkSignatures(SETTLE_SWAP_SIG, safe, "", signers, signatures); - - // Verify the order was filled: Safe's fromToken balance must have decreased + // No partial fills by design — fromToken balance must have dropped by exactly fromAmount. + // A strict equality (vs. "any decrease") defends against future modules that also + // move fromToken behind our back from silently satisfying this check. uint256 currentFromBalance = IERC20(pendingSwap.fromToken).balanceOf(safe); - if (currentFromBalance >= pendingSwap.fromBalanceBefore) revert OrderNotFilled(); + if (currentFromBalance > pendingSwap.fromBalanceBefore - pendingSwap.fromAmount) revert OrderNotFilled(); + if (currentFromBalance < pendingSwap.fromBalanceBefore - pendingSwap.fromAmount) revert UnexpectedFromBalance(); - // Verify the Safe received enough toToken since executeSwap + // Verify the Safe received enough toToken since requestSwap uint256 currentToBalance = IERC20(pendingSwap.toToken).balanceOf(safe); uint256 received = currentToBalance - pendingSwap.toBalanceBefore; if (received < pendingSwap.minToAmount) revert InsufficientReceivedAmount(); - // Revoke router approval and order hash on Safe - _revokeApprovalAndOrderHash(safe, pendingSwap.fromToken, pendingSwap.orderHash); + _revokeApproval(safe, pendingSwap.fromToken); + // Delete local state BEFORE releasing the CashModule withdrawal. cancelWithdrawalByModule + // triggers _cancelOldWithdrawal → IBridgeModule(this).cancelBridgeByCashModule(safe) as a + // callback; with fromAmount already zero, the callback short-circuits (no duplicate event, + // no redundant approval revoke). delete pendingSwaps[safe]; + cashModule.cancelWithdrawalByModule(safe); emit FusionSwapSettled(safe, pendingSwap.fromToken, pendingSwap.toToken, pendingSwap.fromAmount, received); } /** - * @notice Cancels a pending Fusion swap at any stage before settlement - * @dev If executeSwap was called, revokes the router approval and order hash on the Safe. + * @notice Cancels a pending Fusion swap before settlement + * @dev Revokes the router approval and releases the pending withdrawal on CashModule. * Tokens remain on the Safe throughout — nothing to transfer back. * @param safe Address of the EtherFi Safe * @param signers Safe owner addresses authorizing cancellation * @param signatures Signatures from the signers */ - function cancelSwap( - address safe, - address[] calldata signers, - bytes[] calldata signatures - ) external nonReentrant onlyEtherFiSafe(safe) { + function cancelSwap(address safe, address[] calldata signers, bytes[] calldata signatures) external nonReentrant onlyEtherFiSafe(safe) { PendingSwap memory pendingSwap = pendingSwaps[safe]; if (pendingSwap.fromAmount == 0) revert NoPendingSwap(); - _checkSignatures(CANCEL_SWAP_SIG, safe, "", signers, signatures); + bytes32 structHash = keccak256(abi.encode(CANCEL_SWAP_TYPEHASH, safe, IEtherFiSafe(safe).useNonce())); + _verifyStructHash(safe, structHash, signers, signatures); - if (pendingSwap.orderHash != bytes32(0)) { - _revokeApprovalAndOrderHash(safe, pendingSwap.fromToken, pendingSwap.orderHash); - } + _revokeApproval(safe, pendingSwap.fromToken); + // Delete before releasing the CashModule withdrawal (callback short-circuits — see settleSwap) delete pendingSwaps[safe]; + cashModule.cancelWithdrawalByModule(safe); - emit FusionSwapCancelled(safe, pendingSwap.fromToken); + emit FusionSwapCancelled(safe, pendingSwap.fromToken, pendingSwap.orderHash); } // ────────────────────────────────────────────── @@ -340,22 +270,24 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra // ────────────────────────────────────────────── /** - * @notice Called by CashModule to force-cancel a pending swap (e.g. during liquidation) - * @dev Revokes any active approval and order hash on the Safe, then cleans up state. + * @notice Called by CashModule to force-cancel a pending swap (e.g. card spend preemption, liquidation) + * @dev Revokes router approval and cleans up state. Does NOT call cancelWithdrawalByModule + * since CashModule is already inside _cancelOldWithdrawal when we are invoked. + * No nonReentrant: the sole legitimate caller is CashModule (msg.sender check below), + * and this function is also re-entered via cancelWithdrawalByModule from our own + * settleSwap/cancelSwap — adding nonReentrant would clash with those guards. * @param safe Address of the EtherFi Safe */ - function cancelBridgeByCashModule(address safe) external nonReentrant { + function cancelBridgeByCashModule(address safe) external { if (msg.sender != etherFiDataProvider.getCashModule()) revert Unauthorized(); PendingSwap memory pendingSwap = pendingSwaps[safe]; if (pendingSwap.fromAmount == 0) return; - if (pendingSwap.orderHash != bytes32(0)) { - _revokeApprovalAndOrderHash(safe, pendingSwap.fromToken, pendingSwap.orderHash); - } - delete pendingSwaps[safe]; - emit FusionSwapCancelled(safe, pendingSwap.fromToken); + _revokeApproval(safe, pendingSwap.fromToken); + + emit FusionSwapCancelled(safe, pendingSwap.fromToken, pendingSwap.orderHash); } // ────────────────────────────────────────────── @@ -375,19 +307,11 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra // INTERNAL: Classic Swap Logic // ══════════════════════════════════════════════ - function _classicSwap( - address safe, - address fromAsset, - address toAsset, - uint256 fromAssetAmount, - uint256 minToAssetAmount, - bytes calldata data - ) internal { + function _classicSwap(address safe, address fromAsset, address toAsset, uint256 fromAssetAmount, uint256 minToAssetAmount, bytes calldata data) internal { if (fromAsset == toAsset) revert SwappingToSameAsset(); if (minToAssetAmount == 0) revert InvalidInput(); _checkAmountAvailable(safe, fromAsset, fromAssetAmount); - _validateClassicSwapData(safe, fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data); uint256 balBefore; if (toAsset == ETH) balBefore = address(safe).balance; @@ -411,11 +335,7 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra emit ClassicSwap(safe, fromAsset, toAsset, fromAssetAmount, minToAssetAmount, receivedAmt); } - function _classicSwapERC20( - address fromAsset, - uint256 fromAssetAmount, - bytes calldata data - ) internal view returns (address[] memory to, uint256[] memory value, bytes[] memory callData) { + function _classicSwapERC20(address fromAsset, uint256 fromAssetAmount, bytes calldata data) internal view returns (address[] memory to, uint256[] memory value, bytes[] memory callData) { to = new address[](3); value = new uint256[](3); callData = new bytes[](3); @@ -430,10 +350,7 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra callData[2] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, 0); } - function _classicSwapNative( - uint256 fromAssetAmount, - bytes calldata data - ) internal view returns (address[] memory to, uint256[] memory value, bytes[] memory callData) { + function _classicSwapNative(uint256 fromAssetAmount, bytes calldata data) internal view returns (address[] memory to, uint256[] memory value, bytes[] memory callData) { to = new address[](1); value = new uint256[](1); callData = new bytes[](1); @@ -443,75 +360,32 @@ contract OneInchSwapModule is ModuleBase, ModuleCheckBalance, ReentrancyGuardTra callData[0] = data; } - /** - * @notice Validates the 1inch classic swap calldata when the selector matches swap() - * @dev Decodes the SwapDescription from the 1inch router's swap() function and validates - * that the parameters match what was signed by the Safe owners. - * Selector 0x12aa3caf = swap(address,(address,address,address,address,uint256,uint256,uint256),bytes,bytes) - * For other 1inch router functions (unoswapTo, etc.), the balance check in _classicSwap provides safety. - */ - function _validateClassicSwapData( - address safe, - address fromAsset, - address toAsset, - uint256 fromAssetAmount, - uint256 minToAssetAmount, - bytes calldata data - ) internal pure { - if (data.length >= 4 && bytes4(data[:4]) == bytes4(0x12aa3caf)) { - (, OneInchSwapDescription memory desc,,) = abi.decode(data[4:], (address, OneInchSwapDescription, bytes, bytes)); - - if ( - address(desc.srcToken) != fromAsset || - address(desc.dstToken) != toAsset || - desc.dstReceiver != payable(safe) || - desc.amount != fromAssetAmount - ) revert InvalidInput(); - - if (desc.minReturnAmount < minToAssetAmount) revert SlippageTooHigh(); - } - } - // ══════════════════════════════════════════════ // INTERNAL: Fusion Helpers // ══════════════════════════════════════════════ /** - * @dev Revokes the router's approval and the order hash on the Safe + * @dev Revokes the router's fromToken approval on the Safe */ - function _revokeApprovalAndOrderHash(address safe, address fromToken, bytes32 orderHash) internal { - // Revoke router approval + function _revokeApproval(address safe, address fromToken) internal { address[] memory to = new address[](1); uint256[] memory values = new uint256[](1); bytes[] memory data = new bytes[](1); to[0] = fromToken; data[0] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, 0); IEtherFiSafe(safe).execTransactionFromModule(to, values, data); - - // Revoke order hash on Safe - IEtherFiSafe(safe).revokeOrderHash(orderHash); } // ══════════════════════════════════════════════ - // INTERNAL: Signature Verification + // INTERNAL: EIP-712 Signature Verification // ══════════════════════════════════════════════ - function _checkSignatures( - bytes32 selector, - address safe, - bytes memory data, - address[] calldata signers, - bytes[] calldata signatures - ) internal { - bytes32 digestHash = keccak256(abi.encodePacked( - selector, - block.chainid, - address(this), - IEtherFiSafe(safe).useNonce(), - safe, - data - )).toEthSignedMessageHash(); - + /** + * @dev Verifies an EIP-712 structured signature under the Safe's domain. + * Caller computes `structHash` from the appropriate TYPEHASH + fields + fresh nonce. + */ + function _verifyStructHash(address safe, bytes32 structHash, address[] calldata signers, bytes[] calldata signatures) internal view { + bytes32 digestHash = keccak256(abi.encodePacked("\x19\x01", IEtherFiSafe(safe).getDomainSeparator(), structHash)); if (!IEtherFiSafe(safe).checkSignatures(digestHash, signers, signatures)) revert InvalidSignatures(); } } diff --git a/src/safe/EtherFiSafe.sol b/src/safe/EtherFiSafe.sol index 70b032ae..423f8b61 100644 --- a/src/safe/EtherFiSafe.sol +++ b/src/safe/EtherFiSafe.sol @@ -7,7 +7,7 @@ import { EnumerableSetLib } from "solady/utils/EnumerableSetLib.sol"; import { IEtherFiDataProvider } from "../interfaces/IEtherFiDataProvider.sol"; -import { IERC1271 } from "../interfaces/IOneInch.sol"; +import { IERC1271 } from "../interfaces/IERC1271.sol"; import { IEtherFiHook } from "../interfaces/IEtherFiHook.sol"; import { IRoleRegistry } from "../interfaces/IRoleRegistry.sol"; import { ArrayDeDupLib } from "../libraries/ArrayDeDupLib.sol"; @@ -29,27 +29,6 @@ contract EtherFiSafe is EtherFiSafeBase, ModuleManager, RecoveryManager, MultiSi using EnumerableSetLib for EnumerableSetLib.AddressSet; using ArrayDeDupLib for address[]; - /// @custom:storage-location erc7201:etherfi.storage.ERC1271 - struct ERC1271Storage { - /// @notice Order hashes authorized by modules for ERC-1271 validation - mapping(bytes32 orderHash => bool authorized) authorizedOrderHashes; - } - - // keccak256(abi.encode(uint256(keccak256("etherfi.storage.ERC1271")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant ERC1271StorageLocation = 0x95f3323d4da52a4232223e5d284728adc716e07261fde28fdddfa1e90df38000; - - function _getERC1271Storage() internal pure returns (ERC1271Storage storage $) { - assembly { - $.slot := ERC1271StorageLocation - } - } - - /// @notice Emitted when a module authorizes an order hash - event OrderHashAuthorized(address indexed module, bytes32 indexed orderHash); - - /// @notice Emitted when a module revokes an order hash - event OrderHashRevoked(address indexed module, bytes32 indexed orderHash); - /** * @notice Contract constructor * @dev Sets the immutable data provider reference @@ -259,37 +238,35 @@ contract EtherFiSafe is EtherFiSafeBase, ModuleManager, RecoveryManager, MultiSi // ══════════════════════════════════════════════ /** - * @notice Authorizes an order hash for ERC-1271 signature validation - * @param hash The order hash to authorize - * @custom:throws OnlyModules If the caller is not an enabled module - */ - function authorizeOrderHash(bytes32 hash) external { - if (!isModuleEnabled(msg.sender)) revert OnlyModules(); - _getERC1271Storage().authorizedOrderHashes[hash] = true; - emit OrderHashAuthorized(msg.sender, hash); - } - - /** - * @notice Revokes a previously authorized order hash - * @param hash The order hash to revoke - * @custom:throws OnlyModules If the caller is not an enabled module + * @notice ERC-1271 signature validation via direct multi-owner quorum verification + * @dev The `signature` blob is ABI-encoded `(address[] signers, bytes[] sigs)`. The + * hash is considered valid iff `checkSignatures` passes against those inputs. + * Callers that consume this (e.g. 1inch Limit Order Protocol) must provide a + * blob produced by Safe owners signing the given hash. Malformed blobs and + * `checkSignatures` reverts both map to 0xffffffff (no propagation). + * @param hash The hash to validate + * @param signature ABI-encoded (address[] signers, bytes[] sigs) + * @return magicValue 0x1626ba7e if signatures meet quorum, 0xffffffff otherwise */ - function revokeOrderHash(bytes32 hash) external { - if (!isModuleEnabled(msg.sender)) revert OnlyModules(); - delete _getERC1271Storage().authorizedOrderHashes[hash]; - emit OrderHashRevoked(msg.sender, hash); + function isValidSignature(bytes32 hash, bytes calldata signature) external view override returns (bytes4) { + try this.checkSignatureBlob(hash, signature) returns (bool ok) { + return ok ? bytes4(0x1626ba7e) : bytes4(0xffffffff); + } catch { + return 0xffffffff; + } } /** - * @notice ERC-1271 signature validation - * @param hash The hash to validate - * @return magicValue 0x1626ba7e if authorized, 0xffffffff otherwise + * @notice Helper for `isValidSignature` — decodes `(address[], bytes[])` and verifies quorum + * @dev Exposed as external so `isValidSignature` can wrap the call in try/catch. + * Safe for external callers: pure view, no state changes. + * @param hash The hash being validated + * @param signature ABI-encoded (address[] signers, bytes[] sigs) + * @return True if signatures meet quorum */ - function isValidSignature(bytes32 hash, bytes calldata) external view override returns (bytes4) { - if (_getERC1271Storage().authorizedOrderHashes[hash]) { - return 0x1626ba7e; - } - return 0xffffffff; + function checkSignatureBlob(bytes32 hash, bytes calldata signature) external view returns (bool) { + (address[] memory signers, bytes[] memory sigs) = abi.decode(signature, (address[], bytes[])); + return checkSignatures(hash, signers, sigs); } /** diff --git a/src/safe/EtherFiSafeBase.sol b/src/safe/EtherFiSafeBase.sol index a02dfe2e..a2ce3168 100644 --- a/src/safe/EtherFiSafeBase.sol +++ b/src/safe/EtherFiSafeBase.sol @@ -146,7 +146,7 @@ abstract contract EtherFiSafeBase is EtherFiSafeErrors, EIP712Upgradeable { * @return bool True if the signatures are valid and meet the threshold requirements * @dev Implementation varies based on the inheriting contract */ - function checkSignatures(bytes32 digestHash, address[] calldata signers, bytes[] calldata signatures) public view virtual returns (bool); + function checkSignatures(bytes32 digestHash, address[] memory signers, bytes[] memory signatures) public view virtual returns (bool); /** * @dev Consumes a nonce for replay protection diff --git a/src/safe/MultiSig.sol b/src/safe/MultiSig.sol index 5cf406ae..fdde1ad8 100644 --- a/src/safe/MultiSig.sol +++ b/src/safe/MultiSig.sol @@ -223,7 +223,7 @@ abstract contract MultiSig is EtherFiSafeBase { * @custom:throws DuplicateElementFound If the signers array contains duplicate addresses * @custom:throws InvalidSigner If a signer is the zero address or not an owner of the safe */ - function checkSignatures(bytes32 digestHash, address[] calldata signers, bytes[] calldata signatures) public view override returns (bool) { + function checkSignatures(bytes32 digestHash, address[] memory signers, bytes[] memory signatures) public view override returns (bool) { MultiSigStorage storage $ = _getMultiSigStorage(); uint256 len = signers.length; diff --git a/test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol b/test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol index aeb9b638..24e00ba9 100644 --- a/test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol +++ b/test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol @@ -1,19 +1,15 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.28; -import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol"; import {Test} from "forge-std/Test.sol"; import {OneInchSwapModule, ModuleBase, ModuleCheckBalance} from "../../../../src/modules/oneinch-swap/OneInchSwapModule.sol"; -import {OneInchSwapDescription} from "../../../../src/interfaces/IOneInch.sol"; import {ArrayDeDupLib, EtherFiDataProvider, EtherFiSafe, EtherFiSafeErrors, SafeTestSetup, IDebtManager} from "../../SafeTestSetup.t.sol"; import {ICashModule} from "../../../../src/interfaces/ICashModule.sol"; import {CashVerificationLib} from "../../../../src/libraries/CashVerificationLib.sol"; contract OneInchSwapModuleTest is SafeTestSetup { - using MessageHashUtils for bytes32; - OneInchSwapModule public oneInchModule; // 1inch Aggregation Router on Optimism @@ -36,8 +32,11 @@ contract OneInchSwapModuleTest is SafeTestSetup { bool[] memory shouldWhitelist = new bool[](1); shouldWhitelist[0] = true; - vm.prank(owner); + vm.startPrank(owner); dataProvider.configureModules(modules, shouldWhitelist); + // Allow module to register pending withdrawals on CashModule (Fusion flow) + cashModule.configureModulesCanRequestWithdraw(modules, shouldWhitelist); + vm.stopPrank(); // Enable module on safe bytes[] memory setupData = new bytes[](1); @@ -107,46 +106,6 @@ contract OneInchSwapModuleTest is SafeTestSetup { oneInchModule.swap(fakeSafe, address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, data, signers, signatures); } - function test_classic_swap_validatesSwapDescription() public { - deal(address(usdc), address(safe), SWAP_AMOUNT); - - // Build calldata with 1inch swap() selector but wrong dstReceiver - bytes memory swapData = _buildOneInchSwapCalldata( - address(usdc), // srcToken - address(weETH), // dstToken - makeAddr("wrong"), // dstReceiver (wrong - should be safe) - SWAP_AMOUNT, - MIN_TO_AMOUNT - ); - - (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, swapData - ); - - vm.expectRevert(ModuleBase.InvalidInput.selector); - oneInchModule.swap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, swapData, signers, signatures); - } - - function test_classic_swap_validatesSlippage() public { - deal(address(usdc), address(safe), SWAP_AMOUNT); - - // Build calldata where minReturnAmount in desc < minToAssetAmount - bytes memory swapData = _buildOneInchSwapCalldata( - address(usdc), - address(weETH), - address(safe), - SWAP_AMOUNT, - MIN_TO_AMOUNT - 1 // Less than what we're requiring - ); - - (address[] memory signers, bytes[] memory signatures) = _createClassicSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, swapData - ); - - vm.expectRevert(OneInchSwapModule.SlippageTooHigh.selector); - oneInchModule.swap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, swapData, signers, signatures); - } - function test_classic_swap_nonceNotConsumedOnRevert() public { deal(address(usdc), address(safe), SWAP_AMOUNT); uint256 nonceBefore = safe.nonce(); @@ -168,292 +127,226 @@ contract OneInchSwapModuleTest is SafeTestSetup { // ══════════════════════════════════════════════ // ────────────────────────────────────────────── - // requestSwap tests + // requestSwap tests (merged request+execute) // ────────────────────────────────────────────── function test_fusion_requestSwap_works() public { deal(address(usdc), address(safe), SWAP_AMOUNT); (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH ); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); OneInchSwapModule.PendingSwap memory pendingSwap = oneInchModule.getPendingSwap(address(safe)); assertEq(pendingSwap.fromToken, address(usdc)); assertEq(pendingSwap.toToken, address(weETH)); assertEq(pendingSwap.fromAmount, SWAP_AMOUNT); assertEq(pendingSwap.minToAmount, MIN_TO_AMOUNT); - assertEq(pendingSwap.orderHash, bytes32(0)); + assertEq(pendingSwap.orderHash, TEST_ORDER_HASH); + assertEq(pendingSwap.fromBalanceBefore, SWAP_AMOUNT); + assertEq(pendingSwap.toBalanceBefore, 0); - // Tokens stay on Safe assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), SWAP_AMOUNT); + assertEq(cashModule.getData(address(safe)).pendingWithdrawalRequest.recipient, address(oneInchModule)); } function test_fusion_requestSwap_revertsWithNativeETH() public { address ETH_ADDR = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - ETH_ADDR, address(weETH), 1 ether, MIN_TO_AMOUNT + ETH_ADDR, address(weETH), 1 ether, MIN_TO_AMOUNT, TEST_ORDER_HASH ); vm.expectRevert(OneInchSwapModule.NativeETHNotSupported.selector); - oneInchModule.requestSwap(address(safe), ETH_ADDR, address(weETH), 1 ether, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), ETH_ADDR, address(weETH), 1 ether, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); } function test_fusion_requestSwap_revertsWhenSameAsset() public { deal(address(usdc), address(safe), SWAP_AMOUNT); (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(usdc), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(usdc), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH ); vm.expectRevert(OneInchSwapModule.SwappingToSameAsset.selector); - oneInchModule.requestSwap(address(safe), address(usdc), address(usdc), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(usdc), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); } function test_fusion_requestSwap_revertsWhenZeroAmount() public { (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(weETH), 0, MIN_TO_AMOUNT + address(usdc), address(weETH), 0, MIN_TO_AMOUNT, TEST_ORDER_HASH + ); + + vm.expectRevert(ModuleBase.InvalidInput.selector); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), 0, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); + } + + function test_fusion_requestSwap_revertsWhenZeroOrderHash() public { + (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, bytes32(0) ); vm.expectRevert(ModuleBase.InvalidInput.selector); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), 0, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, bytes32(0), signers, signatures); } function test_fusion_requestSwap_revertsWhenInsufficientBalance() public { (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH ); vm.expectRevert(ModuleCheckBalance.InsufficientAvailableBalanceOnSafe.selector); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); } function test_fusion_requestSwap_revertsWhenSwapAlreadyPending() public { deal(address(usdc), address(safe), SWAP_AMOUNT * 2); (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH ); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); + bytes32 orderHash2 = keccak256("second-order"); (address[] memory signers2, bytes[] memory signatures2) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, orderHash2 ); vm.expectRevert(OneInchSwapModule.SwapAlreadyPending.selector); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers2, signatures2); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, orderHash2, signers2, signatures2); } function test_fusion_requestSwap_revertsWithInvalidSignatures() public { deal(address(usdc), address(safe), SWAP_AMOUNT); + // Signers authorize a different fromAmount than what's passed to requestSwap (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT + 1, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT + 1, MIN_TO_AMOUNT, TEST_ORDER_HASH ); vm.expectRevert(OneInchSwapModule.InvalidSignatures.selector); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); } // ────────────────────────────────────────────── - // executeSwap tests + // ERC-1271 direct-validation tests (on Safe) // ────────────────────────────────────────────── - function test_fusion_executeSwap_works() public { - _setupRequestSwap(); - - (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(TEST_ORDER_HASH); - oneInchModule.executeSwap(address(safe), TEST_ORDER_HASH, signers, signatures); - - OneInchSwapModule.PendingSwap memory pendingSwap = oneInchModule.getPendingSwap(address(safe)); - assertEq(pendingSwap.orderHash, TEST_ORDER_HASH); - assertEq(pendingSwap.fromBalanceBefore, SWAP_AMOUNT); - assertEq(pendingSwap.toBalanceBefore, 0); - - // Tokens still on Safe - assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); - - // Safe authorized the order hash (ERC-1271) - assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0x1626ba7e)); - - // Router has approval from Safe - assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), SWAP_AMOUNT); + function test_fusion_isValidSignature_validQuorumReturnsMagic() public view { + bytes memory sigBlob = _buildOwnerSignatureBlob(TEST_ORDER_HASH); + assertEq(safe.isValidSignature(TEST_ORDER_HASH, sigBlob), bytes4(0x1626ba7e)); } - function test_fusion_executeSwap_revertsWhenNoPendingSwap() public { - (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(TEST_ORDER_HASH); + function test_fusion_isValidSignature_nonOwnerSignerReturnsFail() public { + (address nonOwner, uint256 nonOwnerPk) = makeAddrAndKey("nonOwner"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(nonOwnerPk, TEST_ORDER_HASH); - vm.expectRevert(OneInchSwapModule.NoPendingSwap.selector); - oneInchModule.executeSwap(address(safe), TEST_ORDER_HASH, signers, signatures); - } - - function test_fusion_executeSwap_revertsWhenZeroOrderHash() public { - _setupRequestSwap(); + address[] memory signers = new address[](1); + signers[0] = nonOwner; + bytes[] memory sigs = new bytes[](1); + sigs[0] = abi.encodePacked(r, s, v); - (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(bytes32(0)); - - vm.expectRevert(ModuleBase.InvalidInput.selector); - oneInchModule.executeSwap(address(safe), bytes32(0), signers, signatures); + assertEq(safe.isValidSignature(TEST_ORDER_HASH, abi.encode(signers, sigs)), bytes4(0xffffffff)); } - function test_fusion_executeSwap_revertsWhenAlreadyExecuted() public { - _setupRequestAndExecuteSwap(); - - bytes32 newHash = keccak256("new-hash"); - (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(newHash); - - vm.expectRevert(OneInchSwapModule.SwapAlreadyExecuted.selector); - oneInchModule.executeSwap(address(safe), newHash, signers, signatures); + function test_fusion_isValidSignature_malformedBlobReturnsFail() public view { + assertEq(safe.isValidSignature(TEST_ORDER_HASH, hex"deadbeef"), bytes4(0xffffffff)); } - // ────────────────────────────────────────────── - // isValidSignature tests (on Safe, not module) - // ────────────────────────────────────────────── - - function test_fusion_isValidSignature_authorized() public { - _setupRequestAndExecuteSwap(); - assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0x1626ba7e)); + function test_fusion_isValidSignature_emptyBlobReturnsFail() public view { + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); } - function test_fusion_isValidSignature_unauthorized() public view { - assertEq(safe.isValidSignature(keccak256("random"), ""), bytes4(0xffffffff)); + function test_fusion_isValidSignature_mismatchedHashReturnsFail() public view { + bytes memory sigBlob = _buildOwnerSignatureBlob(TEST_ORDER_HASH); + assertEq(safe.isValidSignature(keccak256("other"), sigBlob), bytes4(0xffffffff)); } // ────────────────────────────────────────────── - // settleSwap tests + // settleSwap tests (admin-only, no signatures) // ────────────────────────────────────────────── function test_fusion_settleSwap_works() public { - _setupRequestAndExecuteSwap(); + _setupRequestSwap(); - // Simulate fill: resolver pulls fromToken from Safe, sends toToken to Safe + // Simulate full fill: resolver pulls fromToken from Safe, sends toToken to Safe vm.prank(AGGREGATION_ROUTER); usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); deal(address(weETH), address(safe), MIN_TO_AMOUNT); - (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); - oneInchModule.settleSwap(address(safe), signers, signatures); + // Any safe admin can settle (owners are admins by default) + vm.prank(owner1); + oneInchModule.settleSwap(address(safe)); - // State cleaned up - OneInchSwapModule.PendingSwap memory pendingSwap = oneInchModule.getPendingSwap(address(safe)); - assertEq(pendingSwap.fromAmount, 0); - - // Order hash revoked - assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); - - // Approval revoked + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); + assertEq(cashModule.getData(address(safe)).pendingWithdrawalRequest.recipient, address(0)); } - function test_fusion_settleSwap_revertsWhenNoPendingSwap() public { - (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); - - vm.expectRevert(OneInchSwapModule.NoPendingSwap.selector); - oneInchModule.settleSwap(address(safe), signers, signatures); - } - - function test_fusion_settleSwap_revertsWhenNotExecuted() public { + function test_fusion_settleSwap_revertsWhenNotAdmin() public { _setupRequestSwap(); - (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); + vm.prank(AGGREGATION_ROUTER); + usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); + deal(address(weETH), address(safe), MIN_TO_AMOUNT); - vm.expectRevert(OneInchSwapModule.SwapNotExecuted.selector); - oneInchModule.settleSwap(address(safe), signers, signatures); + vm.prank(makeAddr("random")); + vm.expectRevert(OneInchSwapModule.Unauthorized.selector); + oneInchModule.settleSwap(address(safe)); + } + + function test_fusion_settleSwap_revertsWhenNoPendingSwap() public { + vm.prank(owner1); + vm.expectRevert(OneInchSwapModule.NoPendingSwap.selector); + oneInchModule.settleSwap(address(safe)); } function test_fusion_settleSwap_revertsWhenOrderNotFilled() public { - _setupRequestAndExecuteSwap(); + _setupRequestSwap(); // Don't simulate fill — tokens still on Safe deal(address(weETH), address(safe), MIN_TO_AMOUNT); - (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); - + vm.prank(owner1); vm.expectRevert(OneInchSwapModule.OrderNotFilled.selector); - oneInchModule.settleSwap(address(safe), signers, signatures); + oneInchModule.settleSwap(address(safe)); } function test_fusion_settleSwap_revertsWhenInsufficientReceived() public { - _setupRequestAndExecuteSwap(); + _setupRequestSwap(); - // Simulate fill but safe doesn't receive enough toToken vm.prank(AGGREGATION_ROUTER); usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); - // Don't deal toToken to safe — balance is 0 - - (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); + // Don't deal toToken to safe + vm.prank(owner1); vm.expectRevert(OneInchSwapModule.InsufficientReceivedAmount.selector); - oneInchModule.settleSwap(address(safe), signers, signatures); + oneInchModule.settleSwap(address(safe)); } - function test_fusion_settleSwap_partialFill() public { - _setupRequestAndExecuteSwap(); - - // Simulate partial fill: router only takes half - uint256 halfAmount = SWAP_AMOUNT / 2; - vm.prank(AGGREGATION_ROUTER); - usdc.transferFrom(address(safe), makeAddr("resolver"), halfAmount); - deal(address(weETH), address(safe), MIN_TO_AMOUNT); - - (address[] memory signers, bytes[] memory signatures) = _createSettleSwapSignatures(); - oneInchModule.settleSwap(address(safe), signers, signatures); - - // Remaining half stays on Safe (never left) - assertEq(usdc.balanceOf(address(safe)), halfAmount); - - // State cleaned up - assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); - } + // NOTE: partial-fill tests removed per design D5 (no partial fills allowed). + // A partial fill would leave safe balance < registered pendingWithdrawalAmount, which + // underflows CashLens.getUserTotalCollateral (balance - pending) during postOpHook → + // DebtManagerCore.ensureHealth. That's a CashLens edge case, but the design forbids + // the precondition from occurring. // ────────────────────────────────────────────── // cancelSwap tests // ────────────────────────────────────────────── - function test_fusion_cancelSwap_beforeExecute() public { + function test_fusion_cancelSwap_works() public { _setupRequestSwap(); (address[] memory signers, bytes[] memory signatures) = _createCancelSwapSignatures(); oneInchModule.cancelSwap(address(safe), signers, signatures); assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); - // Tokens still on Safe assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); - } - - function test_fusion_cancelSwap_afterExecute() public { - _setupRequestAndExecuteSwap(); - - (address[] memory signers, bytes[] memory signatures) = _createCancelSwapSignatures(); - oneInchModule.cancelSwap(address(safe), signers, signatures); - - assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); - // Tokens still on Safe - assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); - // Order hash revoked - assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); - // Approval revoked assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); - } - - function test_fusion_cancelSwap_afterPartialFill() public { - _setupRequestAndExecuteSwap(); - - // Simulate partial fill — router took half - uint256 halfAmount = SWAP_AMOUNT / 2; - vm.prank(AGGREGATION_ROUTER); - usdc.transferFrom(address(safe), makeAddr("resolver"), halfAmount); - - (address[] memory signers, bytes[] memory signatures) = _createCancelSwapSignatures(); - oneInchModule.cancelSwap(address(safe), signers, signatures); - - // Half remains on Safe - assertEq(usdc.balanceOf(address(safe)), halfAmount); - assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); - assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); + assertEq(cashModule.getData(address(safe)).pendingWithdrawalRequest.recipient, address(0)); } function test_fusion_cancelSwap_revertsWhenNoPendingSwap() public { @@ -467,29 +360,14 @@ contract OneInchSwapModuleTest is SafeTestSetup { // cancelBridgeByCashModule tests // ────────────────────────────────────────────── - function test_fusion_cancelBridgeByCashModule_beforeExecute() public { + function test_fusion_cancelBridgeByCashModule_works() public { _setupRequestSwap(); vm.prank(address(cashModule)); oneInchModule.cancelBridgeByCashModule(address(safe)); assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); - // Tokens still on Safe assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); - } - - function test_fusion_cancelBridgeByCashModule_afterExecute() public { - _setupRequestAndExecuteSwap(); - - vm.prank(address(cashModule)); - oneInchModule.cancelBridgeByCashModule(address(safe)); - - assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); - // Tokens still on Safe - assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); - // Order hash revoked - assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); - // Approval revoked assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); } @@ -513,9 +391,9 @@ contract OneInchSwapModuleTest is SafeTestSetup { uint256 nonceBefore = safe.nonce(); (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH ); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); assertEq(safe.nonce(), nonceBefore + 1); } @@ -524,15 +402,15 @@ contract OneInchSwapModuleTest is SafeTestSetup { deal(address(usdc), address(safe), SWAP_AMOUNT * 2); (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH ); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); (address[] memory cancelSigners, bytes[] memory cancelSigs) = _createCancelSwapSignatures(); oneInchModule.cancelSwap(address(safe), cancelSigners, cancelSigs); vm.expectRevert(OneInchSwapModule.InvalidSignatures.selector); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); } // ────────────────────────────────────────────── @@ -542,43 +420,33 @@ contract OneInchSwapModuleTest is SafeTestSetup { function test_fusion_fullLifecycle() public { deal(address(usdc), address(safe), SWAP_AMOUNT); - // Request — intent recorded, tokens stay on Safe + // Request — intent recorded, router approved, withdrawal locked, tokens stay on Safe (address[] memory reqSigners, bytes[] memory reqSigs) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH ); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, reqSigners, reqSigs); - assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, reqSigners, reqSigs); - // Execute — Safe approves router, authorizes order hash - (address[] memory execSigners, bytes[] memory execSigs) = _createExecuteSwapSignatures(TEST_ORDER_HASH); - oneInchModule.executeSwap(address(safe), TEST_ORDER_HASH, execSigners, execSigs); - - assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0x1626ba7e)); + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), SWAP_AMOUNT); - // Simulate fill — resolver pulls from Safe, sends toToken to Safe + // ERC-1271: owners' sig blob over order hash validates + bytes memory sigBlob = _buildOwnerSignatureBlob(TEST_ORDER_HASH); + assertEq(safe.isValidSignature(TEST_ORDER_HASH, sigBlob), bytes4(0x1626ba7e)); + + // Simulate fill vm.prank(AGGREGATION_ROUTER); usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); deal(address(weETH), address(safe), MIN_TO_AMOUNT); - // Settle — verify fill, clean up - (address[] memory settleSigners, bytes[] memory settleSigs) = _createSettleSwapSignatures(); - oneInchModule.settleSwap(address(safe), settleSigners, settleSigs); + // Settle (admin-only) + vm.prank(owner1); + oneInchModule.settleSwap(address(safe)); assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); - assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), bytes4(0xffffffff)); assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); + assertEq(cashModule.getData(address(safe)).pendingWithdrawalRequest.recipient, address(0)); } - // ────────────────────────────────────────────── - // Concurrent swaps — different Safes, same token (no mutex needed) - // ────────────────────────────────────────────── - - // Note: Testing concurrent same-token swaps across different safes requires - // deploying a second safe, which depends on the test harness infrastructure. - // The key architectural point is: since tokens never leave each Safe, there is - // no shared custody and no fungible token attribution problem. - // ══════════════════════════════════════════════ // HELPERS // ══════════════════════════════════════════════ @@ -587,33 +455,14 @@ contract OneInchSwapModuleTest is SafeTestSetup { deal(address(usdc), address(safe), SWAP_AMOUNT); (address[] memory signers, bytes[] memory signatures) = _createRequestSwapSignatures( - address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT + address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH ); - oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, signers, signatures); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); } - function _setupRequestAndExecuteSwap() internal { - _setupRequestSwap(); - - (address[] memory signers, bytes[] memory signatures) = _createExecuteSwapSignatures(TEST_ORDER_HASH); - oneInchModule.executeSwap(address(safe), TEST_ORDER_HASH, signers, signatures); - } - - // Unified signature helper - function _createSignatures( - bytes32 selector, - bytes memory data - ) internal view returns (address[] memory, bytes[] memory) { - bytes32 digestHash = keccak256(abi.encodePacked( - selector, - block.chainid, - address(oneInchModule), - safe.nonce(), - address(safe), - data - )).toEthSignedMessageHash(); - - return _signWithOwners(digestHash); + /// @dev Builds EIP-712 digest for a module operation using the Safe's domain separator. + function _eip712Digest(bytes32 structHash) internal view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", safe.getDomainSeparator(), structHash)); } function _createClassicSwapSignatures( @@ -623,30 +472,46 @@ contract OneInchSwapModuleTest is SafeTestSetup { uint256 minToAssetAmount, bytes memory data ) internal view returns (address[] memory, bytes[] memory) { - return _createSignatures(oneInchModule.SWAP_SIG(), abi.encode(fromAsset, toAsset, fromAssetAmount, minToAssetAmount, data)); + bytes32 structHash = keccak256(abi.encode( + oneInchModule.SWAP_TYPEHASH(), + address(safe), + fromAsset, + toAsset, + fromAssetAmount, + minToAssetAmount, + keccak256(data), + safe.nonce() + )); + return _signWithOwners(_eip712Digest(structHash)); } function _createRequestSwapSignatures( address fromToken, address toToken, uint256 fromAmount, - uint256 minToAmount - ) internal view returns (address[] memory, bytes[] memory) { - return _createSignatures(oneInchModule.REQUEST_SWAP_SIG(), abi.encode(fromToken, toToken, fromAmount, minToAmount)); - } - - function _createExecuteSwapSignatures( + uint256 minToAmount, bytes32 orderHash ) internal view returns (address[] memory, bytes[] memory) { - return _createSignatures(oneInchModule.EXECUTE_SWAP_SIG(), abi.encode(orderHash)); - } - - function _createSettleSwapSignatures() internal view returns (address[] memory, bytes[] memory) { - return _createSignatures(oneInchModule.SETTLE_SWAP_SIG(), ""); + bytes32 structHash = keccak256(abi.encode( + oneInchModule.REQUEST_SWAP_TYPEHASH(), + address(safe), + fromToken, + toToken, + fromAmount, + minToAmount, + orderHash, + safe.nonce() + )); + return _signWithOwners(_eip712Digest(structHash)); } function _createCancelSwapSignatures() internal view returns (address[] memory, bytes[] memory) { - return _createSignatures(oneInchModule.CANCEL_SWAP_SIG(), ""); + bytes32 structHash = keccak256(abi.encode( + oneInchModule.CANCEL_SWAP_TYPEHASH(), + address(safe), + safe.nonce() + )); + return _signWithOwners(_eip712Digest(structHash)); } function _signWithOwners(bytes32 digestHash) internal view returns (address[] memory, bytes[] memory) { @@ -664,30 +529,20 @@ contract OneInchSwapModuleTest is SafeTestSetup { return (signers, signatures); } - /// @dev Builds mock 1inch swap() calldata with selector 0x12aa3caf - function _buildOneInchSwapCalldata( - address srcToken, - address dstToken, - address dstReceiver, - uint256 amount, - uint256 minReturnAmount - ) internal pure returns (bytes memory) { - OneInchSwapDescription memory desc = OneInchSwapDescription({ - srcToken: IERC20(srcToken), - dstToken: IERC20(dstToken), - srcReceiver: payable(address(0)), - dstReceiver: payable(dstReceiver), - amount: amount, - minReturnAmount: minReturnAmount, - flags: 0 - }); - - return abi.encodeWithSelector( - bytes4(0x12aa3caf), - address(0), // executor - desc, - "", // permit - "" // data - ); + /// @dev Builds an ERC-1271 sig blob (owners sign `hash` directly — not wrapped, as 1inch + /// passes its own order hash to isValidSignature). + function _buildOwnerSignatureBlob(bytes32 hash) internal view returns (bytes memory) { + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(owner1Pk, hash); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(owner2Pk, hash); + + address[] memory signers = new address[](2); + signers[0] = owner1; + signers[1] = owner2; + + bytes[] memory sigs = new bytes[](2); + sigs[0] = abi.encodePacked(r1, s1, v1); + sigs[1] = abi.encodePacked(r2, s2, v2); + + return abi.encode(signers, sigs); } }