eth_getStorageAt returns the raw 32-byte word stored at a given slot, but Solidity packs multiple variables into one slot and derives mapping and dynamic-array element slots with keccak256, so a naive slot-0 read often returns an unexpected value. This article explains the documented layout rules from the Solidity documentation, shows how to compute declaration-order and derived slots, and provides runnable Node.js examples that mask and shift packed values. It also covers decoding bools, addresses, signed integers, and long strings, verifying a decode against a public view function with eth_call, the proxy storage-layout hazard, and the tradeoff against eth_getProof. The goal is to turn slot arithmetic from guesswork into a checked, reproducible method.
Why a naive slot-0 read returns unexpected data
The Ethereum JSON-RPC specification defines eth_getStorageAt as taking a 32-byte position quantity, a block tag, and returning a 32-byte data word. It does not return a typed value, a variable name, or any delimiter that tells you where one Solidity variable ends and the next begins. The method is a raw window into contract storage, not a field-aware getter.
Solidity's documented layout of state variables in storage places state variables in 32-byte slots in declaration order, and when multiple values fit they are packed into a single slot starting from the low-order bits. That means the variable you want may share slot 0 with several others, so the returned word must be masked and shifted rather than read as a number.
This is the most common source of confusion: a developer reads slot 0, sees a large integer, and assumes the RPC endpoint is wrong. In practice the endpoint returned exactly what the contract stored; the reader simply did not account for packing. The authoritative rules are in the Solidity documentation at https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html and the method semantics are in the Ethereum JSON-RPC specification at https://ethereum.org/en/developers/docs/apis/json-rpc/#eth_getstorageat.
- eth_getStorageAt returns a 32-byte hex string, never a decoded Solidity value.
- Packing means one slot can hold several variables, ordered from the low-order end.
- There is no field delimiter in the response, so decoding is your responsibility.
The packing rule stated operationally
The Solidity documentation on the layout of state variables in storage specifies the packing rule. Values are packed from the low-order end of the slot. The first declared variable of a packing group occupies the least-significant bytes, and subsequent variables fill upward. A uint128 followed by another uint128 therefore returns a single 32-byte word whose low 16 bytes are the first variable and high 16 bytes are the second.
Because offsets depend on declaration order, reordering declarations changes every offset in the packing group. A uint8, uint8, uint128 sequence packs into one slot with the first uint8 in the lowest byte, the second in the next byte, and the uint128 in the upper 16 bytes. If you move the uint128 to the front, the two uint8 values shift to higher bytes and your old mask silently returns the wrong number.
The practical consequence is that any hard-coded mask or shift is tied to a specific declaration order and compiler version. Treat it as a derived value that must be recomputed whenever the contract source changes, not as a stable constant.
- First declared variable of a packing group = least-significant bytes.
- Later variables fill toward the most-significant end of the same slot.
- Reordering declarations invalidates every previously computed offset.
Slot arithmetic for mappings and dynamic arrays
The Solidity storage-layout reference defines the derivation. Mappings and dynamic arrays do not store their elements in counted slots. A mapping occupies a slot that holds only a seed, and an element's slot is keccak256(abi.encode(key, slot)). A dynamic array occupies a slot that holds its length, and an element's slot is keccak256(abi.encode(slot)) + index. These slots are derived, not counted, so you cannot find them by adding one to the declaration slot.
The derivation is deterministic and reproducible in JavaScript with a keccak256 implementation. The key detail is that the mapping key and the slot number are both encoded as 32-byte values before hashing, so a uint256 key and a uint256 slot are concatenated as two 32-byte words. For dynamic arrays, the base slot is hashed alone and the index is added to the resulting big integer.
This is why a mapping read at the declaration slot returns zero or a seed rather than the value you expect. The declaration slot is metadata; the element lives at a hash-derived location that depends on the key.
- Mapping element slot = keccak256(abi.encode(key, slot)).
- Dynamic array element slot = keccak256(abi.encode(slot)) + index.
- Both are derived, so they cannot be reached by simple addition.
Decoding the 32-byte return value by Solidity type
eth_getStorageAt returns a 32-byte hex string regardless of the variable's type, so decoding depends on the Solidity type you expect. A bool is 0x00...01 for true and 0x00...00 for false. An address occupies the low 20 bytes, so the meaningful value is the last 40 hex characters. A signed integer needs two's-complement interpretation, which means a value with the high bit set represents a negative number.
Strings and bytes longer than 31 bytes use a length-plus-data scheme: the slot holds a length field, and the actual data lives in separate slots derived from keccak256 of the base slot. A single eth_getStorageAt call cannot retrieve the full string; you must read the length, then read the data slots, then assemble the bytes.
For values 31 bytes or shorter, Solidity stores the data in the slot itself with the low bit of the last byte used as a flag, so short strings are readable in one call but still require careful decoding. The safest approach is to decode against a known contract and confirm with a public view function.
- bool: 0x00...01 or 0x00...00.
- address: low 20 bytes, last 40 hex characters.
- signed integer: two's-complement interpretation.
- long string/bytes: length in one slot, data in keccak256-derived slots.
Runnable Node.js example: reading a slot and decoding a packed pair
The following example reads a slot, converts the result to a BigInt, and decodes a packed uint128/uint128 pair with an explicit mask and shift. It prints both the raw hex and the decoded values so you can check the arithmetic against a known contract. Replace the RPC URL and contract address with your own values.
The mask uses (1n << 128n) - 1n to isolate the low 128 bits, and the shift uses >> 128n to extract the high 128 bits. This mirrors the documented packing rule where the first declared variable occupies the low-order bytes.
const { ethers } = require('ethers');
async function main() {
const provider = new ethers.JsonRpcProvider('https://your-rpc-endpoint');
const address = '0xYourContractAddress';
const slot = 0;
const raw = await provider.send('eth_getStorageAt', [address, '0x' + slot.toString(16), 'latest']);
console.log('raw:', raw);
const word = BigInt(raw);
const mask128 = (1n << 128n) - 1n;
const low = word & mask128;
const high = word >> 128n;
console.log('low (first declared):', low.toString());
console.log('high (second declared):', high.toString());
}
main().catch(console.error);Runnable Node.js example: deriving a mapping element slot
This example derives a mapping element slot in JavaScript with a keccak256 implementation and reads it. It uses ethers to encode the key and slot as 32-byte values, concatenates them, and hashes the result. The same pattern applies to dynamic arrays, where you hash the base slot and add the index.
The template is intentionally explicit so you can adapt it to any key type. For a mapping at slot 3 with an address key, the encoded key is left-padded to 32 bytes and the slot is also 32 bytes, then keccak256 is applied to the concatenation.
const { ethers } = require('ethers');
async function main() {
const provider = new ethers.JsonRpcProvider('https://your-rpc-endpoint');
const address = '0xYourContractAddress';
const mappingSlot = 3;
const key = '0xYourKeyAddress';
const encoded = ethers.concat([
ethers.zeroPadValue(key, 32),
ethers.zeroPadValue('0x' + mappingSlot.toString(16), 32)
]);
const elementSlot = ethers.keccak256(encoded);
console.log('element slot:', elementSlot);
const raw = await provider.send('eth_getStorageAt', [address, elementSlot, 'latest']);
console.log('raw value:', raw);
console.log('decoded:', BigInt(raw).toString());
}
main().catch(console.error);Verifying a decode against a public view function
The fastest way to catch a layout mistake is to read the same value through a public view function with eth_call and compare. If the contract exposes a getter, call it and compare the returned value to your decoded slot. A mismatch means your slot arithmetic, mask, or shift is wrong, or the contract uses a proxy with a different storage layout.
This turns an assumption about layout into a checked fact. It also catches compiler-version differences and optimization effects that change packing. The eth_call state override simulation page covers related simulation techniques, and the eth_getProof and account/storage proofs page explains how to verify storage without trusting the endpoint.
For a reproducible check, record the raw slot value, your decoded value, and the view-function result in a table. If they disagree, inspect the declaration order and the compiler version before changing the mask.
- Call the public getter with eth_call and compare to your decode.
- Mismatch indicates wrong slot, mask, shift, or proxy layout.
- Record raw, decoded, and expected values for each check.
The proxy storage-layout hazard
In a proxy pattern, the implementation's declaration order does not match the proxy's storage. The readable slots are determined by the proxy's layout, which typically reserves the first slots for admin, implementation address, and other proxy state. Slot arithmetic must be taken from the deployed storage layout rather than the newest implementation source.
This is a common failure mode: a developer reads the implementation source, computes slot 0, and gets the proxy admin address instead of the intended variable. The fix is to obtain the storage layout from the proxy's compiler output or from a verified storage layout tool, and to account for any gaps or reserved slots.
If you are integrating with a proxy, treat the storage layout as part of the deployed contract's interface. The RPC endpoints guide (RPC Assistant) and the API service pages describe how to connect to networks where these contracts are deployed.
- Proxy layout, not implementation source, determines readable slots.
- Reserved slots for admin and implementation shift every variable.
- Use verified storage layout output for the deployed proxy.
Tradeoff against eth_getProof and storage proofs
eth_getProof returns the same value with a Merkle proof that can be verified without trusting the endpoint, at the cost of a heavier response and additional verification logic. eth_getStorageAt is lighter and simpler, but it trusts the endpoint to return the correct value for the requested slot.
For read-only dashboards and debugging, eth_getStorageAt is usually sufficient. For applications that must not trust a single provider, eth_getProof is the stronger choice. The eth_getProof and account/storage proofs article covers the verification workflow in detail.
A practical pattern is to use eth_getStorageAt during development and switch to eth_getProof for production paths where correctness must be independently verifiable. The RPC pricing page can help you estimate the cost difference between the two methods.
- eth_getStorageAt: light, simple, trusts the endpoint.
- eth_getProof: heavier, verifiable without trusting the endpoint.
- Choose based on whether independent verification is required.
Troubleshooting common slot-read failures
When a slot read returns an unexpected value, check the declaration order first. If the variable shares a slot with others, apply the correct mask and shift. If the variable is a mapping or dynamic array element, verify that you derived the slot with keccak256 rather than counting. If the contract is a proxy, confirm you are using the proxy's storage layout.
Another frequent issue is block tag selection. Reading at 'latest' returns the current state, while reading at a historical block returns the state at that block. If a value changed recently, a historical read may return the old value. Also confirm the position is a 32-byte quantity; a short hex string may be rejected or misinterpreted by some providers.
Finally, check the compiler version and optimization settings. Packing behavior is documented but can change between compiler versions, so a decode that worked for one build may fail for another. Re-derive the layout from the current compiler output.
- Verify declaration order and packing group.
- Confirm mapping/array slots are keccak256-derived.
- Check proxy layout and block tag.
- Re-derive layout after compiler or optimization changes.
Measuring your endpoint's behavior with a results table
Provider behavior for eth_getStorageAt is documented but varies by provider in areas such as historical state availability, rate limits, and error formatting. To measure your own endpoint, run a small set of reads against a known contract and record the results in a table. This turns provider claims into observed facts for your integration.
Use a contract with a known storage layout, read a declaration-order slot, a packed slot, and a mapping element slot, and compare each to a public view function. Record the raw hex, the decoded value, the expected value, and the round-trip time. Repeat at 'latest' and at a historical block to observe state availability.
The table below is a template to fill with your own measurements. Do not treat any single provider's numbers as universal; the point is to establish a baseline for your own environment.
- Columns: slot type, raw hex, decoded value, expected value, block tag, round-trip time.
- Rows: declaration slot, packed slot, mapping element slot, historical read.
- Compare decoded values to eth_call getter results for each row.
Limitations and next steps for slot-based integrations
Slot numbers are a compiler implementation detail that can change between compiler versions and with optimizations. Any hard-coded slot is a maintenance liability that must be re-derived whenever the contract is recompiled. Treat slot arithmetic as a build-time artifact, not a runtime constant.
For production integrations, prefer public view functions where available, use eth_getStorageAt for diagnostics and for contracts without getters, and use eth_getProof when independent verification is required. The OnFinality Learn hub collects related guides, and the eth_getProof and account/storage proofs, eth_call state override simulation, EVM nonce management with eth_getTransactionCount, and Ethereum event topic filtering with eth_getLogs articles cover adjacent read patterns.
To get started, connect to an Ethereum endpoint from the networks page, run the examples above against a contract you control, and record your results in the measurement table. The RPC endpoints guide (RPC Assistant) and API service pages explain how to provision and manage endpoints for this workflow.
- Re-derive slots after every recompile or compiler upgrade.
- Prefer view functions when available; use storage reads for diagnostics.
- Use eth_getProof when independent verification is required.