What's the correct approach to upgrade a contract using ERC7201

I want to make sure that I understand correctly the namespaced storage layout pattern.

Let's say I have this contract created using ERC1967Proxy.

pragma solidity ^0.8.20;

contract Example is UUPSUpgradeable, Initializable {
    /// @custom:storage-location erc7201:example.main
    struct MainStorage {
        uint256 x;
        uint256 y;
    }
}

The appropriate way to add new storage variables would be to simply add them in the MainStorage struct?

pragma solidity ^0.8.20;

contract ExampleV2 is UUPSUpgradeable, Initializable {
    /// @custom:storage-location erc7201:example.main
    struct MainStorage {
        uint256 x;
        uint256 y;
        // new state variable
        address z;
    }
}

Your question is incomplete, since there are no state (storage) variables in the given code.

There is only a type (struct) definition.

Hence the answer depends on where exactly a state variable of that type is allocated in the contract.

For example, in this contract, it is safe to extend the MainStorage struct:

contract ExampleV2 is UUPSUpgradeable, Initializable {
    uint256 private a;
    uint256 private b;
    MainStorage private c;
}

While in this contract, it is not safe to extend the MainStorage struct:

contract ExampleV2 is UUPSUpgradeable, Initializable {
    uint256 private a;
    MainStorage private b;
    uint256 private c;
}

My understanding was that with ERC7201 the contract state is handled through such kind of structs which are located on certain storage locations (to avoid collisions), so there are no "standalone" state variables.

Check the ERC20 implementation for example

AFAIK, this applies for a mapping of structs and for a dynamic array of structs (just like it does for a mapping of any other type and for a dynamic array of any other type), but it does not apply for a plain (single variable) struct, and it does not apply for a static array of structs.

This is because the slots for the contents of mappings and dynamic arrays are not calculated directly based on the declaration-order of such variables within the contract (kinda like "pointers" if you will).

For all other variable types, the slots increment respectively to the declaration-order within the contract.

Again - AFAIK.

I'm now more confused :smiley:

How would you add new state variable in the Upgradable ERC20?

I don't know, but there are no state variables in this contract to begin with (i.e., there is only one struct definition, just like the example in your original question).

Also, as far as I understand, you should not change the ERC20Upgradeable contract directly, but rather - inherit it and add whatever you want in the inheriting contract (then your question "moves" to the inheriting contract, which you have not provided here, hence a little hard to answer).

I might be wrong in some of what I've mentioned here, as well as in my previous comments, so perhaps it's best to wait for an OZ member to give some additional input...

I think you are wrong. There are state variables defined by the struct and are accessed through:

 function _getERC20Storage() private pure returns (ERC20Storage storage $) {
        assembly {
            $.slot := ERC20StorageLocation
        }
    }

No, there are no state variables in this contract, only a struct definition!

This function takes a storage input argument, which means that when called, it receives that input as a "pointer" to storage, allowing it to modify the "pointed" data directly.

But that input argument isn't explicitly declared in this contract.

It is assumed to already reside in some storage slot, namely:

// keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20")) - 1)) & ~bytes32(uint256(0xff))
bytes32 private constant ERC20StorageLocation = 0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00;

Mate, the function doesn't accept any arguments. Why do you reply to a topic you are not confident enough?

Can someone elaborate on my original question, please?

Sorry, my bad, it doesn't take an input value, it returns an output value.
The rest of my answer (with regards to this contract, as well as to your original contract) stands as is.
Namely, that there are no state variables in the contract, there is only a struct definition.

And that's the last time I'm helping you out.
Next time do some basic learning of the difference between type definition and variable instantiation, which you obviously lack the understanding of!

Well... there are no instantiated state variables in the traditional way...but you have state management -reading and writing directly to the storage slot... That's far away from my original question about upgrades.

The original question shows a contract with no state variables, and asks if it is safe to add a member to a struct definition, without giving any additional information about the actual instantiation of state variables of this type within that contract.

The original answer tells you that the answer to that depends on the actual state variable being instantiated, including its location within the contract, whether it is used in a mapping or as a "plain" variable, etc.

At this point in the thread, with your contract's code being the only point of reference, there is indeed no storage usage, which is exactly what I've pointed out in the original answer.

Further down the thread, you move to asking about an OZ contract, which despite accessing a storage slot at a known location (as I've pointed out in this comment, still doesn't explicitly declare any state variables in the given scope.

And the fact that this contract implements a function which performs storage-access, has nothing to do with your original question, nor with my original answer to it.

@kraikov I'm assuming you have the storage getter according to ERC7201

Adding the variable as you do in the same storage is perfectly valid.

An alternative would be to define an additional struct, storage location hash and getter.