Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/Ownable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// Minimal two-step Ownable. Pending owner must accept - no foot-gun transfers
/// to an address that can't sign for itself.
abstract contract Ownable {
address public owner;
address public pendingOwner;

event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);

error NotOwner();
error NotPendingOwner();

modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}

constructor() {
owner = msg.sender;
emit OwnershipTransferred(address(0), msg.sender);
}

function transferOwnership(address newOwner) external onlyOwner {
pendingOwner = newOwner;
emit OwnershipTransferStarted(owner, newOwner);
}

function acceptOwnership() external {
if (msg.sender != pendingOwner) revert NotPendingOwner();
address previous = owner;
owner = pendingOwner;
delete pendingOwner;
emit OwnershipTransferred(previous, owner);
}

/// Owner can renounce immediately - no two-step. Use carefully.
function renounceOwnership() external onlyOwner {
address previous = owner;
delete owner;
delete pendingOwner;
emit OwnershipTransferred(previous, address(0));
}
}
85 changes: 85 additions & 0 deletions test/Ownable.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {Ownable} from "../src/Ownable.sol";

/// Concrete impl so we can deploy the abstract Ownable in tests
contract OwnableHarness is Ownable {
uint256 public protectedValue;

function setProtectedValue(uint256 v) external onlyOwner {
protectedValue = v;
}
}

contract OwnableTest is Test {
OwnableHarness public h;
address deployer;
address alice = makeAddr("alice");
address bob = makeAddr("bob");

function setUp() public {
deployer = address(this);
h = new OwnableHarness();
}

function test_InitialOwnerIsDeployer() public view {
assertEq(h.owner(), deployer);
assertEq(h.pendingOwner(), address(0));
}

function test_OnlyOwner_CanCallProtected() public {
h.setProtectedValue(1);
assertEq(h.protectedValue(), 1);

vm.expectRevert(Ownable.NotOwner.selector);
vm.prank(alice);
h.setProtectedValue(2);
}

function test_TwoStepTransfer() public {
h.transferOwnership(alice);
// Owner unchanged until accepted
assertEq(h.owner(), deployer);
assertEq(h.pendingOwner(), alice);

vm.prank(alice);
h.acceptOwnership();
assertEq(h.owner(), alice);
assertEq(h.pendingOwner(), address(0));
}

function test_Accept_RevertWhenNotPending() public {
h.transferOwnership(alice);
vm.expectRevert(Ownable.NotPendingOwner.selector);
vm.prank(bob);
h.acceptOwnership();
}

function test_Transfer_RevertWhenNotOwner() public {
vm.expectRevert(Ownable.NotOwner.selector);
vm.prank(alice);
h.transferOwnership(bob);
}

function test_Renounce_ZerosOwner() public {
h.renounceOwnership();
assertEq(h.owner(), address(0));

// Nobody can call protected anymore
vm.expectRevert(Ownable.NotOwner.selector);
h.setProtectedValue(1);
}

function test_Transfer_OverwritesPendingOwner() public {
h.transferOwnership(alice);
h.transferOwnership(bob);
assertEq(h.pendingOwner(), bob);

// Alice (no longer pending) cannot accept
vm.expectRevert(Ownable.NotPendingOwner.selector);
vm.prank(alice);
h.acceptOwnership();
}
}
Loading