Bitcoin-платежи: идемпотентный журнал платежей
Восьмой урок выделил конкретный выход транзакции как единицу наблюдения. Теперь сохраним его так, чтобы повтор ответа не увеличивал оплату. Это основная задача идемпотентного журнала: сколько бы раз обработчик ни увидел один outpoint в одном состоянии, экономический смысл не должен размножаться.
Одновременно нам нужна история изменений. Выход может сначала быть неподтверждённым, затем попасть в блок, позднее оказаться вне основной цепочки. Если хранить только последнюю строку без происхождения, мы не сможем объяснить решение о доступе. Поэтому разделим текущую проекцию и события наблюдения.
Два вида хранения
Проекция отвечает на вопрос «что мы считаем актуальным сейчас». Её ключ — сеть, txid и vout. Журнал отвечает на вопрос «какие сведения привели нас к этому состоянию». Он хранит переходы и не используется как простая сумма всех увиденных сумм.
В SQLite-совместимой схеме это можно выразить следующими таблицами. SQL сейчас не исполняется, база не создаётся; код показывает ограничения будущей модели.
CREATE TABLE payment_outputs (
network TEXT NOT NULL,
txid TEXT NOT NULL,
vout INTEGER NOT NULL CHECK (vout >= 0),
invoice_id TEXT NOT NULL,
amount_sats INTEGER NOT NULL CHECK (amount_sats > 0),
state TEXT NOT NULL,
block_hash TEXT,
updated_at INTEGER NOT NULL,
PRIMARY KEY (network, txid, vout),
FOREIGN KEY (invoice_id) REFERENCES invoices(id)
);
CREATE TABLE observation_events (
id INTEGER PRIMARY KEY,
event_key TEXT NOT NULL UNIQUE,
network TEXT NOT NULL,
txid TEXT NOT NULL,
vout INTEGER NOT NULL,
source_tip TEXT NOT NULL,
state TEXT NOT NULL,
observed_at INTEGER NOT NULL
);
Для фактического SQLite-соединения внешние ключи должны быть включены согласно документации. Наличие текста FOREIGN KEY в файле само по себе ещё не гарантирует его применение каждым соединением. Наш будущий адаптер хранения проверит это при подготовке окружения.
Неизменяемые и изменяемые поля
У outpoint после достоверной нормализации не должна неожиданно меняться сумма или принадлежность счёту. Такой конфликт данных требует разбирательства. Текущий статус и блок, напротив, изменяются по мере наблюдения цепочки. Поэтому при повторе не выполняем бесконтрольное перезаписывание всех колонок из нового JSON.
Операция применения сначала ищет существующую проекцию. Если запись отсутствует, вставляет её с назначением. Если присутствует, сравнивает неизменяемые поля и обновляет только допустимые сведения состояния. Изменение назначения по совпадению суммы запрещено: связь адреса со счётом уже была установлена раньше.
event_key характеризует конкретное событие источника. В модели он может включать outpoint, tip и нормализованное состояние. Если наблюдатель получает те же данные повторно, уникальное ограничение предотвращает второй журналируемый переход. При этом ключ нельзя строить только из времени чтения: оно меняется при каждом повторе.
Сохраняем данные одной транзакцией
Запись события, обновление проекции и контрольной точки чтения должны согласованно фиксироваться в SQL. Иначе процесс может сохранить курсор раньше наблюдений, упасть и навсегда пропустить платёж. Либо сохранить наблюдения, не сохранить курсор и прочитать их снова — именно для этого нужна идемпотентность.
BEGIN
validate immutable fields
append new observation event if absent
update current output projection
advance source checkpoint
COMMIT
Это алгоритм локальной транзакции базы, а не распределённая транзакция с Bitcoin Core. RPC был выполнен до неё и уже имеет собственный контекст. При конкуренции обработчиков используем отдельную последовательность источника или блокировку его контрольной точки, чтобы поздний старый ответ не откатил новую проекцию случайно.
Для SQLite возможен один назначенный писатель наблюдений и явная транзакция записи. В более крупной системе механизм может измениться, но условие остаётся тем же: сохранение курсора не должно обгонять сохранение данных, которые этот курсор объявляет обработанными.
Где находится сумма оплаты
Не вычисляем оплату как SUM(amount_sats) по таблице событий. Повторные состояния одного выхода увеличили бы сумму несколько раз. Сумма строится по текущим допустимым проекциям конкретного счёта, с отдельной политикой подтверждений и конфликтов.
Допустим, выход 30 000 наблюдался при глубине ноль, один и два блока. Журнал содержит три разных состояния, но экономический выход остаётся один. Сумма счёта не становится 90 000. Этот простой пример полезен для редакционного разбора любой будущей реализации хранения.
Если часть событий применяется повторно после перезапуска, итог должен совпасть с однократным последовательным применением. Это свойство модели, которое позднее можно проверить исполнением. Сейчас мы описываем его как ожидаемый результат и не запускаем ни SQL, ни автоматические проверки.
Меняем условие: сбой посередине сохранения
Представим, процесс получил новый блок и начал локальную транзакцию. После вставки события питание пропало до COMMIT. После восстановления база должна либо содержать согласованный завершённый набор, либо откатить незавершённую запись. Нельзя считать курсор продвинутым только потому, что обработчик успел записать его в память.
Следующее чтение начинается с последней сохранённой контрольной точки и может повторно вернуть тот же выход. Уникальные ключи и сравнение неизменяемых полей позволяют безопасно применить его ещё раз. Таким образом, повтор превращается в механизм восстановления, а не в финансовое дублирование.
Если сбой случился после COMMIT, но до ответа служебному планировщику, обработчик также может повторить задачу. Итог снова не меняется, потому что завершённое событие уже присутствует. Понятие «доставили задачу ровно один раз» здесь не требуется для правильной модели.
Исправление не удаляет денежный факт
Если оператор обнаружил ошибочное назначение выхода, он не удаляет строку журнала как будто перевода не было. Создаётся отдельное корректирующее решение с причиной и полномочиями. Оно описывает перенос назначения или необходимость ручного разбора, сохраняя первоначальные сведения источника. Такая история позволяет объяснить, почему текущая проекция отличается от первой интерпретации.
Удаление SQL-строки само по себе также не возвращает средства отправителю. Денежное движение существует в цепочке независимо от удобства таблицы. Поэтому исправление внутреннего учёта и согласованный возврат будут двумя разными операциями с разными последствиями.
История для объяснения решения
Журнал хранит причину смены состояния, источник и его tip. При обращении читателя служба поддержки сможет увидеть, когда поступление стало видимым и почему доступ ещё ожидал подтверждений. Сырые credentials и приватные пользовательские сведения для такого объяснения не нужны.
Отдельно фиксируется версия нормализатора. Изменение кода не должно без следа переписать старую историю. При повторном построении проекции новые правила применяются как явно описанная миграция, а не выдаются за исходное поведение сервиса.
Мы создали границу между историей наблюдений и текущими денежными данными. Следующий урок превратит их в решение по политике подтверждений. Полный маршрут находится в оглавлении.