Перейти к содержанию

Foundry: генерация отправителей вызова

В предыдущем уроке генератор выбирал номера и отпечатки. Теперь параметризуем адрес, который пытается изменить реестр. Одного читателя address(0xB0B) достаточно для понятного примера, но договор полномочий относится к более широкой области чужих отправителей.

Подготовим свойства запрета публикации и деактивации. В обоих случаях сначала явно создадим выпуск разрешённым издателем, затем попробуем изменить его другим адресом. Код пока не запускался; ожидаемые отказы и сохранённое состояние являются предметом будущих проверок.

Адрес — параметр локального контекста

Foundry может генерировать тип address, а vm.prank задаёт его как непосредственного отправителя следующего исследуемого вызова. Это позволяет моделировать разные роли без создания кошельков и хранения приватных ключей. Наша функция проверяет адресное условие контракта, а не способность реального пользователя подписать транзакцию.

Базовый издатель фиксирован конструктором. Любой другой рассматриваемый адрес не должен пройти условие msg.sender == publisher. Однако в локальной среде есть специальные величины: нулевой адрес и адрес инструмента vm. Для этого свойства исключим их явно вместе с самим издателем.

Такое исключение задаёт область моделируемых ненулевых прикладных отправителей. Оно не означает, что специальным адресам разрешено писать в реестр. Мы избегаем смешения прикладного свойства с устройством вспомогательного окружения. Поведение конкретных исключённых значений можно описать отдельно, если оно потребуется договору.

Чужой адрес не заменяет выпуск

Для проверки сохранения записи используем два различимых отпечатка. Издатель создаёт first, а случайный чужой отправитель пытается записать second. После ожидаемой ошибки читаем прежний выпуск, а не просто ненулевое значение.

function testFuzzNonPublisherCannotReplace(address caller) public {
    vm.assume(caller != publisher);
    vm.assume(caller != address(0));
    vm.assume(caller != address(vm));
    bytes32 first = keccak256("edition-one");
    bytes32 second = keccak256("edition-two");

    vm.prank(publisher);
    catalog.publish(17, first);

    vm.expectRevert(CourseCatalog.Unauthorized.selector);
    vm.prank(caller);
    catalog.publish(17, second);

    assertCourse(17, first, true);
}

Оба отпечатка допустимы. Поэтому причина отказа должна относиться именно к роли. Если передать нулевой хеш, программа с отсутствующей проверкой доступа могла бы отказать из-за валидации данных. Точное ожидание Unauthorized и допустимый вход вместе защищают смысл сценария.

Начальная публикация выполнена отдельно и с явным prank(publisher). Подмена caller располагается только перед запрещённой попыткой. Если случайно распространить чужого отправителя на подготовительный шаг, сценарий завершится до исследуемого действия и не расскажет о запрете замены существующего выпуска.

Запрет относится и к отключению

Сходство методов не освобождает нас от отдельного требования. В будущем при правке deactivate автор может удалить условие доступа, оставив publish правильным. Свойство запрещённой публикации такое изменение не заметит, поэтому добавим вторую функцию.

function testFuzzNonPublisherCannotDeactivate(address caller) public {
    vm.assume(caller != publisher);
    vm.assume(caller != address(0));
    vm.assume(caller != address(vm));
    bytes32 digest = keccak256("edition-one");
    vm.prank(publisher);
    catalog.publish(17, digest);

    vm.expectRevert(CourseCatalog.Unauthorized.selector);
    vm.prank(caller);
    catalog.deactivate(17);

    assertCourse(17, digest, true);
}

Курс специально активен перед попыткой. Иначе неправильная реализация могла бы завершиться NotActive, и причина отказа была бы связана с состоянием. Нам нужен вход, при котором разрешённый издатель смог бы выполнить действие, а чужой адрес запрещён только условием полномочий.

Таким образом, в двух функциях выбран одинаковый исходный выпуск, но разные запрещённые переходы. Названия отражают это различие. При будущей диагностике станет видно, нарушен ли запрет изменения содержания, запрет отключения или оба требования одновременно.

Отправитель не доказывает тип владельца

Сгенерированный адрес может совпасть с адресом существующей программы или выглядеть как обычный аккаунт. prank сам по себе не исполняет код владельца адреса. Поэтому успешный локальный вызов от контрактного издателя нельзя описывать как полную проверку работоспособности multisig или smart account.

Для таких систем понадобится действительная цепочка вызовов через их методы. Внутренние ограничения подписи, порога участников и выбранного вызова относятся к управляющему контракту. Здесь проверяется только то, как CourseCatalog реагирует на непосредственного отправителя уже произошедшего вызова.

Не требуется также делать ненулевой caller владельцем средств. Наш метод не принимает оплату и не использует баланс. Искусственное добавление средств в сценарий не выразило бы новое требование и отвлекло бы от условия роли. Если позже появится платный метод, его проверку следует строить отдельно.

Проверяем исходное полномочие после отказа

Можно дополнить сценарий успешной заменой от издателя после чужой попытки. Ожидаемый результат тогда последовательно меняется: сначала чужой вызов оставляет first, затем разрешённый вызов записывает second. Такая вариация полезна, если будущий контракт получает защитные флаги, которые могут случайно остаться установленными после отказа.

У нашего маленького реестра подобных флагов нет, поэтому не выдаём эту вариацию за найденную проблему. Она показывает общий принцип: отрицательное действие должно сохранить не только данные, но и способность законного оператора продолжить работу согласно договору. При усложнении приложения такое требование иногда заслуживает отдельного сценария восстановления.

Также можно сравнить курс 18, опубликованный до чужого действия над 17. Его хеш и активность должны остаться прежними. Это обнаруживает неожиданное изменение соседней записи, которое чтение только целевого курса не охватывает. Однако проверка всех возможных ключей уже требует другой модели наблюдения. Здесь добавляем конкретный второй объект и ясно называем ограниченность такого примера.

Все эти расширения остаются локальными исходниками. Мы не утверждаем, что генератор уже исследовал тысячи отправителей или нашёл нарушение. В отчёте следует отдельно хранить сформулированное свойство, написанный сценарий и результаты его фактического исполнения, когда такое исполнение будет разрешено.

Меняем вход без изменения свойства

Заменим постоянный номер 17 на параметр id и добавим допущение id != 0. Тогда можно исследовать запрет замены по разным ключам. Но начинающему читателю полезно сначала увидеть одну параметризованную величину: адрес. Номер и отпечаток уже имеют собственные свойства из предыдущих уроков.

Не стоит стремиться объединить все параметры и методы в одну огромную fuzz-функцию. При ошибке станет труднее понять, какое требование нарушено и какая подготовка необходима для воспроизведения. Небольшие свойства с ясной областью легче превратить в конкретный регрессионный пример.

Для отправителя, равного издателю, нужны положительные сценарии, а не ожидаемая ошибка. Они сохранены в пятом и одиннадцатом уроках. Исключённая область здесь соответствует противоположному поведению и не остаётся без представления в лаборатории.

Набор случайных адресов конечен и не доказывает безопасность всех систем вокруг реестра. Он помогает искать нарушение конкретного условия доступа и сохранения записи. В следующем уроке уточним, какие байты стоят за словом «отпечаток». Контекст отправителя описан в Solidity 0.8.30. Оглавление курса.

Оглавление серии