diff --git a/CLAUDE.md b/CLAUDE.md index 89f2351..4635055 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ This repository contains **Euler-CoW Protocol integration contracts** that enabl ### Build ```bash -forge build +forge build --deny notes ``` ### Test @@ -95,8 +95,62 @@ forge snapshot - `SignerECDSA.sol`: ECDSA signature utilities for tests - `EmptyWrapper.sol`: Minimal wrapper for testing wrapper chaining +### Writing CoW Protocol Settlement Tests + +When creating settlement tests, especially multi-user scenarios: + +**1. Leverage Coincidence of Wants** +- CoW Protocol nets out opposing trades within a settlement +- Only swap the NET difference between opposing directions +- Example: If User1+User2 need 10k SUSDS worth of WETH and User3 provides 5k SUSDS worth of WETH, only swap the 5k SUSDS difference +- Don't create separate swaps for each direction - calculate the minimal swaps needed + +**2. Proper Price Ratio Calculations** +- Use `clearingPrices[tokenIndex]` in withdrawal/swap calculations +- Calculate amounts based on what the settlement actually needs: `amount * clearingPrices[buyToken] / clearingPrices[sellToken]` +- Ensure the math balances: withdrawals + swaps must provide exactly what's needed for all trades + +**3. Logical Token Array Ordering** +- Organize tokens in a readable order: base assets first (SUSDS, WETH), then vault tokens (ESUSDS, EWETH) +- Consistent ordering makes trade setup less error-prone +- Use meaningful comments to clarify token indices + +**4. Realistic Trade Amounts** +- Fine-tune amounts so withdrawals, swaps, and repayments balance properly +- The numbers need to actually work for the settlement to succeed +- Test will fail if amounts don't align with vault balances and clearing prices + +**5. Simplified Interaction Design** +- Keep interactions minimal and purposeful - only include what's needed +- Common pattern: withdrawals from vaults → net swaps → implicit transfers via settlement +- Avoid redundant operations + +**6. Helper Functions for DRY Tests** +- Create parameterized helpers like `_setupLeveragedPositionFor()` instead of repeating setup code +- Use helpers for approvals (`_setupClosePositionApprovalsFor()`) and signatures (`_createPermitSignatureFor()`) +- This significantly reduces test length and improves maintainability + +**7. Clear Explanatory Comments** +- Explain the economic logic, not just the technical operations +- Examples: "We only need to swap the difference" or "Coincidence of wants between User1/User2 and User3" +- Help readers understand why the settlement is structured this way + ## Important Implementation Details +### Security Considerations + +- It is generally assumed that the `solvers` (aka, an address for which `CowAuthentication.isSolver()` returns true) is a trusted actor within the system. Only in the case that a solver could steal an entire user's deposit or funds, or steal funds beyond what the user specified as their minimum out/minimum buy amount, assume there is incentive for a solver to provide the best rate/user outcome possible. To be clear, a solver cannot steal funds simply by setting arbitrary `clearingPrices` (as documented a bit later). + - For a solver to be able to steal an entire user's deposit or funds, they must be able to withdraw the users token to an address of their choosing or otherwise in their control (therefore, a "nuisance" transfer between two wallets that the user effectively owns does not count). +- If a user takes on debt, that debt position must be sufficiently collateralized above a set collateralization ratio higher than liquidation ratio before the EVC batch transaction concludes. If it is not, the transaction reverts and nothing can happen. Therefore, there is no risk of undercollateralization to the system due to a user opening a position because the transaction would revert. +- anyone can call the `EVC.batch()` function to initialize a batched call through the EVC. This call is allowed to be reentrant. Therefore, simply checking that a caller is the `address(EVC)` doesn't really offer any added security benefit. +- The parameters supplied by a solver to the settlement contract are all indirectly bounded from within the settlement contract by ceratin restrictions: + - `tokens` -- this is a mapping used by the settlement contract to save on gas. If a token used by an order is missing, it will fail to pass signature checks. + - `clearingPrices` -- these define prices to go with the previously defined `tokens`. These clearing prices are set by the solver and determine exactly how many tokens come out of a trade. **However, if a clearingPrice is lower than any of a user's limit price in `trades`, the transaction will revert. Therefore, it is not possible for a user to steal a users funds simply by setting clearingPrices to an arbitrary value.** There is incentive to provide the best clearingPrice because an auction is held off-chain by CoW Protocol and only the best overall rate outcome is selected. + - `trades` -- List of orders to fulfill. All of the data inside this structure is effectively signed by the user and cannot be altered by solvers, other than adding or removing signed orders. + - `interactions` -- Solvers use this to specify operations that should be executed from within the settlement contract. This could include swaps, pre-hooks, post-hooks, etc. This is completely controlled by the solver. + +- Please consider any potential security vulnerabilities resulting from potential flawed assumptions of the above from any contracts outside this repo, including the Ethereum Vault Connector (EVC), Settlement Contract, or Euler Vaults, out of scope. + ### Wrapper Data Format Wrapper data is passed as a calldata slice with format: ``` diff --git a/foundry.lock b/foundry.lock index ee1cfbe..88070d8 100644 --- a/foundry.lock +++ b/foundry.lock @@ -2,7 +2,7 @@ "lib/cow": { "branch": { "name": "feat/wrapper", - "rev": "1e8127f476f8fef7758cf25033a0010d325dba8d" + "rev": "277be444ca1fce84ec55053f41ccbd9dc5672ffe" } }, "lib/euler-vault-kit": { diff --git a/foundry.toml b/foundry.toml index 3c4963a..c18fee0 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,5 +3,6 @@ src = "src" out = "out" libs = ["lib"] optimizer = true +via_ir = true # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/cow b/lib/cow index 1e8127f..277be44 160000 --- a/lib/cow +++ b/lib/cow @@ -1 +1 @@ -Subproject commit 1e8127f476f8fef7758cf25033a0010d325dba8d +Subproject commit 277be444ca1fce84ec55053f41ccbd9dc5672ffe diff --git a/src/CowEvcOpenPositionWrapper.sol b/src/CowEvcOpenPositionWrapper.sol new file mode 100644 index 0000000..2b8d977 --- /dev/null +++ b/src/CowEvcOpenPositionWrapper.sol @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8; + +import {IEVC} from "evc/EthereumVaultConnector.sol"; + +import {CowWrapper, ICowSettlement} from "./CowWrapper.sol"; +import {IERC4626, IBorrowing} from "euler-vault-kit/src/EVault/IEVault.sol"; +import {PreApprovedHashes} from "./PreApprovedHashes.sol"; + +/// @title CowEvcOpenPositionWrapper +/// @notice A specialized wrapper for opening leveraged positions with EVC +/// @dev This wrapper hardcodes the EVC operations needed to open a position: +/// 1. Enable collateral vault +/// 2. Enable controller (borrow vault) +/// 3. Deposit collateral +/// 4. Borrow assets +/// @dev The settle call by this order should be performing the necessary swap +/// from IERC20(borrowVault.asset()) -> collateralVault. The recipient of the +/// swap should be the `owner` (not this contract). Furthermore, the buyAmountIn should +/// be the same as `maxRepayAmount`. +contract CowEvcOpenPositionWrapper is CowWrapper, PreApprovedHashes { + IEVC public immutable EVC; + + /// @dev The EIP-712 domain type hash used for computing the domain + /// separator. + bytes32 private constant DOMAIN_TYPE_HASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + + /// @dev The EIP-712 domain name used for computing the domain separator. + bytes32 private constant DOMAIN_NAME = keccak256("CowEvcOpenPositionWrapper"); + + /// @dev The EIP-712 domain version used for computing the domain separator. + bytes32 private constant DOMAIN_VERSION = keccak256("1"); + + /// @dev The domain separator used for signing orders that gets mixed in + /// making signatures for different domains incompatible. This domain + /// separator is computed following the EIP-712 standard and has replay + /// protection mixed in so that signed orders are only valid for specific + /// this contract. + bytes32 public immutable DOMAIN_SEPARATOR; + + //// @dev The EVC nonce namespace to use when calling `EVC.permit` to authorize this contract. + uint256 public immutable NONCE_NAMESPACE; + + /// @dev A descriptive label for this contract, as required by CowWrapper + string public override name = "Euler EVC - Open Position"; + + /// @dev Indicates that the current operation cannot be completed with the given msgSender + error Unauthorized(address msgSender); + + /// @dev Indicates that the pre-approved hash is no longer able to be executed because the block timestamp is too old + error OperationDeadlineExceeded(uint256 validToTimestamp, uint256 currentTimestamp); + + /// @dev Emitted when a position is opened via this wrapper + event CowEvcPositionOpened( + address indexed owner, + address account, + address indexed collateralVault, + address indexed borrowVault, + uint256 collateralAmount, + uint256 borrowAmount + ); + + constructor(address _evc, ICowSettlement _settlement) CowWrapper(_settlement) { + EVC = IEVC(_evc); + NONCE_NAMESPACE = uint256(uint160(address(this))); + + DOMAIN_SEPARATOR = + keccak256(abi.encode(DOMAIN_TYPE_HASH, DOMAIN_NAME, DOMAIN_VERSION, block.chainid, address(this))); + } + + /** + * @notice A command to open a debt position against an euler vault using collateral as backing. + * @dev This structure is used, combined with domain separator, to indicate a pre-approved hash. + * the `deadline` is used for deduplication checking, so be careful to ensure this value is unique. + */ + struct OpenPositionParams { + /** + * @dev The ethereum address that has permission to operate upon the account + */ + address owner; + + /** + * @dev The subaccount to open the position on. Learn more about Euler subaccounts https://evc.wtf/docs/concepts/internals/sub-accounts + */ + address account; + + /** + * @dev A date by which this operation must be completed + */ + uint256 deadline; + + /** + * @dev The Euler vault to use as collateral + */ + address collateralVault; + + /** + * @dev The Euler vault to use as leverage + */ + address borrowVault; + + /** + * @dev The amount of collateral to import as margin. Set this to `0` if the vault already has margin collateral. + */ + uint256 collateralAmount; + + /** + * @dev The amount of debt to take out. The borrowed tokens will be converted to `collateralVault` tokens and deposited into the account. + */ + uint256 borrowAmount; + } + + function _parseOpenPositionParams(bytes calldata wrapperData) + internal + pure + returns (OpenPositionParams memory params, bytes memory signature, bytes calldata remainingWrapperData) + { + (params, signature) = abi.decode(wrapperData, (OpenPositionParams, bytes)); + + // Calculate consumed bytes for abi.encode(OpenPositionParams, bytes) + // Structure: + // - 32 bytes: offset to params (0x40) + // - 32 bytes: offset to signature + // - 224 bytes: params data (7 fields × 32 bytes) + // - 32 bytes: signature length + // - N bytes: signature data (padded to 32-byte boundary) + // We can just math this out + uint256 consumed = 224 + 64 + ((signature.length + 31) & ~uint256(31)); + + remainingWrapperData = wrapperData[consumed:]; + } + + /// @notice Helper function to compute the hash that would be approved + /// @param params The OpenPositionParams to hash + /// @return The hash of the signed calldata for these params + function getApprovalHash(OpenPositionParams memory params) external view returns (bytes32) { + return _getApprovalHash(params); + } + + function _getApprovalHash(OpenPositionParams memory params) internal view returns (bytes32 digest) { + bytes32 structHash; + bytes32 separator = DOMAIN_SEPARATOR; + assembly ("memory-safe") { + structHash := keccak256(params, 224) + let ptr := mload(0x40) + mstore(ptr, "\x19\x01") + mstore(add(ptr, 0x02), separator) + mstore(add(ptr, 0x22), structHash) + digest := keccak256(ptr, 0x42) + } + } + + function parseWrapperData(bytes calldata wrapperData) + external + pure + override + returns (bytes calldata remainingWrapperData) + { + (,, remainingWrapperData) = _parseOpenPositionParams(wrapperData); + } + + /// @notice Implementation of GPv2Wrapper._wrap - executes EVC operations to open a position + /// @param settleData Data which will be used for the parameters in a call to `CowSettlement.settle` + /// @param wrapperData Additional data containing OpenPositionParams + function _wrap(bytes calldata settleData, bytes calldata wrapperData, bytes calldata remainingWrapperData) + internal + override + { + // Decode wrapper data into OpenPositionParams + OpenPositionParams memory params; + bytes memory signature; + (params, signature,) = _parseOpenPositionParams(wrapperData); + + // Check if the signed calldata hash is pre-approved + IEVC.BatchItem[] memory signedItems = _getSignedCalldata(params); + bool isPreApproved = signature.length == 0 && _consumePreApprovedHash(params.owner, _getApprovalHash(params)); + + // Build the EVC batch items for opening a position + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](isPreApproved ? signedItems.length + 1 : 2); + + uint256 itemIndex = 0; + + // 1. There are two ways this contract can be executed: either the user approves this contract as + // and operator and supplies a pre-approved hash for the operation to take, or they submit a permit hash + // for this specific instance + if (!isPreApproved) { + items[itemIndex++] = IEVC.BatchItem({ + onBehalfOfAccount: address(0), + targetContract: address(EVC), + value: 0, + data: abi.encodeCall( + IEVC.permit, + ( + params.owner, + address(this), + uint256(NONCE_NAMESPACE), + EVC.getNonce(bytes19(bytes20(params.owner)), NONCE_NAMESPACE), + params.deadline, + 0, + abi.encodeCall(EVC.batch, signedItems), + signature + ) + ) + }); + } else { + require(params.deadline >= block.timestamp, OperationDeadlineExceeded(params.deadline, block.timestamp)); + // copy the operations to execute. we can operate on behalf of the user directly + for (; itemIndex < signedItems.length; itemIndex++) { + items[itemIndex] = signedItems[itemIndex]; + } + } + + // 2. Settlement call + items[itemIndex] = IEVC.BatchItem({ + onBehalfOfAccount: address(this), + targetContract: address(this), + value: 0, + data: abi.encodeCall(this.evcInternalSettle, (settleData, remainingWrapperData)) + }); + + // 3. Account status check (automatically done by EVC at end of batch) + // For more info, see: https://evc.wtf/docs/concepts/internals/account-status-checks + // No explicit item needed - EVC handles this + + // Execute all items in a single batch + EVC.batch(items); + + emit CowEvcPositionOpened( + params.owner, + params.account, + params.collateralVault, + params.borrowVault, + params.collateralAmount, + params.borrowAmount + ); + } + + function getSignedCalldata(OpenPositionParams memory params) external view returns (bytes memory) { + return abi.encodeCall(IEVC.batch, _getSignedCalldata(params)); + } + + function _getSignedCalldata(OpenPositionParams memory params) + internal + view + returns (IEVC.BatchItem[] memory items) + { + items = new IEVC.BatchItem[](4); + + // 1. Enable collateral + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: address(0), + targetContract: address(EVC), + value: 0, + data: abi.encodeCall(IEVC.enableCollateral, (params.account, params.collateralVault)) + }); + + // 2. Enable controller (borrow vault) + items[1] = IEVC.BatchItem({ + onBehalfOfAccount: address(0), + targetContract: address(EVC), + value: 0, + data: abi.encodeCall(IEVC.enableController, (params.account, params.borrowVault)) + }); + + // 3. Deposit collateral + items[2] = IEVC.BatchItem({ + onBehalfOfAccount: params.owner, + targetContract: params.collateralVault, + value: 0, + data: abi.encodeCall(IERC4626.deposit, (params.collateralAmount, params.account)) + }); + + // 4. Borrow assets + items[3] = IEVC.BatchItem({ + onBehalfOfAccount: params.account, + targetContract: params.borrowVault, + value: 0, + data: abi.encodeCall(IBorrowing.borrow, (params.borrowAmount, params.owner)) + }); + } + + /// @notice Internal settlement function called by EVC + function evcInternalSettle(bytes calldata settleData, bytes calldata remainingWrapperData) external payable { + require(msg.sender == address(EVC), Unauthorized(msg.sender)); + (address onBehalfOfAccount,) = EVC.getCurrentOnBehalfOfAccount(address(0)); + require(onBehalfOfAccount == address(this), Unauthorized(onBehalfOfAccount)); + + // Use GPv2Wrapper's _internalSettle to call the settlement contract + // wrapperData is empty since we've already processed it in _wrap + _next(settleData, remainingWrapperData); + } +} diff --git a/src/PreApprovedHashes.sol b/src/PreApprovedHashes.sol new file mode 100644 index 0000000..47c0c8b --- /dev/null +++ b/src/PreApprovedHashes.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8; + +/// @title PreApprovedHashes +/// @notice Abstract contract for managing pre-approved operation hashes +/// @dev Allows users to pre-approve specific operations without requiring signatures each time +abstract contract PreApprovedHashes { + /// @dev Marker value indicating a hash is pre-approved + uint256 private constant PRE_APPROVED = uint256(keccak256("CowEvcWrapper.PreApproved")); + uint256 private constant CONSUMED_PRE_APPROVED = uint256(keccak256("CowEvcWrapper.Consumed")); + + /// @notice Storage indicating whether or not a signed calldata hash has been approved by an owner + /// @dev Maps owner -> hash(signedCalldata) -> approval status + mapping(address => mapping(bytes32 => uint256)) public preApprovedHashes; + + /// @notice Event emitted when an owner pre-approves or revokes a hash + event PreApprovedHash(address indexed owner, bytes32 indexed hash, bool approved); + + /// @notice Event emitted when a pre-approved hash is used and is no longer valid because its consumed + event PreApprovedHashConsumed(address indexed owner, bytes32 indexed hash); + + /// @notice Revert reason given when a hash has already been consumed, and therefore cannot be set + error AlreadyConsumed(address owner, bytes32 hash); + + /// @notice Pre-approve a hash of signed calldata for future execution + /// @dev Once a hash is pre-approved, it can only be consumed once. This prevents replay attacks. + /// @param hash The keccak256 hash of the signed calldata + /// @param approved True to approve the hash, false to revoke approval + function setPreApprovedHash(bytes32 hash, bool approved) external { + require(preApprovedHashes[msg.sender][hash] != CONSUMED_PRE_APPROVED, AlreadyConsumed(msg.sender, hash)); + + if (approved) { + preApprovedHashes[msg.sender][hash] = PRE_APPROVED; + } else { + preApprovedHashes[msg.sender][hash] = 0; + } + emit PreApprovedHash(msg.sender, hash, approved); + } + + /// @notice Check if a hash is pre-approved for an owner + /// @param owner The owner address + /// @param hash The hash to check + /// @return True if the hash is pre-approved, false otherwise + function isHashPreApproved(address owner, bytes32 hash) external view returns (bool) { + return preApprovedHashes[owner][hash] == PRE_APPROVED; + } + + /// @notice Check if a hash is pre-approved for an owner. If it is, changes it to be consumed, and returns true. + /// @param owner The owner address + /// @param hash The hash to check + /// @return True if the hash was pre-approved and marked as consumed, false otherwise + function _consumePreApprovedHash(address owner, bytes32 hash) internal returns (bool) { + if (preApprovedHashes[owner][hash] == PRE_APPROVED) { + preApprovedHashes[owner][hash] = CONSUMED_PRE_APPROVED; + emit PreApprovedHashConsumed(owner, hash); + return true; + } else { + return false; + } + } +} diff --git a/test/CowEvcOpenPositionWrapper.t.sol b/test/CowEvcOpenPositionWrapper.t.sol new file mode 100644 index 0000000..2aa4fa4 --- /dev/null +++ b/test/CowEvcOpenPositionWrapper.t.sol @@ -0,0 +1,533 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8; + +import {GPv2Order} from "cow/libraries/GPv2Order.sol"; + +import {IEVault, IERC4626, IERC20} from "euler-vault-kit/src/EVault/IEVault.sol"; + +import {CowEvcOpenPositionWrapper} from "../src/CowEvcOpenPositionWrapper.sol"; +import {ICowSettlement, CowWrapper} from "../src/CowWrapper.sol"; +import {GPv2AllowListAuthentication} from "cow/GPv2AllowListAuthentication.sol"; +import {PreApprovedHashes} from "../src/PreApprovedHashes.sol"; + +import {CowBaseTest} from "./helpers/CowBaseTest.sol"; +import {SignerECDSA} from "./helpers/SignerECDSA.sol"; + +/// @title E2E Test for CowEvcOpenPositionWrapper +/// @notice Tests the full flow of opening a leveraged position using the new wrapper contract +contract CowEvcOpenPositionWrapperTest is CowBaseTest { + CowEvcOpenPositionWrapper public openPositionWrapper; + SignerECDSA internal ecdsa; + + uint256 constant SUSDS_MARGIN = 5000e18; + uint256 constant DEFAULT_BORROW_AMOUNT = 1e18; + uint256 constant DEFAULT_BUY_AMOUNT = 2495e18; + + function setUp() public override { + super.setUp(); + + // Deploy the new open position wrapper + openPositionWrapper = new CowEvcOpenPositionWrapper(address(EVC), COW_SETTLEMENT); + + // Add wrapper as a solver + GPv2AllowListAuthentication allowList = GPv2AllowListAuthentication(address(COW_SETTLEMENT.authenticator())); + address manager = allowList.manager(); + vm.startPrank(manager); + allowList.addSolver(address(openPositionWrapper)); + vm.stopPrank(); + + ecdsa = new SignerECDSA(EVC); + + // sUSDS is not currently a collateral for WETH borrow, fix it + vm.startPrank(IEVault(EWETH).governorAdmin()); + IEVault(EWETH).setLTV(ESUSDS, 0.9e4, 0.9e4, 0); + vm.stopPrank(); + + // Setup user with SUSDS + deal(SUSDS, user, 10000e18); + } + + struct SettlementData { + bytes orderUid; + GPv2Order.Data orderData; + address[] tokens; + uint256[] clearingPrices; + ICowSettlement.Trade[] trades; + ICowSettlement.Interaction[][3] interactions; + } + + /// @notice Create default OpenPositionParams for testing + function _createDefaultParams(address owner, address account) + internal + view + returns (CowEvcOpenPositionWrapper.OpenPositionParams memory) + { + return CowEvcOpenPositionWrapper.OpenPositionParams({ + owner: owner, + account: account, + deadline: block.timestamp + 1 hours, + collateralVault: ESUSDS, + borrowVault: EWETH, + collateralAmount: SUSDS_MARGIN, + borrowAmount: DEFAULT_BORROW_AMOUNT + }); + } + + /// @notice Setup user approvals for SUSDS deposit + function _setupUserSusdsApproval() internal { + vm.prank(user); + IERC20(SUSDS).approve(ESUSDS, type(uint256).max); + } + + /// @notice Setup user approvals for pre-approved hash flow + function _setupUserPreApprovedFlow(address account, bytes32 hash) internal { + vm.startPrank(user); + IERC20(SUSDS).approve(ESUSDS, type(uint256).max); + EVC.setAccountOperator(user, address(openPositionWrapper), true); + EVC.setAccountOperator(account, address(openPositionWrapper), true); + openPositionWrapper.setPreApprovedHash(hash, true); + vm.stopPrank(); + } + + /// @notice Create permit signature for EVC operator + function _createPermitSignature(CowEvcOpenPositionWrapper.OpenPositionParams memory params) + internal + returns (bytes memory) + { + ecdsa.setPrivateKey(privateKey); + return ecdsa.signPermit( + params.owner, + address(openPositionWrapper), + uint256(uint160(address(openPositionWrapper))), + 0, + params.deadline, + 0, + openPositionWrapper.getSignedCalldata(params) + ); + } + + /// @notice Verify position was opened successfully + function _verifyPositionOpened( + address account, + uint256 expectedCollateral, + uint256 expectedDebt, + uint256 allowedDelta + ) internal view { + assertApproxEqAbs( + IEVault(ESUSDS).convertToAssets(IERC20(ESUSDS).balanceOf(account)), + expectedCollateral, + allowedDelta, + "User should have collateral deposited" + ); + assertEq(IEVault(EWETH).debtOf(account), expectedDebt, "User should have debt"); + } + + /// @notice Create settlement data for opening a leveraged position + /// @dev Sells borrowed WETH to buy SUSDS which gets deposited into the vault + function getOpenPositionSettlement( + address owner, + address receiver, + address sellToken, + address buyVaultToken, + uint256 sellAmount, + uint256 buyAmount + ) public returns (SettlementData memory r) { + uint32 validTo = uint32(block.timestamp + 1 hours); + + // Create trade and extract order data + + // Get tokens and prices + (r.tokens, r.clearingPrices) = getTokensAndPrices(); + + r.trades = new ICowSettlement.Trade[](1); + (r.trades[0], r.orderData, r.orderUid) = + setupCowOrder(r.tokens, 0, 1, sellAmount, buyAmount, validTo, owner, receiver, false); + + // Setup interactions - swap WETH to SUSDS, deposit to vault, and skim + r.interactions = [ + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](3), + new ICowSettlement.Interaction[](0) + ]; + r.interactions[1][0] = getSwapInteraction(sellToken, IERC4626(buyVaultToken).asset(), sellAmount); + r.interactions[1][1] = getDepositInteraction(buyVaultToken, buyAmount + 1 ether); + r.interactions[1][2] = getSkimInteraction(buyVaultToken); + } + + /// @notice Test opening a leveraged position using the new wrapper + function test_OpenPositionWrapper_Success() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + address account = address(uint160(user) ^ 1); + + // Create params using helper + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _createDefaultParams(user, account); + + // Get settlement data + SettlementData memory settlement = + getOpenPositionSettlement(user, account, WETH, ESUSDS, DEFAULT_BORROW_AMOUNT, DEFAULT_BUY_AMOUNT); + + // Setup user approvals + _setupUserSusdsApproval(); + + // User signs order + // Does not need to run here because its done in `setupCowOrder` + + // Create permit signature + bytes memory permitSignature = _createPermitSignature(params); + + // Record balances before + uint256 susdsBalanceBefore = IERC20(ESUSDS).balanceOf(user); + uint256 debtBefore = IEVault(EWETH).debtOf(account); + + // Encode settlement and wrapper data + bytes memory settleData = abi.encodeCall( + ICowSettlement.settle, + (settlement.tokens, settlement.clearingPrices, settlement.trades, settlement.interactions) + ); + bytes memory wrapperData = encodeWrapperData(abi.encode(params, permitSignature)); + + // Expect event emission + vm.expectEmit(true, true, true, true); + emit CowEvcOpenPositionWrapper.CowEvcPositionOpened( + params.owner, + params.account, + params.collateralVault, + params.borrowVault, + params.collateralAmount, + params.borrowAmount + ); + + // Execute wrapped settlement + executeWrappedSettlement(address(openPositionWrapper), settleData, wrapperData); + + // Verify position was created successfully + _verifyPositionOpened(account, DEFAULT_BUY_AMOUNT + SUSDS_MARGIN, DEFAULT_BORROW_AMOUNT, 1 ether); + assertEq(debtBefore, 0, "User should start with no debt"); + assertEq(susdsBalanceBefore, 0, "User should start with no eSUSDS"); + } + + /// @notice Test that unauthorized users cannot call evcInternalSettle directly + function test_OpenPositionWrapper_UnauthorizedInternalSettle() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + bytes memory settleData = ""; + bytes memory wrapperData = ""; + + // Try to call evcInternalSettle directly (not through EVC) + vm.expectRevert(abi.encodeWithSelector(CowEvcOpenPositionWrapper.Unauthorized.selector, address(this))); + openPositionWrapper.evcInternalSettle(settleData, wrapperData); + } + + /// @notice Test that non-solvers cannot call wrappedSettle + function test_OpenPositionWrapper_NonSolverCannotSettle() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + bytes memory settleData = ""; + bytes memory wrapperData = ""; + + // Try to call wrappedSettle as non-solver + vm.expectRevert(abi.encodeWithSelector(CowWrapper.NotASolver.selector, address(this))); + openPositionWrapper.wrappedSettle(settleData, wrapperData); + } + + /// @notice Test parseWrapperData function + function test_OpenPositionWrapper_ParseWrapperData() external view { + address account = address(uint160(user) ^ 1); + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _createDefaultParams(user, account); + + bytes memory wrapperData = abi.encode(params, new bytes(0)); + bytes memory remainingData = openPositionWrapper.parseWrapperData(wrapperData); + + // After parsing OpenPositionParams, remaining data should be empty + assertEq(remainingData.length, 0, "Remaining data should be empty"); + } + + /// @notice Test setting pre-approved hash + function test_OpenPositionWrapper_SetPreApprovedHash() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + address account = address(uint160(user) ^ 1); + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _createDefaultParams(user, account); + bytes32 hash = openPositionWrapper.getApprovalHash(params); + + // Initially hash should not be approved + assertEq(openPositionWrapper.preApprovedHashes(user, hash), 0, "Hash should not be approved initially"); + + // User pre-approves the hash + vm.prank(user); + vm.expectEmit(true, true, false, true); + emit PreApprovedHashes.PreApprovedHash(user, hash, true); + openPositionWrapper.setPreApprovedHash(hash, true); + + // Hash should now be approved + assertGt(openPositionWrapper.preApprovedHashes(user, hash), 0, "Hash should be approved"); + + // User revokes the approval + vm.prank(user); + vm.expectEmit(true, true, false, true); + emit PreApprovedHashes.PreApprovedHash(user, hash, false); + openPositionWrapper.setPreApprovedHash(hash, false); + + // Hash should no longer be approved + assertEq(openPositionWrapper.preApprovedHashes(user, hash), 0, "Hash should not be approved after revocation"); + } + + /// @notice Test opening a position with pre-approved hash (no signature needed) + function test_OpenPositionWrapper_WithPreApprovedHash() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + address account = address(uint160(user) ^ 1); + + // Create params using helper + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _createDefaultParams(user, account); + + // Get settlement data + SettlementData memory settlement = + getOpenPositionSettlement(user, account, WETH, ESUSDS, DEFAULT_BORROW_AMOUNT, DEFAULT_BUY_AMOUNT); + + // Setup user approvals and pre-approve hash + bytes32 hash = openPositionWrapper.getApprovalHash(params); + _setupUserPreApprovedFlow(account, hash); + + // User pre-approves the order on CowSwap + // Does not need to run here because its executed in `setupCowOrder` + + // Record balances before + uint256 debtBefore = IEVault(EWETH).debtOf(account); + + // Encode settlement and wrapper data (empty signature since pre-approved) + bytes memory settleData = abi.encodeCall( + ICowSettlement.settle, + (settlement.tokens, settlement.clearingPrices, settlement.trades, settlement.interactions) + ); + bytes memory wrapperData = encodeWrapperData(abi.encode(params, new bytes(0))); + + // Expect event emission + vm.expectEmit(true, true, true, true); + emit CowEvcOpenPositionWrapper.CowEvcPositionOpened( + params.owner, + params.account, + params.collateralVault, + params.borrowVault, + params.collateralAmount, + params.borrowAmount + ); + + // Execute wrapped settlement + executeWrappedSettlement(address(openPositionWrapper), settleData, wrapperData); + + // Verify the position was created successfully + _verifyPositionOpened(account, DEFAULT_BUY_AMOUNT + SUSDS_MARGIN, DEFAULT_BORROW_AMOUNT, 1 ether); + assertEq(debtBefore, 0, "User should start with no debt"); + } + + /// @notice Test that the wrapper can handle being called three times in the same chain + /// @dev Two users open positions in the same direction (long SUSDS), one user opens opposite (long WETH) + function test_OpenPositionWrapper_ThreeUsers_TwoSameOneOpposite() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + // Configure vault LTVs for both directions + // Already configured: eSUSDS collateral -> eWETH borrow + // Need to configure: eWETH collateral -> eSUSDS borrow + vm.startPrank(IEVault(ESUSDS).governorAdmin()); + IEVault(ESUSDS).setLTV(EWETH, 0.9e4, 0.9e4, 0); + vm.stopPrank(); + + // Setup User1: Has SUSDS, will borrow WETH and swap WETH→SUSDS (long SUSDS). Around 1 ETH + address account1 = address(uint160(user) ^ 1); + deal(SUSDS, user, 1000 ether); + + // Approve SUSDS spending by eSUSDS for user1 + vm.startPrank(user); + IERC20(SUSDS).approve(ESUSDS, type(uint256).max); + + // Approve WETH for COW Protocol for user1 + IERC20(WETH).approve(COW_SETTLEMENT.vaultRelayer(), type(uint256).max); + + vm.stopPrank(); + + // Setup User2: Has SUSDS, will borrow WETH and swap WETH→SUSDS. 3x the size (long SUSDS, same direction as user1). Around 3 ETH + address account2 = address(uint160(user2) ^ 1); + deal(SUSDS, user2, 1000 ether); + + // Approve SUSDS spending by eSUSDS for user2 + vm.startPrank(user2); + IERC20(SUSDS).approve(ESUSDS, type(uint256).max); + + // Approve WETH for COW Protocol for user2 + IERC20(WETH).approve(COW_SETTLEMENT.vaultRelayer(), type(uint256).max); + + vm.stopPrank(); + + // Setup User3: Has WETH, will borrow SUSDS and swap SUSDS→WETH (long WETH, opposite direction). Around $5000 + address account3 = address(uint160(user3) ^ 1); + deal(WETH, user3, 1 ether); + + // Approve WETH spending by eWETH for user2 + vm.startPrank(user3); + IERC20(WETH).approve(EWETH, type(uint256).max); + + // Approve SUSDS for COW Protocol for user3 + IERC20(SUSDS).approve(COW_SETTLEMENT.vaultRelayer(), type(uint256).max); + + vm.stopPrank(); + + // Create params for User1: Deposit SUSDS, borrow WETH + CowEvcOpenPositionWrapper.OpenPositionParams memory params1 = CowEvcOpenPositionWrapper.OpenPositionParams({ + owner: user, + account: account1, + deadline: block.timestamp + 1 hours, + collateralVault: ESUSDS, + borrowVault: EWETH, + collateralAmount: 1000 ether, + borrowAmount: 1 ether + }); + + // Create params for User2: Deposit SUSDS, borrow WETH (same direction as User1) + CowEvcOpenPositionWrapper.OpenPositionParams memory params2 = CowEvcOpenPositionWrapper.OpenPositionParams({ + owner: user2, + account: account2, + deadline: block.timestamp + 1 hours, + collateralVault: ESUSDS, + borrowVault: EWETH, + collateralAmount: 1000 ether, + borrowAmount: 3 ether + }); + + CowEvcOpenPositionWrapper.OpenPositionParams memory params3 = CowEvcOpenPositionWrapper.OpenPositionParams({ + owner: user3, + account: account3, + deadline: block.timestamp + 1 hours, + collateralVault: EWETH, + borrowVault: ESUSDS, + collateralAmount: 1 ether, + borrowAmount: 5000 ether + }); + + // Create permit signatures for all users + ecdsa.setPrivateKey(privateKey); + bytes memory permitSignature1 = ecdsa.signPermit( + params1.owner, + address(openPositionWrapper), + uint256(uint160(address(openPositionWrapper))), + 0, + params1.deadline, + 0, + openPositionWrapper.getSignedCalldata(params1) + ); + + ecdsa.setPrivateKey(privateKey2); + bytes memory permitSignature2 = ecdsa.signPermit( + params2.owner, + address(openPositionWrapper), + uint256(uint160(address(openPositionWrapper))), + 0, + params2.deadline, + 0, + openPositionWrapper.getSignedCalldata(params2) + ); + + ecdsa.setPrivateKey(privateKey3); + bytes memory permitSignature3 = ecdsa.signPermit( + params3.owner, + address(openPositionWrapper), + uint256(uint160(address(openPositionWrapper))), + 0, + params3.deadline, + 0, + openPositionWrapper.getSignedCalldata(params3) + ); + + // Create settlement with all three trades + uint32 validTo = uint32(block.timestamp + 1 hours); + + // Setup tokens array: WETH, eSUSDS, SUSDS, eWETH + address[] memory tokens = new address[](4); + tokens[0] = SUSDS; + tokens[1] = WETH; + tokens[2] = ESUSDS; + tokens[3] = EWETH; + + uint256[] memory clearingPrices = new uint256[](4); + clearingPrices[0] = 1 ether; // SUSDS price + clearingPrices[1] = 2500 ether; // WETH price + clearingPrices[2] = 0.99 ether; // eSUSDS price + clearingPrices[3] = 2495 ether; // eWETH price + + // Create trades and extract orders + ICowSettlement.Trade[] memory trades = new ICowSettlement.Trade[](3); + + // Trade 1: User1 sells WETH for eSUSDS + (trades[0],,) = setupCowOrder(tokens, 1, 2, params1.borrowAmount, 0, validTo, user, account1, false); + + // Trade 2: User2 sells WETH for eSUSDS (same direction as User1) + (trades[1],,) = setupCowOrder(tokens, 1, 2, params2.borrowAmount, 0, validTo, user2, account2, false); + + // Trade 3: User3 sells SUSDS for eWETH (opposite direction) + (trades[2],,) = setupCowOrder(tokens, 0, 3, params3.borrowAmount, 0, validTo, user3, account3, false); + + // Setup interactions to handle the swaps and deposits + ICowSettlement.Interaction[][3] memory interactions; + interactions[0] = new ICowSettlement.Interaction[](0); + interactions[1] = new ICowSettlement.Interaction[](5); + interactions[2] = new ICowSettlement.Interaction[](0); + + // Trade 1 & 2: coincidence of wants: WETH → SUSDS for the difference in all the users trades (2 WETH total difference) + interactions[1][0] = getSwapInteraction(WETH, SUSDS, 2 ether); + // Deposit SUSDS to eSUSDS vault for both user1 and user2 + interactions[1][1] = getDepositInteraction(ESUSDS, 10000 ether); + // Deposit WETH to eWETH vault + interactions[1][2] = getDepositInteraction(EWETH, 2 ether); + + // Skim eSUSDS vault + interactions[1][3] = getSkimInteraction(ESUSDS); + // Skim eWETH vault + interactions[1][4] = getSkimInteraction(EWETH); + + // Encode settlement data + bytes memory settleData = abi.encodeCall(ICowSettlement.settle, (tokens, clearingPrices, trades, interactions)); + + // Chain wrapper data: wrapper(user1) → wrapper(user2) → wrapper(user3) → settlement + // Format: [2-byte len][wrapper1 data][next wrapper address][2-byte len][wrapper2 data][next wrapper address][2-byte len][wrapper3 data] + bytes memory wrapper1Data = abi.encode(params1, permitSignature1); + bytes memory wrapper2Data = abi.encode(params2, permitSignature2); + bytes memory wrapper3Data = abi.encode(params3, permitSignature3); + + bytes memory wrapperData = abi.encodePacked( + uint16(wrapper1Data.length), + wrapper1Data, + address(openPositionWrapper), + uint16(wrapper2Data.length), + wrapper2Data, + address(openPositionWrapper), + uint16(wrapper3Data.length), + wrapper3Data + ); + + // Execute wrapped settlement through solver + // Note: We don't use expectEmit here because there are many Transfer events + // from the complex multi-user settlement that interfere with strict event matching + address[] memory targets = new address[](1); + bytes[] memory datas = new bytes[](1); + targets[0] = address(openPositionWrapper); + datas[0] = abi.encodeCall(openPositionWrapper.wrappedSettle, (settleData, wrapperData)); + solver.runBatch(targets, datas); + + // Verify all three positions were opened successfully + // User1: Should have SUSDS collateral and WETH debt + _verifyPositionOpened(account1, 1000 ether + 2500 ether, 1 ether, 100 ether); + + // User2: Should have SUSDS collateral and WETH debt (same as User1) + _verifyPositionOpened(account2, 1000 ether + 7500 ether, 3 ether, 100 ether); + + // User3: Should have WETH collateral and SUSDS debt + assertApproxEqAbs( + IEVault(EWETH).convertToAssets(IERC20(EWETH).balanceOf(account3)), + 1 ether + 2 ether, + 0.2 ether, + "User3 should have WETH collateral deposited" + ); + assertEq(IEVault(ESUSDS).debtOf(account3), 5000 ether, "User3 should have SUSDS debt"); + } +} diff --git a/test/helpers/CowBaseTest.sol b/test/helpers/CowBaseTest.sol index efec69a..d4df8ba 100644 --- a/test/helpers/CowBaseTest.sol +++ b/test/helpers/CowBaseTest.sol @@ -2,10 +2,11 @@ pragma solidity ^0.8; import {GPv2Order} from "cow/libraries/GPv2Order.sol"; +import {IERC20 as CowERC20} from "cow/interfaces/IERC20.sol"; import {EthereumVaultConnector} from "evc/EthereumVaultConnector.sol"; -import {EVaultTestBase} from "lib/euler-vault-kit/test/unit/evault/EVaultTestBase.t.sol"; -import {IEVault, IVault, IERC20} from "euler-vault-kit/src/EVault/IEVault.sol"; +import {Test} from "forge-std/Test.sol"; +import {IEVault, IVault, IBorrowing, IERC4626, IERC20} from "euler-vault-kit/src/EVault/IEVault.sol"; import {GPv2AllowListAuthentication} from "cow/GPv2AllowListAuthentication.sol"; import {ICowSettlement} from "../../src/CowWrapper.sol"; @@ -22,7 +23,7 @@ contract Solver { } } -contract CowBaseTest is EVaultTestBase { +contract CowBaseTest is Test { uint256 mainnetFork; uint256 constant BLOCK_NUMBER = 22546006; string forkRpcUrl = vm.envOr("FORK_RPC_URL", string("")); @@ -37,19 +38,23 @@ contract CowBaseTest is EVaultTestBase { address internal constant ESUSDS = 0x1e548CfcE5FCF17247E024eF06d32A01841fF404; address internal constant EWETH = 0xD8b27CF359b7D15710a5BE299AF6e7Bf904984C2; - address payable constant REAL_EVC = payable(0x0C9a3dd6b8F28529d72d7f9cE918D493519EE383); address internal swapVerifier = 0xae26485ACDDeFd486Fe9ad7C2b34169d360737c7; ICowSettlement constant COW_SETTLEMENT = ICowSettlement(payable(0x9008D19f58AAbD9eD0D60971565AA8510560ab41)); + EthereumVaultConnector constant EVC = EthereumVaultConnector(payable(0x0C9a3dd6b8F28529d72d7f9cE918D493519EE383)); + MilkSwap public milkSwap; address user; - uint256 privateKey = 123; + address user2; + address user3; + uint256 privateKey; + uint256 privateKey2; + uint256 privateKey3; Solver internal solver; - function setUp() public virtual override { - super.setUp(); + function setUp() public virtual { solver = new Solver(); if (bytes(forkRpcUrl).length == 0) { @@ -59,9 +64,11 @@ contract CowBaseTest is EVaultTestBase { mainnetFork = vm.createSelectFork(forkRpcUrl); vm.rollFork(BLOCK_NUMBER); - evc = EthereumVaultConnector(REAL_EVC); + (user, privateKey) = makeAddrAndKey("user"); - user = vm.addr(privateKey); + // Certain specialized tests could use these additional users + (user2, privateKey2) = makeAddrAndKey("user 2"); + (user3, privateKey3) = makeAddrAndKey("user 3"); // Add wrapper and our fake solver as solver GPv2AllowListAuthentication allowList = GPv2AllowListAuthentication(address(COW_SETTLEMENT.authenticator())); @@ -75,9 +82,15 @@ contract CowBaseTest is EVaultTestBase { milkSwap = new MilkSwap(); deal(SUSDS, address(milkSwap), 10000e18); // Add SUSDS to MilkSwap deal(WETH, address(milkSwap), 10000e18); // Add WETH to MilkSwap - milkSwap.setPrice(WETH, 1000e18); // 1 ETH = 1,000 USD + milkSwap.setPrice(WETH, 2500e18); // 1 ETH = 2,500 USD milkSwap.setPrice(SUSDS, 1e18); // 1 USDS = 1 USD + // deal small amount to the settlement contract that serve as buffer (just makes tests easier...) + deal(SUSDS, address(COW_SETTLEMENT), 200e18); + deal(WETH, address(COW_SETTLEMENT), 0.2e18); + deal(ESUSDS, address(COW_SETTLEMENT), 200e18); + deal(EWETH, address(COW_SETTLEMENT), 0.2e18); + // Set the approval for MilkSwap in the settlement as a convenience vm.startPrank(address(COW_SETTLEMENT)); IERC20(WETH).approve(address(milkSwap), type(uint256).max); @@ -101,8 +114,11 @@ contract CowBaseTest is EVaultTestBase { vm.label(WETH, "WETH"); vm.label(ESUSDS, "eSUSDS"); vm.label(EWETH, "eWETH"); - vm.label(address(COW_SETTLEMENT), "cow settlement"); - vm.label(address(milkSwap), "milkswap"); + vm.label(address(COW_SETTLEMENT), "CoW"); + vm.label(address(COW_SETTLEMENT.authenticator()), "CoW Auth"); + vm.label(address(COW_SETTLEMENT.authenticator()), "CoW Vault Relayer"); + vm.label(address(EVC), "EVC"); + vm.label(address(milkSwap), "MilkSwap"); } function getOrderUid(address owner, GPv2Order.Data memory orderData) public view returns (bytes memory orderUid) { @@ -138,30 +154,33 @@ contract CowBaseTest is EVaultTestBase { }); } - function getSkimInteraction() public pure returns (ICowSettlement.Interaction memory) { + function getSkimInteraction(address vault) public pure returns (ICowSettlement.Interaction memory) { return ICowSettlement.Interaction({ - target: address(ESUSDS), + target: address(vault), value: 0, callData: abi.encodeCall(IVault.skim, (type(uint256).max, address(COW_SETTLEMENT))) }); } - function getTradeData( + function setupCowOrder( + address[] memory tokens, + uint256 sellTokenIndex, + uint256 buyTokenIndex, uint256 sellAmount, uint256 buyAmount, uint32 validTo, address owner, address receiver, bool isBuy - ) public pure returns (ICowSettlement.Trade memory) { + ) public returns (ICowSettlement.Trade memory trade, GPv2Order.Data memory order, bytes memory orderId) { // Set flags for (pre-sign, FoK sell order) // See // https://github.com/cowprotocol/contracts/blob/08f8627d8427c8842ae5d29ed8b44519f7674879/src/contracts/libraries/GPv2Trade.sol#L89-L94 uint256 flags = (3 << 5) | (isBuy ? 1 : 0); // 1100000 - return ICowSettlement.Trade({ - sellTokenIndex: 0, - buyTokenIndex: 1, + trade = ICowSettlement.Trade({ + sellTokenIndex: sellTokenIndex, + buyTokenIndex: buyTokenIndex, receiver: receiver, sellAmount: sellAmount, buyAmount: buyAmount, @@ -172,6 +191,28 @@ contract CowBaseTest is EVaultTestBase { executedAmount: 0, signature: abi.encodePacked(owner) }); + + // Extract order from trade (manually applying GPv2Trade.extractOrder logic) + order = GPv2Order.Data({ + sellToken: CowERC20(tokens[trade.sellTokenIndex]), + buyToken: CowERC20(tokens[trade.buyTokenIndex]), + receiver: trade.receiver, + sellAmount: trade.sellAmount, + buyAmount: trade.buyAmount, + validTo: trade.validTo, + appData: trade.appData, + feeAmount: trade.feeAmount, + kind: isBuy ? GPv2Order.KIND_BUY : GPv2Order.KIND_SELL, + partiallyFillable: false, // FoK orders are not partially fillable + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + + orderId = getOrderUid(owner, order); + + // we basically always want to sign the order id + vm.prank(owner); + COW_SETTLEMENT.setPreSignature(orderId, true); } function getTokensAndPrices() public pure returns (address[] memory tokens, uint256[] memory clearingPrices) { @@ -180,7 +221,22 @@ contract CowBaseTest is EVaultTestBase { tokens[1] = ESUSDS; clearingPrices = new uint256[](2); - clearingPrices[0] = 999; // WETH price (if it was against SUSD then 1000) + clearingPrices[0] = 2495; // WETH price (if it was against SUSD then 2500) clearingPrices[1] = 1; // eSUSDS price } + + /// @notice Encode wrapper data with length prefix + /// @dev Takes already abi.encoded params and signature + function encodeWrapperData(bytes memory paramsAndSignature) internal pure returns (bytes memory) { + return abi.encodePacked(uint16(paramsAndSignature.length), paramsAndSignature); + } + + /// @notice Execute wrapped settlement through solver + function executeWrappedSettlement(address wrapper, bytes memory settleData, bytes memory wrapperData) internal { + address[] memory targets = new address[](1); + bytes[] memory datas = new bytes[](1); + targets[0] = wrapper; + datas[0] = abi.encodeWithSignature("wrappedSettle(bytes,bytes)", settleData, wrapperData); + solver.runBatch(targets, datas); + } } diff --git a/test/unit/CowEvcOpenPositionWrapper.unit.t.sol b/test/unit/CowEvcOpenPositionWrapper.unit.t.sol new file mode 100644 index 0000000..7a4c311 --- /dev/null +++ b/test/unit/CowEvcOpenPositionWrapper.unit.t.sol @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8; + +import {Test} from "forge-std/Test.sol"; +import {IEVC} from "evc/EthereumVaultConnector.sol"; +import {CowEvcOpenPositionWrapper} from "../../src/CowEvcOpenPositionWrapper.sol"; +import {ICowSettlement} from "../../src/CowWrapper.sol"; +import {IERC4626, IBorrowing} from "euler-vault-kit/src/EVault/IEVault.sol"; +import {MockEVC} from "./mocks/MockEVC.sol"; +import {MockCowAuthentication, MockCowSettlement} from "./mocks/MockCowProtocol.sol"; + +/// @title Unit tests for CowEvcOpenPositionWrapper +/// @notice Comprehensive unit tests focusing on isolated functionality testing with mocks +contract CowEvcOpenPositionWrapperUnitTest is Test { + CowEvcOpenPositionWrapper public wrapper; + MockEVC public mockEvc; + MockCowSettlement public mockSettlement; + MockCowAuthentication public mockAuth; + + address constant OWNER = address(0x1111); + address constant ACCOUNT = address(0x1112); + address constant SOLVER = address(0x3333); + address constant COLLATERAL_VAULT = address(0x4444); + address constant BORROW_VAULT = address(0x5555); + + uint256 constant DEFAULT_COLLATERAL_AMOUNT = 1000e18; + uint256 constant DEFAULT_BORROW_AMOUNT = 500e18; + + event PreApprovedHash(address indexed owner, bytes32 indexed hash, bool approved); + event PreApprovedHashConsumed(address indexed owner, bytes32 indexed hash); + + /// @notice Get default OpenPositionParams for testing + function _getDefaultParams() internal view returns (CowEvcOpenPositionWrapper.OpenPositionParams memory) { + return CowEvcOpenPositionWrapper.OpenPositionParams({ + owner: OWNER, + account: ACCOUNT, + deadline: block.timestamp + 1 hours, + collateralVault: COLLATERAL_VAULT, + borrowVault: BORROW_VAULT, + collateralAmount: DEFAULT_COLLATERAL_AMOUNT, + borrowAmount: DEFAULT_BORROW_AMOUNT + }); + } + + /// @notice Create empty settle data + function _getEmptySettleData() internal pure returns (bytes memory) { + return abi.encodeCall( + ICowSettlement.settle, + ( + new address[](0), + new uint256[](0), + new ICowSettlement.Trade[](0), + [ + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0) + ] + ) + ); + } + + /// @notice Encode wrapper data with length prefix + function _encodeWrapperData(CowEvcOpenPositionWrapper.OpenPositionParams memory params, bytes memory signature) + internal + pure + returns (bytes memory) + { + bytes memory wrapperData = abi.encode(params, signature); + return abi.encodePacked(uint16(wrapperData.length), wrapperData); + } + + /// @notice Setup pre-approved hash flow + function _setupPreApprovedHash(CowEvcOpenPositionWrapper.OpenPositionParams memory params) + internal + returns (bytes32) + { + bytes32 hash = wrapper.getApprovalHash(params); + vm.prank(OWNER); + wrapper.setPreApprovedHash(hash, true); + mockEvc.setOperator(OWNER, address(wrapper), true); + mockEvc.setOperator(ACCOUNT, address(wrapper), true); + return hash; + } + + /// @notice Decode signed calldata helper + function _decodeSignedCalldata(bytes memory signedCalldata) internal pure returns (IEVC.BatchItem[] memory) { + bytes memory encodedItems = new bytes(signedCalldata.length - 4); + for (uint256 i = 4; i < signedCalldata.length; i++) { + encodedItems[i - 4] = signedCalldata[i]; + } + return abi.decode(encodedItems, (IEVC.BatchItem[])); + } + + function setUp() public { + mockAuth = new MockCowAuthentication(); + mockSettlement = new MockCowSettlement(address(mockAuth)); + mockEvc = new MockEVC(); + + wrapper = new CowEvcOpenPositionWrapper(address(mockEvc), ICowSettlement(address(mockSettlement))); + + // Set solver as authenticated + mockAuth.setSolver(SOLVER, true); + + // Set the correct onBehalfOfAccount for evcInternalSettle calls + mockEvc.setOnBehalfOf(address(wrapper)); + } + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR TESTS + //////////////////////////////////////////////////////////////*/ + + function test_Constructor_SetsImmutables() public view { + assertEq(address(wrapper.EVC()), address(mockEvc), "EVC not set correctly"); + assertEq(address(wrapper.SETTLEMENT()), address(mockSettlement), "SETTLEMENT not set correctly"); + assertEq(address(wrapper.AUTHENTICATOR()), address(mockAuth), "AUTHENTICATOR not set correctly"); + assertEq(wrapper.NONCE_NAMESPACE(), uint256(uint160(address(wrapper))), "NONCE_NAMESPACE incorrect"); + } + + function test_Constructor_SetsDomainSeparator() public view { + bytes32 expectedDomainSeparator = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("CowEvcOpenPositionWrapper"), + keccak256("1"), + block.chainid, + address(wrapper) + ) + ); + assertEq(wrapper.DOMAIN_SEPARATOR(), expectedDomainSeparator, "DOMAIN_SEPARATOR incorrect"); + } + + /*////////////////////////////////////////////////////////////// + PARSE WRAPPER DATA TESTS + //////////////////////////////////////////////////////////////*/ + + function test_ParseWrapperData_EmptySignature() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes memory wrapperData = abi.encode(params, new bytes(0)); + bytes memory remaining = wrapper.parseWrapperData(wrapperData); + + assertEq(remaining.length, 0, "Should have no remaining data"); + } + + function test_ParseWrapperData_WithExtraData() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes memory signature = new bytes(0); + bytes memory wrapperData = abi.encode(params, signature); + bytes memory extraData = hex"deadbeef"; + wrapperData = abi.encodePacked(wrapperData, extraData); + + bytes memory remaining = wrapper.parseWrapperData(wrapperData); + + assertEq(remaining.length, 4, "Should have 4 bytes remaining"); + assertEq(remaining, extraData, "Extra data should match"); + } + + /*////////////////////////////////////////////////////////////// + APPROVAL HASH TESTS + //////////////////////////////////////////////////////////////*/ + + function test_GetApprovalHash_DifferentForDifferentParams() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params1 = _getDefaultParams(); + + // Change owner field + CowEvcOpenPositionWrapper.OpenPositionParams memory params2 = _getDefaultParams(); + params2.owner = ACCOUNT; + + // Change borrowAmount field + CowEvcOpenPositionWrapper.OpenPositionParams memory params3 = _getDefaultParams(); + params3.borrowAmount = 600e18; + + bytes32 hash1 = wrapper.getApprovalHash(params1); + bytes32 hash2 = wrapper.getApprovalHash(params2); + bytes32 hash3 = wrapper.getApprovalHash(params3); + + assertNotEq(hash1, hash2, "Hash should differ for different params"); + assertNotEq(hash1, hash3, "Hash should differ for different params"); + } + + function test_GetApprovalHash_MatchesEIP712() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes32 structHash = keccak256( + abi.encode( + params.owner, + params.account, + params.deadline, + params.collateralVault, + params.borrowVault, + params.collateralAmount, + params.borrowAmount + ) + ); + + bytes32 expectedDigest = keccak256(abi.encodePacked("\x19\x01", wrapper.DOMAIN_SEPARATOR(), structHash)); + bytes32 actualDigest = wrapper.getApprovalHash(params); + + assertEq(actualDigest, expectedDigest, "Hash should match EIP-712 format"); + } + + /*////////////////////////////////////////////////////////////// + GET SIGNED CALLDATA TESTS + //////////////////////////////////////////////////////////////*/ + + function test_GetSignedCalldata_EnableCollateralItem() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + assertEq(items[0].targetContract, address(mockEvc), "First item should target EVC"); + assertEq( + items[0].data, + abi.encodeCall(IEVC.enableCollateral, (ACCOUNT, COLLATERAL_VAULT)), + "Should enable collateral" + ); + } + + function test_GetSignedCalldata_EnableControllerItem() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + assertEq(items[1].targetContract, address(mockEvc), "Second item should target EVC"); + assertEq( + items[1].data, abi.encodeCall(IEVC.enableController, (ACCOUNT, BORROW_VAULT)), "Should enable controller" + ); + } + + function test_GetSignedCalldata_DepositItem() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + assertEq(items[2].targetContract, COLLATERAL_VAULT, "Third item should target collateral vault"); + assertEq(items[2].onBehalfOfAccount, OWNER, "Should deposit on behalf of owner"); + assertEq( + items[2].data, + abi.encodeCall(IERC4626.deposit, (DEFAULT_COLLATERAL_AMOUNT, ACCOUNT)), + "Should deposit collateral" + ); + } + + function test_GetSignedCalldata_BorrowItem() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + assertEq(items[3].targetContract, BORROW_VAULT, "Fourth item should target borrow vault"); + assertEq(items[3].onBehalfOfAccount, ACCOUNT, "Should borrow on behalf of account"); + assertEq( + items[3].data, abi.encodeCall(IBorrowing.borrow, (DEFAULT_BORROW_AMOUNT, OWNER)), "Should borrow to owner" + ); + } + + /*////////////////////////////////////////////////////////////// + EVC INTERNAL SETTLE TESTS + //////////////////////////////////////////////////////////////*/ + + function test_EvcInternalSettle_OnlyEVC() public { + bytes memory settleData = ""; + bytes memory remainingWrapperData = ""; + + vm.expectRevert(abi.encodeWithSelector(CowEvcOpenPositionWrapper.Unauthorized.selector, address(this))); + wrapper.evcInternalSettle(settleData, remainingWrapperData); + } + + function test_EvcInternalSettle_RequiresCorrectOnBehalfOfAccount() public { + bytes memory settleData = _getEmptySettleData(); + bytes memory remainingWrapperData = ""; + + mockSettlement.setSuccessfulSettle(true); + + // Set incorrect onBehalfOfAccount (not address(wrapper)) + mockEvc.setOnBehalfOf(address(0x9999)); + + vm.prank(address(mockEvc)); + vm.expectRevert(abi.encodeWithSelector(CowEvcOpenPositionWrapper.Unauthorized.selector, address(0x9999))); + wrapper.evcInternalSettle(settleData, remainingWrapperData); + } + + function test_EvcInternalSettle_CanBeCalledByEVC() public { + bytes memory settleData = _getEmptySettleData(); + bytes memory remainingWrapperData = ""; + + mockSettlement.setSuccessfulSettle(true); + + vm.prank(address(mockEvc)); + wrapper.evcInternalSettle(settleData, remainingWrapperData); + } + + /*////////////////////////////////////////////////////////////// + WRAPPED SETTLE TESTS + //////////////////////////////////////////////////////////////*/ + + function test_WrappedSettle_OnlySolver() public { + bytes memory settleData = ""; + bytes memory wrapperData = hex"0000"; + + vm.expectRevert(abi.encodeWithSignature("NotASolver(address)", address(this))); + wrapper.wrappedSettle(settleData, wrapperData); + } + + function test_WrappedSettle_WithPermitSignature() public { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes memory signature = new bytes(65); // Valid ECDSA signature length + bytes memory settleData = _getEmptySettleData(); + bytes memory wrapperData = _encodeWrapperData(params, signature); + + mockEvc.setSuccessfulBatch(true); + + vm.prank(SOLVER); + wrapper.wrappedSettle(settleData, wrapperData); + } + + function test_WrappedSettle_WithPreApprovedHash() public { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + + bytes32 hash = _setupPreApprovedHash(params); + + bytes memory settleData = _getEmptySettleData(); + bytes memory wrapperData = _encodeWrapperData(params, new bytes(0)); + + mockEvc.setSuccessfulBatch(true); + + vm.prank(SOLVER); + wrapper.wrappedSettle(settleData, wrapperData); + + // Verify hash was consumed + assertFalse(wrapper.isHashPreApproved(OWNER, hash), "Hash should be consumed"); + } + + function test_WrappedSettle_PreApprovedHashRevertsIfDeadlineExceeded() public { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + params.deadline = block.timestamp - 1; // Deadline in the past + + _setupPreApprovedHash(params); + + bytes memory settleData = _getEmptySettleData(); + bytes memory wrapperData = _encodeWrapperData(params, new bytes(0)); + + vm.prank(SOLVER); + vm.expectRevert( + abi.encodeWithSelector( + CowEvcOpenPositionWrapper.OperationDeadlineExceeded.selector, params.deadline, block.timestamp + ) + ); + wrapper.wrappedSettle(settleData, wrapperData); + } + + /*////////////////////////////////////////////////////////////// + EDGE CASE TESTS + //////////////////////////////////////////////////////////////*/ + + function test_ZeroCollateralAmount() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + params.collateralAmount = 0; // Zero collateral + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + // Should still have deposit call, just with 0 amount + assertEq(items[2].data, abi.encodeCall(IERC4626.deposit, (0, ACCOUNT)), "Should deposit 0"); + } + + function test_MaxBorrowAmount() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + params.borrowAmount = type(uint256).max; + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + assertEq(items[3].data, abi.encodeCall(IBorrowing.borrow, (type(uint256).max, OWNER)), "Should borrow max"); + } + + function test_SameOwnerAndAccount() public view { + CowEvcOpenPositionWrapper.OpenPositionParams memory params = _getDefaultParams(); + params.account = OWNER; // Same as owner + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + // Should still work, but with same address + assertEq(items[2].onBehalfOfAccount, OWNER, "Deposit should be on behalf of owner"); + assertEq(items[3].onBehalfOfAccount, OWNER, "Borrow should be on behalf of account"); + } +} diff --git a/test/unit/CowWrapperHelpers.t.sol b/test/unit/CowWrapperHelpers.t.sol index 9cd2817..37a7268 100644 --- a/test/unit/CowWrapperHelpers.t.sol +++ b/test/unit/CowWrapperHelpers.t.sol @@ -258,7 +258,7 @@ contract CowWrapperHelpersTest is Test { // Verify the length prefix is correct (first 2 bytes) bytes2 lengthPrefix; - assembly { + assembly ("memory-safe") { lengthPrefix := mload(add(result, 32)) } assertEq(uint16(lengthPrefix), 65535); diff --git a/test/unit/PreApprovedHashes.unit.t.sol b/test/unit/PreApprovedHashes.unit.t.sol new file mode 100644 index 0000000..e0f5493 --- /dev/null +++ b/test/unit/PreApprovedHashes.unit.t.sol @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8; + +import {Test} from "forge-std/Test.sol"; +import {PreApprovedHashes} from "../../src/PreApprovedHashes.sol"; + +/// @title Unit tests for PreApprovedHashes +/// @notice Tests the pre-approved hash management functionality +contract PreApprovedHashesUnitTest is Test { + TestablePreApprovedHashes public preApprovedHashes; + + address constant USER = address(0x1111); + address constant OTHER_USER = address(0x2222); + + event PreApprovedHash(address indexed owner, bytes32 indexed hash, bool approved); + event PreApprovedHashConsumed(address indexed owner, bytes32 indexed hash); + + function setUp() public { + preApprovedHashes = new TestablePreApprovedHashes(); + } + + /*////////////////////////////////////////////////////////////// + SET PRE-APPROVED HASH TESTS + //////////////////////////////////////////////////////////////*/ + + function test_SetPreApprovedHash_EmitsEvent() public { + bytes32 hash = keccak256("test"); + + vm.expectEmit(true, true, false, true); + emit PreApprovedHash(USER, hash, true); + + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, true); + + vm.expectEmit(true, true, false, true); + emit PreApprovedHash(USER, hash, false); + + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, false); + } + + function test_SetPreApprovedHash_CannotApproveConsumed() public { + bytes32 hash = keccak256("test"); + + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, true); + + // Consume the hash + preApprovedHashes.testConsumeHash(USER, hash); + + // Try to approve the consumed hash again + vm.prank(USER); + vm.expectRevert(abi.encodeWithSignature("AlreadyConsumed(address,bytes32)", USER, hash)); + preApprovedHashes.setPreApprovedHash(hash, true); + } + + function test_SetPreApprovedHash_CannotRevokeConsumed() public { + bytes32 hash = keccak256("test"); + + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, true); + + // Consume the hash + preApprovedHashes.testConsumeHash(USER, hash); + + // Try to revoke the consumed hash + vm.prank(USER); + vm.expectRevert(abi.encodeWithSignature("AlreadyConsumed(address,bytes32)", USER, hash)); + preApprovedHashes.setPreApprovedHash(hash, false); + } + + function test_SetPreApprovedHash_RevokeAndReapprove() public { + bytes32 hash = keccak256("test"); + + vm.startPrank(USER); + + // Approve + preApprovedHashes.setPreApprovedHash(hash, true); + assertGt(preApprovedHashes.preApprovedHashes(USER, hash), 0, "Hash should be approved"); + + // Revoke + preApprovedHashes.setPreApprovedHash(hash, false); + assertEq(preApprovedHashes.preApprovedHashes(USER, hash), 0, "Hash should not be approved"); + + // Reapprove + preApprovedHashes.setPreApprovedHash(hash, true); + assertGt(preApprovedHashes.preApprovedHashes(USER, hash), 0, "Hash should be approved again"); + + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + CONSUME PRE-APPROVED HASH TESTS + //////////////////////////////////////////////////////////////*/ + + function test_ConsumePreApprovedHash_EmitsEvent() public { + bytes32 hash = keccak256("test"); + + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, true); + + vm.expectEmit(true, true, false, true); + emit PreApprovedHashConsumed(USER, hash); + + preApprovedHashes.testConsumeHash(USER, hash); + } + + function test_ConsumePreApprovedHash_CannotConsumedTwice() public { + bytes32 hash = keccak256("test"); + + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, true); + + // First consumption + bool consumed1 = preApprovedHashes.testConsumeHash(USER, hash); + assertTrue(consumed1, "First consumption should succeed"); + + // Second consumption attempt + bool consumed2 = preApprovedHashes.testConsumeHash(USER, hash); + assertFalse(consumed2, "Second consumption should fail"); + } + + /*////////////////////////////////////////////////////////////// + STORAGE TESTS + //////////////////////////////////////////////////////////////*/ + + function test_PreApprovedHashesStorage() public { + bytes32 hash = keccak256("test"); + + // Initially 0 + assertEq(preApprovedHashes.preApprovedHashes(USER, hash), 0, "Should be 0 initially"); + + // After approval, non-zero + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, true); + uint256 approvedValue = preApprovedHashes.preApprovedHashes(USER, hash); + assertGt(approvedValue, 0, "Should be non-zero after approval"); + + // After revocation, back to 0 + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, false); + assertEq(preApprovedHashes.preApprovedHashes(USER, hash), 0, "Should be 0 after revocation"); + + // After re-approval, non-zero again + vm.prank(USER); + preApprovedHashes.setPreApprovedHash(hash, true); + assertGt(preApprovedHashes.preApprovedHashes(USER, hash), 0, "Should be non-zero after re-approval"); + + // After consumption, different non-zero value + preApprovedHashes.testConsumeHash(USER, hash); + uint256 consumedValue = preApprovedHashes.preApprovedHashes(USER, hash); + assertGt(consumedValue, 0, "Should be non-zero after consumption"); + assertNotEq(consumedValue, approvedValue, "Consumed value should differ from approved value"); + } + + /*////////////////////////////////////////////////////////////// + FUZZ TESTS + //////////////////////////////////////////////////////////////*/ + + function testFuzz_SetPreApprovedHash(address owner, bytes32 hash) public { + vm.assume(owner != address(0)); + + vm.prank(owner); + preApprovedHashes.setPreApprovedHash(hash, true); + + assertTrue(preApprovedHashes.isHashPreApproved(owner, hash), "Hash should be approved"); + + vm.prank(owner); + preApprovedHashes.setPreApprovedHash(hash, false); + + assertFalse(preApprovedHashes.isHashPreApproved(owner, hash), "Hash should no longer be approved"); + } + + function testFuzz_ConsumePreApprovedHash(address owner, bytes32 hash) public { + vm.assume(owner != address(0)); + + vm.prank(owner); + preApprovedHashes.setPreApprovedHash(hash, true); + + bool consumed = preApprovedHashes.testConsumeHash(owner, hash); + assertTrue(consumed, "Should successfully consume"); + + bool consumedAgain = preApprovedHashes.testConsumeHash(owner, hash); + assertFalse(consumedAgain, "Should not consume twice"); + } + + function testFuzz_MultipleUsersAndHashes(address user1, address user2, bytes32 hash1, bytes32 hash2) public { + vm.assume(user1 != address(0) && user2 != address(0)); + vm.assume(user1 != user2); + vm.assume(hash1 != hash2); + + vm.prank(user1); + preApprovedHashes.setPreApprovedHash(hash1, true); + + vm.prank(user2); + preApprovedHashes.setPreApprovedHash(hash2, true); + + assertTrue(preApprovedHashes.isHashPreApproved(user1, hash1), "User1 hash1 should be approved"); + assertTrue(preApprovedHashes.isHashPreApproved(user2, hash2), "User2 hash2 should be approved"); + + assertFalse(preApprovedHashes.isHashPreApproved(user1, hash2), "User1 should not have user2's hash"); + assertFalse(preApprovedHashes.isHashPreApproved(user2, hash1), "User2 should not have user1's hash"); + } +} + +/// @notice Testable version of PreApprovedHashes that exposes internal functions +contract TestablePreApprovedHashes is PreApprovedHashes { + function testConsumeHash(address owner, bytes32 hash) external returns (bool) { + return _consumePreApprovedHash(owner, hash); + } +} diff --git a/test/unit/mocks/MockERC20AndVaults.sol b/test/unit/mocks/MockERC20AndVaults.sol new file mode 100644 index 0000000..33253d0 --- /dev/null +++ b/test/unit/mocks/MockERC20AndVaults.sol @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8; + +import {IERC20} from "euler-vault-kit/src/EVault/IEVault.sol"; +import {IERC4626, IBorrowing} from "euler-vault-kit/src/EVault/IEVault.sol"; + +/// @title MockERC20 +/// @notice Mock ERC20 token for unit testing +contract MockERC20 is IERC20 { + string public name; + string public symbol; + uint8 public decimals = 18; + + mapping(address => uint256) public override balanceOf; + mapping(address => mapping(address => uint256)) public override allowance; + uint256 public override totalSupply; + + constructor(string memory _name, string memory _symbol) { + name = _name; + symbol = _symbol; + } + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + } + + function approve(address spender, uint256 amount) external override returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } + + function transfer(address to, uint256 amount) external override returns (bool) { + require(balanceOf[msg.sender] >= amount, "ERC20Mock: insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + return true; + } + + function transferFrom(address from, address to, uint256 amount) external override returns (bool) { + if (allowance[from][msg.sender] != type(uint256).max) { + require(allowance[from][msg.sender] >= amount, "ERC20Mock: insufficient allowance"); + allowance[from][msg.sender] -= amount; + } + require(balanceOf[from] >= amount, "ERC20Mock: insufficient balance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + return true; + } +} + + /// @title MockVault + /// @notice Mock ERC4626 vault for unit testing + contract MockVault is IERC4626, MockERC20 { + address public immutable ASSET_ADDRESS; + + constructor(address _asset, string memory _name, string memory _symbol) MockERC20(_name, _symbol) { + ASSET_ADDRESS = _asset; + } + + function asset() external view override returns (address) { + return ASSET_ADDRESS; + } + + function totalAssets() external view override returns (uint256) { + return totalSupply; + } + + function convertToShares(uint256 assets) external pure override returns (uint256) { + return assets; + } + + function convertToAssets(uint256 shares) external pure override returns (uint256) { + return shares; + } + + function maxDeposit(address) external pure override returns (uint256) { + return type(uint256).max; + } + + function maxMint(address) external pure override returns (uint256) { + return type(uint256).max; + } + + function maxWithdraw(address owner) external view override returns (uint256) { + return balanceOf[owner]; + } + + function maxRedeem(address owner) external view override returns (uint256) { + return balanceOf[owner]; + } + + function previewDeposit(uint256 assets) external pure override returns (uint256) { + return assets; + } + + function previewMint(uint256 shares) external pure override returns (uint256) { + return shares; + } + + function previewWithdraw(uint256 assets) external pure override returns (uint256) { + return assets; + } + + function previewRedeem(uint256 shares) external pure override returns (uint256) { + return shares; + } + + function deposit(uint256 assets, address receiver) external override returns (uint256) { + balanceOf[receiver] += assets; + totalSupply += assets; + return assets; + } + + function mint(uint256 shares, address receiver) external override returns (uint256) { + balanceOf[receiver] += shares; + totalSupply += shares; + return shares; + } + + function withdraw(uint256 assets, address receiver, address owner) external override returns (uint256) { + balanceOf[owner] -= assets; + totalSupply -= assets; + balanceOf[receiver] += assets; + return assets; + } + + function redeem(uint256 shares, address receiver, address owner) external override returns (uint256) { + balanceOf[owner] -= shares; + totalSupply -= shares; + balanceOf[receiver] += shares; + return shares; + } + } + + /// @title MockBorrowVault + /// @notice Mock borrowing vault for unit testing + contract MockBorrowVault is MockVault, IBorrowing { + mapping(address => uint256) public debts; + uint256 public repayAmount; + bool public repayAllWasCalled; + + constructor(address _asset, string memory _name, string memory _symbol) MockVault(_asset, _name, _symbol) {} + + function setDebt(address account, uint256 amount) external { + debts[account] = amount; + } + + function setRepayAmount(uint256 amount) external { + repayAmount = amount; + } + + function debtOf(address account) external view override returns (uint256) { + return debts[account]; + } + + function debtOfExact(address account) external view override returns (uint256) { + return debts[account]; + } + + function repay(uint256 amount, address receiver) external override returns (uint256) { + if (amount == type(uint256).max) { + repayAllWasCalled = true; + amount = debts[receiver]; + } + + if (repayAmount > 0) { + amount = repayAmount; + } + + debts[receiver] -= amount; + + // Transfer tokens from sender + require(MockERC20(ASSET_ADDRESS).transferFrom(msg.sender, address(this), amount), "transfer failed"); + + return amount; + } + + function repayAllCalled() external view returns (bool) { + return repayAllWasCalled; + } + + function borrow(uint256 amount, address receiver) external override returns (uint256) { + debts[msg.sender] += amount; + MockERC20(ASSET_ADDRESS).mint(receiver, amount); + return amount; + } + + function pullDebt(uint256, address) external pure override { + revert("Not implemented"); + } + + // Additional required functions from IBorrowing/IEVault + function cash() external pure override returns (uint256) { + return 0; + } + + function dToken() external pure override returns (address) { + return address(0); + } + + function flashLoan(uint256, bytes calldata) external pure override { + revert("Not implemented"); + } + + function interestAccumulator() external pure override returns (uint256) { + return 1e27; + } + + function interestRate() external pure override returns (uint256) { + return 0; + } + + function repayWithShares(uint256, address) external pure override returns (uint256, uint256) { + revert("Not implemented"); + } + + function totalBorrows() external view override returns (uint256) { + return totalSupply; + } + + function totalBorrowsExact() external view override returns (uint256) { + return totalSupply; + } + + function touch() external pure override { + // No-op for mock + } + } diff --git a/test/unit/mocks/MockEVC.sol b/test/unit/mocks/MockEVC.sol new file mode 100644 index 0000000..aefa562 --- /dev/null +++ b/test/unit/mocks/MockEVC.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8; + +import {IEVC} from "evc/EthereumVaultConnector.sol"; + +/// @title MockEVC +/// @notice Mock implementation of EVC for unit testing +contract MockEVC { + mapping(address => mapping(address => bool)) public operators; + mapping(address => uint256) public nonces; + bool public shouldSucceed = true; + address public onBehalfOf; + + function setOperator(address account, address operator, bool authorized) external { + operators[account][operator] = authorized; + } + + function setOnBehalfOf(address shouldBeOnBehalfOf) external { + onBehalfOf = shouldBeOnBehalfOf; + } + + function setAccountOperator(address account, address operator, bool authorized) external { + operators[account][operator] = authorized; + } + + function getNonce(bytes19, uint256) external pure returns (uint256) { + return 0; + } + + function enableCollateral(address, address) external pure {} + + function enableController(address, address) external pure {} + + function disableCollateral(address, address) external pure {} + + function batch(IEVC.BatchItem[] calldata items) external returns (IEVC.BatchItemResult[] memory) { + require(shouldSucceed, "MockEVC: batch failed"); + + // Execute each item + for (uint256 i = 0; i < items.length; i++) { + // Set onBehalfOf to the item's onBehalfOfAccount for the duration of the call + address previousOnBehalfOf = onBehalfOf; + onBehalfOf = items[i].onBehalfOfAccount; + + (bool success, bytes memory reason) = items[i].targetContract.call(items[i].data); + + // Restore previous onBehalfOf + onBehalfOf = previousOnBehalfOf; + + if (!success) { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + } + + return new IEVC.BatchItemResult[](0); + } + + function setSuccessfulBatch(bool success) external { + shouldSucceed = success; + } + + function permit(address, address, uint256, uint256, uint256, uint256, bytes memory, bytes memory) external pure {} + + function getCurrentOnBehalfOfAccount(address) external view returns (address, bool) { + return (onBehalfOf, false); + } +}