Cow Protocol: Euler Integration
Cantina Security Report
Organization
- @cow-protocol
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
High Risk
1 findings
1 fixed
0 acknowledged
Medium Risk
3 findings
3 fixed
0 acknowledged
Low Risk
4 findings
4 fixed
0 acknowledged
Informational
15 findings
12 fixed
3 acknowledged
Gas Optimizations
1 findings
1 fixed
0 acknowledged
High Risk1 finding
EIP-712 Type String Mismatch for CollateralSwapParams
Description
In
CowEvcCollateralSwapWrapper.sol, thePARAMS_TYPE_HASHis computed from a type string that declares a single fielduint256 swapAmount:PARAMS_TYPE_HASH = keccak256( "CollateralSwapParams(address owner,address account,uint256 deadline,address fromVault,address toVault,uint256 swapAmount)");However, the actual
CollateralSwapParamsstruct contains two separate amount fields:struct CollateralSwapParams { address owner; address account; uint256 deadline; address fromVault; address toVault; uint256 fromAmount; // ← not in type string uint256 toAmount; // ← not in type string}Under EIP-712, the
structHashis computed askeccak256(typeHash || encodeData(struct)). TheencodeDataoutput is derived from the actual struct memory layout (7 fields, 7 × 32 = 224 bytes), but thetypeHashdescribes only 6 fields. This mismatch means:- The
PARAMS_TYPE_HASHitself is computed from the wrong string, producing a different hash than what the struct encoding would require. - Off-chain tooling following the EIP-712 spec will compute the struct hash using
swapAmountfrom the type string, which does not map to any single field. The hashes will never match the on-chain computation. - Any
fromAmount/toAmountvalues committed by the user are not properly bound by the EIP-712 signature, undermining the integrity guarantee of the pre-approved hash authorization flow.
Recommendation
Align the type string exactly with the struct definition:
PARAMS_TYPE_HASH = keccak256( "CollateralSwapParams(address owner,address account,uint256 deadline,address fromVault,address toVault,uint256 fromAmount,uint256 toAmount)");Consider adding a compile-time or constructor-time assertion that
PARAMS_SIZE(derived viaabi.encode(...).length) equals(number of fields in type string) * 32to catch future drift between the struct and its type string.- The
Medium Risk3 findings
Silent Successful Execution When Next Wrapper Is an EOA
Severity
- Severity: Medium
Submitted by
Cryptara
Description
In
CowWrapper.sol, the_nextfunction dispatches to the next wrapper in the chain using a high-level call:CowWrapper(nextWrapper).wrappedSettle(settleData, remainingWrapperData);No check is performed to verify that
nextWrapperhas deployed code, implements theICowWrapperinterface, or is associated with the expected settlement contract. In the EVM, acallto an EOA always succeeds and returns empty data. BecausewrappedSettleis declaredvoid(returns no value), the Solidity-generated call does not verify any return data. The call therefore completes successfully while executing zero wrapper logic — the custom pre/post-settlement operations of the intended wrapper are silently skipped, but the settlement continues.Recommendation
Require a recognizable return value from each
wrappedSettlecall to confirm the callee executed real wrapper logic. For example:// Define a magic valuebytes4 constant WRAPPED_SETTLE_MAGIC = bytes4(keccak256("wrappedSettle()")); // In ICowWrapper / CowWrapperfunction wrappedSettle(...) external returns (bytes4); // In _nextbytes4 result = CowWrapper(nextWrapper).wrappedSettle(settleData, remainingWrapperData);require(result == WRAPPED_SETTLE_MAGIC, InvalidWrapper(nextWrapper));This ensures that a call to an EOA (which returns empty bytes, not the magic value) is detected and reverted, preventing silent bypass of wrapper logic.
ecrecover Return Value Not Checked
Severity
- Severity: Medium
Submitted by
Cryptara
Description
In
Inbox.sol, after recovering the signer from an ECDSA signature, the result is compared directly againstBENEFICIARYwith no guard against the zero address:address signer = ecrecover(inboxOrderDigest, v, r, s);require(signer == BENEFICIARY, Unauthorized(signer));ecrecoveris a precompile that returnsaddress(0)for any malformed or invalid signature (e.g.,sout of range, invalidv, all-zero inputs). The checksigner == BENEFICIARYonly protects correctly ifBENEFICIARY != address(0). TheInboxconstructor acceptsbeneficiarywithout a zero-address guard:constructor(address executor, address beneficiary, address settlement) { OPERATOR = executor; BENEFICIARY = beneficiary; // no require(beneficiary != address(0)) ...}If an
Inboxis deployed — intentionally or by mistake — withbeneficiary = address(0), then any caller can pass an entirely invalid signature (e.g., 65 zero bytes) andisValidSignaturewill return the EIP-1271 magic value, granting unrestricted order signing authority on behalf of thatInbox. SinceInboxholds user funds and acts as the CoW order signer for the close-position flow, this would allow an attacker to authorize arbitrary orders draining all tokens held in the contract.Recommendation
Replace the raw
ecrecovercall with OpenZeppelin'sECDSA.recover, which already reverts on an invalid signature (including theaddress(0)case) and handles signature malleability:import {ECDSA} from "openzeppelin/contracts/utils/cryptography/ECDSA.sol"; // reverts with ECDSA.ECDSAInvalidSignature if signer == address(0)address signer = ECDSA.recover(inboxOrderDigest, v, r, s);require(signer == BENEFICIARY, Unauthorized(signer));The library is already available as a transitive dependency via the EVC. This single change eliminates both the zero-address footgun and any future signature malleability concerns without requiring a separate constructor guard.
Stale Nonce Embedded in EVC Permit Calldata Breaks Wrappers That Require Permission in Both Phases
Severity
- Severity: Medium
Submitted by
Cryptara
Description
The wrapper constructs EVC batch calldata by wrapping certain
addItemssegments inside anEVC.permitcall. Inside_addEvcBatchItems, the current nonce is read at batch-construction time viaEVC.getNonce(...)and encoded directly into the permit calldata:data: abi.encodeCall( IEVC.permit, ( owner, address(this), uint256(NONCE_NAMESPACE), EVC.getNonce(bytes19(bytes20(owner)), NONCE_NAMESPACE), // ← read here deadline, 0, _encodePermitData(addItems, param), signature ))_invokeEvccalls_addEvcBatchItemstwice — once for_encodeBatchItemsBeforeand once for_encodeBatchItemsAfter— both beforeEVC.batch(items)is invoked:itemIndex = _addEvcBatchItems(items, partialItems, itemIndex, owner, deadline, needsPermission ? signature : new bytes(0), param); // nonce N embedded // ... settlement callback item added ... itemIndex = _addEvcBatchItems(items, partialItems, itemIndex, owner, deadline, needsPermission ? signature : new bytes(0), param); // nonce N embedded againBecause both
getNoncereads happen before any batch execution, both permit items embed the same nonce valueN. WhenEVC.batchlater executes:- First permit item is processed → nonce verified as
N→ nonce incremented toN+1→ success. - Second permit item is processed → nonce still encoded as
N→ current nonce isN+1→ revert.
Any wrapper that overrides both
_encodeBatchItemsBeforeand_encodeBatchItemsAfterreturningneedsPermission = truewill therefore always revert in the permit flow. Additionally, since_invokeEvcaccepts only a singlesignature, there is no way to supply two independent permit signatures for the two phases anyway.The current production wrappers (
CowEvcOpenPositionWrapper,CowEvcClosePositionWrapper,CowEvcCollateralSwapWrapper) avoid this by design — only one of the two phases requests permission. However, the abstract base contract does not enforce this constraint, and any future wrapper that legitimately needs both pre- and post-settlement EVC operations authorized via permit will hit this bug immediately.Recommendation
Enforce at the base contract level that at most one phase may require a permit signature, by adding a validation in
_invokeEvc:(partialItemsBefore, needsBefore) = _encodeBatchItemsBefore(param);(partialItemsAfter, needsAfter) = _encodeBatchItemsAfter(param);require(!(needsBefore && needsAfter), BothPhasesRequirePermission());Alternatively, if dual-phase authorization is ever needed, each phase must use an independent nonce namespace or the nonce must be read lazily inside the EVC batch execution context rather than encoded at construction time.
Low Risk4 findings
verifyAndBuildWrapperData Silently Omits Two Promised Safety Checks
Description
The NatSpec for
verifyAndBuildWrapperDatainCowWrapperHelpers.soldocuments four validations:/// 1. Verifies each wrapper is authenticated via WRAPPER_AUTHENTICATOR ✅ implemented/// 2. Verifies each wrapper's data is valid via validateWrapperData ✅ implemented/// 3. Verifies all wrappers use the same settlement contract ❌ NOT implemented/// 4. Verifies the settlement contract is not authenticated as a solver ❌ NOT implementedThe custom errors for the missing checks are defined in the contract but thrown nowhere in the codebase:
error SettlementContractShouldNotBeSolver(address settlementContract, address authenticatorContract);error SettlementMismatch(uint256 wrapperIndex, address expectedSettlement, address actualSettlement);Beyond the missing checks, the function also does not verify that each
wrapperCalls[i].targetis a deployed contract (code.length > 0). Because theWRAPPER_AUTHENTICATORmay mark any address as a solver — including EOAs — an EOA can pass the authentication check and be accepted as a valid wrapper target by this helper. Downstream consumers relying on this helper to validate a chain before execution may be misled into believing a fully-validated chain has been produced.Recommendation
Implement the missing validations inside
verifyAndBuildWrapperData:- Settlement consistency: Read
ICowWrapper(target).SETTLEMENT()for each wrapper and assert it matches the first wrapper's settlement contract, reverting withSettlementMismatch. - Settlement not a solver: After collecting the settlement address, assert
!WRAPPER_AUTHENTICATOR.isSolver(settlement), reverting withSettlementContractShouldNotBeSolver. - Code existence: Assert
wrapperCalls[i].target.code.length > 0before callingvalidateWrapperData, to ensure the target is a deployed contract and not an EOA silently accepted by the authenticator.
- Settlement consistency: Read
Pre-Approved Hash Flow Silently Revokes Operator Authorization Without Documentation
Severity
- Severity: Low
Submitted by
Cryptara
Description
In
CowEvcBaseWrapper.sol, after executing an operation via the pre-approved hash flow (i.e.,signature.length == 0), the contract automatically revokes its own operator access on both theownerandaccountEVC sub-accounts:if (signature.length == 0) { uint256 mask = EVC.getOperator(bytes19(bytes20(owner)), address(this)); if ((mask & (1 << (uint160(owner) ^ uint160(account)))) > 0) { EVC.setAccountOperator(account, address(this), false); } if (owner != account && mask & 1 > 0) { EVC.setAccountOperator(owner, address(this), false); }}This behavior is not documented in the NatSpec, the README, or any interface-level comments. Any integration or user that grants persistent operator authorization to the wrapper (e.g., to allow multiple sequential operations) will find their authorization silently revoked after the first successful pre-approved hash execution. The next invocation will fail mid-execution inside the EVC batch when the wrapper attempts to operate on behalf of the user, resulting in a confusing and hard-to-diagnose revert.
Recommendation
Add explicit NatSpec documentation on
_invokeEvcand the pre-approved hash flow clearly stating that operator authorization is revoked as part of the flow:/// @dev When using the pre-approved hash flow (empty signature), this function will/// revoke operator access for both `owner` and `account` after execution completes./// Integrations relying on persistent operator authorization MUST re-grant access/// before the next operation.Additionally, consider making this behavior configurable — for example, by including a flag in the
paramsstruct (e.g.,bool revokeOperatorAfterExecution) so that users or integrations that need persistent operator access can opt out of the automatic revocation._evcInternalSettle reverts when asset equals collateralVault
Severity
- Severity: Low
Submitted by
slowfi
Description
The function
_evcInternalSettlefrom contractCowEvcClosePositionWrappercomputesswapSourceBalanceas the Inbox balance ofparams.collateralVaultandswapResultBalanceas the Inbox balance ofIERC4626(params.borrowVault).asset()after executing the CoW settlement.It then transfers
swapSourceBalance(the remaining source collateral) back toparams.accountbefore performing the debt repayment, while still using the pre-transferswapResultBalanceto deriverepayAmount.When
IERC4626(params.borrowVault).asset()is equal toparams.collateralVault,swapSourceBalanceandswapResultBalancerefer to the same token balance. As a result, the transfer ofswapSourceBalancedrains the token balance that is later assumed to be available for the excess buy token transfer and/orIBorrowing(params.borrowVault).repay, causing the close flow to revert.Closing (or reducing) a position through
CowEvcClosePositionWrapperreverts for configurations where the borrowed asset equals the collateral vault token, preventing this wrapper from being used for those positions.Recommendation
Consider to validate that
IERC4626(params.borrowVault).asset()is different fromparams.collateralVault, or consider to handle the equality case explicitly, for example, repay before returning any source balance and compute leftovers after repayment.Missing onchain signal when close results in a partial repay
Severity
- Severity: Low
Submitted by
slowfi
Description
The close flow in
CowEvcClosePositionWrapperis intentionally designed not to revert if the position is not fully closed (to avoid turning debt drift / state changes into a potential batch-wide DoS vector through CoW Settlement batching).However, when the execution ends up repaying less than the live debt (e.g., due to accrued interest or other debt changes between order construction and execution), the transaction can still succeed while leaving a residual debt open. In that case, the contract does not emit any on-chain signal indicating that the “close” was partial.
This makes it difficult for:
- frontends to reliably inform users that their position remains open, and
- monitoring/automation to detect that follow-up action is required.
Recommendation
Consider to emit an event that indicates whether the close fully repaid the debt, for example including:
owner,account,borrowVault,collateralVaultdebtAmount(measured at the time repayment is attempted)repaidAmountresidualDebt(or a booleanfullyClosed)
This provides a low overhead integration hook for UIs and indexers while preserving the non-reverting design choice. Also could consider reverting if position is not closed.
Informational15 findings
CowEvcCollateralSwapWrapper Never Disables the Old Collateral Vault
Severity
- Severity: Informational
Submitted by
Cryptara
Description
In
CowEvcCollateralSwapWrapper.sol, the wrapper enables the new collateral vault (toVault) for the user's account. However,disableCollateralfor the old collateral vault (fromVault) is never called — the function is absent from the entire codebase.After a collateral swap, the old vault remains enabled as collateral indefinitely. A user who has fully exited
fromVaultstill has it registered as collateral in the EVC, increasing their theoretical collateral exposure without benefit. More significantly, if someone later deposits funds intofromVaultfor this account for an unrelated reason, those funds become encumbered as collateral unexpectedly. The same limitation affectsCowEvcClosePositionWrapper, which cannot calldisableControllerbecause the EVC permit flow does not support signing post-settlement operations.Recommendation
Document this behavior explicitly in the NatSpec of
CowEvcCollateralSwapWrapperto ensure integrators and users understand that disabling the old collateral vault is a required post-swap step. Consider emitting a dedicated event (e.g.,CollateralDisableRequired) or providing a helper function that checks whetherfromVaultbalance is zero and, if so, callsEVC.disableCollateralautomatically. This would reduce the risk of silently leaving stale collateral registrations.Unrestored Memory Slot After In-Place EIP-712 Hash in Inbox.isValidSignature
Severity
- Severity: Informational
Submitted by
Cryptara
Description
In
Inbox.sol, theisValidSignaturefunction allocates abytes memory orderDataarray from calldata and then overwrites its length word in assembly to perform an in-place EIP-712 struct hash:assembly { mstore(orderData, typeHash) // overwrites the length slot structHash := keccak256(orderData, 416)}Unlike the analogous pattern in
CowEvcBaseWrapper.sol, which saves and restores the overwritten word:let wordBeforeParam := mload(wordBeforeParamPtr)mstore(wordBeforeParamPtr, typeHash)structHash := keccak256(...)mstore(wordBeforeParamPtr, wordBeforeParam) // ← restoredInbox.solnever restores the original length value oforderData. Currently this is not exploitable becauseorderDatais not referenced after the assembly block. However, it constitutes a latent memory corruption hazard: any future refactor that reads or passesorderDataafter this block would silently operate on a bytes array whose stored length has been permanently replaced withtypeHash(a 32-byte hash value). This could cause incorrect length-based calculations, unexpected reverts, or subtle security bugs.Recommendation
Restore the original length slot after computing the struct hash, following the same pattern already used in
CowEvcBaseWrapper.sol:assembly { let originalLength := mload(orderData) mstore(orderData, typeHash) structHash := keccak256(orderData, 416) mstore(orderData, originalLength) // restore}Alternatively, add a prominent inline comment making clear that
orderDatamust never be used after this assembly block, to prevent future regressions.Events Emit User-Signed Minimum Amounts Instead of Actual Executed Amounts
Severity
- Severity: Informational
Submitted by
Cryptara
In
CowEvcOpenPositionWrapper.solandCowEvcCollateralSwapWrapper.sol, theCowEvcPositionOpenedandCowEvcCollateralSwappedevents are emitted with parameter values drawn directly from the user-signedparamsstruct:emit CowEvcPositionOpened( params.owner, params.account, params.collateralVault, params.borrowVault, params.collateralAmount, // ← minimum signed amount, not actual params.borrowAmount // ← minimum signed amount, not actual);In a CoW Protocol sell order,
collateralAmountandborrowAmountrepresent the minimum acceptable amounts specified by the user, not the amounts actually received after settlement. The solver may provide a better rate, resulting in larger actual amounts. Off-chain indexers, analytics dashboards, and monitoring systems that interpret these events as recording actual position sizes will systematically underreport leverage and collateral values.Recommendation
Document clearly in the event NatSpec that
collateralAmountandborrowAmountin the emitted events reflect user-signed minimum values and not actual execution amounts:/// @notice Emitted when a position is opened./// @param collateralAmount The minimum collateral amount specified by the user (NOT the actual deposited amount)/// @param borrowAmount The minimum borrow amount specified by the user (NOT the actual borrowed amount)event CowEvcPositionOpened(...)If on-chain tracking of actual amounts is required in the future, a post-settlement balance snapshot approach (recording vault shares before and after the
_nextcall) could be introduced.InboxFactory Does Not Validate EVC Subaccount Relationship
Severity
- Severity: Informational
Submitted by
Cryptara
Description
InboxFactory.getInboxand its internal counterpart_getInboxaccept arbitraryownerandsubaccountaddresses and deploy anInboxcontract for them without verifying thatsubaccountis a valid EVC subaccount ofowner. In the EVC, a valid subaccount must share the same highest 19 bytes (bytes19 prefix) as its owner.CowEvcBaseWrapper._invokeEvcenforces this invariant at execution time:require(bytes19(bytes20(owner)) == bytes19(bytes20(account)), SubaccountMustBeControlledByOwner(account, owner));However, no equivalent guard exists in the factory:
// InboxFactory._getInboxAddress — no bytes19 checkfunction _getInboxAddress(address owner, address subaccount) internal view returns (address creationAddress, bytes memory creationCode, bytes32 salt){ salt = bytes32(uint256(uint160(subaccount))); creationCode = abi.encodePacked(type(Inbox).creationCode, abi.encode(address(this), owner, INBOX_SETTLEMENT)); creationAddress = Create2.computeAddress(salt, keccak256(creationCode));}Because
getInboxisexternal, anyone can trigger anInboxdeployment for an invalid(owner, subaccount)pair where the two addresses share no EVC subaccount relationship. The resultingInboxhasBENEFICIARY = ownerandOPERATOR = address(this)(the wrapper), but any wrapper invocation using that pair will revert in_invokeEvcbefore the inbox is actually used. The inbox is therefore deployed and permanently unoperatable through the wrapper, though theBENEFICIARYretains direct recovery access viacallTransfer.Recommendation
Add the same bytes19 prefix check already present in
_invokeEvcto both the publicgetInboxentry point and the internal_getInbox, so that inbox deployment is only permitted for valid EVC subaccount pairs:require( bytes19(bytes20(owner)) == bytes19(bytes20(subaccount)), SubaccountMustBeControlledByOwner(subaccount, owner));setPreApprovedHash(false) Transitions Unapproved Hashes Directly to CONSUMED
Severity
- Severity: Informational
Submitted by
Cryptara
Description
PreApprovedHashes.solimplements a three-state machine for operation hashes:0 (unset) → PRE_APPROVED → CONSUMEDThe
setPreApprovedHashfunction's revocation branch (approved = false) unconditionally writesCONSUMEDto storage without first verifying that the hash is currently in thePRE_APPROVEDstate:function setPreApprovedHash(bytes32 hash, bool approved) external { require(preApprovedHashes[msg.sender][hash] != CONSUMED, AlreadyConsumed(msg.sender, hash)); if (approved) { preApprovedHashes[msg.sender][hash] = PRE_APPROVED; } else { preApprovedHashes[msg.sender][hash] = CONSUMED; // ← no check on current state } emit PreApprovedHash(msg.sender, hash, approved);}A user who calls
setPreApprovedHash(hash, false)while the hash is still in state0(never approved) will silently trigger the direct transition0 → CONSUMED. BecauseCONSUMEDis a terminal state — the top-levelrequiregates any future call with!= CONSUMED— the hash is permanently blacklisted from ever being approved. The user loses the ability to use that hash for the pre-approved flow, with no warning and no way to recover.The intended use of
falseis to revoke a previously granted approval, so the only valid prior state should bePRE_APPROVED. Calling revoke on an unapproved hash is almost certainly a user error and should revert with a clear message rather than silently poison the hash slot.Recommendation
Add an explicit state guard to the
falsebranch so that revocation only succeeds from thePRE_APPROVEDstate:} else { require(preApprovedHashes[msg.sender][hash] == PRE_APPROVED, HashNotApproved(msg.sender, hash)); preApprovedHashes[msg.sender][hash] = CONSUMED;}This enforces the intended
PRE_APPROVED → CONSUMEDtransition and prevents the unintended0 → CONSUMEDshortcut.Error Is Defined but Never Thrown
Severity
- Severity: Informational
Submitted by
Cryptara
Description
CowEvcClosePositionWrapper.soldeclares three custom errors intended for runtime validation of the close-position flow:error NoSwapOutput(address inboxForSwap); // ✅ thrownerror InsufficientDebt(uint256 expectedMinDebt, uint256 actualDebt); // ❌ never thrownerror UnexpectedRepayResult(uint256 expectedRepayAmount, uint256 actualRepaidAmount); // ✅ thrownNoSwapOutputandUnexpectedRepayResultare both actively used as runtime guards in_evcInternalSettle.InsufficientDebtis defined with parameters that clearly describe a minimum-debt guard (expectedMinDebt,actualDebt), but it is thrown nowhere in the codebase.The intended check would compare the live
debtOf(params.account)value against a user-specified minimum at execution time:uint256 debtAmount = IBorrowing(params.borrowVault).debtOf(params.account);// intended: require(debtAmount >= params.minDebt, InsufficientDebt(params.minDebt, debtAmount));However,
ClosePositionParamshas nominDebtfield, so the guard could not be implemented even if the revert were added. The struct and the error are misaligned — the error implies a planned feature that was never completed. Without this check, a user cannot set a lower bound on the outstanding debt at the moment of execution. If the account's debt changes significantly between signing and settlement (e.g., due to interest accrual or a concurrent partial repayment), the close will proceed unconditionally against whatever debt remains.Recommendation
Either remove
InsufficientDebtif the check is not intended to be implemented, or complete the implementation by adding aminDebtfield toClosePositionParamsand throwing the error whendebtAmount < params.minDebt. Leaving a defined-but-unused error alongside two actively used sibling errors is misleading and signals incomplete logic to future maintainers.Interest Accrual Between Signing and Execution Can Leave a Residual Debt After Close
Severity
- Severity: Informational
Submitted by
Cryptara
Description
In
CowEvcClosePositionWrapper.sol, the debt at execution time is read live from the vault:uint256 debtAmount = IBorrowing(params.borrowVault).debtOf(params.account);This value is captured after the CoW settlement executes, using the debt that has accrued at that point. The wrapper correctly handles the surplus case — when the swap produces more than the current debt, the excess is refunded to the owner:
if (repayAmount > debtAmount) { inbox.callTransfer(address(borrowAsset), params.owner, repayAmount - debtAmount); repayAmount = debtAmount;}However, there is no symmetric handling for the deficit case. When a user constructs a
KIND_BUYCoW order withbuyAmountequal to their debt at signing time, and interest accrues between signing and settlement execution, the swap produces exactly the signedbuyAmount— which is now less than the growndebtAmount. The wrapper silently repays only the partial amount, and the transaction succeeds with a small residual debt remaining open:Signing time: debt = 5.00 ETH → user sets buyAmount = 5.00 ETHExecution time: debt = 5.05 ETH → swap produces 5.00 ETH repayAmount (5.00) < debtAmount (5.05) → repays 5.00 ETH, 0.05 ETH residual debt remainsThe user intended a full position close but ends up with an open debt position they may not be monitoring. Depending on the vault's liquidation parameters, even a small residual debt could eventually pose a liquidation risk if collateral is not adjusted.
Recommendation
Document this behavior prominently in the NatSpec of
ClosePositionParamsand in user-facing integration guides. Off-chain tooling and frontends constructing close orders should account for interest accrual by either:- Adding a small buffer to
buyAmountforKIND_BUYorders (e.g.,currentDebt * 1.01) to absorb interest that accrues before settlement. - Using a
KIND_SELLorder instead, which sells a fixed amount of collateral and repays whatever debt asset is received, naturally accommodating interest growth without producing residual debt. - Querying the live debt amount as late as possible before order submission to minimise the accrual window.
Cantina
The documentation was updated in
https://github.com/cowprotocol/euler-integration-contracts/commit/2599574to reflect the intentions.wrappedSettle does not bind signed wrapper parameters to settleData
Severity
- Severity: Informational
Submitted by
slowfi
Description
The function
wrappedSettlefrom contractCowWrapperauthenticatesmsg.senderas a solver and forwardssettleDatato_wrapafter slicingchainedWrapperDataby length.Across wrappers in this repository, user authorization is validated over wrapper parameters through
CowEvcBaseWrapper._invokeEvcusing either a permit signature or a pre-approved hash. However, the wrappers do not decode or validatesettleDatato enforce any relationship between the signed wrapper parameters and the CoW settlement payload. The function_nextonly checks thatsettleDatastarts with theICowSettlement.settleselector before calling the settlement contract.Correctness relies on the integration ensuring that the CoW settlement payload is consistent with the user-authorized wrapper parameters. This relationship is not enforced onchain by the wrappers.
Recommendation
Consider to document explicitly that wrapper parameter authorization does not cover
settleData, and that offchain components must ensuresettleDatamatches the intended wrapper parameters. Also could consider to add an optional onchain binding between wrapper parameters and the settlement payload where compatible with the wrapper chaining design._evcInternalSettle emits collateralAmount as input not collateral spent
Severity
- Severity: Informational
Submitted by
slowfi
Description
The function
_evcInternalSettlefrom contractCowEvcClosePositionWrapperemitsCowEvcPositionClosedwith thecollateralAmountfield set toparams.collateralAmount.In the same execution, the wrapper reads the remaining
params.collateralVaultbalance in the Inbox after_nextreturns and transfers that remaining balance back toparams.account. This means the settlement does not necessarily consume the fullparams.collateralAmount.As a result, the emitted
collateralAmountcan overstate the actual amount of collateral spent in the settlement.Offchain consumers that interpret
CowEvcPositionClosed.collateralAmountas the actual collateral spent can compute incorrect analytics or accounting. This does not affect the onchain execution path.Recommendation
Consider to align the event field naming and documentation with what is emitted. Also could consider to emit the returned collateral amount alongside
params.collateralAmount, so offchain consumers can derive the net collateral spent when needed._evcInternalSettle swap output check can pass without any settlement output
Severity
- Severity: Informational
Submitted by
slowfi
Description
The function
_evcInternalSettlefrom contractCowEvcClosePositionWrapperchecks whether the CoW settlement produced swap output by readingborrowAsset.balanceOf(address(inbox))after_nextreturns and reverting only when the resulting balance is zero.This check is based on the absolute post-settlement balance of the Inbox, not on the balance increase caused by the settlement. If the Inbox already holds a non-zero balance of the borrow asset before the settlement executes, the condition
swapResultBalance == 0is false even when the settlement sends the buy token to a different receiver or does not send any buy token to the Inbox.The wrapper can continue the close flow without verifying that the settlement delivered any swap output to the Inbox. This makes the receiver correctness protection incomplete.
Recommendation
Consider to validate swap output using the Inbox balance delta by recording the borrow asset balance before calling
_nextand requiring it to increase after settlement. Also could consider to base the repay amount on this balance increase, so the repayment reflects swap output rather than any pre-existing Inbox balance.Inbox hardcodes settlement domain separator
Severity
- Severity: Informational
Submitted by
slowfi
Description
The constructor from contract
InboxcomputesSETTLEMENT_DOMAIN_SEPARATORlocally using fixed values for the EIP-712 domain name and version and the providedsettlementaddress.This does not read
ICowSettlement(settlement).domainSeparator(). If the actual settlement contract uses different domain parameters, the computedSETTLEMENT_DOMAIN_SEPARATORdiverges from the settlement domain separator andInbox.isValidSignaturereverts withOrderHashMismatchfor otherwise valid orders.Orders that rely on the Inbox EIP-1271 signature path are not executable when the settlement domain separator differs from the hardcoded inputs.
Recommendation
Consider to read the domain separator from the settlement contract during construction and store it, so the signature verification is aligned with the deployed settlement contract.
Missing Euler vault validation in open/close wrappers
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
OpenPositionParams.borrowVault/collateralVaultandClosePositionParams.borrowVault/collateralVaultare user-supplied addresses (covered by user authorization), and the wrapper logic later treats them as Euler vaults.However, there is no onchain validation that the provided vault addresses:
- were deployed by a trusted Euler factory / registry, or
- are vaults connected to the expected EVC instance.
This contradicts the project’s own suggested security checklist item: “Validate that the user cannot specify untrusted contracts (ex. arbitrary Euler vault contracts) to steal other user's funds or harm the solver”.
In practice, because the vault addresses are user-authorized, this is primarily a griefing / reliability concern (e.g., forcing solvers to spend gas on calls to contracts that revert or behave unexpectedly, and only failing later in the flow). It also increases the chance that integrators accidentally route to non-Euler or wrong-EVC vaults, resulting in failed operations and harder-to-debug incidents.
Recommendation
Consider to enforce vault provenance either:
-
On-chain, for example by:
- checking the vault is a proxy deployed by a trusted Euler factory.
- checking the vault reports the expected EVC address.
GenericFactory(factory).isProxy(vault) == trueIEVault(vault).EVC() == address(EVC)- Off-chain, by allowlisting verified Euler vault addresses and rejecting untrusted vaults before producing signatures.
If this is an assumed risk, consider to explicitly document that assumption and adjust the checklist wording to avoid implying that this validation is currently enforced.
CoW: The harms of trying to restrict this at the contract level far outweigh the benefits. So no change will be made to the contracts. There is some risk to solvers and etc. but as Darek said, the risk extends beyond just the Vaults themselves (ex. if the vault is configured with a hook). So there is not too much we can do. Will update the doc checklist to ensure proper expectations are set for this in the future.
The Euler frontend does use the "Perspectives" to help protect the user and ensure they are buying into trusted vaults, so we do have that protection.
Cantina Managed: Acknowledged by CoW team.
Unconditional max approval to the vault relayer
Severity
- Severity: Informational
Submitted by
slowfi
Description
The close wrapper always executes:
inbox.callApprove(params.collateralVault, VAULT_RELAYER, type(uint256).max);This matches the common CoW Protocol trust model where orders typically rely on large allowances to the settlement/relayer. However, performing an unconditional max approval on every close has a main drawback:
- Allowance persistence / blast radius: the
Inboxkeeps a long-lived unlimited allowance forparams.collateralVaulttowardVAULT_RELAYER. Even if this is an accepted trust assumption, it increases the impact of an unexpected allowance reuse across operations, and it is not scoped to the single close.
This is primarily a design/operational consideration rather than a correctness bug.
Recommendation
Consider to choose one of the following consistent approaches:
- Scope to operation (least privilege): approve only the required amount for the close flow and reset allowance after completion (if feasible with the call pattern and token behavior).
- Keep as-is but document: if the intention is to prevent user revocations from causing close failures, consider to explicitly document that the wrapper re-approves on each close to maintain liveness.
toAmount parameter in CowEvcCollateralSwapWrapper is unused
Severity
- Severity: Informational
Submitted by
slowfi
Description
toAmountis documented as: “The amount of toVault traded out. Same asbuyAmountin the CoW order”, but the wrapper does not use this value.As a result, the wrapper itself does not enforce any min-output / slippage condition based on
toAmount, and correctness relies entirely on the CoW Settlement path to ensure that the executed swap respects user intent.If a solver (or any authorized caller, depending on the deployment/auth model) can execute a wrapper call that results in:
- settlement not applying the expected limit, or
- the swap output being routed incorrectly,
the wrapper would not detect it via
toAmount, despite the parameter suggesting such protection exists.
Even if, in practice, settlement always applies slippage constraints for valid orders, leaving an unused buyAmount like parameter increases integrator confusion and makes it easier to accidentally assume an on-chain min-out enforcement that is not present.
Recommendation
Consider to either:
- enforce
toAmountin the wrapper (e.g., require theInbox/ relevant receiver balance delta of the destination token is>= toAmountafter settlement), or - remove / rename / document
toAmountto avoid implying it is validated on-chain, and explicitly document that slippage/min-output is enforced exclusively by settlement/order constraints.
No interfaces provided for integrators
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
slowfi
Description
ICowWrapperis declared insideCowWrapper.solalongside implementation logic. This matches the stated goal of keeping the wrapper easy to copy/paste as a standalone unit.However, co-locating interfaces with implementation can reduce discoverability and standard “entry point” expectations for integrators and auditors, who often look for interface definitions under a dedicated
interfaces/directory.Recommendation
Consider to keep the current “single-file copy/paste” design, but also provide a mirrored
interfaces/ICowWrapper.sol(or similar) as a canonical reference location for interface definitions and NatSpec, if repo ergonomics for integrators/auditors is a priority.
Gas Optimizations1 finding
Unused constants
Severity
- Severity: Gas optimization
Submitted by
slowfi
Description
The contract
CowEvcBaseWrapperdefines the constantsKIND_SELLandKIND_BUY.These constants are not referenced anywhere in the repository code paths that inherit from
CowEvcBaseWrapper, andCowEvcBaseWrapperitself does not compute or validate CoW order struct hashes. As a result, the constants and their associated comments are dead code.Increased maintenance surface and reduced clarity around what data is actually validated onchain.
Recommendation
Consider to remove
KIND_SELLandKIND_BUYand the related comments, or consider to use them in the code path that computes or validates an order struct hash if that is intended.