diff --git a/src/CowEvcClosePositionWrapper.sol b/src/CowEvcClosePositionWrapper.sol new file mode 100644 index 0000000..f035bc4 --- /dev/null +++ b/src/CowEvcClosePositionWrapper.sol @@ -0,0 +1,395 @@ +// 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, IERC20} from "euler-vault-kit/src/EVault/IEVault.sol"; +import {SafeERC20Lib} from "euler-vault-kit/src/EVault/shared/lib/SafeERC20Lib.sol"; +import {PreApprovedHashes} from "./PreApprovedHashes.sol"; + +/// @title CowEvcClosePositionWrapper +/// @notice A specialized wrapper for closing leveraged positions with EVC +/// @dev This wrapper hardcodes the EVC operations needed to close a position: +/// 1. Execute settlement to acquire repayment assets +/// 2. Repay debt and return remaining assets to user +/// @dev The settle call by this order should be performing the necessary swap +/// from collateralVault -> IERC20(borrowVault.asset()). The recipient of the +/// swap should *THIS* contract so that it can repay on behalf of the owner. Furthermore, +/// the order should be of type GPv2Order.KIND_BUY to prevent excess from being sent to the contract. +/// If a full close is being performed, leave a small buffer for intrest accumultation, and the dust will +/// be returned to the owner's wallet. +contract CowEvcClosePositionWrapper 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("CowEvcClosePositionWrapper"); + + /// @dev The EIP-712 domain version used for computing the domain separator. + bytes32 private constant DOMAIN_VERSION = keccak256("1"); + + /// @dev The marker value for a sell order for computing the order struct + /// hash. This allows the EIP-712 compatible wallets to display a + /// descriptive string for the order kind (instead of 0 or 1). + /// + /// This value is pre-computed from the following expression: + /// ``` + /// keccak256("sell") + /// ``` + bytes32 private constant KIND_SELL = hex"f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775"; + + /// @dev The OrderKind marker value for a buy order for computing the order + /// struct hash. + /// + /// This value is pre-computed from the following expression: + /// ``` + /// keccak256("buy") + /// ``` + bytes32 private constant KIND_BUY = hex"6ed88e868af0a1983e3886d5f3e95a2fafbd6c3450bc229e27342283dc429ccc"; + + /// @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 - Close 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 Indicates that this contract did not receive enough repayment assets from the settlement contract in order to cover all user's orders + error InsufficientRepaymentAsset(address vault, uint256 balanceAmount, uint256 repayAmount); + + /// @dev Indicates that the close order cannot be executed becuase the necessary pricing data is not present in the `tokens`/`clearingPrices` variable + error PricesNotFoundInSettlement(address collateralVaultToken, address borrowToken); + + /// @dev Indicates that a user attempted to interact with an account that is not their own + error SubaccountMustBeControlledByOwner(address subaccount, address owner); + + /// @dev Emitted when a position is closed via this wrapper + event CowEvcPositionClosed( + address indexed owner, + address account, + address indexed borrowVault, + address indexed collateralVault, + uint256 collateralAmount, + uint256 repayAmount, + bytes32 kind + ); + + 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 close a debt position against an euler vault by repaying debt and returning collateral. + * @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 ClosePositionParams { + /** + * @dev The ethereum address that has permission to operate upon the account + */ + address owner; + + /** + * @dev The subaccount to close 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 from which debt was borrowed + */ + address borrowVault; + + /** + * @dev The Euler vault used as collateral + */ + address collateralVault; + + /** + * @dev + */ + uint256 collateralAmount; + + /** + * @dev The amount of debt to repay. If greater than the actual debt, the full debt is repaid + */ + uint256 repayAmount; + + /** + * @dev Whether the `collateralAmount` or `repayAmount` is the exact amount. Either `GPv2Order.KIND_BUY` or `GPv2Order.KIND_SELL` + */ + bytes32 kind; + } + + function _parseClosePositionParams(bytes calldata wrapperData) + internal + pure + returns (ClosePositionParams memory params, bytes memory signature, bytes calldata remainingWrapperData) + { + (params, signature) = abi.decode(wrapperData, (ClosePositionParams, bytes)); + + // Calculate consumed bytes for abi.encode(ClosePositionParams, bytes) + // Structure: + // - 32 bytes: offset to params (0x40) + // - 32 bytes: offset to signature + // - 256 bytes: params data (8 fields × 32 bytes) + // - 32 bytes: signature length + // - N bytes: signature data (padded to 32-byte boundary) + // We can just math this out + uint256 consumed = 256 + 64 + ((signature.length + 31) & ~uint256(31)); + + remainingWrapperData = wrapperData[consumed:]; + } + + /// @notice Helper function to compute the hash that would be approved + /// @param params The ClosePositionParams to hash + /// @return The hash of the signed calldata for these params + function getApprovalHash(ClosePositionParams memory params) external view returns (bytes32) { + return _getApprovalHash(params); + } + + function _getApprovalHash(ClosePositionParams memory params) internal view returns (bytes32 digest) { + bytes32 structHash; + bytes32 separator = DOMAIN_SEPARATOR; + assembly ("memory-safe") { + structHash := keccak256(params, 256) + 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) = _parseClosePositionParams(wrapperData); + } + + function getSignedCalldata(ClosePositionParams memory params) external view returns (bytes memory) { + return abi.encodeCall(IEVC.batch, _getSignedCalldata(params)); + } + + function _getSignedCalldata(ClosePositionParams memory params) + internal + view + returns (IEVC.BatchItem[] memory items) + { + items = new IEVC.BatchItem[](1); + + // 1. Repay debt and return remaining assets + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: params.account, + targetContract: address(this), + value: 0, + data: abi.encodeCall(this.helperRepay, (params.borrowVault, params.owner, params.account)) + }); + } + + /// @notice Called by the EVC after a CoW swap is completed to repay the user's debt. Will use all available collateral in the user's account to do so. + /// @param vault The Euler vault in which the repayment should be made + /// @param owner The address that should be receiving any surplus dust that may exist after the repayment is complete + /// @param account The subaccount that should be receiving the repayment of debt + function helperRepay(address vault, address owner, address account) external { + require(msg.sender == address(EVC), Unauthorized(msg.sender)); + (address onBehalfOfAccount,) = EVC.getCurrentOnBehalfOfAccount(address(0)); + require(onBehalfOfAccount == account, Unauthorized(onBehalfOfAccount)); + + IERC20 asset = IERC20(IERC4626(vault).asset()); + + uint256 debtAmount = IBorrowing(vault).debtOf(account); + + // repay as much debt as we can + uint256 repayAmount = asset.balanceOf(owner); + if (repayAmount > debtAmount) { + // the user intends to repay all their debt. we will revert if their balance is not sufficient. + repayAmount = debtAmount; + } + + // pull funds from the user (they should have approved spending by this contract) + SafeERC20Lib.safeTransferFrom(asset, owner, address(this), repayAmount, address(0)); + + // repay what was requested on the vault + asset.approve(vault, repayAmount); + IBorrowing(vault).repay(repayAmount, account); + } + + /// @notice Implementation of GPv2Wrapper._wrap - executes EVC operations to close a position + /// @param settleData Data which will be used for the parameters in a call to `CowSettlement.settle` + /// @param wrapperData Additional data containing ClosePositionParams + function _wrap(bytes calldata settleData, bytes calldata wrapperData, bytes calldata remainingWrapperData) + internal + override + { + // Decode wrapper data into ClosePositionParams + ClosePositionParams memory params; + bytes memory signature; + (params, signature,) = _parseClosePositionParams(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)); + + // Calculate the number of items needed + uint256 baseItemCount = 2; + uint256 itemCount = isPreApproved ? baseItemCount - 1 + signedItems.length : baseItemCount; + + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](itemCount); + uint256 itemIndex = 0; + + // Build the EVC batch items for closing a position + // 1. Settlement call + items[itemIndex++] = IEVC.BatchItem({ + onBehalfOfAccount: address(this), + targetContract: address(this), + value: 0, + data: abi.encodeCall(this.evcInternalSettle, (settleData, wrapperData, remainingWrapperData)) + }); + + // 2. There are two ways this contract can be executed: either the user approves this contract as + // an 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 + uint256 signedItemIndex = 0; + for (; itemIndex < itemCount; itemIndex++) { + items[itemIndex] = signedItems[signedItemIndex++]; + } + } + + // 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 CowEvcPositionClosed( + params.owner, + params.account, + params.borrowVault, + params.collateralVault, + params.collateralAmount, + params.repayAmount, + params.kind + ); + } + + function _findRatePrices(bytes calldata settleData, address collateralVault, address borrowVault) + internal + view + returns (uint256 collateralVaultPrice, uint256 borrowPrice) + { + address borrowAsset = IERC4626(borrowVault).asset(); + (address[] memory tokens, uint256[] memory clearingPrices,,) = + abi.decode(settleData[4:], (address[], uint256[], ICowSettlement.Trade[], ICowSettlement.Interaction[][3])); + for (uint256 i = 0; i < tokens.length; i++) { + if (tokens[i] == collateralVault) { + collateralVaultPrice = clearingPrices[i]; + } else if (tokens[i] == borrowAsset) { + borrowPrice = clearingPrices[i]; + } + } + require(collateralVaultPrice != 0 && borrowPrice != 0, PricesNotFoundInSettlement(collateralVault, borrowAsset)); + } + + /// @notice Internal settlement function called by EVC + function evcInternalSettle( + bytes calldata settleData, + bytes calldata wrapperData, + 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)); + + ClosePositionParams memory params; + (params,,) = _parseClosePositionParams(wrapperData); + _evcInternalSettle(settleData, remainingWrapperData, params); + } + + function _evcInternalSettle( + bytes calldata settleData, + bytes calldata remainingWrapperData, + ClosePositionParams memory params + ) internal { + // If a subaccount is being used, we need to transfer the required amount of collateral for the trade into the owner's wallet. + // This is required becuase the settlement contract can only pull funds from the wallet that signed the transaction. + // Since its not possible for a subaccount to sign a transaction due to the private key not existing and their being no + // contract deployed to the subaccount address, transferring to the owner's account is the only option. + // Additionally, we don't transfer this collateral directly to the settlement contract because the settlement contract + // requires receiving of funds from the user's wallet, and cannot be put in the contract in advance. + if (params.owner != params.account) { + require( + bytes19(bytes20(params.owner)) == bytes19(bytes20(params.account)), + SubaccountMustBeControlledByOwner(params.account, params.owner) + ); + + uint256 transferAmount = params.collateralAmount; + + if (params.kind == KIND_BUY) { + (uint256 collateralVaultPrice, uint256 borrowPrice) = + _findRatePrices(settleData, params.collateralVault, params.borrowVault); + transferAmount = params.repayAmount * borrowPrice / collateralVaultPrice; + } + + SafeERC20Lib.safeTransferFrom( + IERC20(params.collateralVault), params.account, params.owner, transferAmount, address(0) + ); + } + + // 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/test/CowEvcClosePositionWrapper.t.sol b/test/CowEvcClosePositionWrapper.t.sol new file mode 100644 index 0000000..c786dab --- /dev/null +++ b/test/CowEvcClosePositionWrapper.t.sol @@ -0,0 +1,585 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8; + +import {GPv2Order} from "cow/libraries/GPv2Order.sol"; + +import {IEVC} from "evc/EthereumVaultConnector.sol"; +import {IEVault, IERC4626, IERC20} from "euler-vault-kit/src/EVault/IEVault.sol"; + +import {CowEvcClosePositionWrapper} from "../src/CowEvcClosePositionWrapper.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 CowEvcClosePositionWrapper +/// @notice Tests the full flow of closing a leveraged position using the new wrapper contract +contract CowEvcClosePositionWrapperTest is CowBaseTest { + CowEvcClosePositionWrapper public closePositionWrapper; + SignerECDSA internal ecdsa; + + uint256 constant SUSDS_MARGIN = 2000e18; + uint256 constant DEFAULT_SELL_AMOUNT = 2510 ether; + uint256 constant DEFAULT_BUY_AMOUNT = 1.001 ether; + + function setUp() public override { + super.setUp(); + + // Deploy the new close position wrapper + closePositionWrapper = new CowEvcClosePositionWrapper(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(closePositionWrapper)); + 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 ClosePositionParams for testing + function _createDefaultParams(address owner, address account) + internal + view + returns (CowEvcClosePositionWrapper.ClosePositionParams memory) + { + return CowEvcClosePositionWrapper.ClosePositionParams({ + owner: owner, + account: account, + deadline: block.timestamp + 1 hours, + borrowVault: EWETH, + collateralVault: ESUSDS, + collateralAmount: DEFAULT_SELL_AMOUNT, + repayAmount: DEFAULT_BUY_AMOUNT, + kind: GPv2Order.KIND_BUY + }); + } + + /// @notice Setup pre-approved hash flow for close position + function _setupPreApprovedFlow(address account, bytes32 hash) internal { + vm.startPrank(user); + + // Set operators + EVC.setAccountOperator(user, address(closePositionWrapper), true); + EVC.setAccountOperator(account, address(closePositionWrapper), true); + + // Pre-approve hash + closePositionWrapper.setPreApprovedHash(hash, true); + + // Approve vault shares from subaccount + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](1); + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: account, + targetContract: ESUSDS, + value: 0, + data: abi.encodeCall(IERC20.approve, (address(closePositionWrapper), type(uint256).max)) + }); + EVC.batch(items); + + // Approve vault shares for settlement + IEVault(ESUSDS).approve(COW_SETTLEMENT.vaultRelayer(), type(uint256).max); + + // Approve wrapper to spend WETH for repayment + IERC20(WETH).approve(address(closePositionWrapper), type(uint256).max); + + vm.stopPrank(); + } + + /// @notice Setup approvals for a specific user to close their position + function _setupClosePositionApprovalsFor( + address owner, + address account, + address collateralVault, + address repaymentAsset + ) internal { + vm.startPrank(owner); + + // Approve vault shares from subaccount + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](1); + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: account, + targetContract: collateralVault, + value: 0, + data: abi.encodeCall(IERC20.approve, (address(closePositionWrapper), type(uint256).max)) + }); + EVC.batch(items); + + // Approve vault shares for settlement + IEVault(collateralVault).approve(COW_SETTLEMENT.vaultRelayer(), type(uint256).max); + + // Approve wrapper to spend repayment asset + IERC20(repaymentAsset).approve(address(closePositionWrapper), type(uint256).max); + + vm.stopPrank(); + } + + /// @notice Create permit signature for any user + function _createPermitSignatureFor( + CowEvcClosePositionWrapper.ClosePositionParams memory params, + uint256 userPrivateKey + ) internal returns (bytes memory) { + ecdsa.setPrivateKey(userPrivateKey); + return ecdsa.signPermit( + params.owner, + address(closePositionWrapper), + uint256(uint160(address(closePositionWrapper))), + 0, + params.deadline, + 0, + closePositionWrapper.getSignedCalldata(params) + ); + } + + /// @notice Create settlement data for closing a leveraged position + /// @dev Sells vault shares to buy repayment token (WETH) + function getClosePositionSettlement( + address owner, + address receiver, + address sellVaultToken, + address buyToRepayToken, + uint256 sellAmount, + uint256 buyAmount + ) public returns (SettlementData memory r) { + uint32 validTo = uint32(block.timestamp + 1 hours); + + // Get tokens and prices + r.tokens = new address[](2); + r.tokens[0] = sellVaultToken; + r.tokens[1] = buyToRepayToken; + + r.clearingPrices = new uint256[](2); + r.clearingPrices[0] = milkSwap.prices(IERC4626(sellVaultToken).asset()); + r.clearingPrices[1] = milkSwap.prices(buyToRepayToken); + + // Get trade data + r.trades = new ICowSettlement.Trade[](1); + (r.trades[0], r.orderData, r.orderUid) = + setupCowOrder(r.tokens, 0, 1, sellAmount, buyAmount, validTo, owner, receiver, true); + + // Setup interactions - withdraw from vault, swap to repayment token + r.interactions = [ + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](2), + new ICowSettlement.Interaction[](0) + ]; + r.interactions[1][0] = getWithdrawInteraction(sellVaultToken, buyAmount * r.clearingPrices[1] / 1e18); + r.interactions[1][1] = getSwapInteraction( + IERC4626(sellVaultToken).asset(), buyToRepayToken, buyAmount * r.clearingPrices[1] / 1e18 + ); + } + + /// @notice Test closing a leveraged position using the new wrapper + function test_ClosePositionWrapper_SuccessFullRepay() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + uint256 borrowAmount = 1e18; + uint256 collateralAmount = SUSDS_MARGIN + 2495e18; + + address account = address(uint160(user) ^ uint8(0x01)); + + // First, set up a leveraged position + setupLeveragedPositionFor(user, account, ESUSDS, EWETH, collateralAmount, borrowAmount); + + // Verify position exists + uint256 debtBefore = IEVault(EWETH).debtOf(account); + assertEq(debtBefore, borrowAmount, "Position should have debt"); + + // Create params using helper + CowEvcClosePositionWrapper.ClosePositionParams memory params = _createDefaultParams(user, account); + + // Get settlement data + SettlementData memory settlement = + getClosePositionSettlement(user, user, ESUSDS, WETH, DEFAULT_SELL_AMOUNT, DEFAULT_BUY_AMOUNT); + + // User signs order (already done in setupCowOrder) + + // Setup approvals + _setupClosePositionApprovalsFor(user, account, ESUSDS, WETH); + + // Create permit signature + bytes memory permitSignature = _createPermitSignatureFor(params, privateKey); + + // Record balances before closing + uint256 collateralBefore = IERC20(ESUSDS).balanceOf(user); + uint256 collateralBeforeAccount = IERC20(ESUSDS).balanceOf(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 CowEvcClosePositionWrapper.CowEvcPositionClosed( + params.owner, + params.account, + params.borrowVault, + params.collateralVault, + params.collateralAmount, + params.repayAmount, + params.kind + ); + + // Execute wrapped settlement + executeWrappedSettlement(address(closePositionWrapper), settleData, wrapperData); + + // Verify the position was closed successfully + assertEq(IEVault(EWETH).debtOf(account), 0, "User should have no debt after closing"); + assertLt( + IERC20(ESUSDS).balanceOf(account), collateralBeforeAccount, "User should have less collateral after closing" + ); + assertGt(IERC20(ESUSDS).balanceOf(account), 0, "User should have some collateral remaining"); + assertEq(IERC20(ESUSDS).balanceOf(user), collateralBefore, "User main account balance should not have changed"); + } + + /// @notice Test that unauthorized users cannot call evcInternalSettle directly + function test_ClosePositionWrapper_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(CowEvcClosePositionWrapper.Unauthorized.selector, address(this))); + closePositionWrapper.evcInternalSettle(settleData, wrapperData, wrapperData); + } + + /// @notice Test that non-solvers cannot call wrappedSettle + function test_ClosePositionWrapper_NonSolverCannotSettle() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + bytes memory settleData = ""; + bytes memory wrapperData = hex"0000"; + + // Try to call wrappedSettle as non-solver + vm.expectRevert(abi.encodeWithSelector(CowWrapper.NotASolver.selector, address(this))); + closePositionWrapper.wrappedSettle(settleData, wrapperData); + } + + /// @notice Test shrinking the position with partial repayment + function test_ClosePositionWrapper_PartialRepay() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + uint256 borrowAmount = 2e18; + uint256 collateralAmount = SUSDS_MARGIN + 4990e18; + uint256 sellAmount = 2500e18; + uint256 buyAmount = 0.98e18; + + address account = address(uint160(user) ^ uint8(0x01)); + + // First, set up a leveraged position + setupLeveragedPositionFor(user, account, ESUSDS, EWETH, collateralAmount, borrowAmount); + + // Create params with custom amounts and KIND_SELL + CowEvcClosePositionWrapper.ClosePositionParams memory params = _createDefaultParams(user, account); + params.collateralAmount = sellAmount; + params.repayAmount = buyAmount; + params.kind = GPv2Order.KIND_SELL; + + // Get settlement data + SettlementData memory settlement = getClosePositionSettlement(user, user, ESUSDS, WETH, sellAmount, buyAmount); + + // User signs order (already done in setupCowOrder) + + // Setup approvals + _setupClosePositionApprovalsFor(user, account, ESUSDS, WETH); + + // Create permit signature + bytes memory permitSignature = _createPermitSignatureFor(params, privateKey); + + // 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 CowEvcClosePositionWrapper.CowEvcPositionClosed( + params.owner, + params.account, + params.borrowVault, + params.collateralVault, + params.collateralAmount, + params.repayAmount, + params.kind + ); + + // Execute wrapped settlement + executeWrappedSettlement(address(closePositionWrapper), settleData, wrapperData); + + // Verify partial repayment + uint256 debtAfter = IEVault(EWETH).debtOf(account); + assertApproxEqAbs(debtAfter, borrowAmount - buyAmount, 0.01e18, "Debt should be reduced by repaid amount"); + assertEq(IERC20(WETH).balanceOf(user), 0, "User should have used any collateral they received to repay"); + } + + /// @notice Test parseWrapperData function + function test_ClosePositionWrapper_ParseWrapperData() external view { + address account = address(uint160(user) ^ uint8(0x01)); + CowEvcClosePositionWrapper.ClosePositionParams memory params = _createDefaultParams(user, account); + params.collateralAmount = 0; + params.repayAmount = type(uint256).max; + + bytes memory wrapperData = abi.encode(params, new bytes(65)); + bytes memory remainingData = closePositionWrapper.parseWrapperData(wrapperData); + + // After parsing ClosePositionParams, remaining data should be empty + assertEq(remainingData.length, 0, "Remaining data should be empty"); + } + + /// @notice Test setting pre-approved hash + function test_ClosePositionWrapper_SetPreApprovedHash() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + address account = address(uint160(user) ^ uint8(0x01)); + CowEvcClosePositionWrapper.ClosePositionParams memory params = _createDefaultParams(user, account); + params.collateralAmount = 0; + params.repayAmount = type(uint256).max; + + bytes32 hash = closePositionWrapper.getApprovalHash(params); + + // Initially hash should not be approved + assertEq(closePositionWrapper.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); + closePositionWrapper.setPreApprovedHash(hash, true); + + // Hash should now be approved + assertGt(closePositionWrapper.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); + closePositionWrapper.setPreApprovedHash(hash, false); + + // Hash should no longer be approved + assertEq(closePositionWrapper.preApprovedHashes(user, hash), 0, "Hash should not be approved after revocation"); + } + + /// @notice Test closing a position with pre-approved hash (no signature needed) + function test_ClosePositionWrapper_WithPreApprovedHash() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + uint256 borrowAmount = 1e18; + uint256 collateralAmount = SUSDS_MARGIN + 2495e18; + + address account = address(uint160(user) ^ uint8(0x01)); + + // First, set up a leveraged position + setupLeveragedPositionFor(user, account, ESUSDS, EWETH, collateralAmount, borrowAmount); + + // Create params using helper + CowEvcClosePositionWrapper.ClosePositionParams memory params = _createDefaultParams(user, account); + + // Get settlement data + SettlementData memory settlement = + getClosePositionSettlement(user, user, ESUSDS, WETH, DEFAULT_SELL_AMOUNT, DEFAULT_BUY_AMOUNT); + + // Setup pre-approved flow + bytes32 hash = closePositionWrapper.getApprovalHash(params); + _setupPreApprovedFlow(account, hash); + + // User signs order (already done in setupCowOrder) + + // Record balances before closing + 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 CowEvcClosePositionWrapper.CowEvcPositionClosed( + params.owner, + params.account, + params.borrowVault, + params.collateralVault, + params.collateralAmount, + params.repayAmount, + params.kind + ); + + // Execute wrapped settlement + executeWrappedSettlement(address(closePositionWrapper), settleData, wrapperData); + + // Verify the position was closed successfully + assertEq(IEVault(EWETH).debtOf(account), 0, "User should have no debt after closing"); + assertEq(debtBefore, borrowAmount, "User should have started with debt"); + } + + /// @notice Test that the wrapper can handle being called three times in the same chain + /// @dev Two users close positions in the same direction (long SUSDS), one user closes opposite (long WETH) + function test_ClosePositionWrapper_ThreeUsers_TwoSameOneOpposite() external { + vm.skip(bytes(forkRpcUrl).length == 0); + + // Configure vault LTVs for both directions + vm.startPrank(IEVault(ESUSDS).governorAdmin()); + IEVault(ESUSDS).setLTV(EWETH, 0.9e4, 0.9e4, 0); + vm.stopPrank(); + + // Setup accounts + address account1 = address(uint160(user) ^ 1); + address account2 = address(uint160(user2) ^ 1); + address account3 = address(uint160(user3) ^ 1); + + // Setup User1: Long SUSDS (SUSDS collateral, WETH debt). ~1 ETH debt + setupLeveragedPositionFor(user, account1, ESUSDS, EWETH, 3500 ether, 1 ether); + + // Setup User2: Long SUSDS (SUSDS collateral, WETH debt). ~3 ETH debt + setupLeveragedPositionFor(user2, account2, ESUSDS, EWETH, 10000 ether, 3 ether); + + // Setup User3: Long WETH (WETH collateral, SUSDS debt). ~5000 SUSDS debt + setupLeveragedPositionFor(user3, account3, EWETH, ESUSDS, 3 ether, 5000 ether); + + // Verify positions exist + assertEq(IEVault(EWETH).debtOf(account1), 1 ether, "User1 should have WETH debt"); + assertEq(IEVault(EWETH).debtOf(account2), 3 ether, "User2 should have WETH debt"); + assertEq(IEVault(ESUSDS).debtOf(account3), 5000 ether, "User3 should have SUSDS debt"); + + // Setup approvals for all users + _setupClosePositionApprovalsFor(user, account1, ESUSDS, WETH); + _setupClosePositionApprovalsFor(user2, account2, ESUSDS, WETH); + _setupClosePositionApprovalsFor(user3, account3, EWETH, SUSDS); + + // Create params for all users + CowEvcClosePositionWrapper.ClosePositionParams memory params1 = CowEvcClosePositionWrapper.ClosePositionParams({ + owner: user, + account: account1, + deadline: block.timestamp + 1 hours, + borrowVault: EWETH, + collateralVault: ESUSDS, + collateralAmount: 2550 ether, + repayAmount: 1.001 ether, + kind: GPv2Order.KIND_BUY + }); + + CowEvcClosePositionWrapper.ClosePositionParams memory params2 = CowEvcClosePositionWrapper.ClosePositionParams({ + owner: user2, + account: account2, + deadline: block.timestamp + 1 hours, + borrowVault: EWETH, + collateralVault: ESUSDS, + collateralAmount: 7600 ether, + repayAmount: 3.003 ether, + kind: GPv2Order.KIND_BUY + }); + + CowEvcClosePositionWrapper.ClosePositionParams memory params3 = CowEvcClosePositionWrapper.ClosePositionParams({ + owner: user3, + account: account3, + deadline: block.timestamp + 1 hours, + borrowVault: ESUSDS, + collateralVault: EWETH, + collateralAmount: 2.1 ether, + repayAmount: 5005 ether, + kind: GPv2Order.KIND_BUY + }); + + // Create permit signatures for all users + bytes memory permitSignature1 = _createPermitSignatureFor(params1, privateKey); + bytes memory permitSignature2 = _createPermitSignatureFor(params2, privateKey2); + bytes memory permitSignature3 = _createPermitSignatureFor(params3, privateKey3); + + // Create settlement with all three trades + uint32 validTo = uint32(block.timestamp + 1 hours); + + 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 + + ICowSettlement.Trade[] memory trades = new ICowSettlement.Trade[](3); + (trades[0],,) = + setupCowOrder(tokens, 2, 1, params1.collateralAmount, params1.repayAmount, validTo, user, user, true); + (trades[1],,) = + setupCowOrder(tokens, 2, 1, params2.collateralAmount, params2.repayAmount, validTo, user2, user2, true); + (trades[2],,) = + setupCowOrder(tokens, 3, 0, params3.collateralAmount, params3.repayAmount, validTo, user3, user3, true); + + // Setup interactions + ICowSettlement.Interaction[][3] memory interactions; + interactions[0] = new ICowSettlement.Interaction[](0); + interactions[1] = new ICowSettlement.Interaction[](3); + interactions[2] = new ICowSettlement.Interaction[](0); + + // We pull the money out of the euler vaults + interactions[1][0] = getWithdrawInteraction( + ESUSDS, (params1.repayAmount + params2.repayAmount) * clearingPrices[1] / clearingPrices[0] + ); + interactions[1][1] = getWithdrawInteraction(EWETH, params3.repayAmount * clearingPrices[0] / clearingPrices[1]); + + // We swap. We only need to swap the difference of the 3 closes (since coincidence of wants) + // It comes out to 5000 SUSDS needs to become WETH + interactions[1][2] = getSwapInteraction(SUSDS, WETH, 5000 ether); + + // Encode settlement data + bytes memory settleData = abi.encodeCall(ICowSettlement.settle, (tokens, clearingPrices, trades, interactions)); + + // Chain wrapper 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(closePositionWrapper), + uint16(wrapper2Data.length), + wrapper2Data, + address(closePositionWrapper), + uint16(wrapper3Data.length), + wrapper3Data + ); + + // Execute wrapped settlement + address[] memory targets = new address[](1); + bytes[] memory datas = new bytes[](1); + targets[0] = address(closePositionWrapper); + datas[0] = abi.encodeCall(closePositionWrapper.wrappedSettle, (settleData, wrapperData)); + solver.runBatch(targets, datas); + + // Verify all positions closed successfully + assertEq(IEVault(EWETH).debtOf(account1), 0, "User1 should have no WETH debt after closing"); + assertEq(IEVault(EWETH).debtOf(account2), 0, "User2 should have no WETH debt after closing"); + assertEq(IEVault(ESUSDS).debtOf(account3), 0, "User3 should have no SUSDS debt after closing"); + } +} diff --git a/test/helpers/CowBaseTest.sol b/test/helpers/CowBaseTest.sol index d4df8ba..9e4a38a 100644 --- a/test/helpers/CowBaseTest.sol +++ b/test/helpers/CowBaseTest.sol @@ -121,6 +121,28 @@ contract CowBaseTest is Test { vm.label(address(milkSwap), "MilkSwap"); } + function getEmptySettlement() + public + pure + returns ( + IERC20[] memory tokens, + uint256[] memory clearingPrices, + ICowSettlement.Trade[] memory trades, + ICowSettlement.Interaction[][3] memory interactions + ) + { + return ( + new IERC20[](0), + new uint256[](0), + new ICowSettlement.Trade[](0), + [ + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0) + ] + ); + } + function getOrderUid(address owner, GPv2Order.Data memory orderData) public view returns (bytes memory orderUid) { // Generate order digest using EIP-712 bytes32 orderDigest = GPv2Order.hash(orderData, COW_SETTLEMENT.domainSeparator()); @@ -154,6 +176,18 @@ contract CowBaseTest is Test { }); } + function getWithdrawInteraction(address vault, uint256 sellAmount) + public + pure + returns (ICowSettlement.Interaction memory) + { + return ICowSettlement.Interaction({ + target: vault, + value: 0, + callData: abi.encodeCall(IERC4626.withdraw, (sellAmount, address(COW_SETTLEMENT), address(COW_SETTLEMENT))) + }); + } + function getSkimInteraction(address vault) public pure returns (ICowSettlement.Interaction memory) { return ICowSettlement.Interaction({ target: address(vault), @@ -225,6 +259,33 @@ contract CowBaseTest is Test { clearingPrices[1] = 1; // eSUSDS price } + /// @notice Helper to set up a leveraged position for any user + /// @dev More flexible version that accepts owner, account, and vault parameters + /// The proceeds of the `borrow` are *NOT* deposited in the account for convienience of setup. + /// So make sure that `collateralAmount` is margin + borrowValue if that is something you care about. + function setupLeveragedPositionFor( + address owner, + address account, + address collateralVault, + address borrowVault, + uint256 collateralAmount, + uint256 borrowAmount + ) internal { + address collateralAsset = address(IEVault(collateralVault).asset()); + + deal(collateralAsset, owner, collateralAmount); + + vm.startPrank(owner); + IERC20(collateralAsset).approve(collateralVault, type(uint256).max); + EVC.enableCollateral(account, collateralVault); + EVC.enableController(account, borrowVault); + IERC4626(collateralVault).deposit(collateralAmount, account); + vm.stopPrank(); + + vm.prank(account); + IBorrowing(borrowVault).borrow(borrowAmount, address(1)); + } + /// @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) { diff --git a/test/unit/CowEvcClosePositionWrapper.unit.t.sol b/test/unit/CowEvcClosePositionWrapper.unit.t.sol new file mode 100644 index 0000000..2c34e20 --- /dev/null +++ b/test/unit/CowEvcClosePositionWrapper.unit.t.sol @@ -0,0 +1,775 @@ +// 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 {CowEvcClosePositionWrapper} from "../../src/CowEvcClosePositionWrapper.sol"; +import {ICowSettlement} from "../../src/CowWrapper.sol"; +import {MockEVC} from "./mocks/MockEVC.sol"; +import {MockCowAuthentication, MockCowSettlement} from "./mocks/MockCowProtocol.sol"; +import {MockERC20, MockVault, MockBorrowVault} from "./mocks/MockERC20AndVaults.sol"; + +/// @title Unit tests for CowEvcClosePositionWrapper +/// @notice Comprehensive unit tests focusing on isolated functionality testing with mocks +contract CowEvcClosePositionWrapperUnitTest is Test { + CowEvcClosePositionWrapper public wrapper; + MockEVC public mockEvc; + MockCowSettlement public mockSettlement; + MockCowAuthentication public mockAuth; + MockERC20 public mockAsset; + MockVault public mockCollateralVault; + MockBorrowVault public mockBorrowVault; + + address constant OWNER = address(0x1111); + address constant ACCOUNT = address(0x1112); + address constant SOLVER = address(0x3333); + + uint256 constant DEFAULT_REPAY_AMOUNT = 1000e18; + bytes32 constant KIND_BUY = hex"6ed88e868af0a1983e3886d5f3e95a2fafbd6c3450bc229e27342283dc429ccc"; + + event PreApprovedHash(address indexed owner, bytes32 indexed hash, bool approved); + event PreApprovedHashConsumed(address indexed owner, bytes32 indexed hash); + + /// @notice Get default ClosePositionParams for testing + function _getDefaultParams() internal view returns (CowEvcClosePositionWrapper.ClosePositionParams memory) { + return CowEvcClosePositionWrapper.ClosePositionParams({ + owner: OWNER, + account: ACCOUNT, + deadline: block.timestamp + 1 hours, + borrowVault: address(mockBorrowVault), + collateralVault: address(mockCollateralVault), + collateralAmount: 0, + repayAmount: DEFAULT_REPAY_AMOUNT, + kind: KIND_BUY + }); + } + + /// @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 Create settle data with tokens and prices + function _getSettleDataWithTokens() internal view returns (bytes memory) { + address[] memory tokens = new address[](2); + tokens[0] = mockBorrowVault.asset(); + tokens[1] = address(mockCollateralVault); + uint256[] memory prices = new uint256[](2); + prices[0] = 1e18; + prices[1] = 1e18; + + return abi.encodeCall( + ICowSettlement.settle, + ( + tokens, + prices, + 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(CowEvcClosePositionWrapper.ClosePositionParams 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(CowEvcClosePositionWrapper.ClosePositionParams 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(); + mockAsset = new MockERC20("Mock Asset", "MOCK"); + mockCollateralVault = new MockVault(address(mockAsset), "Mock Collateral", "mCOL"); + mockBorrowVault = new MockBorrowVault(address(mockAsset), "Mock Borrow", "mBOR"); + + wrapper = new CowEvcClosePositionWrapper(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("CowEvcClosePositionWrapper"), + keccak256("1"), + block.chainid, + address(wrapper) + ) + ); + assertEq(wrapper.DOMAIN_SEPARATOR(), expectedDomainSeparator, "DOMAIN_SEPARATOR incorrect"); + } + + /*////////////////////////////////////////////////////////////// + PARSE WRAPPER DATA TESTS + //////////////////////////////////////////////////////////////*/ + + function test_ParseWrapperData_EmptySignature() public view { + CowEvcClosePositionWrapper.ClosePositionParams 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 { + CowEvcClosePositionWrapper.ClosePositionParams 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 { + CowEvcClosePositionWrapper.ClosePositionParams memory params1 = _getDefaultParams(); + + // Change owner field + CowEvcClosePositionWrapper.ClosePositionParams memory params2 = _getDefaultParams(); + params2.owner = ACCOUNT; + + // Change repayAmount field + CowEvcClosePositionWrapper.ClosePositionParams memory params3 = _getDefaultParams(); + params3.repayAmount = 2000e18; + + 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 { + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + + bytes32 structHash = keccak256( + abi.encode( + params.owner, + params.account, + params.deadline, + params.borrowVault, + params.collateralVault, + params.collateralAmount, + params.repayAmount, + params.kind + ) + ); + + 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_PartialRepay() public { + mockBorrowVault.setDebt(ACCOUNT, DEFAULT_REPAY_AMOUNT); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + params.repayAmount = 500e18; // Less than debt + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + assertEq(items.length, 1, "Should have 1 batch item for partial repay"); + } + + function test_GetSignedCalldata_RepayItem() public { + mockBorrowVault.setDebt(ACCOUNT, DEFAULT_REPAY_AMOUNT); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + assertEq(items[0].targetContract, address(wrapper), "First item should target wrapper"); + assertEq(items[0].onBehalfOfAccount, ACCOUNT, "Should call on behalf of account"); + assertEq( + items[0].data, + abi.encodeCall(wrapper.helperRepay, (address(mockBorrowVault), OWNER, ACCOUNT)), + "Should call helperRepay" + ); + } + + /*////////////////////////////////////////////////////////////// + HELPER REPAY TESTS + //////////////////////////////////////////////////////////////*/ + + function test_HelperRepay_SuccessfulRepay() public { + // Give owner some tokens (not wrapper) + mockAsset.mint(OWNER, 1000e18); + + // Owner must approve wrapper to spend their tokens + vm.prank(OWNER); + mockAsset.approve(address(wrapper), 1000e18); + + // Set up borrow vault with debt + mockBorrowVault.setDebt(ACCOUNT, 1000e18); + mockBorrowVault.setRepayAmount(1000e18); + + // Set the correct onBehalfOfAccount for authentication check + mockEvc.setOnBehalfOf(ACCOUNT); + + // Call through EVC batch + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](1); + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: ACCOUNT, + targetContract: address(wrapper), + value: 0, + data: abi.encodeCall(wrapper.helperRepay, (address(mockBorrowVault), OWNER, ACCOUNT)) + }); + + vm.prank(address(mockEvc)); + mockEvc.batch(items); + + // Verify owner's tokens were used for repayment + assertEq(mockAsset.balanceOf(OWNER), 0, "Owner should have no tokens left"); + } + + function test_HelperRepay_WithDust() public { + // Give owner more tokens than needed for repay + mockAsset.mint(OWNER, 1100e18); + + // Owner must approve wrapper to spend their tokens + vm.prank(OWNER); + mockAsset.approve(address(wrapper), 1100e18); + + // Set up borrow vault with debt + mockBorrowVault.setDebt(ACCOUNT, 1000e18); + mockBorrowVault.setRepayAmount(1000e18); // Only 1000 actually needed + + // Set the correct onBehalfOfAccount for authentication check + mockEvc.setOnBehalfOf(ACCOUNT); + + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](1); + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: ACCOUNT, + targetContract: address(wrapper), + value: 0, + data: abi.encodeCall(wrapper.helperRepay, (address(mockBorrowVault), OWNER, ACCOUNT)) + }); + + vm.prank(address(mockEvc)); + mockEvc.batch(items); + + // Owner should have 100 tokens left (1100 - 1000 repaid) + assertEq(mockAsset.balanceOf(OWNER), 100e18, "Owner should have dust remaining"); + } + + function test_HelperRepay_PartialRepayWhenInsufficientBalance() public { + // Give owner insufficient tokens to fully repay debt + mockAsset.mint(OWNER, 500e18); + + // Owner must approve wrapper to spend their tokens + vm.prank(OWNER); + mockAsset.approve(address(wrapper), 500e18); + + mockBorrowVault.setDebt(ACCOUNT, 1000e18); + mockBorrowVault.setRepayAmount(500e18); // Will only repay what's available + + // Set the correct onBehalfOfAccount for authentication check + mockEvc.setOnBehalfOf(ACCOUNT); + + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](1); + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: ACCOUNT, + targetContract: address(wrapper), + value: 0, + data: abi.encodeCall(wrapper.helperRepay, (address(mockBorrowVault), OWNER, ACCOUNT)) + }); + + vm.prank(address(mockEvc)); + mockEvc.batch(items); + + // Should repay partial amount (500e18) + assertEq(mockAsset.balanceOf(OWNER), 0, "Owner should have no tokens left"); + } + + function test_HelperRepay_RepayAll() public { + mockAsset.mint(OWNER, 1100e18); + + // Owner must approve wrapper to spend their tokens + vm.prank(OWNER); + mockAsset.approve(address(wrapper), 1100e18); + + mockBorrowVault.setDebt(ACCOUNT, 1000e18); + mockBorrowVault.setRepayAmount(1000e18); + + // Set the correct onBehalfOfAccount for authentication check + mockEvc.setOnBehalfOf(ACCOUNT); + + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](1); + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: ACCOUNT, + targetContract: address(wrapper), + value: 0, + data: abi.encodeCall(wrapper.helperRepay, (address(mockBorrowVault), OWNER, ACCOUNT)) + }); + + vm.prank(address(mockEvc)); + mockEvc.batch(items); + + // Dust should remain with owner (100e18) + assertEq(mockAsset.balanceOf(OWNER), 100e18, "Owner should have dust remaining"); + } + + function test_HelperRepay_OnlyEVC() public { + vm.expectRevert(abi.encodeWithSelector(CowEvcClosePositionWrapper.Unauthorized.selector, address(this))); + wrapper.helperRepay(address(mockBorrowVault), OWNER, ACCOUNT); + } + + function test_HelperRepay_RequiresCorrectOnBehalfOfAccount() public { + mockAsset.mint(OWNER, 1000e18); + + vm.prank(OWNER); + mockAsset.approve(address(wrapper), 1000e18); + + mockBorrowVault.setDebt(ACCOUNT, 1000e18); + + // Create a batch item that specifies ACCOUNT but the helperRepay expects a different account + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](1); + address wrongAccount = address(0x9999); + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: wrongAccount, // This will be set as getCurrentOnBehalfOfAccount + targetContract: address(wrapper), + value: 0, + data: abi.encodeCall( + wrapper.helperRepay, + (address(mockBorrowVault), OWNER, ACCOUNT) // But we're trying to repay for ACCOUNT + ) + }); + + vm.prank(address(mockEvc)); + vm.expectRevert(abi.encodeWithSelector(CowEvcClosePositionWrapper.Unauthorized.selector, wrongAccount)); + mockEvc.batch(items); + } + + /*////////////////////////////////////////////////////////////// + EVC INTERNAL SETTLE TESTS + //////////////////////////////////////////////////////////////*/ + + function test_EvcInternalSettle_OnlyEVC() public { + bytes memory settleData = ""; + bytes memory wrapperData = ""; + bytes memory remainingWrapperData = ""; + + vm.expectRevert(abi.encodeWithSelector(CowEvcClosePositionWrapper.Unauthorized.selector, address(this))); + wrapper.evcInternalSettle(settleData, wrapperData, remainingWrapperData); + } + + function test_EvcInternalSettle_RequiresCorrectOnBehalfOfAccount() public { + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + params.account = OWNER; + + bytes memory settleData = _getEmptySettleData(); + bytes memory wrapperData = abi.encode(params, new bytes(0)); + bytes memory remainingWrapperData = ""; + + mockSettlement.setSuccessfulSettle(true); + + // Set incorrect onBehalfOfAccount (not address(wrapper)) + mockEvc.setOnBehalfOf(address(0x9999)); + + vm.prank(address(mockEvc)); + vm.expectRevert(abi.encodeWithSelector(CowEvcClosePositionWrapper.Unauthorized.selector, address(0x9999))); + wrapper.evcInternalSettle(settleData, wrapperData, remainingWrapperData); + } + + function test_EvcInternalSettle_CanBeCalledByEVC() public { + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + params.account = OWNER; // Same account, no transfer needed + + bytes memory settleData = _getEmptySettleData(); + bytes memory wrapperData = abi.encode(params, new bytes(0)); + bytes memory remainingWrapperData = ""; + + mockSettlement.setSuccessfulSettle(true); + + vm.prank(address(mockEvc)); + wrapper.evcInternalSettle(settleData, wrapperData, remainingWrapperData); + } + + function test_EvcInternalSettle_WithSubaccountTransfer() public { + // Set up scenario where owner != account + CowEvcClosePositionWrapper.ClosePositionParams memory params = CowEvcClosePositionWrapper.ClosePositionParams({ + owner: OWNER, + account: ACCOUNT, // Different from owner + deadline: block.timestamp + 1 hours, + borrowVault: address(mockBorrowVault), + collateralVault: address(mockCollateralVault), + collateralAmount: 1000e18, + repayAmount: 1000e18, + kind: hex"6ed88e868af0a1983e3886d5f3e95a2fafbd6c3450bc229e27342283dc429ccc" // KIND_BUY + }); + + // Give account some collateral vault tokens + mockCollateralVault.mint(ACCOUNT, 2000e18); + + // These tokens need to be spendable by the wrapper + vm.prank(ACCOUNT); + mockCollateralVault.approve(address(wrapper), 2000e18); + + // Create settle data with tokens and prices + address[] memory tokens = new address[](2); + tokens[0] = address(mockCollateralVault); + tokens[1] = address(mockAsset); + + uint256[] memory prices = new uint256[](2); + prices[0] = 1e18; // 1:1 price for simplicity + prices[1] = 1e18; + + bytes memory settleData = abi.encodeCall( + ICowSettlement.settle, + ( + tokens, + prices, + new ICowSettlement.Trade[](0), + [ + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0) + ] + ) + ); + bytes memory wrapperData = abi.encode(params, new bytes(0)); + bytes memory remainingWrapperData = ""; + + mockSettlement.setSuccessfulSettle(true); + + vm.prank(address(mockEvc)); + wrapper.evcInternalSettle(settleData, wrapperData, remainingWrapperData); + + // Verify transfer occurred from account to owner + assertLt(mockCollateralVault.balanceOf(ACCOUNT), 2000e18, "Account balance should decrease"); + assertGt(mockCollateralVault.balanceOf(OWNER), 0, "Owner should receive tokens"); + } + + function test_EvcInternalSettle_PricesNotFoundReverts() public { + CowEvcClosePositionWrapper.ClosePositionParams memory params = CowEvcClosePositionWrapper.ClosePositionParams({ + owner: OWNER, + account: ACCOUNT, + deadline: block.timestamp + 1 hours, + borrowVault: address(mockBorrowVault), + collateralVault: address(mockCollateralVault), + collateralAmount: 0, + repayAmount: 1000e18, + kind: hex"6ed88e868af0a1983e3886d5f3e95a2fafbd6c3450bc229e27342283dc429ccc" // KIND_BUY + }); + + // Create settle data with empty tokens (should fail to find prices) + address[] memory tokens = new address[](0); + uint256[] memory prices = new uint256[](0); + + bytes memory settleData = abi.encodeCall( + ICowSettlement.settle, + ( + tokens, + prices, + new ICowSettlement.Trade[](0), + [ + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0) + ] + ) + ); + bytes memory wrapperData = abi.encode(params, new bytes(0)); + bytes memory remainingWrapperData = ""; + + vm.expectRevert( + abi.encodeWithSelector( + CowEvcClosePositionWrapper.PricesNotFoundInSettlement.selector, + mockCollateralVault, + mockBorrowVault.asset() + ) + ); + vm.prank(address(mockEvc)); + wrapper.evcInternalSettle(settleData, wrapperData, remainingWrapperData); + } + + function test_EvcInternalSettle_SubaccountMustBeControlledByOwner() public { + // Create an account that is NOT a valid subaccount of the owner + // Valid subaccount would share first 19 bytes, but this one doesn't + address invalidSubaccount = address(0x9999999999999999999999999999999999999999); + + // Approve the wrapper to transfer from the subaccount (in case it succeeds) + vm.prank(invalidSubaccount); + mockCollateralVault.approve(address(wrapper), type(uint256).max); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = CowEvcClosePositionWrapper.ClosePositionParams({ + owner: OWNER, + account: invalidSubaccount, // Invalid subaccount + deadline: block.timestamp + 1 hours, + borrowVault: address(mockBorrowVault), + collateralVault: address(mockCollateralVault), + collateralAmount: 1000e18, + repayAmount: 1000e18, + kind: hex"6ed88e868af0a1983e3886d5f3e95a2fafbd6c3450bc229e27342283dc429ccc" // KIND_BUY + }); + + // Give account some collateral vault tokens + mockCollateralVault.mint(invalidSubaccount, 2000e18); + + // Create settle data with tokens and prices + address[] memory tokens = new address[](2); + tokens[0] = address(mockCollateralVault); + tokens[1] = address(mockAsset); + + uint256[] memory prices = new uint256[](2); + prices[0] = 1e18; + prices[1] = 1e18; + + bytes memory settleData = abi.encodeCall( + ICowSettlement.settle, + ( + tokens, + prices, + new ICowSettlement.Trade[](0), + [ + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0) + ] + ) + ); + bytes memory wrapperData = abi.encode(params, new bytes(0)); + bytes memory remainingWrapperData = ""; + + mockSettlement.setSuccessfulSettle(true); + + vm.prank(address(mockEvc)); + vm.expectRevert( + abi.encodeWithSelector( + CowEvcClosePositionWrapper.SubaccountMustBeControlledByOwner.selector, invalidSubaccount, OWNER + ) + ); + wrapper.evcInternalSettle(settleData, wrapperData, 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 { + mockBorrowVault.setDebt(ACCOUNT, 1000e18); + + mockCollateralVault.mint(ACCOUNT, 2000e18); + + // Mint repayment assets to OWNER (not wrapper) since helperRepay pulls from owner + MockERC20(mockBorrowVault.asset()).mint(OWNER, 1000e18); + + // Owner must approve wrapper to spend repayment assets + vm.prank(OWNER); + MockERC20(mockBorrowVault.asset()).approve(address(wrapper), 1000e18); + + // These tokens need to be spendable by the wrapper + vm.prank(ACCOUNT); + mockCollateralVault.approve(address(wrapper), 2000e18); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = CowEvcClosePositionWrapper.ClosePositionParams({ + owner: OWNER, + account: ACCOUNT, + deadline: block.timestamp + 1 hours, + borrowVault: address(mockBorrowVault), + collateralVault: address(mockCollateralVault), + collateralAmount: 0, + repayAmount: 1000e18, + kind: hex"6ed88e868af0a1983e3886d5f3e95a2fafbd6c3450bc229e27342283dc429ccc" // KIND_BUY + }); + + address[] memory tokens = new address[](2); + tokens[0] = mockBorrowVault.asset(); + tokens[1] = address(mockCollateralVault); + uint256[] memory prices = new uint256[](2); + prices[0] = 1e18; + prices[1] = 1e18; + + bytes memory signature = new bytes(65); + bytes memory settleData = abi.encodeCall( + ICowSettlement.settle, + ( + tokens, + prices, + new ICowSettlement.Trade[](0), + [ + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0), + new ICowSettlement.Interaction[](0) + ] + ) + ); + bytes memory wrapperData = abi.encode(params, signature); + wrapperData = abi.encodePacked(uint16(wrapperData.length), wrapperData); + + mockEvc.setSuccessfulBatch(true); + + vm.prank(SOLVER); + wrapper.wrappedSettle(settleData, wrapperData); + } + + function test_WrappedSettle_WithPreApprovedHash() public { + mockBorrowVault.setDebt(ACCOUNT, DEFAULT_REPAY_AMOUNT); + mockCollateralVault.mint(ACCOUNT, 2000e18); + MockERC20(mockBorrowVault.asset()).mint(OWNER, DEFAULT_REPAY_AMOUNT); + + vm.startPrank(OWNER); + MockERC20(mockBorrowVault.asset()).approve(address(wrapper), DEFAULT_REPAY_AMOUNT); + vm.stopPrank(); + + vm.prank(ACCOUNT); + mockCollateralVault.approve(address(wrapper), 2000e18); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + + bytes32 hash = _setupPreApprovedHash(params); + + bytes memory settleData = _getSettleDataWithTokens(); + bytes memory wrapperData = _encodeWrapperData(params, new bytes(0)); + + mockEvc.setSuccessfulBatch(true); + + vm.prank(SOLVER); + wrapper.wrappedSettle(settleData, wrapperData); + + assertFalse(wrapper.isHashPreApproved(OWNER, hash), "Hash should be consumed"); + } + + function test_WrappedSettle_PreApprovedHashRevertsIfDeadlineExceeded() public { + mockBorrowVault.setDebt(ACCOUNT, DEFAULT_REPAY_AMOUNT); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + params.deadline = block.timestamp - 1; // Past deadline + + _setupPreApprovedHash(params); + + bytes memory settleData = _getEmptySettleData(); + bytes memory wrapperData = _encodeWrapperData(params, new bytes(0)); + + vm.prank(SOLVER); + vm.expectRevert( + abi.encodeWithSelector( + CowEvcClosePositionWrapper.OperationDeadlineExceeded.selector, params.deadline, block.timestamp + ) + ); + wrapper.wrappedSettle(settleData, wrapperData); + } + + /*////////////////////////////////////////////////////////////// + EDGE CASE TESTS + //////////////////////////////////////////////////////////////*/ + + function test_MaxRepayAmount() public { + mockBorrowVault.setDebt(ACCOUNT, DEFAULT_REPAY_AMOUNT); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + params.repayAmount = type(uint256).max; + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + // Should create repay item + assertEq(items.length, 1, "Should have 1 item for repay with max amount"); + } + + function test_SameOwnerAndAccount() public { + mockBorrowVault.setDebt(OWNER, DEFAULT_REPAY_AMOUNT); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + params.account = OWNER; // Same as owner + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + + assertEq(items[0].onBehalfOfAccount, OWNER, "Should operate on behalf of same account"); + } + + function test_ZeroDebt() public { + mockBorrowVault.setDebt(ACCOUNT, 0); + + CowEvcClosePositionWrapper.ClosePositionParams memory params = _getDefaultParams(); + + bytes memory signedCalldata = wrapper.getSignedCalldata(params); + IEVC.BatchItem[] memory items = _decodeSignedCalldata(signedCalldata); + assertEq(items.length, 1, "Should have 1 item"); + } +}