Help understanding UUPSProxies, Ownership and Pausability

Hello,

I have some questions about the UUPS proxy pattern after reading through the documentation and implementing what I think is correct. First, here's a simplified version of my contracts:

Implementation/(Logic) Contract:

pragma solidity ^0.8.21;

import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";


contract MyLogic is Initializable, OwnableUpgradeable, PausableUpgradeable, UUPSUpgradeable {

    /**
     * see: https://forum.openzeppelin.com/t/what-does-disableinitializers-function-mean/28730/4
     * To summarize, the implementation contract (logic contract) should look similar to the 
     * first post. It makes sense to have a both _disableInitializer constructor and initialize 
     * function. The constructor is to disable the **implementation** contract from being initialized, 
     * while the initializer function is to allow the **proxy** to be initialized."
     */
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
    
    function initialize(
        address initialOwner_
    ) initializer public {
        __Ownable_init(initialOwner_);
        __Pausable_init();
        __UUPSUpgradeable_init();
    }

    function pause() public onlyOwner {
        _pause();
    }

    function unpause() public onlyOwner {
        _unpause();
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}
}

Proxy Contract:

pragma solidity 0.8.21;

import "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract MyProxy is ERC1967Proxy {
    constructor(
        address implementation_
    ) ERC1967Proxy(implementation_, abi.encodeWithSignature(
            "initialize(address)",
            msg.sender
        )) {
    }

    function implementation() public view returns (address) {
        return _implementation();
    }

    receive() external payable {}
}

Now, here's how I'm deploying them for testing (using a local hardhat node w/ brownie connected to it):

  1. Deploy the MyLogic contract from accounts[0]
  2. Deploy the MyProxy contract from accounts[1], passing it the logic address from (1)
  3. Create a generic Contract instance (this is a brownie thing) with Contract.from_abi("MyProxyContract", proxy.address, logic.abi) (it should be mostly clear what's going on here, let me know if not). This is the "real" proxy that I will interact with

In general, this is how I believe the contracts should be deployed, because if I do this I can interact w/ the address from (2) (via the contract instance created in step (3)), and see all the functions/vars from (1). Making changes to the contract works as expected, where the state of the proxy is updated.

Now, my questions/observations:

  1. I noticed that after deploying the logic contract (1), I can call .owner() on it (presumably from the inherited OwnableUpgradeable functionality), but the owner is the zero address. However, if I look at the _owner variable on it, it's set to accounts[0], which is what I would have expected .owner() to return. If I call .owner() on the contract created in step (3), I get the accounts[1] address as expected.

    The proxy portion seems right, but as far as the logic part, am I doing something wrong or am I misunderstanding something? Shouldn't the owner of the logic contract be accounts[0]? (Note accounts[0] is not the zero address...it's the first hardhat account in the local hardhat node)

  2. As you can see I wrote an implementation() function on my proxy to easily return the logic address. Is there anything wrong w/ doing this? Is there a better way to get the address? I noticed that I cannot call .implementation() on the Contract instance created in step (3), presumably because it's using the abi of the logic contract which doesn't have an implementation() function...but how would I setup my proxy so that it's easy to get the implementation address (not the storage slot, the actual address) off it?

  3. Finally, if I call .pause()on the contract from step (3) (the "real" proxy), it works as expected. I can check if it's paused, unpaused, and change it as the owner, etc. I can also deploy any number of additional proxies like in step (2), from any accounts, connect them to the same logic address, and it seems it all works right. Ownership/pausability of each proxy is enforced as expected.

    However, I don't see an obvious way to pause the logic contract, since its owner is the zero address and therefore I can't call .pause() on it since it is an onlyOwner function. Is there a way to implement pausability at the implementation level, such that as the owner of the logic contract I could pause that contract and stop all proxies from interacting with it?

  4. Finally, in general, is there anything obvious that I'm doing wrong here? Am I misunderstanding anything about the implementation of this UUPS pattern?

Thanks so much for reading and for any insight you can provide!

Hi @angryetherpotamus, thanks for the detailed and well described questions :slight_smile:

Here are my responses:

  1. I would expect owner() and _owner at the logic address to be 0, because the logic contract itself is not initialized so the default value of the _owner variable is the 0 address. Are you using OpenZeppelin Contracts v5? For example, see https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/v5.0.2/contracts/access/OwnableUpgradeable.sol -- there is no constructor for the upgradeable variant, and the initializer is disabled (as you correctly did using _disableInitializers) for the logic contract itself.
  2. It is not recommended to add public functions in the proxy, due to possible function selector clashes between the proxy and logic contract. See https://blog.nomic.foundation/malicious-backdoors-in-ethereum-proxies-62629adf3357 for a detailed explanation.
    Usually, you should not need to reimplement a proxy contract. You can just deploy one of the proxy contracts from OpenZeppelin Contracts directly, such as ERC1967Proxy.
    You can read the storage slot to get the address, for example by using tools such as Upgrades Plugins.
  3. The implementation contract itself shouldn't need to be paused, because it is expected to be disabled from initialization and therefore stateless. See What does `_disableInitializers();` function mean? - #2 by ericglau. Even if you initialized it, the state of the implementation contract would be unrelated to the states of the proxies! (For example, the _paused boolean's state is not synchronized between the implementation and proxy).
    To do what you described, you would need to explore a different pattern.
  4. Mainly addressed in my other points above, but let me know if you have further questions.
