Проверка файла и границы доказательства
Оглавление серии «Реестр» · Урок 10 из 10.
Реестр уже даёт чтение, изменение и историю. Теперь соберём пользовательскую проверку файла. Посетитель выбирает оригинальный документ и указывает известный доверенный адрес издателя. Браузер рассчитывает SHA-256 локально, читает запись в конкретном блоке и сравнивает три условия: совпал идентификатор, совпал издатель, запись не отозвана. Ожидаемый результат — понятный отчёт с сетью, контрактом и блоком наблюдения. Он не будет утверждать личность получателя, качество обучения или юридический статус документа.
Эта граница важнее красивой зелёной галочки. Веб-страница может технически найти активную запись от неизвестного адреса, но доверие не появляется из самого факта существования. Адрес организации должен быть известен по отдельному надёжному каналу. В лаборатории читатель указывает собственный доверенный адрес. Для продукта потребуется механизм предоставления и обновления списка издателей, объясняющий происхождение этой информации. Произвольный ввод адреса остаётся условием, заданным человеком, а не внешней аккредитацией.
Расчёт от выбранного файла
Финальная кнопка заново вычисляет хеш из объекта File. Она не полагается на значение редактируемого поля #digest, которое человек мог изменить вручную. Файл ограничен пятью мебибайтами, как в первом уроке. Байты не отправляются RPC: по сети передаётся только рассчитанный идентификатор getter. Имя файла не используется как доказательство и не входит в отчёт. Переименование не мешает проверке, а повторный экспорт PDF с изменёнными байтами может дать другую запись.
Нулевой доверенный адрес запрещён. Если запись отсутствует, mapping вернёт нулевого издателя, а сравнение с ненулевым адресом не выполнится. Поэтому отсутствие не превращается в активность только из-за revoked == false. Результат accepted определяется совокупностью условий. Название означает лишь принятие по выбранной модели реестра на данном блоке. Для продукта полезно показывать отдельные причины, например неизвестная запись, другой издатель или отзыв, чтобы человек мог понять отказ.
Один блок для состояния
Перед getter получаем номер блока и передаём его в readContract как blockNumber. Так отчёт относится к явно указанному состоянию, а не к неопределённому «когда-то сейчас». Если после этого будет отправлен отзыв, старый отчёт останется снимком прежнего блока. Он не обещает вечную действительность. Повторная проверка должна получить новую границу и новое значение. В публичной сети потребуется ещё политика финальности; в нашей лаборатории рассматриваем локальное состояние Anvil.
Ниже полный main.js десятого состояния. Он сохраняет отдельные действия чтения, кошелька, транзакций и истории, а также добавляет проверку файла и экспорт. Обратите внимание на revision: результат долгой операции публикуется только если пользователь не изменил исходные данные после её начала. Это правило связано с текущим интерфейсом, а не с блокчейном. Асинхронный расчёт файла и ожидание RPC могут завершиться позже, чем изменился выбор человека.
import {fileDigest} from './hash.js';
import {isAddress,zeroAddress} from 'viem';
import {deployment,digestValue,certificate,publicClient} from './client.js';
import {walletState} from './wallet.js';
import {abi} from './abi.js';
import {history} from './history.js';
const status=document.querySelector('#status'),input=document.querySelector('#digest');
function message(value){status.textContent=value;}
async function action(task) {let token;try{const job=task();token=revision;await job;}catch(error){if(token===undefined || token===revision)message(error.shortMessage || error.message);}}
document.querySelector('#hash').addEventListener('click',()=>action(async()=>{
invalidate();const token=revision;const digest=await fileDigest(document.querySelector('#file').files[0]);if(token!==revision)return;input.value=digest;message('Хеш посчитан локально; файл не отправлен.');
}));
async function read(readDigest,readConfig,readRevision) {
if(readRevision===undefined){invalidate();readRevision=revision;}
const digest=readDigest || digestValue(input.value);const config=readConfig || await deployment();const item=await certificate(config,digest);
if(readRevision!==revision)return;
document.querySelector('#record').textContent=JSON.stringify({digest,issuer:item.issuer,issuedAt:item.issuedAt.toString(),revoked:item.revoked},null,2);
message(item.issuer===zeroAddress?'Запись отсутствует':item.revoked?'Запись отозвана':'Запись найдена');
}
document.querySelector('#read').addEventListener('click',()=>action(read));
const wallet=walletState(message);
document.querySelector('#connect').addEventListener('click',()=>action(async()=>message('Подключён '+await wallet.connect())));
window.addEventListener('pagehide',()=>wallet.dispose());
let pending=false;
async function transact(functionName) {
if(pending)return;invalidate();const token=revision;const notify=value=>{if(token===revision)message(value);};pending=true;
for(const id of ['issue','revoke'])document.querySelector('#'+id).disabled=true;
try {
const digest=digestValue(input.value);const config=await deployment();const {client,account}=await wallet.current();
notify('Проверяем вызов в текущем состоянии сети');
const {request}=await publicClient.simulateContract({address:config.address,abi,functionName,args:[digest],account});
const latest=await wallet.current();if(latest.account.toLowerCase()!==account.toLowerCase())throw Error('Аккаунт изменился после симуляции');
if(token!==revision)return;notify('Ожидаем решение кошелька');const hash=await client.writeContract(request);
notify('Транзакция отправлена: '+hash);
const receipt=await publicClient.waitForTransactionReceipt({hash,timeout:60000});
if(receipt.status!=='success')throw Error('Транзакция завершилась revert');
await read(digest,config,token);notify(`Подтверждено в блоке ${receipt.blockNumber}`);
} finally {pending=false;for(const id of ['issue','revoke'])document.querySelector('#'+id).disabled=false;}
}
for(const method of ['issue','revoke'])document.querySelector('#'+method).addEventListener('click',()=>action(()=>transact(method)));
document.querySelector('#history').addEventListener('click',()=>action(async()=>{
invalidate();const token=revision;const digest=digestValue(input.value);const config=await deployment();const rows=await history(config,digest);if(token!==revision)return;
document.querySelector('#events').textContent=JSON.stringify(rows,null,2);message('События получены; актуальное состояние читается отдельно.');
}));
let report=null,revision=0;
function invalidate(){++revision;report=null;document.querySelector('#export').disabled=true;message('Исходные данные изменены; выполните нужное действие заново.');}
for(const id of ['file','trusted','digest'])document.querySelector('#'+id).addEventListener('input',invalidate);
document.querySelector('#verify').addEventListener('click',()=>action(async()=>{
invalidate();const token=revision,file=document.querySelector('#file').files[0];
const trusted=document.querySelector('#trusted').value.trim();if(!isAddress(trusted) || trusted===zeroAddress)throw Error('Укажите доверенный адрес издателя');
const digest=await fileDigest(file);
const config=await deployment();const block=await publicClient.getBlockNumber();
const item=await publicClient.readContract({address:config.address,abi,functionName:'get',args:[digest],blockNumber:block});
if(token!==revision)return;
const accepted=item.issuer.toLowerCase()===trusted.toLowerCase() && !item.revoked;
report={schema:1,chainId:31337,contract:config.address,block:block.toString(),digest,trusted,issuer:item.issuer,revoked:item.revoked,accepted};
input.value=digest;document.querySelector('#record').textContent=JSON.stringify(report,null,2);
document.querySelector('#export').disabled=false;message(accepted?'На выбранном блоке совпали хеш, издатель и активный статус.':'Условия доверия не выполнены.');
}));
document.querySelector('#export').addEventListener('click',()=>{
if(!report)return;const url=URL.createObjectURL(new Blob([JSON.stringify(report,null,2)],{type:'application/json'}));
const link=document.createElement('a');link.href=url;link.download='certificate-check.json';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
});
Устаревший результат не должен стать новым
Функция invalidate очищает отчёт, отключает экспорт и увеличивает ревизию. Её вызывают изменения файла, доверенного адреса и ручного хеша. При начале проверки сохраняем текущий номер. После вычисления и чтения сравниваем его с новым значением. Если данные уже изменены, не публикуем старый результат. Без такой проверки человек мог бы выбрать второй файл, пока читается первый, и получить галочку рядом с новым именем, относящуюся фактически к прежнему содержимому.
Обычная кнопка вычисления хеша тоже учитывает ревизию. Программное присваивание input.value само по себе не создаёт пользовательское событие input, поэтому нельзя полагаться только на обработчик поля. Собственная выдача или отзыв также очищает прошлый отчёт перед действием. При этом внешнее изменение цепочки не будет автоматически обнаружено нашим локальным интерфейсом. Именно поэтому в отчёте сохраняется блок: он описывает наблюдение, а не постоянную подписку на состояние.
Тот же номер используется при обычном чтении и запросе истории. Новое действие очищает возможность экспорта прежнего отчёта; поздний результат другого запроса не может перезаписать панель. Хеш для транзакции снимается до первого ожидания, чтобы изменение поля во время проверки среды не сменило исходное намерение. До запроса подписи также проверяется актуальность номера. После отправки отменить цепочку таким номером уже нельзя: он ограничивает публикацию результата в интерфейсе, а не исполнение принятой транзакции.
Состав экспортируемого результата
JSON содержит версию схемы, chainId, адрес контракта, номер блока, хеш, доверенный адрес, фактического издателя, признак отзыва и итог сравнения. BigInt номера блока превращается в строку. Документ и приватные данные кошелька не включены. Экспорт создаётся через Blob, временный объектный URL и ссылку загрузки. После действия URL отзывается. Это локальная подготовка файла в браузере, без отправки результата внешнему получателю или серверу.
Экспорт не имеет цифровой подписи проверяющего сервиса. Его можно редактировать как любой обычный JSON. Поэтому он удобен для воспроизводимого описания того, что хотели проверить, но не является самостоятельным удостоверением подлинности. Другой участник может использовать указанные сеть, адрес, блок и хеш для собственного чтения. В случае локального Anvil он должен иметь доступ к соответствующему состоянию лаборатории: номер 31337 у другого человека не обозначает вашу конкретную цепочку автоматически.
Это тонкое различие идентификатора сети и идентичности данных особенно заметно в локальном примере. Две независимые лаборатории могут иметь одинаковый chainId и даже совпадающие адреса после похожей последовательности развёртывания, но разные записи. Настоящая система должна точнее определять доверенную цепочку и способ обращения к ней. Наш отчёт намеренно связан с собственным RPC читателя и не выдаётся за переносимое доказательство существования записи в общественной сети.
Проверка байтов и доверие к содержанию
Даже полный успех означает ограниченное утверждение: данная последовательность байтов имеет активную запись от заданного адреса в выбранном экземпляре и блоке. Он не доказывает, кто выбрал файл, кому он принадлежит или насколько верны написанные внутри сведения. Ошибочный издатель может зарегистрировать неправильный документ, а владелец может разрешить неподходящий адрес. Процедуры выдачи и исправления должны существовать вне короткой функции сравнения и быть понятны пользователям сервиса.
Хеш не является шифрованием. Публикация его в цепочке не скрывает известный документ от сопоставления и не отменяет требований к размещению персональных данных. Лаборатория использует собственные маленькие файлы без информации учащихся. Если расширять пример, сначала определите, какие идентификаторы допускается публиковать, кто имеет доступ к оригиналам и как обрабатываются ошибочные выдачи. Технический реестр предоставляет механизм, а не все правила организации учебного процесса.
Ожидаемый итог серии
Самостоятельно выдайте хеш собственного файла и проверьте его с правильным доверенным адресом: условия должны совпасть. Затем укажите другой адрес: итог должен стать отрицательным при той же найденной записи. После отзыва новая проверка должна увидеть изменённый статус. Измените файл во время ожидания старого запроса: прежний результат не должен разрешить экспорт для нового выбора. Все эти случаи являются ожидаемыми по исходникам; компиляция, кошелёк и транзакции при подготовке не запускались.
Работа с блоком описана в readContract viem, локальный хеш — в Web Crypto digest, создание файла — в Blob. Курс завершает один цельный путь от байтов документа до ограниченного результата проверки. Дальнейшая настоящая система потребует аудита контракта, управления издателями, сохранения состояния, политики подтверждений и самостоятельной модели доверия. Эти задачи можно изучать отдельно, сохранив точные обещания каждого уже реализованного шага.
Исходники этого шага
Архив серии содержит полную папку lesson-10. Возьмите именно её; переносить файлы из соседних папок не требуется. Версии и самостоятельная подготовка описаны в README. Пример, установка зависимостей и его выполнение при написании не запускались; результаты в тексте ожидаются по разбору исходников. Материал сохранён локально как Markdown-черновик.