Receiver hooks в Solidity: приём токенов и полномочия
Слова «контракт поддерживает NFT» недостаточно для понимания его поведения. Контракт может корректно принять токен, но не иметь способа передать его дальше. Он также может выполнить дополнительный код во время приёма. Рассмотрим небольшой собственный контейнер для LibraryPass и явно отделим согласие принять объект от полномочия распоряжаться им.
Используем ERC-721 из предыдущих уроков, а не объединённый универсальный кошелёк. Новый PassLocker принимает только заранее выбранную коллекцию. Единственный административный owner контейнера может передать принятый номер указанному получателю. Первоначальный держатель токена не получает право вывода автоматически через факт отправки.
Полный учебный контейнер
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract PassLocker is IERC721Receiver, Ownable, ReentrancyGuard {
error WrongCollection();
IERC721 public immutable pass;
constructor(address collection, address admin) Ownable(admin) {
if (collection == address(0)) revert WrongCollection();
pass = IERC721(collection);
}
function onERC721Received(address, address, uint256, bytes calldata)
external view returns (bytes4)
{
if (msg.sender != address(pass)) revert WrongCollection();
return IERC721Receiver.onERC721Received.selector;
}
function release(uint256 id, address to) external onlyOwner nonReentrant {
pass.safeTransferFrom(address(this), to, id);
}
}
Листинг является собственной лабораторной моделью, не сервисом хранения чужих ценностей. Нет обещания возврата отправителю и нет индивидуального реестра депозиторов. Если использовать такой код как пользовательское хранилище без объяснения, передающий адрес может ошибочно считать себя будущим получателем вывода.
Субъект приёма
Во время безопасной передачи непосредственным msg.sender hook является контракт токена. Аргумент operator описывает субъекта, который инициировал передачу в стандартной операции. Аргумент from — предыдущего владельца. Эти адреса имеют разный смысл; подмена одного другим в проверке разрешения создаёт ошибочную границу доверия.
Наш callback проверяет именно адрес коллекции через msg.sender. Посторонний X не должен получить приёмный ответ как доказательство, что Locker принял настоящую передачу выбранного LibraryPass. Параметры from и tokenId, присланные произвольным прямым вызовом, сами по себе не подтверждают токенное состояние.
При этом выбранная коллекция тоже является доверенной зависимостью. Если администратор указал чужой контракт с произвольным поведением, совпадение msg.sender не докажет соответствие обычному ERC-721. Поэтому конструкторная настройка входит в модель доверия, а не исчезает за импортом интерфейса IERC721.
Магическое значение
При успешном приёме возвращается селектор onERC721Received. Это предусмотренный протоколом ответ согласия. Возврат true, пустых байтов либо любого случайного bytes4 не является равнозначным результатом. Токен ожидает определённую форму ответа и при несоответствии отклоняет безопасную передачу.
Наш hook не пишет отдельный mapping принятых номеров. Для проверки владения Locker использует состояние выбранной коллекции через ownerOf. Такая простота устраняет второй список, который мог бы рассинхронизироваться. Но она также означает отсутствие предметной истории, кому обещан возврат и по каким условиям.
Если библиотека добавит такую историю, её нельзя строить только на аргументе from без решения об операторской передаче. Оператор может отправить ресурс владельца, а получателю нужен явный договор услуги. История движения токена и согласованное пользовательское поручение контейнеру — разные вещи.
Порядок при выводе
Owner контейнера вызывает release(1,Q). Непосредственным субъектом safeTransferFrom внутри коллекции становится Locker, который должен владеть номером 1. После ожидаемого успеха token owner становится Q. Административное владение Locker и коллекции не меняется вследствие этой передачи.
Если Q является контрактом, его hook может выполниться до возврата из release. Поэтому release защищён nonReentrant. Но модификатор не доказывает все возможные бизнес-инварианты и не защищает автоматически любой новый метод, который позже добавится в тот же контейнер.
Например, если будущий метод выдаёт право администратора на основании промежуточного баланса, нужен отдельный анализ его взаимодействия с callback. Общий guard на одном пути не решает ошибочную логику другого пути. В нашей маленькой модели такого метода нет, поэтому не приписываем ей несуществующий сценарий начисления.
Безопасная и прямая передача
Проверка WrongCollection действует на безопасный приём через hook. Другой токен может быть отправлен в Locker обычным transferFrom без предусмотренного обращения к получателю. Наличие фильтра callback не делает адрес физически неспособным владеть любыми другими токенами.
У release разрешена только выбранная коллекция. Поэтому случайно присланный иной ERC-721 может оказаться без маршрута вывода в показанном интерфейсе. Дополнительный метод восстановления изменил бы область административного влияния и потребовал бы собственного договора. Мы не выдаём отсутствие метода за скрытую автоматическую защиту.
Аналогично отправка ETH не рассматривается здесь как депозит услуги. ERC-721 hook не является receive и не описывает обработку денежных переводов. Объединение разных видов ресурсов в универсальном контейнере усложняет учёт и не происходит только потому, что адрес один.
ERC-1155 требует другого интерфейса
Для LibraryResources необходимы одиночный и пакетный hook стандарта ERC-1155 и соответствующая поддержка интерфейса. ERC721Receiver не принимает его по совпадению общего слова «токен». Переход к универсальному контейнеру требует описать оба протокола, ограничения вида и полномочия вывода количеств.
В закреплённом ERC1155 обновление балансов происходит до проверки callback, а отказ откатывает всё изменение. Приёмник может читать новый баланс внутри своего кода. Это удобно для согласованного наблюдения, но требует внимания к любому добавленному учёту обязательств.
Для упражнения разберите четыре случая: выбранный LibraryPass отправляется безопасно; другая коллекция отправляется безопасно; другая коллекция отправляется напрямую; посторонний вызывает release. Для каждого укажите, какой именно компонент принимает или отклоняет действие. Затем объясните, почему отправитель принятого токена не становится owner Locker. Так понятие приёмного hook получает точную границу вместо общего обещания «совместимости».
Передача администрирования контейнера
Если owner Locker меняется с A на N, право release получают N, включая токены, ранее принятые при A. Предыдущие отправители не становятся участниками этой передачи управления. Поэтому она является существенным изменением доверенного субъекта хранения, даже если ни один tokenId не переместился в момент смены owner.
Если owner отказался от владения, показанный onlyOwner-маршрут release перестаёт быть обычно доступным. При этом коллекция может продолжать считать Locker владельцем уже находящихся у него номеров. Отказ от административной власти не означает автоматическую раздачу содержимого обратно отправителям.
Эти два случая полезно включить в предметный разбор до любого реального использования контейнера. Они показывают, почему корректное magic value при приёме является только одной частью поведения. Следует отдельно знать доступные методы дальнейшего распоряжения и то, как их полномочия меняются со временем.
Для самостоятельного уточнения: ERC721Utils.sol (5.4.0), ERC1155Utils.sol (5.4.0), ReentrancyGuard.sol (5.4.0).
Оглавление курса. Листинги, расчёты и наблюдения здесь разобраны как ожидаемое поведение; компиляция, учебные программы и проверки при написании не запускались.