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

Bitcoin-платежи: защищённый адаптер RPC

Браузер уже умеет показать инструкцию оплаты. Но узнавать о получении средств он не должен через открытый RPC Bitcoin Core. Между кошельком и публичным HTTP-интерфейсом поставим служебный адаптер. Его задача — получить ограниченное наблюдение и передать нормализованные данные внутреннему обработчику.

В этой главе напишем небольшой клиент чтения для Python 3.12. Он не является готовым платёжным сервером и не запускается. Отдельно обсудим происхождение endpoint, cookie, перечень методов и пределы ответа. Авторизация пользователей сайта останется другим уровнем, а создание адреса счёта — отдельной служебной операцией.

Определяем границу доверия

Core в нашей лаборатории слушает loopback. Его cookie хранится в каталоге данных и читается только служебным процессом. Браузеру не выдаются ни cookie, ни путь к нему, ни возможность выбрать RPC-метод. Публичная форма «введите метод и параметры» разрушила бы это разделение, даже если скрыть поле за красивым интерфейсом.

В документации JSON-RPC Core 31.0 описаны root- и wallet-endpoints. Для операций кошелька используем фиксированное имя library. Имя не приходит из URL пользователя: иначе параметр пути мог бы перенаправить вызов на другую роль или создать неоднозначность маршрутизации.

Учитываем также транспорт. Обычный HTTP RPC не обеспечивает шифрование по открытой сети. Loopback в локальной лаборатории является частью профиля, а разрешение чужих IP не превращает соединение в защищённое. Здесь не расширяем поверхность доступа и не публикуем порт RPC через сайт.

Разрешаем только необходимые чтения

Наблюдателю достаточно небольшого списка. Для информации о цепочке нужен getblockchaininfo, для кошельковой истории — listsinceblock, для уточнения известной транзакции — gettransaction. В дальнейшем методы сверки добавляются отдельным изменением адаптера после разбора их назначения.

READ_METHODS = {
    "getblockchaininfo": "root",
    "listsinceblock": "wallet",
    "gettransaction": "wallet",
}

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

Операция getnewaddress изменяет состояние кошелька и поэтому не включена в читательский адаптер. Её использует отдельный обработчик назначения адресов из четвёртого урока. Команды подписи и отправления отсутствуют в обоих публичных маршрутах. Ограничение важно проверять по фактическому списку, а не по названию класса ReadClient.

Формируем JSON-RPC 2.0

Приведём ядро клиента с фиксированным origin и ограничением размера ответа. Путь cookie передаёт конфигурация службы, а не HTTP-запрос читателя. Для простоты используется отдельный request id на каждый вызов.

import base64
import json
import uuid
from decimal import Decimal
from urllib.request import Request, build_opener, ProxyHandler

class ReadRpc:
    def __init__(self, cookie_path):
        self.cookie_path = cookie_path
        self.opener = build_opener(ProxyHandler({}))

    def call(self, method, params):
        scope = READ_METHODS.get(method)
        if scope is None:
            raise ValueError("method is not permitted")
        request_id = uuid.uuid4().hex
        credentials = self.cookie_path.read_text().strip()
        auth = base64.b64encode(credentials.encode()).decode("ascii")
        url = "http://127.0.0.1:18443/"
        if scope == "wallet":
            url += "wallet/library/"
        data = json.dumps({
            "jsonrpc": "2.0", "id": request_id,
            "method": method, "params": params
        }).encode("utf-8")
        request = Request(url, data=data, headers={
            "Content-Type": "application/json",
            "Authorization": "Basic " + auth
        })
        with self.opener.open(request, timeout=5) as response:
            raw = response.read(2_000_001)
        if len(raw) > 2_000_000:
            raise ValueError("RPC response too large")
        payload = json.loads(raw, parse_float=Decimal)
        if payload.get("id") != request_id:
            raise ValueError("RPC response id mismatch")
        if "error" in payload:
            raise RuntimeError("RPC operation rejected")
        if "result" not in payload:
            raise ValueError("RPC result missing")
        return payload["result"]

Ожидаем, что разрешённое чтение возвращает result, а запрещённый метод отвергается до отправки запроса. Это ожидаемое поведение текста программы, не отчёт о выполненном вызове. Код намеренно не выводит credentials и сырой ответ в журнал.

Отключение наследуемого прокси помогает удержать выбранный путь loopback. Однако стандартный opener и этот короткий класс не заменяют полный транспортный обзор: для эксплуатационной реализации потребуется запрет нежелательных редиректов, строгая проверка envelope, ограничения времени соединения и чтения, а также политика ошибок. Мы явно оставляем эти вопросы отдельными этапами.

Сверяем смысл ответа

Core при JSON-RPC 2.0 может вернуть ошибку операции с HTTP 200. Поэтому одного успешного HTTP-кода недостаточно. С другой стороны, отсутствие корректного JSON после обрыва соединения ещё не означает, что сервер отверг операцию: результат мог потеряться по дороге.

После envelope проверяется схема конкретного result: наличие chain для информации о сети, массивы для истории, правильные типы сумм и идентификаторов. Не стоит складывать числа из произвольного JSON только потому, что объект прошёл проверку request id. Граница денежного преобразования из третьего урока применяется к нужным полям отдельно.

Cookie может обновляться после перезапуска Core. Чтение перед вызовом позволяет не держать прежнее значение бесконечно. Ошибка доступа не должна печатать содержимое файла в сообщении пользователю. Вместо этого служебный журнал сообщает категорию отказа и идентификатор операции, а публичный статус говорит о задержке наблюдения.

Проверяем полномочия параметров

Сам по себе разрешённый метод тоже может быть использован неправильно. Например, обработчик истории не должен принимать произвольный контрольный блок из публичной формы: иначе внешний пользователь будет запускать тяжёлые повторные чтения. Контрольная точка принадлежит служебному наблюдателю и обновляется по его журналу. Аналогично txid для уточнения сначала связывается с известным счётом, если запрос начинается от пользовательского действия.

Для эксплуатации добавляем отдельный обработчик запрета HTTP-редиректов. Базовая авторизация предназначена фиксированному локальному endpoint и не должна сопровождать запрос на неожиданное направление. При изменении адреса RPC конфигурация проходит явный пересмотр; автоматическое следование перенаправлению не заменяет эту процедуру. Короткий учебный клиент показывает ядро envelope, а транспортная защита остаётся явно обозначенным незавершённым компонентом.

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

Меняем условие: клиент пытается отправить средства

Предположим, внутренний разработчик случайно передал sendtoaddress через общий метод call. В нашей модели такой запрос прекращается до сетевого обращения. Это важнее, чем затем заметить списание в кошельке. Для будущей отправки возврата потребуется другой процесс с отдельным согласованием, а не расширение публичного чтения.

Если же браузер меняет параметр wallet=reader, сервер вообще не должен использовать его для построения endpoint. Публичный API принимает только идентификатор собственного счёта. Какой источник наблюдения связан с этим счётом, определяет сохранённая конфигурация библиотеки.

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

Оглавление серии