Root + Impact
Description
-
The contract requires exactly 3 confirmations to execute any transaction, but allows the signer count to drop below 3.
-
The owner can revoke signers (including themselves) as long as at least 1 signer remains.
-
If the signer count drops to 2 or fewer, the 3-confirmation quorum becomes mathematically impossible to achieve, permanently locking all funds.
uint256 private constant REQUIRED_CONFIRMATIONS = 3;
function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
@> if (s_signerCount <= 1) {
revert MultiSigTimelock__CannotRevokeLastSigner();
}
}
Risk
Likelihood:
-
Reason 1 // Owner accidentally revokes too many signers during key rotation
-
Reason 2 // Malicious owner intentionally bricks the wallet to grief other signers
-
Reason 3 // Owner revokes compromised signers without realizing quorum impact
Impact:
-
All ETH in the contract becomes permanently inaccessible
-
No recovery mechanism exists - funds are locked forever
-
Quorum can never be reached with fewer than 3 signers
Proof of Concept
This test demonstrates a scenario where the owner removes signers during a routine key rotation, inadvertently reducing the signer count below the required quorum. With only 2 signers remaining, both confirm a withdrawal transaction, but execution fails because 3 confirmations are required. The funds become permanently locked with no recovery path.
function test_PermanentFundLockWithInsufficientSigners() public grantSigningRoles {
vm.deal(address(multiSigTimelock), 10 ether);
multiSigTimelock.revokeSigningRole(SIGNER_THREE);
multiSigTimelock.revokeSigningRole(SIGNER_FOUR);
multiSigTimelock.revokeSigningRole(SIGNER_FIVE);
assertEq(multiSigTimelock.getSignerCount(), 2);
uint256 txnId = multiSigTimelock.proposeTransaction(SPENDER_ONE, 1 ether, "");
vm.prank(OWNER);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(SIGNER_TWO);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(OWNER);
vm.expectRevert(
abi.encodeWithSelector(
MultiSigTimelock.MultiSigTimelock__InsufficientConfirmations.selector,
3,
2
)
);
multiSigTimelock.executeTransaction(txnId);
assertEq(address(multiSigTimelock).balance, 10 ether);
}
Recommended Mitigation
Enforce minimum signer count equal to required confirmations:
function revokeSigningRole(address _account) external nonReentrant onlyOwner noneZeroAddress(_account) {
if (!s_isSigner[_account]) {
revert MultiSigTimelock__AccountIsNotASigner();
}
- if (s_signerCount <= 1) {
- revert MultiSigTimelock__CannotRevokeLastSigner();
- }
+ if (s_signerCount <= REQUIRED_CONFIRMATIONS) {
+ revert MultiSigTimelock__WouldBreakQuorum();
+ }
// ... rest of function
}