Foundry: управление временем и номером блока в тесте
В предыдущем уроке отдельный читатель возвращал активный выпуск. Теперь добавим наблюдателя, который сохраняет время и номер блока своего чтения. Это новая программа вокруг реестра: базовый CourseCatalog не имеет срока действия и не меняется.
Подготовим CourseObserver и сценарий точной границы свежести. Время моделируется через vm.warp, номер блока — через vm.roll. Примеры пока не исполнялись; выбранные отметки являются условиями локального сценария и не выдаются за время настоящей цепочки.
Возраст чего мы измеряем
Наблюдатель знает момент своего последнего успешного чтения. Он не знает дату создания учебника, дату публикации предыдущего выпуска или момент получения файла браузером. Поэтому слово «свежесть» в данном уроке означает возраст сохранённого наблюдения, а не качество или актуальность содержания.
Блок и время — разные величины. Номер блока задаёт положение в цепочке, timestamp — временную отметку. Нельзя просто считать один блок одной секундой. Даже если у сети есть ожидаемый интервал, фактическая модель приложения должна ясно различать эти единицы.
Сохраним файл src/CourseObserver.sol. В запись попадут digest, время и блок чтения. Метод isFresh принимает максимально допустимый возраст в секундах и считает равенство порогу свежим состоянием. Такая включительная граница выбрана явно и будет проверена рядом с соседним моментом.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {CourseReader} from "./CourseReader.sol";
contract CourseObserver {
struct Observation {
bytes32 digest;
uint256 seenAt;
uint256 seenBlock;
}
CourseReader public immutable reader;
mapping(uint256 => Observation) public observations;
constructor(CourseReader reader_) {
reader = reader_;
}
function refresh(uint256 id) external {
bytes32 digest = reader.activeDigest(id);
observations[id] = Observation(
digest, block.timestamp, block.number
);
}
function isFresh(uint256 id, uint256 maxAge) external view
returns (bool)
{
Observation memory item = observations[id];
if (item.digest == bytes32(0)) return false;
if (block.timestamp < item.seenAt) return false;
return block.timestamp - item.seenAt <= maxAge;
}
}
Нулевой digest различает отсутствующее наблюдение. Если локальная среда искусственно переместит время назад, метод возвращает false перед вычитанием, избегая арифметического underflow. Это защитная трактовка состояния сценария; мы не утверждаем, что обычная подтверждённая история сети свободно двигает timestamp назад.
Создаём реальную локальную цепочку компонентов
Для подготовки CourseObserverTest нужны импорты Test, CourseCatalog, CourseReader вместе с ICourseCatalog и нового CourseObserver. В отличие от mock-главы, здесь можно использовать настоящий getter локального реестра. Общая конфигурация роли остаётся прежней.
CourseCatalog internal catalog;
CourseReader internal reader;
CourseObserver internal observer;
address internal publisher = address(0xA11CE);
function setUp() public {
catalog = new CourseCatalog(publisher);
reader = new CourseReader(ICourseCatalog(address(catalog)));
observer = new CourseObserver(reader);
vm.prank(publisher);
catalog.publish(17, keccak256("edition-one"));
}
Этот фрагмент помещается внутри отдельного CourseObserverTest is Test в файле test/CourseObserver.t.sol. Предварительная публикация нужна именно этому набору и не добавляется в setUp исходного CourseCatalogTest. Так остальные сценарии сохраняют свою пустую подготовку.
Проверяем равенство порогу и следующий момент
Пусть наблюдение выполнено при timestamp 1000, а допустимый возраст составляет 90 секунд. В момент 1090 оно ещё свежее, в 1091 — уже нет. Два соседних случая обнаруживают ошибку между < и <=, которую один произвольный момент может скрыть.
function testFreshnessBoundaryUsesSeconds() public {
vm.warp(1000);
vm.roll(10);
observer.refresh(17);
vm.warp(1090);
assertTrue(observer.isFresh(17, 90));
vm.warp(1091);
assertFalse(observer.isFresh(17, 90));
}
warp меняет временную отметку учебной среды. Он не является реальным ожиданием девяноста секунд и не запускает таймер браузера. Все строки выполняются в рамках будущего локального сценария, поэтому его результат не измеряет задержку RPC или скорость появления нового блока.
Отдельно изменяем номер блока
Если после refresh увеличить только block.number, возраст в нашей модели не должен измениться. Время остаётся 1000, поэтому при том же допустимом возрасте наблюдение свежее. Такой пример предназначен для обнаружения случайного использования блока вместо timestamp.
function testBlockNumberAloneDoesNotAgeObservation() public {
vm.warp(1000);
vm.roll(10);
observer.refresh(17);
vm.roll(100);
assertTrue(observer.isFresh(17, 90));
(, uint256 seenAt, uint256 seenBlock) = observer.observations(17);
assertEq(seenAt, 1000);
assertEq(seenBlock, 10);
}
Сохранённый seenBlock описывает момент чтения и не становится текущим блоком автоматически. Его изменение потребовало бы нового refresh. Поле полезно для происхождения снимка, но само по себе не доказывает финальность или принадлежность конкретному block hash.
Меняем выпуск после наблюдения
Представим, что реестр получает новый digest сразу после сохранённого чтения. Наблюдатель продолжает хранить старый выпуск, пока не вызван refresh. Его возраст может оставаться маленьким, но он уже не равен текущему реестру. Следовательно, свежесть по времени не гарантирует актуальность при каждом изменении.
Для продукта понадобятся подписка на события, согласованные снимки или иная процедура обновления. Наш observer показывает ограниченное временное правило, а не решает всю задачу кеширования. В браузерном курсе отдельно рассматривается связь снимка с сетью, адресом и блоком.
Если курс отключён, последующий refresh получит отказ читателя и не должен штатно записать новое наблюдение. Старый снимок останется с прежним возрастом. Нужно явно решить, как продукт трактует такой снимок; нельзя считать отказ обновления доказательством продолжающейся активности курса.
Кто обновляет наблюдение
Метод refresh нашего наблюдателя открыт любому отправителю. Он не принимает произвольный digest от пользователя, а сам получает его через CourseReader. Поэтому чужой вызов может обновить момент наблюдения, но не должен выбрать собственный хеш в обход зависимости. Если продукту нужен запрет обновления посторонними, это отдельное требование, которого текущий учебный тип не содержит.
Повторное чтение того же выпуска в позднем блоке обновит seenAt. Наблюдение станет молодым, хотя сам материал мог не изменяться месяцами. Это наглядное подтверждение границы нашей метрики: измеряется время проверки источника, а не возраст учебника. Для даты редакции манифест должен иметь собственное поле и правила доверия к нему.
Также частое обновление не доказывает доступность файла. Реестр и reader сообщают digest и активность, но не загружают материал из IPFS или с веб-сервера. Отдельный мониторинг может подтвердить доступность выбранного источника, однако его результат нельзя автоматически приписать Solidity-наблюдателю. Разделение этих признаков помогает не выводить из одной свежей отметки слишком широкое обещание.
Если мы изменим максимально допустимый возраст с девяноста секунд на сто двадцать, сохранённая запись не меняется. Меняется только правило запроса isFresh. Такие параметры стоит записывать рядом с потребительским сценарием, чтобы разные интерфейсы не называли один снимок свежим по несовместимым условиям без объяснения.
Сохраняем модель единиц и границ
Для maxAge = 0 правило допускает только тот же timestamp. Это корректная особенность выбранного включительного сравнения, а не автоматически рекомендуемая настройка приложения. Для больших допустимых возрастов правило становится мягче; выбор значения зависит от конкретной задачи наблюдения.
Мы не используем timestamp как источник надёжной случайности и не выводим из него авторство публикации. Он предоставляет одну величину окружения, которую приложение должно интерпретировать по определённому договору. Проверка точной границы помогает выразить этот договор без неявного смешения секунд и блоков.
В следующем уроке подключим известный локальный снимок. API изменения времени и блока приведён в Vm 1.9.7, глобальные величины — в Solidity 0.8.30. Оглавление курса.