Services Approach Projects Research About Request Engagement
EVM & Solidity

Solidity Assembly, Part 3: Reading a Contract Nobody Explained to You

The final part of a four part series on Solidity assembly. Every command here is run against live mainnet contracts holding real money. Who can replace the code behind Aave's lending pool, why the answer is not in its source, and how to read any account's token balance straight out of raw storage and check your own arithmetic against the contract.

Intermediate Solidity 10 min read Aug 17, 2026

This is the last of four parts. Part 1 compiled a contract and read its opcodes. Part 2 covered writing assembly yourself.

This part emulates the situation you're actually in when you look at somebody else's contract. Everything below is run against live mainnet, and every number is real output rather than an example.

Start with the question that matters.

Who can replace this code?

Take Aave's v3 lending pool at 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2. Billions of dollars of deposits sit behind it.

That address does not contain Aave's lending logic. It is a proxy. It holds almost no code of its own and forwards every call to another contract, and the address it forwards to can be changed.

So the question is not what the source says the pool does. The question is what code runs there right now, and who is allowed to swap it. Neither is answerable from Solidity, because both live in storage.

Reading it

output
cast storage 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 \ 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

Real output:

output
0x000000000000000000000000728a138a4823392c2efa55e028d434f526fe03cf

An address is 20 bytes and a storage slot is 32, so the value is left-padded with twelve zero bytes. Strip them and you have the answer:

output
implementation = 0x728a138a4823392c2efa55e028d434f526fe03cf

That is the contract actually executing when anyone deposits into Aave. Check the sizes and the split is obvious:

output
proxy 2,400 bytes implementation 21,146 bytes

The proxy is a doorway. The building is somewhere else.

Why that specific slot?

That long hex number is not arbitrary.

A proxy runs the implementation's code in the proxy's own storage. So if the proxy kept the implementation address in slot 0, and the implementation declared any state variable of its own, that variable would also be slot 0. One would overwrite the other.

ERC-1967 solves this by choosing a slot nothing else can land on. The address is stored at:

output
keccak256("eip1967.proxy.implementation") - 1 = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

Two reasons that works. The compiler hands out slots starting at 0 and counting up, so it will never reach a number that large.

There are two more standard slots, derived the same way:

output
admin keccak256("eip1967.proxy.admin") - 1 = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 beacon keccak256("eip1967.proxy.beacon") - 1 = 0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50

I recomputed all three rather than copying them from the spec, and they match.

The admin slot is empty, and that is not good news

Read Aave's admin slot and you get nothing:

output
cast storage 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 \ 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103 0x0000000000000000000000000000000000000000000000000000000000000000

An empty standard slot means the authority is not there. It does not mean the authority does not exist.

Aave's proxy stores its admin as an immutable. Immutables do not use storage at all. The compiler reserves space for them inside the contract's code, and the constructor writes the value into those bytes before the code is deployed. So the answer is sitting in the bytecode, in plain sight, if you know to look:

output
cast code 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2 | grep -o 2f39d218133afab8f2b819b1066c7e434ad94e9e

That address appears six times in the proxy's code. It is Aave's PoolAddressesProvider, and it is what controls upgrades.

The general lesson: when a standard slot is empty, the authority moved, and the two places to look next are the contract's own code and a different contract entirely.

Not every proxy follows the standard

ERC-1967 was finalized in 2019. Plenty of important contracts predate it.

USDC is one. 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 is a proxy, but its ERC-1967 implementation slot is empty because it uses the older ZeppelinOS convention:

output
keccak256("org.zeppelinos.proxy.implementation") = 0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3
output
cast storage 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ 0x7050c9e0f4ca769c69bd3a8ef740bc37934f8e2c036e5a723fd8ee048ed3f8c3 0x00000000000000000000000043506849d7c04f9138d1a2050bbf3a0c054402dd

So the code behind every USDC transfer lives at 0x43506849d7c04f9138d1a2050bbf3a0c054402dd, and Circle can change it.

Checking the standard slot first is still right. It just means an empty result sends you looking rather than telling you there is nothing to find.

Reading a balance out of raw storage

Proxy slots are fixed constants. Now the general skill: finding a value the compiler placed dynamically.

Take a token balance. A mapping(address => uint256) balances could hold billions of entries, so the compiler cannot lay them out sequentially. It does not store the keys at all. Instead it computes a location from the key:

output
location = keccak256(key padded to 32 bytes ++ slot number as 32 bytes)

That is the whole rule. Hash the key together with the slot the mapping was declared at, and the result is where the value lives.

USDC declares its balances mapping at slot 9. Take Binance's hot wallet at 0x28C6c06298d514Db089934071355E5743bf21d60:

