китаев.tech

Чтение Storage напрямую через getStorageAt

Как читать raw storage смарт-контракта через ethers v6: получить storage slot, декодировать uint256, bytes32, address и mapping.

Что такое getStorageAt

getStorageAt — это RPC-вызов eth_getStorageAt, который читает 32 байта из storage slot контракта.

В ethers v6 метод называется provider.getStorage(address, slot). Он возвращает raw hex-значение, а декодировать его нужно вручную.


Читаем storage локального контракта

Создайте проект:

bash
mkdir get-storage-at-example
cd get-storage-at-example

npm init -y
npm pkg set type="module"
npm install ethers solc tsx
npm install --save-dev hardhat typescript @types/node
mkdir contracts scripts

Создайте файл hardhat.config.ts:

typescript
import { defineConfig } from "hardhat/config";

export default defineConfig({
  solidity: {
    version: "0.8.20",
  },
});

Создайте файл contracts/StorageDemo.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract StorageDemo {
    uint256 public count;                 // slot 0
    bytes32 public label;                 // slot 1
    address public owner;                 // slot 2
    mapping(address => uint256) balances; // slot 3

    constructor(bytes32 initialLabel) {
        count = 42;
        label = initialLabel;
        owner = msg.sender;
        balances[msg.sender] = 1000;
    }

    function balanceOf(address account) external view returns (uint256) {
        return balances[account];
    }
}

Создайте файл scripts/read-storage.ts:

typescript
import { ethers } from "ethers";
import solc from "solc";
import { readFileSync } from "node:fs";

const source = readFileSync("contracts/StorageDemo.sol", "utf8");

const input = {
  language: "Solidity",
  sources: {
    "StorageDemo.sol": {
      content: source,
    },
  },
  settings: {
    outputSelection: {
      "*": {
        "*": ["abi", "evm.bytecode.object"],
      },
    },
  },
};

const output = JSON.parse(solc.compile(JSON.stringify(input)));
const contract = output.contracts["StorageDemo.sol"].StorageDemo;

const abi = contract.abi;
const bytecode = `0x${contract.evm.bytecode.object}`;

const provider = new ethers.JsonRpcProvider("http://127.0.0.1:8545");

// Первый тестовый аккаунт Hardhat. Не используйте этот ключ в mainnet.
const signer = new ethers.Wallet(
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
  provider,
);

const factory = new ethers.ContractFactory(abi, bytecode, signer);
const storageDemo = await factory.deploy(ethers.encodeBytes32String("demo"));

await storageDemo.waitForDeployment();

const contractAddress = await storageDemo.getAddress();

const rawCount = await provider.getStorage(contractAddress, 0);
const rawLabel = await provider.getStorage(contractAddress, 1);
const rawOwner = await provider.getStorage(contractAddress, 2);

const mappingSlot = ethers.keccak256(
  ethers.AbiCoder.defaultAbiCoder().encode(
    ["address", "uint256"],
    [signer.address, 3],
  ),
);

const rawBalance = await provider.getStorage(contractAddress, mappingSlot);

const count = BigInt(rawCount);
const label = ethers.decodeBytes32String(rawLabel);
const owner = ethers.getAddress(ethers.dataSlice(rawOwner, 12));
const balance = BigInt(rawBalance);

console.log("Contract:", contractAddress);
console.log("slot 0 raw:", rawCount);
console.log("count:", count.toString());
console.log("slot 1 raw:", rawLabel);
console.log("label:", label);
console.log("slot 2 raw:", rawOwner);
console.log("owner:", owner);
console.log("mapping slot:", mappingSlot);
console.log("raw balance:", rawBalance);
console.log("balance:", balance.toString());

Запустите локальную сеть Hardhat в отдельном терминале:

bash
npx hardhat node

Во втором терминале выполните скрипт:

bash
npx tsx scripts/read-storage.ts

Скрипт задеплоит StorageDemo, прочитает raw storage slots и декодирует их в понятные значения.


Как это работает

provider.getStorage(contractAddress, 0) — читает slot 0. В нём лежит uint256 count.

BigInt(rawCount) — переводит 32-байтный hex в число.

provider.getStorage(contractAddress, 1) — читает slot 1. В нём лежит bytes32 label.

ethers.decodeBytes32String(rawLabel) — декодирует bytes32 обратно в строку.

provider.getStorage(contractAddress, 2) — читает slot 2. В нём лежит address owner.

ethers.dataSlice(rawOwner, 12) — отрезает первые 12 байт padding и оставляет 20 байт адреса.

provider.getStorage(contractAddress, mappingSlot) — читает значение mapping по вычисленному slot.


Как найти slot mapping

Обычная переменная хранится по номеру slot:

text
count -> slot 0
label -> slot 1
owner -> slot 2

Mapping хранится иначе. Для mapping(address => uint256) balances с базовым slot 3 значение лежит по адресу:

text
keccak256(abi.encode(key, 3))

Для balances[signer.address] код делает то же самое:

typescript
const mappingSlot = ethers.keccak256(
  ethers.AbiCoder.defaultAbiCoder().encode(
    ["address", "uint256"],
    [signer.address, 3],
  ),
);

В самом slot 3 значение mapping не лежит. Он используется как seed для вычисления storage slot каждого ключа.


getStorage в ethers v6

На уровне JSON-RPC метод называется eth_getStorageAt:

text
eth_getStorageAt(address, position, blockTag)

В ethers v6 это обёрнуто в:

typescript
const raw = await provider.getStorage(address, position);

Можно передать и blockTag, если RPC поддерживает чтение старого состояния:

typescript
const raw = await provider.getStorage(address, position, 19_000_000);

Для старых блоков часто нужен archive RPC.


Когда это нужно

Прямое чтение storage используют редко, но оно полезно для диагностики:

  • проверить storage layout proxy перед upgrade
  • прочитать приватную переменную контракта
  • проверить mapping без ABI функции чтения
  • сравнить raw State с тем, что возвращает view функция

private в Solidity не означает секретность. Это только запрет доступа из других контрактов. Любой RPC может прочитать storage напрямую.


Частые ошибки

Искать mapping в его базовом slot → в slot 3 не лежит balances[user]. Нужно считать keccak256(abi.encode(key, slot)).

Декодировать address как весь bytes32 → адрес занимает последние 20 байт. Используйте ethers.dataSlice(raw, 12).

Забывать про packing → маленькие типы могут лежать в одном slot. В этой статье типы подобраны так, чтобы slots были простыми.

Читать старый блок через обычный RPC → для исторического storage может понадобиться archive node.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.