Skip to content

Add Ownable2StepSign, an extension of Ownable2Step #5628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/witty-ducks-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': major
---

`Ownable2StepSign`: Introduce Ownable2Step extension which allows for implicit ownership acceptance via signature.
87 changes: 87 additions & 0 deletions contracts/access/OwnableRecipientSignOff.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Ownable} from "./Ownable.sol";
import {SignatureChecker} from "../utils/cryptography/SignatureChecker.sol";
import {EIP712} from "../utils/cryptography/EIP712.sol";

/**
* @dev Contract module which provides a basic access control mechanism, where
* there is an account (an owner) that can be granted exclusive access to
* specific functions.
*
* This extension of the {Ownable} includes a signature protected ownership transfer mechanism,
* where the `newOwner` must provide a signature in order to replace the old one.
* This can help prevent common mistakes, such as transfers of ownership to incorrect accounts,
* and improve the safety of a `newOwner` wallet, as it can stay offline at a time.
* Function `transferOwnership` provides a safe way of making an ownership transfer to contracts as well.
*
* The initial owner is specified at deployment time in the constructor for `Ownable`. This
* can later be changed with {transferOwnership}.
*
* This module is used through inheritance. It will make available all functions
* from parent (Ownable).
*/
abstract contract OwnableRecipientSignOff is Ownable, EIP712 {
/**
* @dev Nonce used for signatures.
*/
uint256 private _nonce;

bytes32 private constant TRANSFER_OWNERSHIP_TYPEHASH =
keccak256("TransferOwnership(uint256 nonce,uint256 deadline)");

/**
* @dev Permit deadline has expired.
*/
error ExpiredSignature(uint256 deadline);

/**
* @dev Mismatched signature.
*/
error InvalidSigner();

constructor(string memory name) EIP712(name, "1") {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
constructor(string memory name) EIP712(name, "1") {}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added this only to fix the version to 1, if that is not desirable I will remove the constructor.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @Amxx, just pinging to make sure you want this suggestion applied (considering the comment that I previously made).


/**
* @dev Transfers ownership of the contract to a new account (`newOwner`).
* Can only be called by the current owner.
* Requires a `newOwner` account's signature.
*/
function transferOwnership(address newOwner, uint256 deadline, bytes memory signature) public virtual onlyOwner {
if (block.timestamp > deadline) {
revert ExpiredSignature(deadline);
}
if (
!SignatureChecker.isValidSignatureNow(
newOwner,
_hashTypedDataV4(keccak256(abi.encode(TRANSFER_OWNERSHIP_TYPEHASH, _useNonce(), deadline))),
signature
)
) {
revert InvalidSigner();
}
super._transferOwnership(newOwner);
}

/**
* @dev Consumes a nonce.
*
* Returns the current value and increments nonce.
*/
function _useNonce() internal virtual returns (uint256) {
// Flow guarantees that the nonce never overflows.
unchecked {
// It is important to do x++ and not ++x here.
return _nonce++;
}
}

/**
* @dev Returns the next unused nonce.
*/
function nonce() public view virtual returns (uint256) {
return _nonce;
}
}
90 changes: 90 additions & 0 deletions test/access/OwnableRecipientSignOff.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

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

import {OwnableRecipientSignOff} from "@openzeppelin/contracts/access/OwnableRecipientSignOff.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract OwnableRecipientSignOffMock is OwnableRecipientSignOff {
bytes32 private constant TRANSFER_OWNERSHIP_TYPEHASH =
keccak256("TransferOwnership(uint256 nonce,uint256 deadline)");

constructor(address initialOwner) OwnableRecipientSignOff("OwnableRecipientSignOff") Ownable(initialOwner) {}

function computeHash(uint256 nonce, uint256 deadline) external view returns (bytes32) {
return _hashTypedDataV4(keccak256(abi.encode(TRANSFER_OWNERSHIP_TYPEHASH, nonce, deadline)));
}
}

contract OwnableRecipientSignOffTest is Test {
address private constant INITIAL_OWNER = address(1);
OwnableRecipientSignOffMock private _ownableRecipientSignMock;
Vm.Wallet private _newOwner;

function setUp() external {
_ownableRecipientSignMock = new OwnableRecipientSignOffMock(INITIAL_OWNER);
_newOwner = vm.createWallet("_newOwner");
assertEq(_ownableRecipientSignMock.owner(), INITIAL_OWNER);
}

function testTransferOwnership() public {
uint256 deadline = block.timestamp;
uint256 nonce = 0;

bytes32 hash = _ownableRecipientSignMock.computeHash(nonce, deadline);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(_newOwner, hash);
bytes memory signature = abi.encodePacked(r, s, v);

vm.prank(INITIAL_OWNER);
_ownableRecipientSignMock.transferOwnership(_newOwner.addr, deadline, signature);

assertEq(_ownableRecipientSignMock.owner(), _newOwner.addr);
assertEq(_ownableRecipientSignMock.nonce(), nonce + 1);
}

function testRevertWhenUnauthorizedCallIsMadeToTransferOwnership() external {
uint256 deadline = block.timestamp;
uint256 nonce = 0;
address unauthorizedCaller = address(2);

bytes32 hash = _ownableRecipientSignMock.computeHash(nonce, deadline);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(_newOwner, hash);
bytes memory signature = abi.encodePacked(r, s, v);

vm.prank(unauthorizedCaller);
vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, unauthorizedCaller));
_ownableRecipientSignMock.transferOwnership(_newOwner.addr, deadline, signature);
}

function testRevertTransferOwnershipWhenSignatureExpires() external {
uint256 deadline = block.timestamp - 1;
uint256 nonce = 0;

bytes32 hash = _ownableRecipientSignMock.computeHash(nonce, deadline);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(_newOwner, hash);
bytes memory signature = abi.encodePacked(r, s, v);

vm.prank(INITIAL_OWNER);
vm.expectRevert(abi.encodeWithSelector(OwnableRecipientSignOff.ExpiredSignature.selector, deadline));
_ownableRecipientSignMock.transferOwnership(_newOwner.addr, deadline, signature);
}

function testRevertTransferOwnershipWhenSignatureIsInvalid() external {
Vm.Wallet memory falseSigner = vm.createWallet("falseSigner");
uint256 deadline = block.timestamp;
uint256 nonce = 0;

bytes32 hash = _ownableRecipientSignMock.computeHash(nonce, deadline);
// Data is valid but signed by an inadequate wallet.
(uint8 v, bytes32 r, bytes32 s) = vm.sign(falseSigner, hash);
bytes memory signature = abi.encodePacked(r, s, v);

vm.prank(INITIAL_OWNER);
vm.expectRevert(OwnableRecipientSignOff.InvalidSigner.selector);
_ownableRecipientSignMock.transferOwnership(_newOwner.addr, deadline, signature);
}
}
Loading