Utils
CollectionShutdown.sol
The CollectionShutdown contract allows the holders of a dormant collection, with a supply of 4 collection tokens or fewer, to vote for the remaining NFTs to be liquidated and the proceeds to be claimed pro rata.
[MED] A shutdown can be cancelled by anyone in a single transaction
The cancel function is permissionless and is only protected by two checks. The shutdown must be flagged as canExecute and the total supply of the collection token must be above MAX_SHUTDOWN_TOKENS:
if (!params.canExecute) revert ShutdownNotReachedQuorum();
// Only cancellable once the collection has grown past the ceiling, which is the signal
// that it has stopped being the dormant collection the round was opened against.
if (params.collectionToken.totalSupply() <= MAX_SHUTDOWN_TOKENS) {
revert InsufficientTotalSupplyToCancel();
}
Both of these can be set, and then unwound, by the caller inside a single transaction:
Locker.depositmints 1 collection token per NFT and has no awareness of the shutdown, so depositing NFTs will push the supply above the ceilingvotecompares the votes against thequorumVotesthat was set whenstartwas called. As the supply at that point can be no more than 4 tokens, the quorum can be no more than 2. The freshly minted tokens count in full against this, so the caller can reach quorum themselves and setcanExecutecancelwill now pass both checks, settingcanExecutetofalseandquorumVotesto zeroreclaimVotereturns the collection tokens, andLocker.redeemreturns the same NFTs
This requires the caller to hold between 1 and 4 NFTs from the collection for the duration of one transaction, and costs only gas. It does not require the shutdown to have reached quorum beforehand, so can be called in the same block as start.
Once cancelled, each voter must make a reclaimVote call to recover their collection tokens, which vote will have taken their entire balance of. Holders with a balance too small to redeem an NFT lose their only route to liquidation, and the same call can be made against every subsequent attempt. No funds are stolen or permanently locked.
We considered if the owner could mitigate this. Pausing the Locker does block the deposit, but it would need to be in place before start is called and remain until execute, which would halt deposits, redemptions and listings for every collection. Deploying a new CollectionShutdown does not help, as the same call works against it.
We would recommend that cancel does not act on the supply at the moment it is called. This could either be a two step process that only cancels if the supply is still above the ceiling after a delay, or the permissionless cancel could be removed entirely. The execute function already validates the supply ceiling and recalculates the quorum, and the owner is able to call abortShutdown. We would also recommend that _vote compares against a quorum calculated from the current supply, rather than the value stored at start.
It should be noted that this logic remains unchanged in the feat/shutdown-auction branch.
[LOW] A single unreclaimed vote will prevent a new shutdown from starting
After a shutdown has been cancelled, start will revert whilst any votes remain in the contract:
if (params.shutdownVotes != 0) revert ShutdownProcessAlreadyStarted();
As reclaimVote can only be called by the voter, the votes will only return to zero if every voter makes this call. A throwaway address that votes with 1 wei and never reclaims will permanently block start, and also preventShutdown, for that collection. When combined with the issue above, this allows a single address to make the cancellation permanent.
The owner can recover from this by deploying a new CollectionShutdown contract and calling Locker.setCollectionShutdownContract. For this reason, and as no funds are at risk, we have kept this as a low severity issue.
The feat/shutdown-auction branch introduces a reclaimVoteFor function that resolves this, and we would recommend that this is carried through.