This is the first of three separate findings we discovered on the Uniswap V4 codebase by Grego AI. Two vulnerabilities were confirmed for V4 CCA and one for V4 Core. All findings were confirmed as Low severity.
This first finding is on the @Uniswap V4 Continuous Clearing Auction repo:
https://github.com/Uniswap/continuous-clearing-auction
Humble thanks to the Uniswap team to award us a bounty for this find. Team has acknowledged and fixed below:
If you would like to engage our AI security services for your protocol contact @0xriptide or @0xitsgreg to discuss.
Bug report follows …
Summary
An attacker can shape the clearing path so bids experience both fully-filled and partial-at-clearing phases, causing ContinuousClearingAuction.exitPartiallyFilledBid to hit a double-ceiling rounding overshoot that underflows ContinuousClearingAuction._processExit, permanently bricking exits. Meanwhile ContinuousClearingAuction.sweepCurrency will sweep currencyRaised to the recipient while victims’ tokens and refunds remain permanently locked.
Finding Description
This vulnerability arises from chaining multiple weaknesses: two independent ceiling-rounded spend computations are summed and forwarded unbounded to a sink subtraction, which underflows and reverts; this state is reachable by any caller for any bid; and raised funds can be swept regardless of stranded refunds.
- Sink subtracts aggregated “spent” (Q96) from deposit (Q96) with no clamp, causing underflow when the sum of two ceil-rounded parts exceeds the deposit by one Q96:
// continuous-clearing-auction/src/ContinuousClearingAuction.sol
function _processExit(uint256 bidId, uint256 tokensFilled, uint256 currencySpentQ96) internal {
Bid storage $bid = _getBid(bidId);
address _owner = $bid.owner;
// underflows if currencySpentQ96 > $bid.amountQ96
uint256 refund = ($bid.amountQ96 - currencySpentQ96) >> FixedPoint96.RESOLUTION;
$bid.tokensFilled = tokensFilled;
$bid.exitedBlock = uint64(block.number);
if (refund > 0) {
CURRENCY.transfer(_owner, refund);
}
emit BidExited(bidId, _owner, tokensFilled, refund);
}
This subtraction is fed by exitPartiallyFilledBid, which sums two independently ceiling-rounded spends.
- Both the fully-filled window and the partial-at-clearing window compute “spent” with ceiling division (fullMulDivUp), creating two independent rounding sources:
// continuous-clearing-auction/src/libraries/CheckpointAccountingLib.sol
function calculateFill(Bid memory bid, uint256 cumulativeMpsPerPriceDelta, uint24 cumulativeMpsDelta)
internal
pure
returns (uint256 tokensFilled, uint256 currencySpentQ96)
{
uint24 mpsRemainingInAuctionAfterSubmission = bid.mpsRemainingInAuctionAfterSubmission();
// ceil(amountQ96 * cumulativeMpsDelta / mpsRemaining)
→ currencySpentQ96 = bid.amountQ96.fullMulDivUp(cumulativeMpsDelta, mpsRemainingInAuctionAfterSubmission);
...
}
function accountPartiallyFilledCheckpoints(
Bid memory bid,
uint256 tickDemandQ96,
ValueX7 currencyRaisedAtClearingPriceQ96_X7
) internal pure returns (uint256 tokensFilled, uint256 currencySpentQ96) {
if (tickDemandQ96 == 0) return (0, 0);
uint256 denominator = tickDemandQ96 * bid.mpsRemainingInAuctionAfterSubmission();
// ceil(amountQ96 * raisedAtClearing / (tickDemand * mpsRemaining))
→ currencySpentQ96 = bid.amountQ96.fullMulDivUp(ValueX7.unwrap(currencyRaisedAtClearingPriceQ96_X7), denominator);
...
}
- exitPartiallyFilledBid aggregates both spends without a bound and passes the sum to _processExit().
// continuous-clearing-auction/src/ContinuousClearingAuction.sol
function exitPartiallyFilledBid(uint256 bidId, uint64 lastFullyFilledCheckpointBlock, uint64 outbidBlock) external {
...
if (lastFullyFilledCheckpoint.clearingPrice > 0) {
→ (tokensFilled, currencySpentQ96) =
_accountFullyFilledCheckpoints(lastFullyFilledCheckpoint, startCheckpoint, bid);
}
...
if (upperCheckpoint.clearingPrice == bidMaxPrice) {
uint256 tickDemandQ96 = _getTick(bidMaxPrice).currencyDemandQ96;
(uint256 partialTokensFilled, uint256 partialCurrencySpentQ96) = _accountPartiallyFilledCheckpoints(
bid, tickDemandQ96, upperCheckpoint.currencyRaisedAtClearingPriceQ96_X7
);
tokensFilled += partialTokensFilled;
→ currencySpentQ96 += partialCurrencySpentQ96; // no clamp before sink subtraction
}
→ _processExit(bidId, tokensFilled, currencySpentQ96);
}
Because ceil(x) + ceil(y) ≥ ceil(x + y) + 1 in realistic discrete cases, the sum can become amountQ96 + 1, making the subtraction revert, leaving exitedBlock unset and bricking the bid exit permanently.
- Claiming requires a successful exit (exitedBlock must be set); otherwise, token claims are blocked indefinitely:
// continuous-clearing-auction/src/ContinuousClearingAuction.sol
function claimTokens(uint256 _bidId) external onlyAfterClaimBlock ensureEndBlockIsCheckpointed {
if (!_isGraduated()) revert NotGraduated();
(address owner, uint256 tokensFilled) = _internalClaimTokens(_bidId);
...
}
function _internalClaimTokens(uint256 bidId) internal returns (address owner, uint256 tokensFilled) {
Bid storage $bid = _getBid(bidId);
if ($bid.exitedBlock == 0) revert BidNotExited();
...
}
- Regardless of stranded refunds, raised funds can be swept permissionlessly to the funds recipient:
// continuous-clearing-auction/src/ContinuousClearingAuction.sol
function sweepCurrency() external onlyAfterAuctionIsOver ensureEndBlockIsCheckpointed {
if (sweepCurrencyBlock != 0) revert CannotSweepCurrency();
if (!_isGraduated()) revert NotGraduated();
_sweepCurrency(_currencyRaised());
}
// continuous-clearing-auction/src/TokenCurrencyStorage.sol
function _sweepCurrency(uint256 amount) internal {
sweepCurrencyBlock = block.number;
if (amount > 0) {
CURRENCY.transfer(FUNDS_RECIPIENT, amount);
}
emit CurrencySwept(FUNDS_RECIPIENT, amount);
}
_currencyRaised() uses cumulative accounting and does not account for “refund balances” stuck due to failed exits, so FUNDS_RECIPIENT receives the raised currency while victims are denied both tokens and refunds.
Likelihood (low)
The prerequisite path (a fully-filled phase followed by a partial-at-clearing phase) is a natural outcome in continuous clearing auctions and can be reinforced cheaply by attacker bids.
However, Low given the nature of the attack setup, the clearing price of the auction must not move above p until the end of the auction, AND the attacker must place their bid in the last bid of the last block in the auction
Impact (medium)
The attack permanently bricks exits and claims for many participants irreversibly, effectively confiscating their “spent” portion to the funds recipient via sweepCurrency while leaving their remaining refunds and tokens irretrievably locked. This breaks distribution at scale: “sold” tokens remain unclaimable, refunds are stuck, and the recipient still receives the raised currency used later for liquidity migration. Amount of funds frozen is unbounded.
However, Medium given that an attacker must explicitly choose a target price p, the impact is naturally limited to bids where maxPrice == p. Higher impact is negated due to the 1) limited number of bids affected and 2) relative TVL of CCA auctions compared to Uniswap AMMs.
Mitigation
In ContinuousClearingAuction._processExit, clamp spend to deposit before subtraction:
uint256 spentQ96 = currencySpentQ96 > $bid.amountQ96 ? $bid.amountQ96 : currencySpentQ96;
uint256 refund = ($bid.amountQ96 - spentQ96) >> FixedPoint96.RESOLUTION;