All articles
Web3 FoundationsSeptember 16, 202610 min read

The Solidity Proxy Pattern: How to Find the Code That Actually Runs

Why a verified contract can contain no functions you recognise, what a proxy really holds, and the exact storage slot that tells you which implementation address calls are going to right now.

By Carlos (Bloqarl)

TL;DR

  • A short verified file with a fallback and almost nothing else is a proxy. The functions you were looking for are not missing, they are at another address.
  • OpenZeppelin's Proxy is sixty nine lines including comments, and the function that says where calls go is a declaration with a semicolon and no body.
  • The proxy file alone can never tell you the destination. That is deliberate: the answer lives in storage, not in source.
  • ERC1967Proxy fills the hole by reading one fixed slot: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc.
  • You can read that slot yourself with one command. It tells you where calls go right now, not where they went last month.

Why does a verified contract have no functions I recognise?

Because you are reading a proxy. It is a contract whose entire job is to catch every call it does not recognise and forward it to code stored at a different address. The functions you expected are all there, in a second contract, and the proxy never mentions its name.

This is the most common reason a competent reader concludes they cannot read Solidity. They open a famous protocol on a block explorer, following the method in how to read a contract on Etherscan, find sixty lines of assembly and a fallback, and assume the hard part is hidden. It is not hidden. It is one address away, and finding it is a short mechanical procedure.

The machinery underneath is delegatecall, covered properly in delegatecall explained. This article is about the shape you are staring at and how to get from it to the code.

What is actually inside a proxy contract?

Three functions and a fallback, sixty nine lines in OpenZeppelin's version including comments. A fallback that catches unmatched calls, a one line helper that decides where to send them, and an assembly block that copies the call data across and hands back whatever comes back.

Start at the bottom of the file, because that is the entry point:

    fallback() external payable virtual {
        _fallback();
    }

That runs when no other function in the contract matches the call. In a proxy, no other function ever matches, because there are no other public functions. Every call lands here.

One step up:

    function _fallback() internal virtual {
        _delegate(_implementation());
    }

Read that line right to left. Work out the implementation address, then delegate to it. Two ideas, one line, and the whole pattern is in it.

The delegation itself is assembly, and the interesting part is short:

            calldatacopy(0x00, 0x00, calldatasize())

            // Call the implementation.
            // out and outsize are 0 because we don't know the size yet.
            let result := delegatecall(gas(), implementation, 0x00, calldatasize(), 0x00, 0x00)

Copy the incoming call data exactly as it arrived, then hand it to the implementation. Nothing is inspected, filtered or renamed. That is why a proxy needs no knowledge of the functions it forwards, and why the file stays sixty nine lines no matter how large the implementation grows.

Why can the proxy file not tell you where the code is?

Because the function that answers that question has no body. In OpenZeppelin's Proxy it is a declaration ending in a semicolon, a named hole for a child contract to fill. The file describes the forwarding perfectly and is silent, by design, about the destination.

Here is the entire function:

    function _implementation() internal view virtual returns (address);

No braces. No return. A signature and a semicolon. This is what makes Proxy an abstract contract, in the sense covered in Solidity inheritance: it inherits nothing unimplemented, but it declares something it never implements, so it cannot be deployed on its own.

For a reader this line is the most useful one in the file. It tells you, on the authority of the source itself, that you have reached the end of what this file can answer. Rereading the assembly harder will not produce an address. A different file fills the hole, and that is the file to find.

Where is the implementation address actually stored?

In one fixed storage slot on the proxy itself, named by the ERC-1967 standard. ERC1967Proxy fills the hole left by Proxy with a single line that reads that slot, and the constant it reads is a hard coded thirty two byte number the whole ecosystem agrees on.

The fill is three lines:

    function _implementation() internal view virtual override returns (address) {
        return ERC1967Utils.getImplementation();
    }

And the thing it calls is one line more:

    function getImplementation() internal view returns (address) {
        return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value;
    }

IMPLEMENTATION_SLOT is where the trail ends, and it is worth quoting with its comment, because the comment explains the number:

    /**
     * @dev Storage slot with the address of the current implementation.
     * This is the keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1.
     */
    // solhint-disable-next-line private-vars-leading-underscore
    bytes32 internal constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

Read the comment literally, because it is a recipe rather than a description. Take the string eip1967.proxy.implementation, hash it with keccak-256, subtract one from the result, and you get that constant. Run it yourself and the hash ends in bbd, the constant ends in bbc. Every ERC-1967 proxy on every chain keeps its implementation address in that same slot, which is exactly why a tool can find it without being told anything about the protocol.

Why a named slot instead of an ordinary state variable?

Because ordinary state variables are numbered from zero upwards, and the implementation's own variables take those numbers when its code runs against the proxy's storage. The named slot is a very large number that counting up from zero never reaches, so the two sets of values sit in different places.

That is the whole reasoning, and it follows directly from how EVM storage layout works. The first state variable a contract declares lives at slot 0, the next at slot 1, and so on in declaration order. Under a proxy, the implementation's code executes but the proxy's storage is the storage being written, so the implementation's first variable claims the proxy's slot 0.

