Bitcoin-платежи: счёт, заказ и адрес
В прошлом уроке установили точное представление суммы. Теперь свяжем её с конкретным заказом: читатель хочет получить курс 17, сервер назначает цену 30 000 сатоши и выделяет адрес получения. Только после сохранения этой связи адрес можно показать в интерфейсе оплаты.
Будем различать заказ, счёт и наблюдение платежа. Заказ описывает намерение купить материал. Счёт задаёт условия его оплаты. Наблюдение сообщает, что источник увидел определённый выход транзакции. Ни один из этих объектов не заменяет два других, хотя на простом экране они выглядят как одна карточка.
Почему одного адреса недостаточно
Допустим, на странице написан общий адрес библиотеки. Два читателя одновременно отправляют одинаковые суммы. Из цепочки видно получение средств, но не следует, какой пользователь оплатил какой заказ. Комментарий в чужом кошельке не передаётся как достоверный атрибут нашего сервера.
Поэтому в учебной модели каждому on-chain счёту назначаем собственный адрес. Он выдаётся кошельком библиотеки и связывается со счётом в базе до публикации. Этот подход упрощает назначение поступления, но не превращает адрес в идентификатор личности отправителя: оплатить счёт может другой человек, и входы транзакции не являются надёжным «обратным адресом» покупателя.
В будущем можно использовать descriptor-наблюдение или специализированный платёжный сервис. В этом уроке оставляем один локальный источник library и не меняем архитектуру ключей. Серверное создание счёта не включает отправку средств, а приложение браузера никогда не получает доступ к кошельковому RPC.
Сохраняем неизменяемые условия
Опишем минимальную структуру в SQLite-совместимом SQL. Код представляет схему учебного проекта, а не уже созданную базу. Применять её сейчас не будем.
CREATE TABLE invoices (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
course_id INTEGER NOT NULL,
amount_sats INTEGER NOT NULL CHECK (amount_sats > 0),
network TEXT NOT NULL CHECK (network = 'regtest'),
address TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
policy_version TEXT NOT NULL,
CHECK (expires_at > created_at)
);
Поле id содержит случайный непрозрачный идентификатор счёта. Условия не извлекаются из самого идентификатора, а читаются из базы. Его непредсказуемость помогает уменьшить перебор, но не заменяет авторизацию: показать сведения чужому пользователю всё равно нельзя.
amount_sats, course_id, user_id и policy_version фиксируются при создании. Позднейшее изменение цены курса не переписывает старый счёт. Иначе читатель отправит сумму из открытой вкладки, а сервер уже будет сравнивать её с другой ценой. Срок тоже является частью снимка условий, а не вычисляется заново при каждом чтении.
Где берётся адрес
Служебный адаптер вызывает getnewaddress в кошельке library. Полученную строку сопоставляем с сетью лаборатории и записываем в invoices.address. Нельзя принять адрес из клиентского JSON и назвать его адресом продавца: пользователь тогда смог бы направить платёж себе.
Появляется важная граница между внешним вызовом и локальной транзакцией базы. RPC уже мог выделить адрес, а запись счёта ещё не успела сохраниться. Не существует общей транзакции, которая атомарно охватывает Bitcoin Core и SQL. Поэтому обработчик должен явно учитывать незавершённое назначение, а не обещать такую атомарность.
Для лаборатории сначала резервируем запрос создания по уникальному order_id, затем получаем адрес и завершаем счёт. Если процесс падает после получения адреса, оставшийся адрес не выдаётся другому счёту произвольно. Незавершённый запрос попадает в служебное восстановление; возможное поступление на него будет учтено как неназначенное до выяснения связи.
Повтор создания одного заказа
Читатель дважды нажал «Оплатить». Сетевой ответ первого запроса задержался, второй уже пришёл. Если просто дважды вызвать кошелёк, появятся два адреса для одного намерения покупки. Интерфейс начнёт показывать разные инструкции в зависимости от ответа, а служба поддержки получит два независимых счёта.
Уникальное order_id задаёт ключ повторения операции. Если завершённый счёт уже существует, возвращаем его прежние условия. Если создание находится в процессе, сообщаем промежуточный статус. Если тот же ключ используется с другим курсом или пользователем, возвращаем конфликт, а не тихо переносим назначение.
Это правило должно выполняться внутри серверного хранения при конкуренции запросов. Проверка «сначала поискали, потом вставили» без уникального ограничения недостаточна: два обработчика могут одновременно не найти строку. Ограничение базы является последней границей сохранения одного назначения.
Что отдаём интерфейсу
Публичный объект содержит только сведения, необходимые для оплаты владельцем заказа: идентификатор, название курса, строку сатоши, сеть, адрес, срок и текущий статус. Локальные пути cookie, имена кошельков, внутренняя очередь наблюдения и сведения других пользователей не нужны браузеру.
{
"invoiceId": "inv_lab_course17",
"courseId": 17,
"amountSats": "30000",
"network": "regtest",
"expiresAt": "2026-10-12T12:30:00Z",
"status": "awaiting_payment"
}
Это условный объект для объяснения полей, а не фактически созданный счёт. Настоящий случайный идентификатор и адрес появятся только после будущей подготовки. Адрес намеренно не выдумываем в примере JSON. Окончательный ответ должен включать сохранённый адрес, иначе интерфейс не сможет сформировать платёжную ссылку.
Меняем условие: курс подорожал
Пусть после создания счёта цена курса стала 35 000 сатоши. Новый заказ получает новую цену, старый сохраняет 30 000 до своего срока. Экран обязан показывать цену именно своего счёта, а не текущую карточку каталога. Если пользователь хочет перейти на новые условия, сервер создаёт новое намерение по установленной политике.
Предыдущий адрес при этом не исчезает из наблюдения. Перевод на него может прийти позже, и деньги потребуется учесть. Запись старого счёта остаётся в истории даже после истечения срока. Удаление неудобной строки не отменяет существование адреса и не решает вопрос поздней оплаты.
Разбор результата
У нашего счёта теперь есть назначение, сумма, владелец, сеть и срок. Все эти значения определяются сервером и сохраняются до показа инструкции. Наблюдатель позже сможет связать полученный выход с единственным адресом счёта, а политика решит, достаточно ли такого поступления для доступа.
Пока не добавляем поле paid=true и не доверяем нажатию «Я оплатил». Для подтверждения потребуются наблюдения, глубина и независимая выдача доступа. Следующий урок посвятим платёжной ссылке и QR, которые передают инструкцию кошельку, сохраняя серверные условия. Оглавление курса содержит весь маршрут.