Misclassification of read and write methods on Etherscan of an OpenZeppelin-based upgradeable token

I have just followed this OpenZeppelin tutorial for creating an upgradeable contract.

The only difference I made is that rather than use Box.sol contract (see tutorial), I wrote an ERC-20 token logic along with an access control mechanism. The code of my token appears below.

I have deployed the contract to Rinkeby.

Proxy: 0x9d85447F7616Ee1FD720cDc844Ead7C12F786457

Logic: 0x19fFeaB0141AF5765216a3b19958F68565ca6d4e

My concern is that Etherscan, when looking at the proxy's 'Read Contract' says: "Sorry, there are no available Contract ABI methods to read. Unable to read contract info". I noticed that some of the 'Write Contract' methods should be classified as reads.

I can still programmatically interact with the proxy as I normally do, but would prefer to implement the contract so that Etherscan properly recognizes which methods are 'reads' and which are 'writes'. How can I do this?

:1234: Code to reproduce

pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol";

contract SimpleToken is Initializable, ERC20Upgradeable, AccessControlUpgradeable {

    bytes32 public constant USER_ROLE = keccak256("USER_ROLE");

    function initialize() public initializer {
        _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
        __ERC20_init("SimpleToken","SMT");
        _mint(address(0xeF008d5228cEFD52d806876586aFf7d0371Eb91B), 32 * 10 ** decimals());
    }
    
    function mint(address to, uint256 amount) public {
        require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "Must be ADMIN.");
        require(hasRole(USER_ROLE, to), "Recipient must be USER.");
        _mint(to, amount);
    }

    function burn(address from, uint256 amount) public {
        require(hasRole(DEFAULT_ADMIN_ROLE, msg.sender), "Must be ADMIN.");
        _burn(from, amount);
    }

    function transfer(address to, uint256 value) public override returns (bool) {
        require(hasRole(USER_ROLE, to), "Recipient must be USER.");
        return super.transfer(to, value);
    }

    function transferFrom(address from, address to, uint256 value) public override returns (bool) {
        require(hasRole(USER_ROLE, to), "Recipient must be USER.");
        require(hasRole(USER_ROLE, msg.sender), "You must hold USER status to send tokens.");
        return super.transferFrom(from, to, value);
    }
}

if you are referring to the admin()- and implementation()-functions, they are meant to be "write" functions. Not sure whats the reasoning behind it, but as you can see from the implementation they modify variables:

/**
     * @dev Returns the current admin.
     *
     * NOTE: Only the admin can call this function. See {ProxyAdmin-getProxyAdmin}.
     *
     * TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using the
     * https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
     * `0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103`
     */
    function admin() external ifAdmin returns (address admin_) {
        admin_ = _getAdmin();
    }

    /**
     * @dev Returns the current implementation.
     *
     * NOTE: Only the admin can call this function. See {ProxyAdmin-getProxyImplementation}.
     *
     * TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using the
     * https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
     * `0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc`
     */
    function implementation() external ifAdmin returns (address implementation_) {
        implementation_ = _implementation();
    }

Thank you - I was expecting these two methods to be reads, so I appreciate this. However, your point raises an obvious question: how can the public easily determine what the admin and implementation addresses are? It can be found by examining blockchain history, but this is not user friendly at all.

A more technically savy user could make an RPC-call as mentioned in the code comment:

TIP: To get this value clients can read directly from the storage slot shown below (specified by EIP1967) using the
https://eth.wiki/json-rpc/API#eth_getstorageat[`eth_getStorageAt`] RPC call.
`0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103`

a less technically savy user can still check the implementation address on etherscan, after the contract has been correctly marked as proxy. I tried doing that on your contract, now you can see the implementation contract under "Read as Proxy" : https://rinkeby.etherscan.io/address/0x9d85447F7616Ee1FD720cDc844Ead7C12F786457#readProxyContract

The admin address does not seem to be shown on etherscan though

2 Likes

To provide some context:

  • When you look at the proxy address on Etherscan, "Read Contract"/"Write Contract" are for the proxy contract's own functions.

  • To interact with your implementation's functions through the proxy, mark the contract as a proxy (if you use the verify task in the Hardhat upgrades plugin, it will do this automatically for you) then use "Read as Proxy"/"Write as Proxy" as @minh_trng mentioned above.

  • Admin related functions in TransparentUpgradeableProxy are meant to be accessible by the admin only, whereas the fallback function is accessible by anyone EXCEPT the admin. This is to avoid any possible function selector clashes as explained in this blog post.

  • Alternatively, consider UUPS proxies where upgrade related functions and who can perform an upgrade are part of the implementation. Then you can just use Etherscan to figure out, for example, who owns the contract or who has a specific role.

2 Likes