If the proxy had declared its implementation address as a normal state variable, it would also want slot 0. The standard sidesteps that by picking a slot at the far end of a 2^256 space, which counting upward one variable at a time does not reach. It is a filing decision, made once, for the whole ecosystem, and it is why the recipe below works on contracts nobody has ever looked at.

How do you read the implementation address yourself?

Ask the chain for the contents of that one slot. A block explorer's Read as Proxy tab does it for you, and one command line call or one JSON-RPC request does it directly. The answer comes back as a full thirty two byte word, and the address is its last twenty bytes.

The source file tells you this in its own comment, above _implementation:

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

With Foundry installed, that is one command:

cast storage 0xYourProxyAddress \
  0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc \
  --rpc-url https://your-rpc-endpoint

Or straight over JSON-RPC, with no tooling at all beyond curl:

curl -s https://your-rpc-endpoint -X POST \
  -H 'Content-Type: application/json' \
  --data '{"jsonrpc":"2.0","id":1,"method":"eth_getStorageAt","params":["0xYourProxyAddress","0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc","latest"]}'

What comes back is a 32 byte word with twelve zero bytes of padding at the front. The address is the final twenty bytes. Paste those into the explorer, open the Contract tab there, and you are finally looking at the code that runs, ready for the ordinary method in how to read a smart contract.

One thing to hold onto: that read is a snapshot. The slot is written whenever the implementation is replaced, and the write announces itself:

    function upgradeToAndCall(address newImplementation, bytes memory data) internal {
        _setImplementation(newImplementation);
        emit IERC1967.Upgraded(newImplementation);

The event is declared once, in the ERC-1967 interface:

    event Upgraded(address indexed implementation);

So the explorer's Events tab gives you the history the slot does not: every address this proxy has ever pointed at, in order, with the block each change happened in. The slot answers "where do calls go now". The event log answers "where have they gone before, and when did that change".

How do you recognise a proxy in three seconds?

Four signals, any one of which is close to conclusive. A file under a hundred lines at an address holding serious value. A fallback marked payable. A block of inline assembly containing delegatecall. And a contract name, or an inheritance list, containing the word Proxy.

Add one negative signal that is just as reliable: the functions the address is famous for are absent. If an address everyone calls a lending market has no function with borrow in the name, you are at its front door, not the market.

Explorers usually spot this and offer a Read as Proxy tab, and when they do, take it. When they do not, the slot recipe above works regardless, because it does not depend on the explorer recognising anything. It depends only on the contract following ERC-1967, which the overwhelming majority of upgradeable deployments do.

Delete the line and ask what breaks

  • Delete payable from the fallback and the proxy stops accepting calls that carry ETH. Every payable function in the implementation becomes unreachable through the proxy, while still existing perfectly in the implementation's source.
  • Delete _implementation() from _fallback, leaving _delegate with nothing to send to, and the file stops compiling. There is no default destination anywhere in the pattern.
  • Delete the override on ERC1967Proxy._implementation and the compiler rejects it. Proxy declared the hole, and filling it without saying so is not allowed.
  • Change one hex character in IMPLEMENTATION_SLOT and the proxy reads a slot nothing was ever written to, gets zero, and forwards every call to the zero address. The file still compiles, and every function call returns nothing.

Related questions

How do I find the implementation address of a proxy? Read storage slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc on the proxy address with eth_getStorageAt or cast storage, and take the last twenty bytes of the returned word. Most explorers also expose it through a Read as Proxy tab.

Why is the proxy contract's source so short? Because it holds no business logic at all. Its whole job is to forward unmatched calls elsewhere, which takes one fallback, one lookup and one assembly block. The length of the file is unrelated to the size of the protocol behind it.

What is the ERC-1967 storage slot? A fixed location agreed across the ecosystem for storing a proxy's implementation address, equal to the keccak-256 hash of the string eip1967.proxy.implementation minus one. ERC-1967 defines companion slots for the admin and beacon addresses in the same way.

Can I read a proxy's implementation without any tools? Yes. A block explorer's Read as Proxy tab resolves it in the browser, and the Events tab shows every Upgraded event the proxy has emitted, which gives you both the current implementation and the history of previous ones.

Does the implementation address ever change? It can, and that is the point of the pattern. Each change writes the slot and emits Upgraded. A slot read tells you the destination at the moment you asked, so anything you concluded from the implementation's code is scoped to that version.

Where do a proxy's state variables actually live? In the proxy's own storage. The implementation supplies the code, the proxy supplies the storage, so the implementation's variables occupy the proxy's numbered slots counting up from zero while the implementation's own storage stays empty.

Where to go next

A proxy is not an obstacle to reading, it is one extra hop with a fixed procedure: recognise the shape, read the slot, open the address you get back, then read normally. After the first time it costs thirty seconds, and it turns the commonest dead end on a block explorer into a routine step.

The mechanism that makes the whole arrangement possible is one opcode, and it is worth understanding on its own terms rather than as proxy trivia: delegatecall explained covers what it actually does to code and storage. For why the slot numbering matters in the first place, EVM storage layout is the companion piece.

Tagged

SoliditySmart ContractsLearn to Code