Интерфейсы: договор между контрактами
- Зачем это нужно
- Интерфейс — список подписей без тел
- Приведение адреса к договору
- Двухшаговый протокол: approve, потом transferFrom
- Возвращаемый признак надо проверять
- Три способа ошибиться адресом — три разных отката
- Интерфейс, абстрактный контракт и наследование
- Один договор — все токены системы
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
В задании 2026 года хранилище принимает депозиты в USDC. USDC — отдельный контракт, развёрнутый кем-то другим по своему адресу. Хранилище знает про него ровно одно: двадцать байт адреса.
А у адреса нет функций. Строка token.balanceOf(address(this)) при объявлении address token; не компилируется вовсе — компилятор не знает, что по этому адресу что-то умеет считать балансы.
Нужен договор: список функций, которые контракт по тому адресу обязуется предоставлять. Это и есть интерфейс. Из него же вырастает двухшаговый протокол «разрешить — списать», без которого не работает ни один перевод токенов в системе задания.
Интерфейс — список подписей без тел
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
}
Внутри интерфейса разрешено немногое: подписи функций, события, свои ошибки, структуры и перечисления. Запрещено — тела функций, переменные состояния, конструктор, модификаторы и любая видимость, кроме external.
Приведение адреса к договору
address tokenAddress = 0x5FbD…;
IERC20 token = IERC20(tokenAddress); // приведение типа
uint256 mine = token.balanceOf(address(this));
Запись IERC20(tokenAddress) не делает ничего в сети. Это приведение типа — такое же, как uint8(x) из главы про типы, только для адреса. Байты остаются те же двадцать; меняется лишь то, что компилятор теперь разрешает писать вызовы.
Из этого следует главное: приведение к интерфейсу — обещание компилятору, а не сети. Никакой проверки «а точно ли по этому адресу такой контракт» не происходит. Если адрес неверный, узнаете об этом только при вызове — и в самой неудобной форме.
Двухшаговый протокол: approve, потом transferFrom
Контракт не может сам забрать чужие токены. Владелец сначала выдаёт разрешение, и только потом кто-то может им воспользоваться.
Разрешения хранятся в токене вложенным словарём: «владелец → кому → сколько». Отсюда два шага:
- Пользователь вызывает у токена:
approve(vault, 1000)— «хранилищу можно списать с меня до 1000». - Пользователь вызывает у хранилища:
deposit(600)— а хранилище внутри вызывает у токенаtransferFrom(пользователь, себя, 600).
Первая модель проходит оба шага и показывает, что происходит:
── шаг 1: deposit(600) без approve ──
allowance(lender → vault): 0
откат: reverted with reason string 'MiniToken: не хватает разрешения (allowance)'
первые 4 байта данных отката: 0x08c379a0 — это код Error(string)
баланс lender после отката: 1000
deposits[lender] после отката: 0
── шаг 2: approve(vault, 1000), потом deposit(600) ──
allowance после approve: 1000
lender: 1000 → 400
vault: 0 → 600
deposits[lender]: 600
allowance после deposit: 400 (1000 − 600: разрешение расходуется)
газ deposit(600): 82171
── шаг 3: transfer(vault, 100) напрямую, мимо deposit ──
токенов на контракте (totalHeld): 700
deposits[lender]: 600 — хранилище о 100 не знает
allowance не тронут: 400
Три наблюдения.
Текст отката пришёл от токена. Пользователь вызывал хранилище, а сообщение написал токен — причина отказа пробрасывается сквозь внешний вызов целиком. Это удобно при отладке: видно, кто именно отказал.
Разрешение расходуется. После списания 600 из разрешённой тысячи осталось 400. Разрешение — это лимит, а не разовый пропуск.
Перевод напрямую ломает учёт. Если пользователь переведёт токены прямо на адрес хранилища, деньги придут, но deposit никто не вызывал — и запись не появится. На балансе 700, в учёте 600. Отсюда правило: хранилище считает свои записи, а не свой баланс.
Возвращаемый признак надо проверять
По стандарту переводы возвращают true или false, и стандарт прямо требует обрабатывать false. Компилятор к этому не принуждает:
token.transferFrom(msg.sender, address(this), amount); // так писать нельзя
require(token.transferFrom(msg.sender, address(this), amount), "…"); // а так правильно
Первая строка компилируется без единого предупреждения. Токен, который в случае неудачи возвращает false вместо отката, тихо ничего не переведёт — а хранилище запишет депозит.
Три способа ошибиться адресом — три разных отката
Вторая модель подсовывает хранилищу неверные адреса и вызывает через интерфейс:
── 1. адрес обычного аккаунта (EOA), кода нет ──
код по адресу, байт: 0
deposit(): Transaction reverted: function returned an unexpected amount of data
данные отката: 0x
pokeToken(): Transaction reverted: function call to a non-contract account
── 2. контракт есть, а функции transferFrom нет ──
код по адресу, байт: 63
deposit(): Transaction reverted: function selector was not recognized
and there's no fallback function
── 3. transferFrom есть, но не возвращает bool ──
код по адресу, байт: 862
deposit(): Transaction reverted: function returned an unexpected amount of data
баланс lender в NoBoolToken после всего: 1000 —
списание внутри откатилось вместе со всем вызовом
Общее у всех трёх: данные отката пустые, никакого текста. Интерфейс — обещание компилятору, и сеть его не проверяла; выяснилось всё только в момент вызова.
Третий случай самый коварный, потому что это не выдумка: так устроен реальный крупный токен. Функция transferFrom у него есть и работает, но ничего не возвращает. Селектор совпал, перевод внутри прошёл — а вызывающий ждал 32 байта ответа, получил ноль и откатил всю транзакцию вместе с переводом.
Отсюда практический вывод для площадки: свои токены пишите строго по стандарту, с returns (bool). Если приходится работать с чужим — используют обёртку, которая умеет обрабатывать оба поведения.
Интерфейс, абстрактный контракт и наследование
Три похожие вещи, которые легко перепутать:
| Есть тела функций | Переменные состояния | Можно развернуть | |
|---|---|---|---|
interface | нет | нет | нет |
abstract contract | частично | да | нет |
contract | да | да | да |
Контракт может объявить, что выполняет договор: contract MiniToken is IERC20. Тогда компилятор проверит, что все функции интерфейса реализованы. Пропустили одну:
TypeError: Contract "SimpleVault" should be marked as abstract.
Note: Missing implementation:
function depositOf(address account) external view returns (uint256);
Компилятор прямо показывает, чего не хватает.
Третья модель прогоняет через компилятор семь неправильных интерфейсов:
── 1. функция с телом в интерфейсе ──
TypeError: Functions in interfaces cannot have an implementation.
── 2. переменная состояния в интерфейсе ──
TypeError: Variables cannot be declared in interfaces.
── 3. public вместо external ──
TypeError: Functions in interfaces must be declared external.
── 4. конструктор в интерфейсе ──
TypeError: Constructor cannot be defined in interfaces.
── 5. контракт не реализовал функцию интерфейса ──
TypeError: Contract "SimpleVault" should be marked as abstract.
Note: Missing implementation: …
Один договор — все токены системы
В задании токенов несколько: USDC для хранилища, залоговые токены для маркетов. Все они соответствуют одному стандарту — значит, один файл интерфейса обслуживает их все.
contract Market {
IERC20 public loanToken;
IERC20 public collateralToken;
constructor(address _loan, address _collateral) {
loanToken = IERC20(_loan);
collateralToken = IERC20(_collateral);
}
}
Контракт хранит адрес, а не тип токена. Поменялся токен — поменялся адрес при развёртывании, код остался прежним. Так же маркет разговаривает с хранилищем, а дашборд — со всеми контрактами сразу: через договоры.
Типичные ошибки
Тело функции в интерфейсе. Functions in interfaces cannot have an implementation.
Переменная состояния в интерфейсе. Variables cannot be declared in interfaces.
public вместо external. Functions in interfaces must be declared external.
Конструктор в интерфейсе. Constructor cannot be defined in interfaces.
Контракт не реализовал функцию договора. Contract "X" should be marked as abstract плюс подсказка, какой именно функции не хватает.
Вызов функции у переменной типа address. Компилятор не знает о такой функции — нужно приведение к интерфейсу.
Результат перевода не проверен. Компилируется молча; токен вернул false, перевода не было, запись появилась.
Депозит без предварительного разрешения. Откат с текстом токена — и это правильное поведение, а не ошибка контракта.
Учёт по балансу вместо записей. Перевод напрямую на адрес контракта ломает такой учёт мгновенно.
Слова главы
interface— договор: подписи функций без тел; развернуть нельзя.- Подпись функции (сигнатура) — имя и типы параметров; из неё считается селектор.
IERC20(addr)— приведение адреса к договору; в сети не проверяется ничего.- Внешний вызов — переход исполнения в другой контракт; там
msg.sender— адрес вызывающего контракта. allowance— разрешение «владелец → кому → сколько»; расходуется при списании.approve/transferFrom— два шага протокола: разрешить, затем списать.is IERC20— контракт обязуется реализовать все функции договора.- Абстрактный контракт — между интерфейсом и контрактом: есть состояние и часть тел.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
interface IERC20 {
На чемпионате
Модуль А, 37 баллов. Демонстрация идёт по шагам вида «зайти под Lender1 и внести депозит 1000 USDC в Vault1, показать изменения всех затронутых балансов». За этим шагом стоит вся эта глава: разрешение, вызов депозита, внешний вызов токена, изменение трёх чисел — баланс пользователя, баланс хранилища, запись учёта.
Практические выводы:
- Интерфейс токена — отдельный файл, один на всю систему.
- Адреса токенов передаются в конструкторы при развёртывании, а не зашиваются в код.
- Результат каждого перевода проверяется.
- Учёт ведётся по своим записям: баланс контракта может отличаться от суммы записей, и это нормально.
- В инструкции по запуску решения стоит написать, что перед депозитом нужно разрешение, — иначе эксперт увидит откат и посчитает шаг невыполненным.
Модуль В, 14 баллов. Отсюда идут пункты про непроверенный результат перевода (информационная безопасность), про учёт по балансу вместо записей (логика) и про жёстко зашитые адреса (оптимизация и сопровождение).
Что дальше
Контракты научились разговаривать. Следующая глава — про то, как не писать одно и то же дважды: библиотеки функций без состояния, подключение файлов и хеширование. Там же разберём keccak256 — ту самую функцию, которой в эталонном контракте задания пытаются хранить пароли, — и покажем на прогоне, почему это не работает.
Углубиться
- Проверка существования контракта — почему у функций без возвращаемого значения текст отката другой и что изменилось в версии 0.8.10.
- Обёртки для нестандартных токенов — как библиотеки обрабатывают токены без
returns (bool). try/catchпри внешнем вызове — как не упасть вместе с чужим контрактом.- Селектор из подписи — почему
transferFrom(address,address,uint256)даёт именно эти четыре байта. - Обнуление разрешения — известная тонкость: некоторые токены требуют сначала
approve(0), и только потом новое значение.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- Интерфейсы — docs.soliditylang.org — формальные правила и запреты.
- Стандарт ERC-20 — полный текст, включая требование обрабатывать
false. - Абстрактные контракты — чем отличаются от интерфейсов.
- Внешние вызовы и их обработка —
try/catch, проверка существования контракта. - Стандарт ERC-4626 — договор для долей хранилища из модуля А.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.