output
key as 32 bytes: 0x00000000000000000000000028c6c06298d514db089934071355e5743bf21d60 slot as 32 bytes: 0x0000000000000000000000000000000000000000000000000000000000000009 keccak256(key ++ slot) = 0x07081a045c3dbf2e63b62a407ef205e7586e2629d2e2b95ff093308ca0ff3727

Read that slot:

output
cast storage 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ 0x07081a045c3dbf2e63b62a407ef205e7586e2629d2e2b95ff093308ca0ff3727 \ --block 25775301 0x0000000000000000000000000000000000000000000000000000000bd59cdc78

That is 50,828,467,320. USDC has six decimals, so 50,828.47 USDC. Now check the arithmetic against the contract itself:

output
cast call 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \ "balanceOf(address)(uint256)" 0x28C6c06298d514Db089934071355E5743bf21d60 \ --block 25775301 50828467320

Identical. That is the point of the exercise: you computed where a value lives from first principles and read it without calling any function the contract chose to expose.

Both commands are pinned to block 25,775,301 with --block. A hot wallet's balance changes every few minutes, so without the pin the two reads would disagree simply because time passed between them. Anyone can reproduce these exact numbers.

Two things that follow. A mapping's declared slot 9 stays permanently empty, reserved so neighbours cannot collide with it, so scanning for non-zero slots will never reveal a mapping exists. And nested mappings apply the rule twice: allowance[owner][spender] is a hash of the spender against the result of hashing the owner, not one hash of both.

Finding functions without an ABI

The last piece. A contract's code begins with a dispatcher that compares the first four bytes of your calldata against a list of known values and jumps accordingly. Those four bytes are the function selector, the first four bytes of keccak256 over the function's signature:

output
transfer(address,uint256) 0xa9059cbb balanceOf(address) 0x70a08231 upgradeTo(address) 0x3659cfe6 implementation() 0x5c60da1b

The selectors are sitting in the bytecode as PUSH4 instructions, opcode 0x63. Pull them out and you have the contract's external surface without an ABI:

output
cast code <address> | grep -oE '63[0-9a-f]{8}'

Run that against USDC's proxy and the real ones that come back include:

output
0x3659cfe6 upgradeTo(address) 0x4f1ef286 upgradeToAndCall(address,bytes) 0x5c60da1b implementation()

Seeing upgradeTo in a dispatcher tells you the contract is upgradeable before you read a line of source.

That grep also returns 0x616c6c20, 0x68616e67 and 0x6f6e7472, which are not selectors at all. They are fragments of revert strings that happen to sit after a 0x63 byte: "all ", "hang", "ontr". A naive byte scan cannot tell code from data. Resolve everything through cast 4byte and discard what does not come back, or read the dispatcher properly rather than grepping the whole file.

Securing the unseen

Four questions you can now answer about any address, without an ABI, without verified source, and without anyone's cooperation.

Is it a proxy, and what does the implementation slot hold right now? If that slot is empty, is it using an older convention, or has the authority moved into the code? Who or what is in the admin slot, and is that an ordinary wallet or a contract with its own rules? Does the dispatcher contain upgradeTo or upgradeToAndCall?

An audit tells you what a version of the source did on the day somebody read it. Storage tells you what the contract points at right now.

Learn more at 0xhades.io/research

Sources

8 references · 5 primary
  1. ERC-1967: Proxy Storage Slots The implementation, admin and beacon slot derivations as keccak256(...) - 1. All three constants were recomputed for this article and match the spec byte for byte. eips.ethereum.org primary
  2. Solidity documentation, Layout of State Variables in Storage Sequential allocation from slot 0, the mapping location rule keccak256(h(k) . p), and that a mapping's own declared slot is never written. docs.soliditylang.org primary
  3. Solidity documentation, Constant and Immutable State Variables Immutables occupy no storage slot; space is reserved in the code and the constructor patches those bytes before the runtime code is deployed. This is why Aave's admin is absent from storage. docs.soliditylang.org primary
  4. Solidity documentation, Contract ABI Specification The function selector as the first four bytes of the Keccak-256 hash of the canonical signature. docs.soliditylang.org primary
  5. Foundry Book, cast storage Command syntax for cast storage, cast code, cast call and cast 4byte, used throughout. book.getfoundry.sh
  6. Ethereum JSON-RPC specification eth_getStorageAt and eth_getCode return the account's current storage word and deployed runtime code respectively. ethereum.github.io primary
  7. Aave v3 Pool proxy on Etherscan The live contract read throughout. Implementation, empty admin slot, code sizes and the embedded PoolAddressesProvider were all read directly from mainnet on 17 August 2026. etherscan.io
  8. USDC proxy on Etherscan The ZeppelinOS implementation slot, its resolved implementation address, and the dispatcher reads. The balance figures are pinned to block 25,775,301. etherscan.io