Skip to content
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

Make TransparentUpgradeableProxy admin immutable #4354

Merged
Show file tree
Hide file tree
Changes from 5 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/happy-falcons-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': major
---

`TransparentUpgradeableProxy`: Added an immutable admin set during construction to avoid unnecessary storage reads on every proxy call, and removed the ability to change the admin from both proxy and `ProxyAdmin`
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
12 changes: 12 additions & 0 deletions contracts/mocks/DummyImplementation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

pragma solidity ^0.8.19;

import "../interfaces/IERC1967.sol";
import "../utils/StorageSlot.sol";

abstract contract Impl {
function version() public pure virtual returns (string memory);
}
Expand All @@ -11,6 +14,9 @@ contract DummyImplementation {
string public text;
uint256[] public values;

// bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
bytes32 internal constant _ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103;
ernestognw marked this conversation as resolved.
Show resolved Hide resolved

function initializeNonPayable() public {
value = 10;
}
Expand Down Expand Up @@ -44,6 +50,12 @@ contract DummyImplementation {
function reverts() public pure {
require(false, "DummyImplementation reverted");
}

// Use for forcing an unsafe TransparentUpgradeableProxy admin override
// solhint-disable-next-line private-vars-leading-underscore
function _unsafeOverrideAdmin(address newAdmin) public {
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
}
}

contract DummyImplementationV2 is DummyImplementation {
Expand Down
2 changes: 1 addition & 1 deletion contracts/mocks/proxy/ClashingImplementation.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pragma solidity ^0.8.19;
contract ClashingImplementation {
event ClashingImplementationCall();

function changeAdmin(address) external payable {
function upgradeTo(address) external payable {
emit ClashingImplementationCall();
}

Expand Down
2 changes: 1 addition & 1 deletion contracts/proxy/ERC1967/ERC1967Upgrade.sol
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ abstract contract ERC1967Upgrade is IERC1967 {
* https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
* `0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103`
*/
function _getAdmin() internal view returns (address) {
function _getAdmin() internal view virtual returns (address) {
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
return StorageSlot.getAddressSlot(_ADMIN_SLOT).value;
}

Expand Down
11 changes: 0 additions & 11 deletions contracts/proxy/transparent/ProxyAdmin.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,6 @@ contract ProxyAdmin is Ownable {
*/
constructor(address initialOwner) Ownable(initialOwner) {}

/**
* @dev Changes the admin of `proxy` to `newAdmin`.
*
* Requirements:
*
* - This contract must be the current admin of `proxy`.
*/
function changeProxyAdmin(ITransparentUpgradeableProxy proxy, address newAdmin) public virtual onlyOwner {
proxy.changeAdmin(newAdmin);
}

/**
* @dev Upgrades `proxy` to `implementation`. See {TransparentUpgradeableProxy-upgradeTo}.
*
Expand Down
27 changes: 14 additions & 13 deletions contracts/proxy/transparent/TransparentUpgradeableProxy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import "../ERC1967/ERC1967Proxy.sol";
* include them in the ABI so this interface must be used to interact with it.
*/
interface ITransparentUpgradeableProxy is IERC1967 {
function changeAdmin(address) external;

function upgradeTo(address) external;

function upgradeToAndCall(address, bytes memory) external payable;
Expand Down Expand Up @@ -46,12 +44,21 @@ interface ITransparentUpgradeableProxy is IERC1967 {
* fully implement transparency without decoding reverts caused by selector clashes between the proxy and the
* implementation.
*
* IMPORTANT: This contract avoids unnecessary storage reads by setting the admin only during construction as an immutable variable,
frangio marked this conversation as resolved.
Show resolved Hide resolved
* preventing any changes thereafter. However, the admin slot defined in ERC-1967 can still be overwritten by the implementation
* logic pointed to by this proxy. In such cases, the contract may end up in an undesirable state where the admin slot is different
* from the actual admin.
*
* WARNING: It is not recommended to extend this contract to add additional external functions. If you do so, the compiler
* will not check that there are no selector conflicts, due to the note above. A selector clash between any new function
* and the functions declared in {ITransparentUpgradeableProxy} will be resolved in favor of the new one. This could
* render the admin operations inaccessible, which could prevent upgradeability. Transparency may also be compromised.
*/
contract TransparentUpgradeableProxy is ERC1967Proxy {
// An immutable address for the admin avoid unnecessary SLOADs before each call
// at the expense of removing the ability to change the admin once it's set.
address private immutable _admin;

/**
* @dev The proxy caller is the current admin, and can't fallback to the proxy target.
*/
Expand All @@ -67,6 +74,7 @@ contract TransparentUpgradeableProxy is ERC1967Proxy {
* optionally initialized with `_data` as explained in {ERC1967Proxy-constructor}.
*/
constructor(address _logic, address admin_, bytes memory _data) payable ERC1967Proxy(_logic, _data) {
_admin = admin_;
_changeAdmin(admin_);
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
}

Expand All @@ -81,8 +89,6 @@ contract TransparentUpgradeableProxy is ERC1967Proxy {
ret = _dispatchUpgradeTo();
} else if (selector == ITransparentUpgradeableProxy.upgradeToAndCall.selector) {
ret = _dispatchUpgradeToAndCall();
} else if (selector == ITransparentUpgradeableProxy.changeAdmin.selector) {
ret = _dispatchChangeAdmin();
} else {
revert ProxyDeniedAdminAccess();
}
Expand All @@ -95,17 +101,12 @@ contract TransparentUpgradeableProxy is ERC1967Proxy {
}

/**
* @dev Changes the admin of the proxy.
* @dev Returns the current immutable admin.
*
* Emits an {AdminChanged} event.
* Overrides ERC1967's admin in favor of an immutable value to avoid unnecessary SLOADs on each proxy call.
*/
function _dispatchChangeAdmin() private returns (bytes memory) {
_requireZeroValue();

address newAdmin = abi.decode(msg.data[4:], (address));
_changeAdmin(newAdmin);

return "";
function _getAdmin() internal view virtual override returns (address) {
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
return _admin;
}

/**
Expand Down
21 changes: 2 additions & 19 deletions test/proxy/transparent/ProxyAdmin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ const ProxyAdmin = artifacts.require('ProxyAdmin');
const TransparentUpgradeableProxy = artifacts.require('TransparentUpgradeableProxy');
const ITransparentUpgradeableProxy = artifacts.require('ITransparentUpgradeableProxy');

const { getAddressInSlot, ImplementationSlot, AdminSlot } = require('../../helpers/erc1967');
const { getAddressInSlot, ImplementationSlot } = require('../../helpers/erc1967');
const { expectRevertCustomError } = require('../../helpers/customError');

contract('ProxyAdmin', function (accounts) {
const [proxyAdminOwner, newAdmin, anotherAccount] = accounts;
const [proxyAdminOwner, anotherAccount] = accounts;

before('set implementations', async function () {
this.implementationV1 = await ImplV1.new();
Expand All @@ -32,23 +32,6 @@ contract('ProxyAdmin', function (accounts) {
expect(await this.proxyAdmin.owner()).to.equal(proxyAdminOwner);
});

describe('#changeProxyAdmin', function () {
it('fails to change proxy admin if its not the proxy owner', async function () {
await expectRevertCustomError(
this.proxyAdmin.changeProxyAdmin(this.proxy.address, newAdmin, { from: anotherAccount }),
'OwnableUnauthorizedAccount',
[anotherAccount],
);
});

it('changes proxy admin', async function () {
await this.proxyAdmin.changeProxyAdmin(this.proxy.address, newAdmin, { from: proxyAdminOwner });

const newProxyAdmin = await getAddressInSlot(this.proxy, AdminSlot);
expect(newProxyAdmin).to.be.equal(newAdmin);
});
});

describe('#upgrade', function () {
context('with unauthorized account', function () {
it('fails to upgrade', async function () {
Expand Down
92 changes: 39 additions & 53 deletions test/proxy/transparent/TransparentUpgradeableProxy.behaviour.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ module.exports = function shouldBehaveLikeTransparentUpgradeableProxy(createProx
});
});

describe('proxyAdmin', function () {
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
it('sets the admin in the storage ', async function () {
expect(await getAddressInSlot(this.proxy, AdminSlot)).to.be.equal(proxyAdminAddress);
});

it('can overwrite the admin by the implementation', async function () {
const dummy = new DummyImplementation(this.proxyAddress);
await dummy._unsafeOverrideAdmin(anotherAccount);
const ERC1967AdminSlotValue = await getAddressInSlot(this.proxy, AdminSlot);
expect(ERC1967AdminSlotValue).to.be.equal(anotherAccount);

// Still allows previous admin to execute admin operations
expect(ERC1967AdminSlotValue).to.not.equal(proxyAdminAddress);
expectEvent(await this.proxy.upgradeTo(this.implementationV1, { from: proxyAdminAddress }), 'Upgraded', {
implementation: this.implementationV1,
});
});
});

describe('upgradeTo', function () {
describe('when the sender is the admin', function () {
const from = proxyAdminAddress;
Expand Down Expand Up @@ -258,51 +277,14 @@ module.exports = function shouldBehaveLikeTransparentUpgradeableProxy(createProx
});
});

describe('changeAdmin', function () {
describe('when the new proposed admin is not the zero address', function () {
const newAdmin = anotherAccount;

describe('when the sender is the admin', function () {
beforeEach('transferring', async function () {
this.receipt = await this.proxy.changeAdmin(newAdmin, { from: proxyAdminAddress });
});

it('assigns new proxy admin', async function () {
const newProxyAdmin = await getAddressInSlot(this.proxy, AdminSlot);
expect(newProxyAdmin).to.be.equal(anotherAccount);
});

it('emits an event', function () {
expectEvent(this.receipt, 'AdminChanged', {
previousAdmin: proxyAdminAddress,
newAdmin: newAdmin,
});
});
});

describe('when the sender is not the admin', function () {
it('reverts', async function () {
await expectRevert.unspecified(this.proxy.changeAdmin(newAdmin, { from: anotherAccount }));
});
});
});

describe('when the new proposed admin is the zero address', function () {
it('reverts', async function () {
await expectRevertCustomError(
this.proxy.changeAdmin(ZERO_ADDRESS, { from: proxyAdminAddress }),
'ERC1967InvalidAdmin',
[ZERO_ADDRESS],
);
});
});
});

describe('transparent proxy', function () {
beforeEach('creating proxy', async function () {
const initializeData = Buffer.from('');
this.impl = await ClashingImplementation.new();
this.proxy = await createProxy(this.impl.address, proxyAdminAddress, initializeData, { from: proxyAdminOwner });
this.clashingImplV0 = (await ClashingImplementation.new()).address;
this.clashingImplV1 = (await ClashingImplementation.new()).address;
this.proxy = await createProxy(this.clashingImplV0, proxyAdminAddress, initializeData, {
from: proxyAdminOwner,
});
this.clashing = new ClashingImplementation(this.proxy.address);
});

Expand All @@ -315,24 +297,28 @@ module.exports = function shouldBehaveLikeTransparentUpgradeableProxy(createProx
});

describe('when function names clash', function () {
it('when sender is proxy admin should run the proxy function', async function () {
const receipt = await this.proxy.changeAdmin(anotherAccount, { from: proxyAdminAddress, value: 0 });
expectEvent(receipt, 'AdminChanged');
it('executes the proxy function if the sender is the admin', async function () {
const receipt = await this.proxy.upgradeTo(this.clashingImplV1, { from: proxyAdminAddress, value: 0 });
expectEvent(receipt, 'Upgraded', { implementation: this.clashingImplV1 });
});

it('when sender is other should delegate to implementation', async function () {
const receipt = await this.proxy.changeAdmin(anotherAccount, { from: anotherAccount, value: 0 });
expectEvent.notEmitted(receipt, 'AdminChanged');
it('delegates the call to implementation when sender is not the admin', async function () {
const receipt = await this.proxy.upgradeTo(this.clashingImplV1, { from: anotherAccount, value: 0 });
expectEvent.notEmitted(receipt, 'Upgraded');
expectEvent.inTransaction(receipt.tx, this.clashing, 'ClashingImplementationCall');
});

it('when sender is proxy admin value should not be accepted', async function () {
await expectRevert.unspecified(this.proxy.changeAdmin(anotherAccount, { from: proxyAdminAddress, value: 1 }));
it('requires 0 value calling upgradeTo by proxy admin', async function () {
await expectRevertCustomError(
this.proxy.upgradeTo(this.clashingImplV1, { from: proxyAdminAddress, value: 1 }),
'ProxyNonPayableFunction',
[],
);
});

it('when sender is other value should be accepted', async function () {
const receipt = await this.proxy.changeAdmin(anotherAccount, { from: anotherAccount, value: 1 });
expectEvent.notEmitted(receipt, 'AdminChanged');
it('allows calling with value if sender is not the admin', async function () {
const receipt = await this.proxy.upgradeTo(this.clashingImplV1, { from: anotherAccount, value: 1 });
expectEvent.notEmitted(receipt, 'Upgraded');
expectEvent.inTransaction(receipt.tx, this.clashing, 'ClashingImplementationCall');
});
});
Expand Down