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/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/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/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 8b1daed3..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 @@ -50,5 +50,12 @@ interface IEtherFiSafe { */ function useNonce() external returns (uint256); - function isAdmin(address account) external view returns (bool); + function isAdmin(address account) external view returns (bool); + + /** + * @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 getDomainSeparator() external view returns (bytes32); } diff --git a/src/modules/oneinch-swap/OneInchSwapModule.sol b/src/modules/oneinch-swap/OneInchSwapModule.sol new file mode 100644 index 00000000..cb8139ef --- /dev/null +++ b/src/modules/oneinch-swap/OneInchSwapModule.sol @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.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 + * @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): + * 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 { + // ────────────────────────────────────────────── + // 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; + + // ────────────────────────────────────────────── + // EIP-712 type hashes (unified with EtherFiSafe's signing scheme) + // ────────────────────────────────────────────── + + /// @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: 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: 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, 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); + + /// @notice Emitted when a Fusion swap is cancelled + event FusionSwapCancelled(address indexed safe, address indexed fromToken, bytes32 indexed orderHash); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + error SwappingToSameAsset(); + error InvalidSignatures(); + error OutputLessThanMinAmount(); + error NoPendingSwap(); + error SwapAlreadyPending(); + error InsufficientReceivedAmount(); + error OrderNotFilled(); + error UnexpectedFromBalance(); + 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) || _aggregationRouter.code.length == 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) { + _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 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 (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, 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 || orderHash == bytes32(0)) revert InvalidInput(); + if (pendingSwaps[safe].fromAmount != 0) revert SwapAlreadyPending(); + + _verifyRequestSwap(safe, fromToken, toToken, fromAmount, minToAmount, orderHash, signers, signatures); + _checkAmountAvailable(safe, fromToken, fromAmount); + + // Register a pending withdrawal on CashModule so card spend can preempt via cancelBridgeByCashModule + cashModule.requestWithdrawalByModule(safe, fromToken, fromAmount); + + 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] = fromToken; + data[0] = abi.encodeWithSelector(IERC20.approve.selector, aggregationRouter, fromAmount); + IEtherFiSafe(safe).execTransactionFromModule(to, values, data); + + emit FusionSwapRequested(safe, fromToken, toToken, fromAmount, minToAmount, orderHash); + } + + /** + * @notice Finalizes the Fusion swap after the 1inch order has been filled + * @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 + */ + 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(); + + // 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 - pendingSwap.fromAmount) revert OrderNotFilled(); + if (currentFromBalance < pendingSwap.fromBalanceBefore - pendingSwap.fromAmount) revert UnexpectedFromBalance(); + + // 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(); + + _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 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) { + PendingSwap memory pendingSwap = pendingSwaps[safe]; + if (pendingSwap.fromAmount == 0) revert NoPendingSwap(); + + bytes32 structHash = keccak256(abi.encode(CANCEL_SWAP_TYPEHASH, safe, IEtherFiSafe(safe).useNonce())); + _verifyStructHash(safe, structHash, signers, signatures); + + _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, pendingSwap.orderHash); + } + + // ────────────────────────────────────────────── + // IBridgeModule (called by CashModule for force-cancellation) + // ────────────────────────────────────────────── + + /** + * @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 { + if (msg.sender != etherFiDataProvider.getCashModule()) revert Unauthorized(); + + PendingSwap memory pendingSwap = pendingSwaps[safe]; + if (pendingSwap.fromAmount == 0) return; + + delete pendingSwaps[safe]; + _revokeApproval(safe, pendingSwap.fromToken); + + emit FusionSwapCancelled(safe, pendingSwap.fromToken, pendingSwap.orderHash); + } + + // ────────────────────────────────────────────── + // 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); + + 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; + } + + // ══════════════════════════════════════════════ + // INTERNAL: Fusion Helpers + // ══════════════════════════════════════════════ + + /** + * @dev Revokes the router's fromToken approval on the Safe + */ + 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); + } + + // ══════════════════════════════════════════════ + // INTERNAL: EIP-712 Signature Verification + // ══════════════════════════════════════════════ + + /** + * @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 9897cfb7..423f8b61 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/IERC1271.sol"; import { IEtherFiHook } from "../interfaces/IEtherFiHook.sol"; import { IRoleRegistry } from "../interfaces/IRoleRegistry.sol"; import { ArrayDeDupLib } from "../libraries/ArrayDeDupLib.sol"; @@ -23,7 +24,7 @@ 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[]; @@ -232,6 +233,42 @@ contract EtherFiSafe is EtherFiSafeBase, ModuleManager, RecoveryManager, MultiSi return _useNonce(); } + // ══════════════════════════════════════════════ + // ERC-1271: Smart Contract Signature Validation + // ══════════════════════════════════════════════ + + /** + * @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 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 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 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); + } + /** * @dev Implementation of abstract function from ModuleManager * @dev Checks if a module is whitelisted on the data provider 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 new file mode 100644 index 00000000..24e00ba9 --- /dev/null +++ b/test/safe/modules/oneinch-swap/OneInchSwapModule.t.sol @@ -0,0 +1,548 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +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 {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 { + 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.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); + _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_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 (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, TEST_ORDER_HASH + ); + + 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, TEST_ORDER_HASH); + assertEq(pendingSwap.fromBalanceBefore, SWAP_AMOUNT); + assertEq(pendingSwap.toBalanceBefore, 0); + + 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, TEST_ORDER_HASH + ); + + vm.expectRevert(OneInchSwapModule.NativeETHNotSupported.selector); + 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, TEST_ORDER_HASH + ); + + vm.expectRevert(OneInchSwapModule.SwappingToSameAsset.selector); + 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, 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), 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, TEST_ORDER_HASH + ); + + vm.expectRevert(ModuleCheckBalance.InsufficientAvailableBalanceOnSafe.selector); + 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, TEST_ORDER_HASH + ); + 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, orderHash2 + ); + + vm.expectRevert(OneInchSwapModule.SwapAlreadyPending.selector); + 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, TEST_ORDER_HASH + ); + + vm.expectRevert(OneInchSwapModule.InvalidSignatures.selector); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); + } + + // ────────────────────────────────────────────── + // ERC-1271 direct-validation tests (on Safe) + // ────────────────────────────────────────────── + + 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_isValidSignature_nonOwnerSignerReturnsFail() public { + (address nonOwner, uint256 nonOwnerPk) = makeAddrAndKey("nonOwner"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(nonOwnerPk, TEST_ORDER_HASH); + + address[] memory signers = new address[](1); + signers[0] = nonOwner; + bytes[] memory sigs = new bytes[](1); + sigs[0] = abi.encodePacked(r, s, v); + + assertEq(safe.isValidSignature(TEST_ORDER_HASH, abi.encode(signers, sigs)), bytes4(0xffffffff)); + } + + function test_fusion_isValidSignature_malformedBlobReturnsFail() public view { + assertEq(safe.isValidSignature(TEST_ORDER_HASH, hex"deadbeef"), bytes4(0xffffffff)); + } + + function test_fusion_isValidSignature_emptyBlobReturnsFail() public view { + assertEq(safe.isValidSignature(TEST_ORDER_HASH, ""), 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 (admin-only, no signatures) + // ────────────────────────────────────────────── + + function test_fusion_settleSwap_works() public { + _setupRequestSwap(); + + // 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); + + // Any safe admin can settle (owners are admins by default) + vm.prank(owner1); + oneInchModule.settleSwap(address(safe)); + + 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_revertsWhenNotAdmin() public { + _setupRequestSwap(); + + vm.prank(AGGREGATION_ROUTER); + usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); + deal(address(weETH), address(safe), MIN_TO_AMOUNT); + + 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 { + _setupRequestSwap(); + + // Don't simulate fill — tokens still on Safe + deal(address(weETH), address(safe), MIN_TO_AMOUNT); + + vm.prank(owner1); + vm.expectRevert(OneInchSwapModule.OrderNotFilled.selector); + oneInchModule.settleSwap(address(safe)); + } + + function test_fusion_settleSwap_revertsWhenInsufficientReceived() public { + _setupRequestSwap(); + + vm.prank(AGGREGATION_ROUTER); + usdc.transferFrom(address(safe), makeAddr("resolver"), SWAP_AMOUNT); + // Don't deal toToken to safe + + vm.prank(owner1); + vm.expectRevert(OneInchSwapModule.InsufficientReceivedAmount.selector); + oneInchModule.settleSwap(address(safe)); + } + + // 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_works() public { + _setupRequestSwap(); + + (address[] memory signers, bytes[] memory signatures) = _createCancelSwapSignatures(); + oneInchModule.cancelSwap(address(safe), signers, signatures); + + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); + assertEq(cashModule.getData(address(safe)).pendingWithdrawalRequest.recipient, address(0)); + } + + 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_works() public { + _setupRequestSwap(); + + vm.prank(address(cashModule)); + oneInchModule.cancelBridgeByCashModule(address(safe)); + + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + 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, TEST_ORDER_HASH + ); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, 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, TEST_ORDER_HASH + ); + 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, TEST_ORDER_HASH, signers, signatures); + } + + // ────────────────────────────────────────────── + // Full lifecycle test + // ────────────────────────────────────────────── + + function test_fusion_fullLifecycle() public { + deal(address(usdc), address(safe), SWAP_AMOUNT); + + // 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, TEST_ORDER_HASH + ); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, reqSigners, reqSigs); + + assertEq(usdc.balanceOf(address(safe)), SWAP_AMOUNT); + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), SWAP_AMOUNT); + + // 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 (admin-only) + vm.prank(owner1); + oneInchModule.settleSwap(address(safe)); + + assertEq(oneInchModule.getPendingSwap(address(safe)).fromAmount, 0); + assertEq(usdc.allowance(address(safe), AGGREGATION_ROUTER), 0); + assertEq(cashModule.getData(address(safe)).pendingWithdrawalRequest.recipient, address(0)); + } + + // ══════════════════════════════════════════════ + // 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, TEST_ORDER_HASH + ); + oneInchModule.requestSwap(address(safe), address(usdc), address(weETH), SWAP_AMOUNT, MIN_TO_AMOUNT, TEST_ORDER_HASH, signers, signatures); + } + + /// @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( + address fromAsset, + address toAsset, + uint256 fromAssetAmount, + uint256 minToAssetAmount, + bytes memory data + ) internal view returns (address[] memory, bytes[] memory) { + 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, + bytes32 orderHash + ) internal view returns (address[] memory, bytes[] memory) { + 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) { + 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) { + (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 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); + } +}