1 Like

Hi @ericglau,

Thanks so much for the response! I think I understand better after spending the last few days learning, and your responses make sense now in that context. To answer your question on v5, yes I am using v5.0.2 and the lack of initialization does explain the owner() function returning the zero address on the Logic contract. It turns out that _owner returning the "real" owner is actually an illusion: _owner when called in the Brownie console is actually a class attribute on Brownie's ProjectContract class, not the _owner storage variable from the contract! However, the .owner() function is a callable alias on that same class to the actual owner() function on the contract, and it returns the actual owner which - as you correctly pointed out - is the zero address, since the contract is not initialized.

Anyway, that said, for my specific use case I opted to call _transferOwnership(msg.sender) in the constructor of the logic contract to actually set the owner before calling _disableInitializers(). This allows me to have real ownership over any onlyOwner functions on the logic contract which should modify the state of the logic contract, if needed. Most of the time onlyOwner functions will only be executed in the context of the proxies using this implementation contract, but it feels a bit weird/restrictive to have an implementation contract with no owner, even though it will almost always be used in the context of a proxy's owner. Can you see any issues w/ this thought process? There might be unexpected vulnerabilities created by allowing the implementation contract's state to be modified that I'm not seeing here, so I'd be open to hearing your thoughts on that since technically the contract doesn't really need to be owned by anyone in order to function with respect to proxies.


As far as making the implementation address easily accessible from the smart contract, I moved the implementation function to the implementation contract as follows:

function implementation() public view whenNotPaused onlyProxy returns (address) {
    return ERC1967Utils.getImplementation();
}

I think that this allows me to safely call .implementation() on any proxy using this logic contract and introspect the implementation address using an audited function from OpenZeppelin. Would you agree this is safe? Since I am doing all this in the context of a python application, I'm trying to avoid too much integration w/ javascript/hardhat plugins, so this is an alternative solution and it seems to work fine, but as with all things EVM-based I am skeptical of its security :slight_smile:


Finally, you 100% nailed the last point and I came to the same conclusion after my own experimentation too :slight_smile: So, I opted to switch to a beacon pattern and implemented "global pausability" by:

  1. Making the UpgradeableBeacon Pausable
  2. Overriding the BeaconProxy's _implementation method:
pragma solidity ^0.8.21;

import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
import "./MyBeacon.sol";

contract MyProxy is BeaconProxy {
    constructor(
        address beaconAddress_,
        ...
    ) BeaconProxy(
        beaconAddress_,
        abi.encodeWithSignature(
            ...
        )) {}

    function _implementation() internal view virtual override returns (address) {
        MyBeacon beacon = MyBeacon(_getBeacon());
        require(!beacon.paused(), "Beacon is paused");
        return super._implementation();
    }
}

In the above, MyBeacon is a simple UpgradeableBeacon which also inherits Pausable and has the standard pause/unpause methods. When I pause the beacon, any proxy trying to get the implementation address will revert due to the overridden internal _implementation function. Does this make sense? As a nice side effect, it also makes all my proxies upgradeable in a single transaction :grin:

Thanks again for your response - definitely open to hearing your thoughts on my reply!

  1. I think it's fine to set a specific owner for the logic contract. For example, a previous UUPS vulnerability could have been avoided if an owner was set.
  2. I don't see an issue with this. Although if using python, perhaps you could just use https://web3py.readthedocs.io/en/latest/web3.eth.html#web3.eth.Eth.get_storage_at to read storage slots.
  3. It could be easy to get around the paused beacon if someone deploys a further customized proxy though. And this has additional overhead for each call.
1 Like

Thanks again for the responses. On your 3rd point, could you expand a little? I'm not sure who the "someone" would be in this situation. If someone else deploys their own proxy connected to my beacon solely to be able to interact with my implementation contract (which would update the state of their own proxy), is this really a concern assuming the implementation contract is secure/sound? My goal with "global pausability" in this sense is really only to temporarily pause my own proxy contracts during upgrades to the implementation contract which would only be for a few seconds at a time and mostly done as a way of making upgrades a bit more predictable (e.g., pause beacon -> upgrade implementation -> unpause beacon -> business as usual)

Does that make sense? Either way, thank you for your insights...very helpful!! :smiley:

Makes sense to me, although it shouldn't be necessary to pause the beacon during an upgrade. Also note that this does not replace a security audit, and you should thoroughly review/test your changes especially since the ideas above are not part of our usual proxy patterns.