Root + Impact
Description
-
The README explicitly states that the project is a decentralized multi-signature wallet where permission to propose transactions is tied to the SIGNING_ROLE: "Propose new transactions (permission is tied to the role, so any signer can propose)".
-
However, the implementation of proposeTransaction uses the onlyOwner modifier, which restricts this core functionality to the deployer only.
function proposeTransaction(address to, uint256 value, bytes calldata data)
external
nonReentrant
noneZeroAddress(to)
@> onlyOwner
returns (uint256)
Risk
Likelihood:
Impact:
-
Centralization Risk: The wallet has a Single Point of Failure. If the Owner's private key is lost or compromised, the entire multi-sig becomes non-functional as no other signers can initiate a transaction.
-
Business Logic Violation: The contract fails to meet its primary objective of distributed governance.
Proof of Concept
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
contract PoC_Centralization is Test {
MultiSigTimelock public multiSig;
address public OWNER = address(this);
address public SIGNER_2 = makeAddr("signer2");
function setUp() public {
multiSig = new MultiSigTimelock();
multiSig.grantSigningRole(SIGNER_2);
}
function test_SignerCannotProposeTransaction() public {
vm.startPrank(SIGNER_2);
vm.expectRevert();
multiSig.proposeTransaction(address(0x123), 1 ether, "");
vm.stopPrank();
console.log("Verified: Signer cannot propose a transaction due to onlyOwner restriction.");
}
}
Run:
forge test --match-path test/PoC_Centralization.t.sol -vvvv
Output:
Ran 1 test for test/PoC_Centralization.t.sol:PoC_Centralization
[PASS] test_SignerCannotProposeTransaction() (gas: 24835)
Logs:
Verified: Signer cannot propose a transaction due to onlyOwner restriction.
Traces:
[24835] PoC_Centralization::test_SignerCannotProposeTransaction()
├─
├─ [0] console::log("Verified: Signer cannot propose a transaction due to onlyOwner restriction.") [staticcall]
│ └─ ← [Stop]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped;
Recommended Mitigation
Change the onlyOwner modifier to onlyRole(SIGNING_ROLE) or add a s_isSigner[msg.sender] check:
- function proposeTransaction(address to, uint256 value, bytes calldata data) external nonReentrant noneZeroAddress(to) onlyOwner returns (uint256)
+ function proposeTransaction(address to, uint256 value, bytes calldata data) external nonReentrant noneZeroAddress(to) onlyRole(SIGNING_ROLE) returns (uint256)