MultiSig Timelock

First Flight #55
Beginner FriendlyWallet
100 EXP
Submission Details
Impact: high
Likelihood: high

Contract Unusable Until 3 Signers Added - No Transactions Can Execute

Author Revealed upon completion

Description

  • The contract requires 3 confirmations (REQUIRED_CONFIRMATIONS = 3) to execute any transaction, but the constructor only adds 1 signer (the deployer).

  • This means the multisig wallet is completely non-functional after deployment until the owner manually adds at least 2 more signers. Any ETH deposited during this period is effectively locked.

uint256 private constant REQUIRED_CONFIRMATIONS = 3; // @> Requires 3 confirmations
constructor() Ownable(msg.sender) {
s_signers[0] = msg.sender;
s_isSigner[msg.sender] = true;
s_signerCount = 1; // @> Only 1 signer at deployment
_grantRole(SIGNING_ROLE, msg.sender);
// @> No additional signers added, but 3 are needed to execute anything
}

Risk

Likelihood:

  • Occurs on every deployment - 100% of newly deployed contracts are in this unusable state

  • Users may deposit ETH before additional signers are configured

Impact:

  • Funds deposited to the contract are locked until owner adds 2+ more signers

  • If owner loses access before adding signers, funds are permanently locked

  • No emergency withdrawal mechanism exists

  • Violates the principle that a deployed contract should be functional

Proof of Concept

function testContractUnusableAfterDeployment() public {
// Fresh deployment - only 1 signer (owner)
MultiSigTimelock freshMultisig = new MultiSigTimelock();
// Deposit funds
vm.deal(address(freshMultisig), 10 ether);
// Owner proposes transaction
uint256 txnId = freshMultisig.proposeTransaction(address(0x123), 1 ether, "");
// Owner confirms
freshMultisig.confirmTransaction(txnId);
// Owner tries to execute - FAILS because only 1 confirmation, need 3
vm.expectRevert(
abi.encodeWithSelector(
MultiSigTimelock.MultiSigTimelock__InsufficientConfirmations.selector,
3, // required
1 // current
)
);
freshMultisig.executeTransaction(txnId);
// Funds are LOCKED until owner adds 2 more signers
// If owner loses access, funds are permanently locked
}

Recommended Mitigation

Option 1: Require minimum signers at deployment:

- constructor() Ownable(msg.sender) {
+ constructor(address[] memory _initialSigners) Ownable(msg.sender) {
+ require(_initialSigners.length >= 3, "Need at least 3 signers");
+ require(_initialSigners.length <= 5, "Max 5 signers");
+
+ for (uint256 i = 0; i < _initialSigners.length; i++) {
+ require(_initialSigners[i] != address(0), "Invalid signer");
+ require(!s_isSigner[_initialSigners[i]], "Duplicate signer");
+
+ s_signers[i] = _initialSigners[i];
+ s_isSigner[_initialSigners[i]] = true;
+ _grantRole(SIGNING_ROLE, _initialSigners[i]);
+ }
+ s_signerCount = _initialSigners.length;
- }
+ }

Option 2: Add an emergency withdrawal function for owner when signer count < 3:

+ function emergencyWithdraw(address to, uint256 amount) external onlyOwner {
+ require(s_signerCount < REQUIRED_CONFIRMATIONS, "Use normal flow");
+ require(amount <= address(this).balance, "Insufficient balance");
+ (bool success,) = payable(to).call{value: amount}("");
+ require(success, "Transfer failed");
+ }

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!