Clove
Cantina Security Report
Organization
- @clove
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
High Risk
2 findings
2 fixed
0 acknowledged
Low Risk
3 findings
3 fixed
0 acknowledged
Informational
4 findings
4 fixed
0 acknowledged
High Risk2 findings
Canonical fill IDs and nonce checks missing
Description
SpotSettlement.settleSpotBatchtracks replay withfillUsage[fillId]and writes the mapping entry directly from thefillIdvalue supplied in eachMatchedFill. The function does not derive a canonical fill identifier from the full match semantics before checking replay state, which allows equivalent settlement semantics to be replayed with freshfillIdvalues while keeping the operator-level checks valid.for (uint256 i = 0; i < fills.length; i++) { bytes32 fillId = fills[i].fillId; uint256 priorBatch = fillUsage[fillId]; if (priorBatch != 0) revert FillAlreadyConsumed(fillId, priorBatch); fillUsage[fillId] = batch.batchId; emit FillConsumed(fillId, batch.batchId);}The same function does not consume
NonceManagernonces or verify order intent expiry for either side of a match, so replay prevention is disconnected from user-intent state.function verifyOrderIntent(OrderIntent calldata intent, bytes calldata sig) external view returns (bool) { bytes32 structHash = keccak256( abi.encode( ORDER_INTENT_TYPEHASH, intent.user, intent.baseToken, intent.quoteToken, intent.amount, intent.limitPrice, intent.nonce, intent.expiry, intent.isBuy ) ); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)); return _recoverSigner(digest, sig) == intent.user;}function useNonce(address user, uint256 nonce) external { uint256 floor = minValidNonce[user]; if (nonce < floor) revert NonceTooLow(floor, nonce); if (nonceUsed[user][nonce]) revert NonceAlreadyUsed(user, nonce);}This is critical because valid economic matches can be represented under alternate identifiers and still advance state when user-level replay protections are not enforced in settlement execution.
There is a second gap in the same settlement path. After the replay checks,
SpotSettlementforwardsbatch.deltasdirectly toAccountLedgerwithout any on-chain check that the deltas are economically consistent with the provided fills and without any per-token conservation constraint. That means replay protection alone does not prevent an authorized operator from submitting internally valid signatures over economically invalid balance transitions.IAccountLedger.Delta[] memory ledgerDeltas = new IAccountLedger.Delta[](batch.deltas.length);...IAccountLedger(accountLedger).applyNetDeltas(ledgerDeltas, batch.batchId);Recommendation
Derive a canonical fill hash inside settlement and use that hash as the replay key. Add intent signature verification and expiry checks for both participants, and consume nonces through
NonceManagerbefore marking the canonical fill as used.In the same hardening pass, add on-chain consistency checks that bind deltas to validated fill economics and enforce conservation constraints before calling
AccountLedger.applyNetDeltas.bytes32 canonicalFillId = keccak256( abi.encode( fills[i].maker, fills[i].taker, fills[i].makerOrderHash, fills[i].takerOrderHash, fills[i].baseAmount, fills[i].quoteAmount ));if (fillUsage[canonicalFillId] != 0) revert FillAlreadyConsumed(canonicalFillId, fillUsage[canonicalFillId]);nonceManager.useNonce(maker.user, makerIntent.nonce);fillUsage[canonicalFillId] = batch.batchId;Clove: Fixed in PR #12.
Cantina: Fix verified. Canonical fill replay and token conservation were implemented, but nonce integration into settlement execution was not wired in, so the issue is not 100% addressed.
SetupProd deploys publicly mintable collateral
Description
The production deployment script instantiates
MockERC20contracts as core collateral assets.MockERC20exposes an unrestrictedmintfunction, so any caller can create arbitrary balances. In an isolated local environment this can be considered intentiona but in a production-labeled workflow this is a critical trust failure because collateral integrity is no longer anchored to a real asset supply.MockERC20 usdc = new MockERC20("USD Coin", "USDC", 6);MockERC20 weth = new MockERC20("Wrapped Ether", "wETH", 18);MockERC20 wbtc = new MockERC20("Wrapped Bitcoin", "wBTC", 8);function mint(address to, uint256 amount) external { _mint(to, amount);}If this script is ever used on a shared testnet or live environment, any external actor can mint collateral-like tokens, deposit them and drain honest reserves through normal withdrawal paths.
Impact is high because it can invalidate the protocol's entire accounting model and compromise all counterparties relying on those balances.
Recommendation
Ensure these
MockERC20contracts are not used for production deployments and deployERC20contracts instead.Clove: Fixed in PR #13 (commit
28027e4).SetupProd.s.solis renamed toSetupDev.s.soland now contains explicit warnings about unrestricted mock minting.Cantina: Fix verified.
Low Risk3 findings
ReentrancyGuard on Storage-Only Functions
Description
The functions
creditDeposit,lock,unlockandapplyNetDeltascurrently usenonReentranteven though they perform only internal state updates and do not invoke external callbacks. Entering and exitingReentrancyGuardwrites to and reads dedicated storage, which adds fixed overhead on every call even when no reentrant surface exists. In high-volume settlement and balance update flows this overhead compounds and adds non-trivial gas cost without increasing safety because withdrawals are already protected by separate checks and external-call paths.Removing the modifier from these storage-only functions preserves behavior and lowers gas consumption on every call path.
Recommendation
Restrict
nonReentrantto paths that perform external calls tovaultand keep a minimal set of guarded entry points. For example:function creditDeposit(address user, address token, uint256 amount) external onlyRole(VAULT_ROLE) whenNotPaused{ ...}Clove: Fixed in PR #14 (commit
a69aa3f).AccountLedger.creditDeposit,lock,unlockandapplyNetDeltasno longer usenonReentrantin PR 14, matching the storage-only optimization request.Cantina: Fix verified. The same PR removes unnecessary
nonReentrantfrom the storage-only functions while preserving it on the external-call withdrawal paths (debitForWithdrawal/debitForWithdrawalWithUnwrap).Custom ECDSA recovery without malleability checks
Description
SpotSettlement._recoverSigner(...)is a custom signer recovery helper and is also used indirectly byverifyOrderIntent. The implementation accepts a 65-byte signature after basic parsing and passes it toecrecoverwithout low-senforcement.function _recoverSigner(bytes32 hash, bytes calldata signature) internal pure returns (address) { if (signature.length != 65) revert InvalidOperatorSignature(); bytes32 r; bytes32 s; uint8 v; assembly { r := calldataload(signature.offset) s := calldataload(add(signature.offset, 32)) v := byte(0, calldataload(add(signature.offset, 64))) } if (v < 27) v += 27; address recovered = ecrecover(hash, v, r, s); if (recovered == address(0)) revert InvalidOperatorSignature(); return recovered;}Signature malleability through high-
svariants is not prevented. In many current flows this may be harmless, but it becomes risky if signatures are treated as unique identifiers or persisted as proof artifacts.Recommendation
Consider using
ECDSA.recoverfrom OpenZeppelin for standardized recovery semantics. This enforces canonical signatures, including validvhandling and low-schecks.using ECDSA for bytes32;address recovered = hash.toEthSignedMessageHash().recover(signature);If your stack uses short signatures, add explicit support for EIP-2098 packed signatures alongside the 65-byte path.
Clove: Fixed in PR #14 (commit
a69aa3f).SpotSettlement._recoverSignernow usesECDSA.tryRecover, replacing the custom parser/recover logic with OpenZeppelin standard recovery checks (including low-s/vvalidation).Cantina: Fix verified. The custom hand-rolled recovery path was replaced by OZ
ECDSArecovery in PR 14.Immutable DOMAIN_SEPARATOR can allow signature replay across hard-forks
State
- Fixed
PR #21
Severity
- Severity: Low
Submitted by
Jonatas Martins
Description
The
DOMAIN_SEPARATORis set as immutable, if a hard fork results in a chain-id change, the contract may continue validating signatures tied to the pre-fork domain, which enables replay attack across forked chains.Recommendation
Derive the
DOMAIN_SEPARATORfrom the current block.chainid (or cache it with the chain id and recompute when block.chainid changes), following theEIP-712pattern used by Openzeppelin.Clove: Fixed in PR #21.
Cantina: Fix verified. The domain separator replay exposure across chain-id changes was addressed with a fork-safe/chain-id-aware EIP-712 pattern.
Informational4 findings
Zero address can brick Exchange roles
Description
SpotSettlementgrants role state in constructor from theadminandoperatorparameters without zero-address validation. If any are set to zero, role recovery and emergency controls can become impossible depending on which constructor argument is affected.constructor(address admin, address operator, address initialAccountLedger, address initialNonceManager) { _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(PAUSER_ROLE, admin); _grantRole(OPERATOR_ROLE, operator); accountLedger = initialAccountLedger; nonceManager = initialNonceManager;}Because there is no zero guard, governance and operator pathways can be permanently unusable from an invalid deployment configuration.
Recommendation
Fail deployment on zero-address role parameters for both
adminandoperator, and apply the same pattern to any other security-critical role-initializing constructor.if (admin == address(0) || operator == address(0)) revert ZeroAddress();Clove: Fixed in PR #15.
SpotSettlementnow guards admin and operator constructor args withZeroAddress, so role assignment can’t be initialized toaddress(0)and permanently brick role controls.Cantina: Fix verified. This matches the issue’s exact remediation (
admin == 0oroperator == 0now reverts before role grants), so the zero-address role-bricking condition is addressed in that PR.Zero address can brick Vault roles
Description
VaultV2constructor assignsDEFAULT_ADMIN_ROLEandPAUSER_ROLEdirectly from theadminargument andinitialLedgeris optional only by zero check. A zero admin leaves role administration and policy control unassignable.constructor(address admin, address initialLedger) { _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(PAUSER_ROLE, admin); if (initialLedger != address(0)) { accountLedger = initialLedger; }}A misconfigured zero role address can make role recovery and operational recovery actions unreachable for the vault path.
Recommendation
Consider adding the following checks in the constructor:
if (admin == address(0)) revert ZeroAddress();if (initialLedger != address(0) && initialLedger.code.length == 0) revert InvalidAddress();Clove: Fixed in PR #15.
VaultV2now reverts ifadmin == address(0)in the constructor, so role setup forDEFAULT_ADMIN_ROLE/PAUSER_ROLEcan’t be initialized to zero and brick role control.Cantina: Fix verified.
Vault deposit assumes full amount received
Description
VaultV2.depositcredits internal accounting with the user-declaredamountand then performssafeTransferFromwith that same amount, without measuring the effective token delta on the vault address. If a token has transfer tax, rebasing behavior or non-standard accounting, the vault can receive less thanamountwhile internal balances still record a full credit.Recommendation
If you want to support fee on transfer tokens consider computing the actual received values and use
balanceAfter - balanceBeforeas the credited amount, or restrict the supported token set to standard fixed-supply transfer semantics.uint256 before = IERC20(token).balanceOf(address(this));IERC20(token).safeTransferFrom(msg.sender, address(this), amount);uint256 received = IERC20(token).balanceOf(address(this)) - before;totalTokenBalance[token] += received;IAccountLedger(accountLedger).creditDeposit(receiver, token, received);Clove: Fixed in PR #22 (commit
c1c12de).VaultV2.depositnow computesbalanceOfbefore/after transfer and credits only the actual received amount; this directly addresses transfer-fee/non-standard token behavior.Cantina: Fix verified. The PR includes
VaultV2delta-based deposit accounting and adds tests for fee-on-transfer handling (contracts/test/v2/VaultFeeOnTransfer.t.sol).Unused perpClearing in AccountLedger
State
- Fixed
PR #20
Severity
- Severity: Informational
Submitted by
Jonatas Martins
Description
The
perpClearingvariable is defined inSpotSettlementbut never used. Since the contract is not upgradeable, this placeholder serves no practical purpose. It introduces dead code that may confuse reviewers and complicate maintenance.Recommendation
Remove
perpClearingand its associated roles to maintain the code clean.Clove: Fixed in PR #20 (commit 78454b0 (https://github.com/Clove-Labs/clob/commit/78454b01bd7132f7ec6906481a5ef05d591b9e)).
Cantina: Fix verified. The unused
perpClearingrole/state inAccountLedgerwas removed, including the deadPERP_CLEARING_ROLEreferences.