pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {MultiSigTimelock} from "src/MultiSigTimelock.sol";
contract DualOwnershipDesyncTest is Test {
MultiSigTimelock multiSigTimelock;
address deployer = makeAddr("deployer");
address newOwner = makeAddr("newOwner");
address signerA = makeAddr("signerA");
address signerB = makeAddr("signerB");
function setUp() public {
vm.prank(deployer);
multiSigTimelock = new MultiSigTimelock();
vm.prank(deployer);
multiSigTimelock.grantSigningRole(signerA);
vm.prank(deployer);
multiSigTimelock.grantSigningRole(signerB);
console2.log("INITIAL STATE:");
console2.log(" Ownable owner:", multiSigTimelock.owner());
console2.log(" DEFAULT_ADMIN_ROLE:", multiSigTimelock.hasRole(multiSigTimelock.DEFAULT_ADMIN_ROLE(), deployer));
console2.log(" SIGNING_ROLE:", multiSigTimelock.hasRole(multiSigTimelock.getSigningRole(), deployer));
}
function testDualOwnershipDesynchronization() public {
bytes32 signingRole = multiSigTimelock.getSigningRole();
bytes32 adminRole = multiSigTimelock.DEFAULT_ADMIN_ROLE();
vm.prank(deployer);
multiSigTimelock.transferOwnership(newOwner);
console2.log("AFTER transferOwnership(newOwner):");
assertEq(multiSigTimelock.owner(), newOwner, "Ownable.owner() -> newOwner");
assertFalse(multiSigTimelock.hasRole(adminRole, deployer), "Deployer: NO DEFAULT_ADMIN_ROLE (never had it)");
assertFalse(multiSigTimelock.hasRole(adminRole, newOwner), "NewOwner: NO DEFAULT_ADMIN_ROLE");
assertTrue(multiSigTimelock.hasRole(signingRole, deployer), "Deployer: STILL has SIGNING_ROLE");
assertFalse(multiSigTimelock.hasRole(signingRole, newOwner), "NewOwner: NO SIGNING_ROLE");
console2.log(" Ownable owner: newOwner [PASS]");
console2.log(" Deployer DEFAULT_ADMIN_ROLE: false [NOTE]");
console2.log(" NewOwner DEFAULT_ADMIN_ROLE: false [NOTE]");
console2.log(" Deployer SIGNING_ROLE: true [FAIL]");
console2.log(" NewOwner SIGNING_ROLE: false [NOTE]");
vm.deal(address(multiSigTimelock), 10 ether);
vm.prank(newOwner);
uint256 txnId = multiSigTimelock.proposeTransaction(signerA, 5 ether, "");
console2.log("NewOwner CAN proposeTransaction [PASS] txnId:", txnId);
vm.prank(newOwner);
vm.expectRevert();
multiSigTimelock.confirmTransaction(txnId);
console2.log("NewOwner CANNOT confirmTransaction [FAIL] (no SIGNING_ROLE)");
vm.prank(deployer);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(signerA);
multiSigTimelock.confirmTransaction(txnId);
vm.prank(signerB);
multiSigTimelock.confirmTransaction(txnId);
vm.warp(block.timestamp + 2 days);
vm.prank(deployer);
multiSigTimelock.executeTransaction(txnId);
console2.log("Deployer STILL executes despite no longer Ownable owner [FAIL]");
vm.prank(newOwner);
multiSigTimelock.grantSigningRole(newOwner);
console2.log("NewOwner grants self SIGNING_ROLE (centralized workaround) [PASS]");
assertTrue(multiSigTimelock.hasRole(signingRole, deployer), "Deployer retains SIGNING_ROLE control");
console2.log("CRITICAL: Deployer retains SIGNING_ROLE -> can still execute transactions despite ownership transfer");
}
}
forge test --match-test testDualOwnershipDesynchronization -vvv
[⠢] Compiling...
No files changed, compilation skipped
Ran 1 test for test/testDualOwnershipDesynchronization.sol:DualOwnershipDesyncTest
[PASS] testDualOwnershipDesynchronization() (gas: 414607)
Logs:
INITIAL STATE:
Ownable owner: 0xaE0bDc4eEAC5E950B67C6819B118761CaAF61946
DEFAULT_ADMIN_ROLE: false
SIGNING_ROLE: true
AFTER transferOwnership(newOwner):
Ownable owner: newOwner [PASS]
Deployer DEFAULT_ADMIN_ROLE: false [NOTE]
NewOwner DEFAULT_ADMIN_ROLE: false [NOTE]
Deployer SIGNING_ROLE: true [FAIL]
NewOwner SIGNING_ROLE: false [NOTE]
NewOwner CAN proposeTransaction [PASS] txnId: 0
NewOwner CANNOT confirmTransaction [FAIL] (no SIGNING_ROLE)
Deployer STILL executes despite no longer Ownable owner [FAIL]
NewOwner grants self SIGNING_ROLE (centralized workaround) [PASS]
CRITICAL: Deployer retains SIGNING_ROLE -> can still execute transactions despite ownership transfer
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 835.03µs (228.91µs CPU time)
Ran 1 test suite in 8.63ms (835.03µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
// - remove this code
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
// Remove: is Ownable(msg.sender)
// + add this code - Pure AccessControl
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ADMIN_ROLE, msg.sender);
_grantRole(SIGNING_ROLE, msg.sender);
_setRoleAdmin(SIGNING_ROLE, ADMIN_ROLE); // Admin manages signers
}
function grantSigningRole(address account) external onlyRole(ADMIN_ROLE) {
// ... existing logic
}
// Replace onlyOwner → onlyRole(ADMIN_ROLE)
function proposeTransaction(...) external onlyRole(ADMIN_ROLE) { ... }