MultiSig Timelock

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

# [M-02] Timelock delay starts at proposal instead of confirmation, bypassing the review period

Author Revealed upon completion

Description

The MultisigTimelock contract calculates the execution delay based on txn.proposedAt. In _executeTransaction, the requirement is block.timestamp >= txn.proposedAt + requiredDelay ➡️ {_getTimelockDelay(txn.value)}.

In a MultiSig security model, a timelock is intended to provide a "veto" or "exit" window for stakeholders (SIGNERS) after a transaction has gained enough signatures to be valid. Because the current implementation starts the timer at the moment of proposal, the delay can expire while the transaction has zero or insufficient confirmations. If the quorum is reached after the delay has already passed, the transaction can be executed immediately, leaving zero time for SIGNERS to react to a fully authorized (and potentially malicious) action.

Risk (Impact)

Medium Severity. The security "buffer" provided by the dynamic timelock (17 days) is effectively neutralized.
A malicious or compromised OWNER can propose a 100 ETH transfer, wait 7 days, and then gather the required signatures(just 2 more) in the final minute of that week.
The transaction can be executed instantly upon the final confirmation.
This defeats the protocol's promise of transparency, as the community (EVERY SIGNER) never sees a fully authorized transaction sitting in a pending state before it is finalized.

PoC (Proof of Concept)

Monday 12:00 PM: Owner proposes a 100 ETH transfer. proposedAt is set. The requiredDelay is 7 days.
Tuesday - Sunday: The transaction sits with 0 confirmations. Users ignore it as it is not authorized.
Next Monday 11:55 AM: Required signers provide confirmations. txn.confirmations now meets REQUIRED_CONFIRMATIONS.
Next Monday 12:01 PM: block.timestamp is now greater than proposedAt + 7 days.
Result: The transaction is executed immediately. The "7-day delay" occurred while the transaction was unconfirmed, giving all the addresses with the SIGNING_ROLE 0 seconds of warning after the quorum was actually met.

Recommended Mitigation

Modify the Transaction struct to include a quorumReachedAt timestamp. The timer must start only when REQUIRED_CONFIRMATIONS is reached (>3) and must reset if a confirmation is revoked (REQUEST_CONFIRMATIONS --).

//UPDATE STRUCT (Adding quorumReachedAt)

Updated _confirmTransaction:

Solidity

function _confirmTransaction(uint256 txnId) internal {
if (s_signatures[txnId][msg.sender]) {
revert MultiSigTimeLock__UserAlreadySigned();
}
s_signatures[txnId][msg.sender] = true;
Transaction storage txn = s_transactions[txnId];
txn.confirmations++;
// Added check ⬅️
// Start the timelock clock only when quorum is first reached
if (txn.confirmations >= REQUIRED_CONFIRMATIONS && txn.quorumReachedAt == 0) {
txn.quorumReachedAt = block.timestamp;
}
emit TransactionConfirmed(txnId, msg.sender);

}

Updated _revokeConfirmation:

Solidity

function _revokeConfirmation(uint256 txnId) internal {
// ... existing signature checks ...
Transaction storage txn = s_transactions[txnId];
txn.confirmations--;
// Added check ⬅️
// Reset the clock if quorum is lost. that is: if there is a revocation that makes confirmations < 3
if (txn.confirmations < REQUIRED_CONFIRMATIONS) {
txn.quorumReachedAt = 0;
}
emit TransactionRevoked(txnId, msg.sender);
}

Updated _executeTransaction:

Solidity

function _executeTransaction(uint256 txnId) internal {
Transaction storage txn = s_transactions[txnId];
//...EXISTING CHECKS...
// Check if timelock period has passed
// INSTEAD OF THIS
// uint256 requiredDelay = _getTimelockDelay(txn.value);
// uint256 executionTime = txn.proposedAt + requiredDelay;
// CHECK FOR THIS (updated check) ⬅️
// Ensure a quorum is active and the delay has passed since it was reached
require(txn.quorumReachedAt > 0, "Quorum not active");
uint256 requiredDelay = _getTimelockDelay(txn.value);
uint256 executionTime = txn.quorumReachedAt + requiredDelay;
if (block.timestamp < executionTime) {
revert MultiSigTimelock__TimelockHasNotExpired(executionTime);
}
// Rest of the original code
}

Why this update is critical:

Neutralizes "Stealth" Proposals: Without this fix, the 7-day delay can be "served" while the transaction has zero signatures. This allows a malicious quorum to execute high-value transfers instantly the moment they decide to sign, providing the public with zero warning that the transaction was actually authorized.
Enforces Continuous Quorum: By resetting the timer on **confirmation and revocation,** we ensure the protocol is protected by a continuous 7-day window of consensus. This prevents "Ghost Timers" where a transaction "pre-ages" and becomes a ticking time bomb that can be triggered months later without a fresh review period.
Aligns Code with Intent: The protocol advertises "granular delays" to protect funds. The current code only provides a "delay since creation," which offers no protection against a coordinated "flash-signature" attack.

Support

FAQs

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

Give us feedback!