Narrow Opaque Platypus
Medium
An attacker can cancel the auction without losing any funds.
In the StreamEscrow::cancelStream() function, the canceler can recover all of their funds. https://github.com/sherlock-audit/2024-11-nounsdao/blob/main/nouns-monorepo/packages/nouns-contracts/contracts/StreamEscrow.sol#L167
NounsAuctionHouseV3::immediateTreasuryBPs = 0
N/A
When immediateTreasuryBPs = 0, the attacker can acquire the noun at a higher price than any other participant. The attacker calls NounsAuctionHouseV3::settleCurrentAndCreateNewAuction() on time and then immediately invokes StreamEscrow::cancelStream() to recover all of the funds.
No nouns can be auctioned.
NounsAuctionHouseV3.sol
325: function _settleAuction() internal {
[...]
333: uint256 amountToSendTreasury = (_auction.amount * immediateTreasuryBPs) / 10_000;
uint256 amountToStream = _auction.amount - amountToSendTreasury;
if (amountToSendTreasury > 0) {
_safeTransferETHWithFallback(owner(), amountToSendTreasury);
}
if (amountToStream > 0) {
streamEscrow.forwardAllAndCreateStream{ value: amountToStream }(_auction.nounId, streamLengthInTicks);
} else {
streamEscrow.forwardAll();
}
[...]
}
Here, amountToSendTreasury = 0;
StreamEscrow.sol
167: function cancelStream(uint256 nounId) public {
require(isStreamActive(nounId), 'stream not active');
// transfer noun to treasury
nounsToken.transferFrom(msg.sender, nounsRecipient, nounId);
// cancel stream
streams[nounId].canceled = true;
Stream memory stream = streams[nounId];
ethStreamedPerTick -= stream.ethPerTick;
ethStreamEndingAtTick[stream.lastTick] -= stream.ethPerTick;
// calculate how much needs to be refunded
180: uint256 ticksLeft = stream.lastTick - currentTick;
uint256 amountToRefund = stream.ethPerTick * ticksLeft;
(bool sent, ) = msg.sender.call{ value: amountToRefund }('');
require(sent, 'failed to send eth');
emit StreamCanceled(nounId, amountToRefund, ethStreamedPerTick);
}
Since cancelStream() is called immediately after createStream() is called, currentTick doesn't increase, allowing the attacker to recover all the funds.
StreamEscrow.sol
167: function cancelStream(uint256 nounId) public {
require(isStreamActive(nounId), 'stream not active');
// transfer noun to treasury
nounsToken.transferFrom(msg.sender, nounsRecipient, nounId);
// cancel stream
streams[nounId].canceled = true;
Stream memory stream = streams[nounId];
- ethStreamedPerTick -= stream.ethPerTick;
ethStreamEndingAtTick[stream.lastTick] -= stream.ethPerTick;
+ ethStreamEndingAtTick[currentTick + 1] += stream.ethPerTick;
// calculate how much needs to be refunded
- uint256 ticksLeft = stream.lastTick - currentTick;
+ uint256 ticksLeft = stream.lastTick - currentTick - 1;
uint256 amountToRefund = stream.ethPerTick * ticksLeft;
(bool sent, ) = msg.sender.call{ value: amountToRefund }('');
require(sent, 'failed to send eth');
emit StreamCanceled(nounId, amountToRefund, ethStreamedPerTick);
}