Взаимодействие с Proxy контрактом
Как работать с proxy-контрактом через ethers v6: найти implementation по EIP-1967 slot и вызывать функции через proxy address.
Что такое proxy-контракт
Proxy — это контракт-адрес, который хранит состояние, но исполняет код другого контракта.
Пользователь и приложение вызывают proxy address. Proxy через delegatecall передаёт выполнение в implementation, а storage остаётся на proxy.
Читаем USDC proxy
Создайте проект:
mkdir proxy-contract-example
cd proxy-contract-example
npm init -y
npm pkg set type="module"
npm install ethers tsx typescript @types/node
mkdir scriptsСоздайте файл scripts/read-usdc-proxy.ts:
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider("https://ethereum.publicnode.com");
// USDC в Ethereum mainnet работает через proxy.
const usdcProxyAddress = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const vitalikAddress = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
// EIP-1967 implementation slot:
// bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
const implementationSlot =
"0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc";
const rawImplementationSlot = await provider.getStorage(
usdcProxyAddress,
implementationSlot,
);
const implementationAddress = ethers.getAddress(
ethers.dataSlice(rawImplementationSlot, 12),
);
const proxyCode = await provider.getCode(usdcProxyAddress);
const implementationCode = await provider.getCode(implementationAddress);
const usdcAbi = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function balanceOf(address account) view returns (uint256)",
];
const usdc = new ethers.Contract(usdcProxyAddress, usdcAbi, provider);
const [name, symbol, decimals, balance] = await Promise.all([
usdc.name(),
usdc.symbol(),
usdc.decimals(),
usdc.balanceOf(vitalikAddress),
]);
console.log("Proxy address:", usdcProxyAddress);
console.log("Implementation address:", implementationAddress);
console.log("Proxy bytecode bytes:", (proxyCode.length - 2) / 2);
console.log("Implementation bytecode bytes:", (implementationCode.length - 2) / 2);
console.log("Token:", name);
console.log("Symbol:", symbol);
console.log("Vitalik balance:", ethers.formatUnits(balance, decimals), symbol);Запустите скрипт:
npx tsx scripts/read-usdc-proxy.tsКод найдёт implementation-адрес USDC proxy и прочитает ERC-20 данные через proxy address.
Как это работает
usdcProxyAddress — адрес, с которым взаимодействуют пользователи. Балансы USDC лежат в storage этого адреса.
implementationSlot — стандартный EIP-1967 slot, где proxy хранит адрес implementation.
provider.getStorage(usdcProxyAddress, implementationSlot) — читает raw 32-байтное значение из storage proxy.
ethers.dataSlice(rawImplementationSlot, 12) — отрезает первые 12 байт padding и оставляет 20 байт адреса.
provider.getCode(implementationAddress) — проверяет, что по implementation address действительно есть bytecode.
new ethers.Contract(usdcProxyAddress, usdcAbi, provider) — создаёт контракт по адресу proxy, но с ABI implementation.
usdc.balanceOf(...) — вызывает proxy. Proxy делает delegatecall в implementation и читает storage proxy.
Главное правило
Для обычного read/write используйте:
const contract = new ethers.Contract(proxyAddress, implementationAbi, providerOrSigner);Не подставляйте implementation address как адрес контракта для пользовательских вызовов.
Implementation содержит код, но не хранит пользовательское состояние proxy. Если вызвать balanceOf() напрямую на implementation, вы прочитаете не те storage slots.
Proxy vs implementation
Proxy отвечает за постоянный адрес и состояние:
balances
owner
roles
initialized flagsImplementation отвечает за логику:
balanceOf()
transfer()
approve()
upgrade logicПри upgrade меняется implementation address, но proxy address остаётся прежним. Поэтому пользователи не меняют адрес в приложении.
Как понять, что адрес — proxy
Есть три быстрых признака:
- EIP-1967 implementation slot содержит ненулевой адрес
- Etherscan показывает вкладку
Read as ProxyилиWrite as Proxy - bytecode proxy заметно меньше и содержит логику делегирования
Но главный источник для кода — explorer или storage slot. Имя контракта в UI не гарантирует, что это proxy.
Частые ошибки
Вызывать implementation address вместо proxy → код выполнится, но состояние будет не тем. Для приложения почти всегда нужен proxy address.
Использовать ABI proxy вместо ABI implementation → proxy ABI часто содержит только admin-функции. Для transfer, balanceOf и других бизнес-функций нужен ABI implementation.
Думать, что upgrade меняет адрес токена → при upgrade меняется implementation. Proxy address остаётся тем же.
Игнорировать storage layout → при upgrade нельзя переставлять storage-поля как попало. Старый proxy storage должен совпадать с новой implementation-логикой.
Что дальше
- Вызов view-функций — read — читать функции proxy через ABI implementation
- Вызов state-changing функций — write — отправлять транзакции к proxy address
- Верификация контракта на Etherscan — проверить source code proxy и implementation в explorer