pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
contract RoleRenunciationQuorumBypassTest is Test {
MultiSigTimelock multiSigTimelock;
address owner = makeAddr("owner");
address signerA = makeAddr("signerA");
address signerB = makeAddr("signerB");
address signerC = makeAddr("signerC");
address signerD = makeAddr("signerD");
address signerE = makeAddr("signerE");
function setUp() public {
vm.prank(owner);
multiSigTimelock = new MultiSigTimelock();
vm.prank(owner);
multiSigTimelock.grantSigningRole(signerA);
vm.prank(owner);
multiSigTimelock.grantSigningRole(signerB);
vm.prank(owner);
multiSigTimelock.grantSigningRole(signerC);
vm.prank(owner);
multiSigTimelock.grantSigningRole(signerD);
}
function testRoleRenunciationBricksQuorum() public {
bytes32 signingRole = multiSigTimelock.getSigningRole();
assertEq(multiSigTimelock.getSignerCount(), 5, "BEFORE: Should have 5 signers");
assertTrue(multiSigTimelock.hasRole(signingRole, owner), "BEFORE: Owner has role");
assertTrue(multiSigTimelock.hasRole(signingRole, signerA), "BEFORE: SignerA has role");
assertTrue(multiSigTimelock.hasRole(signingRole, signerB), "BEFORE: SignerB has role");
assertTrue(multiSigTimelock.hasRole(signingRole, signerC), "BEFORE: SignerC has role");
assertTrue(multiSigTimelock.hasRole(signingRole, signerD), "BEFORE: SignerD has role");
console2.log("BEFORE: Wallet functional - 5 signers with SIGNING_ROLE");
vm.prank(signerA);
multiSigTimelock.renounceRole(signingRole, signerA);
vm.prank(signerB);
multiSigTimelock.renounceRole(signingRole, signerB);
vm.prank(signerC);
multiSigTimelock.renounceRole(signingRole, signerC);
assertEq(multiSigTimelock.getSignerCount(), 5, "AFTER: s_signerCount still shows 5");
assertTrue(multiSigTimelock.hasRole(signingRole, owner), "AFTER: Owner still has role");
assertFalse(multiSigTimelock.hasRole(signingRole, signerA), "AFTER: SignerA lost role");
assertFalse(multiSigTimelock.hasRole(signingRole, signerB), "AFTER: SignerB lost role");
assertFalse(multiSigTimelock.hasRole(signingRole, signerC), "AFTER: SignerC lost role");
assertTrue(multiSigTimelock.hasRole(signingRole, signerD), "AFTER: SignerD still has role");
vm.deal(owner, 1 ether);
vm.prank(owner);
uint256 txnId = multiSigTimelock.proposeTransaction(signerD, 0.1 ether, "");
vm.prank(owner);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(signerD);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(signerA);
vm.expectRevert();
multiSigTimelock.confirmTransaction(txnId);
console2.log("AFTER: Wallet BRICKED - only 2 signers remain, cannot reach required 3 confirmations");
console2.log(" Existing txs with prior confirmations may still execute, but new txs are permanently blocked");
}
}
forge test --match-test testRoleRenunciationBricksQuorum -vvv
[⠆] Compiling...
No files changed, compilation skipped
Ran 1 test for test/testRoleRenunciationBricksQuorum.sol:RoleRenunciationQuorumBypassTest
[PASS] testRoleRenunciationBricksQuorum() (gas: 260611)
Logs:
BEFORE: Wallet functional - 5 signers with SIGNING_ROLE
AFTER: Wallet BRICKED - only 2 signers remain, cannot reach required 3 confirmations
Existing txs with prior confirmations may still execute, but new txs are permanently blocked
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 820.82µs (206.86µs CPU time)
Ran 1 test suite in 6.69ms (820.82µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
function renounceRole(bytes32 role) public override(AccessControl) {
if (role == SIGNING_ROLE) {
if (s_signerCount <= 1) {
revert MultiSigTimelockCannotRevokeLastSigner();
}
s_isSigner[msg.sender] = false;
s_signerCount--;
for (uint256 i = 0; i < s_signerCount; i++) {
if (s_signers[i] == msg.sender) {
s_signers[i] = s_signers[s_signerCount];
s_signers[s_signerCount] = address(0);
break;
}
}
}
super.renounceRole(role);
}