MultiSig Timelock

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

Documentation Claims Any Signer Can Propose Transactions, but proposeTransaction Is Restricted to onlyOwner

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: In a multisig workflow, any authorized signer should be able to create/propose a transaction for the other signers to review and confirm, matching the README statement: “permission is tied to the role, so any signer can propose”.


  • Issue: The implementation restricts transaction proposal creation to the contract owner (onlyOwner). As a result, signers who hold SIGNING_ROLE cannot propose transactions, contradicting the documented behavior and materially changing the governance/user-interaction model.


function proposeTransaction(address to, uint256 value, bytes calldata data)
external
nonReentrant
noneZeroAddress(to)
// @> onlyOwner // contradicts README claim that any signer can propose
returns (uint256)
{
return _proposeTransaction(to, value, data);
}

Risk

Likelihood:

  • In normal operation, teams onboard multiple signers and expect them to initiate proposals based on the README-described workflow.


Impact:

  • The system operates as “owner proposes, signers approve” instead of the documented “any signer proposes”, centralizing proposal power in the owner account


Proof of Concept

Paste this test into MultiSigTimelock.t.sol. It shows that an account with SIGNING_ROLE still cannot call proposeTransaction (it reverts due to onlyOwner).

function test_SignerCannotProposeDespiteREADMEClaim() public grantSigningRoles {
// ARRANGE
// Fund the signer just to remove any confusion about balances;
// proposeTransaction itself doesn't spend msg.sender ETH, but this keeps the setup clean.
vm.deal(SIGNER_TWO, OWNER_BALANCE_ONE);
// Sanity: SIGNER_TWO is a valid signer per AccessControl
assertTrue(multiSigTimelock.hasRole(multiSigTimelock.getSigningRole(), SIGNER_TWO));
// ACT & ASSERT
// README claims any signer can propose; implementation requires onlyOwner and should revert.
vm.prank(SIGNER_TWO);
vm.expectRevert(); // Ownable revert (string-based in OZ), selector not guaranteed
multiSigTimelock.proposeTransaction(SPENDER_ONE, OWNER_BALANCE_ONE, hex"");
}

Recommended Mitigation

function proposeTransaction(address to, uint256 value, bytes calldata data)
external
nonReentrant
noneZeroAddress(to)
- onlyOwner
+ onlyRole(SIGNING_ROLE)
returns (uint256)
{
return _proposeTransaction(to, value, data);
}

Support

FAQs

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

Give us feedback!