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

Симуляция, подпись и подтверждение выдачи

Оглавление серии «Реестр» · Урок 8 из 10.

Кошелёк подключается отдельным действием и проверяет локальную сеть. Теперь добавим выдачу и отзыв из веб-формы. Ожидаемый результат — приложение сначала проверяет вызов в текущем состоянии, затем просит подпись, получает хеш отправленной транзакции и отдельно ждёт успешного включения в блок. Эти стадии не следует объединять одним сообщением «готово». Пользователь должен понимать, требуется ли его решение, отправлена ли операция и действительно ли контракт изменил запись.

Кнопки вызывают функции issue и revoke с введённым хешем. Контракт сохраняет собственные проверки полномочий. Для владельца первая выдача неизвестного ненулевого идентификатора должна быть допустима. Для другого неразрешённого адреса — отвергнута. Интерфейс не пытается подделать владельца в аргументе: account относится к аккаунту подписанта, а контракт получит фактического отправителя. Чтение статуса перед кнопкой может помочь человеку, но не отменяет проверку при исполнении.

Симуляция не создаёт запись

simulateContract выполняет проверяемый вызов через Public Client без отправки новой транзакции. Он может обнаружить AlreadyIssued, отсутствие полномочий или неправильный идентификатор и вернуть декодированную ошибку. При успешном результате получаем подготовленный request, который передаём Wallet Client. Такая последовательность снижает число заведомо неудачных запросов подписи и сохраняет согласованность аргументов. Но симуляция относится к одному состоянию цепочки, а будущая транзакция выполнится позднее.

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

Полный интерфейс операции

Ниже полный main.js восьмого состояния. Предыдущие действия вычисления хеша, чтения и подключения остаются. Новая функция transact захватывает конфигурацию, хеш и аккаунт для конкретной операции. Во время ожидания блокируются две кнопки записи и устанавливается pending. Это предотвращает случайную повторную отправку из одной страницы. Оно не является защитой всей сети от повторов: окончательные ограничения уникальности и отзыва выполняются контрактом.

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';
const status=document.querySelector('#status'),input=document.querySelector('#digest');
function message(value){status.textContent=value;}
async function action(task) {try{await task();}catch(error){message(error.shortMessage || error.message);}}
document.querySelector('#hash').addEventListener('click',()=>action(async()=>{
 input.value=await fileDigest(document.querySelector('#file').files[0]);message('Хеш посчитан локально; файл не отправлен.');
}));
async function read(readDigest,readConfig) {
 const digest=readDigest || digestValue(input.value);const config=readConfig || await deployment();const item=await certificate(config,digest);
 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;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();
  message('Проверяем вызов в текущем состоянии сети');
  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('Аккаунт изменился после симуляции');
  message('Ожидаем решение кошелька');const hash=await client.writeContract(request);
  message('Транзакция отправлена: '+hash);
  const receipt=await publicClient.waitForTransactionReceipt({hash,timeout:60000});
  if(receipt.status!=='success')throw Error('Транзакция завершилась revert');
  await read(digest,config);message(`Подтверждено в блоке ${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)));

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

Подпись остаётся решением пользователя

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

Текст интерфейса не заменяет сведения самого кошелька. В лаборатории пользователю важно проверить локальную сеть, адрес контракта и назначение функции. Для производственного приложения понадобятся более содержательные объяснения и управление рисками подписи. Здесь операции относятся только к собственной сети Anvil и учебным документам. Настоящая сеть не выбирается как резервный вариант, если локальный RPC недоступен. Ошибка среды должна быть исправлена в той же лаборатории, где находится нужный экземпляр.

Квитанция — новая граница

waitForTransactionReceipt ожидает квитанцию по хешу. В примере задан тайм-аут 60 секунд. Его истечение не доказывает, что транзакция никогда не выполнится: ожидание приложения закончилось, а сеть может продолжить обрабатывать операцию. Для настоящего продукта потребуется хранить хеш незавершённой операции и позволять проверить её позже. Нельзя при тайм-ауте безусловно отправлять новую выдачу, поскольку первая могла уже попасть в блок или попасть туда через некоторое время.

В квитанции проверяем status. Только success позволяет сообщить подтверждение изменения. При reverted состояние операции не считается успешно применённым. Даже успешный локальный блок не обсуждает все вопросы финальности публичной цепочки: реорганизации и политика числа подтверждений требуют отдельного решения. В лаборатории используем непосредственный результат Anvil, чтобы изучить стадию квитанции без внешней сетевой инфраструктуры и настоящих средств.

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

Ошибки остаются предметными

ABI включает собственные ошибки контракта. Библиотека может показать различимые причины отказа, а наш обработчик предпочитает краткое сообщение, если оно есть. Для продукта можно сопоставить известные ошибки русским объяснениям. Например, повторная выдача требует посмотреть существующую запись, а отсутствие полномочий — проверить издателя. Общая фраза «попробуйте ещё раз» для обоих случаев толкает пользователя к бессмысленным повторным действиям и скрывает правило, которое он должен понять.

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

Ожидаемые последовательности

Самостоятельно выдайте новый хеш владельцем и дождитесь квитанции: запись должна стать найденной. Повторная выдача ожидается отвергнутой при симуляции. Затем отзовите существующую запись: издатель сохранится, а статус изменится. Откажитесь от подписи другого вызова: кнопки должны восстановиться без сообщения об успешной записи. Эти последовательности при написании не запускались. API описан в simulateContract, writeContract и ожидании квитанции. Дальше рассмотрим журналы, которые объясняют историю изменений, сохранив getter источником актуального состояния.

Исходники этого шага

Архив серии содержит полную папку lesson-08. Возьмите именно её; переносить файлы из соседних папок не требуется. Версии и самостоятельная подготовка описаны в README. Пример, установка зависимостей и его выполнение при написании не запускались; результаты в тексте ожидаются по разбору исходников. Материал сохранён локально как Markdown-черновик.