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

Bitcoin-платежи: сатоши и точность расчёта суммы

В лаборатории появились две роли кошельков, но приложение ещё не умеет выражать цену. Bitcoin Core возвращает многие денежные поля в BTC, тогда как счёт нашей библиотеки стоит 30 000 сатоши. Если случайно трактовать эти числа как одну единицу, ошибка будет не округлением в копейках, а изменением суммы на восемь порядков.

В этом уроке установим единый денежный контракт между RPC, сервером, базой и браузером. Серверные расчёты выполняются над целыми сатоши. Десятичная запись BTC используется только на внешней границе. Примеры написаны для Python 3.12 и браузерного JavaScript; они сейчас не выполняются.

Выбираем внутреннюю единицу

Один BTC содержит сто миллионов сатоши. Значит, цена курса 30 000 соответствует десятичной строке 0.00030000 BTC. Внутри модели храним 30000, а название поля делаем явным: amount_sats. Имя amount без указания единицы слишком легко скопировать в другой интерфейс.

Не вводим дробные сатоши для on-chain счёта. Lightning позже использует миллисатоши и получит отдельный тип с другим названием. Возможность протокола представить более мелкую величину не меняет автоматически единицу нашего заказа. Преобразование между платёжными маршрутами будет явно проверяться на точность.

SQL-поле для сатоши должно хранить целое значение с достаточным диапазоном. Максимальное теоретическое количество сатоши Bitcoin меньше предела знакового 64-битного целого. Однако граница типа не является разрешением выставлять любые суммы: коммерческая модель библиотеки устанавливает собственные меньшие лимиты заказа и возврата.

Получаем число без двоичного округления

Десятичные дроби вроде 0.1 не обязаны точно представляться двоичным float. Поэтому сначала прочитаем денежное значение из JSON как Decimal. В документации Python json для этой задачи есть параметр parse_float; правила Decimal описаны в отдельном справочнике.

import json
from decimal import Decimal

SATOSHIS_PER_BTC = 100_000_000

def btc_to_sats(value: Decimal | int) -> int:
    if type(value) is int:
        value = Decimal(value)
    if not isinstance(value, Decimal) or not value.is_finite() or value < 0:
        raise ValueError("invalid BTC amount")
    parts = value.as_tuple()
    if len(parts.digits) > 64 or not -64 <= parts.exponent <= 64:
        raise ValueError("amount representation too large")
    numerator, denominator = value.as_integer_ratio()
    amount, remainder = divmod(numerator * SATOSHIS_PER_BTC, denominator)
    if remainder != 0:
        raise ValueError("fractional satoshi")
    if amount > 21_000_000 * SATOSHIS_PER_BTC:
        raise ValueError("amount exceeds Bitcoin supply bound")
    return amount

payload = json.loads('{"amount":0.00030000}', parse_float=Decimal)
price_sats = btc_to_sats(payload["amount"])

Ожидаемый результат последней строки — целое 30000. Это разбор конкретного входного текста, а не снятый ответ нашего узла. Важен порядок действий: сначала точное десятичное чтение, потом точное рациональное масштабирование, затем проверка остатка от целочисленного деления. Преобразование через float, а после него через строку уже не гарантирует сохранение исходного числа.

Метод as_integer_ratio возвращает точное отношение, поэтому операция не зависит от округления текущего контекста Decimal. До неё ограничиваем длину и экспоненту представления: это собственный предел ресурсов входа, а не новая денежная единица. Целое JSON-число вроде нуля тоже допустимо и превращается в Decimal без промежуточного float. Логическое значение такой веткой не принимается.

Функция принимает только неотрицательные суммы. В RPC некоторые поля, например комиссия отправления, имеют отрицательный знак. Их нормализуем отдельно по смыслу поля, а не передаём в эту функцию как сумму полученного выхода. Одна универсальная функция «денежное число любого назначения» скрыла бы ошибку направления.

Форматируем BTC обратно

Для платёжной ссылки или будущего параметра RPC понадобится десятичная запись. Её можно получить целочисленным делением, вообще не создавая дробное число:

def sats_to_btc_text(amount_sats: int) -> str:
    if type(amount_sats) is not int or amount_sats < 0:
        raise ValueError("non-negative integer satoshis required")
    if amount_sats > 21_000_000 * SATOSHIS_PER_BTC:
        raise ValueError("amount out of range")
    whole, fraction = divmod(amount_sats, SATOSHIS_PER_BTC)
    return f"{whole}.{fraction:08d}"

Для 30000 ожидаем 0.00030000, для одного сатоши — 0.00000001. В строке используется точка, отсутствуют разделители тысяч и научная запись. Проверка type(...) is int намеренно исключает логические значения Python: True не должен незаметно становиться ценой в один сатоши.

Строка удобна для URI. При обращении к конкретному RPC отдельно сверяем требуемый тип аргумента: одни методы принимают денежные строки, другие схемы ждут число. Нельзя механически считать, что каждый сервер понимает одинаковое представление только потому, что JSON допускает оба варианта.

Передаём сумму в браузер

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

function readSats(value) {
  if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value)) {
    throw new Error('Invalid satoshi amount');
  }
  const amount = BigInt(value);
  if (amount > 21000000n * 100000000n) {
    throw new Error('Amount out of range');
  }
  return amount;
}

const price = readSats('30000');

BigInt применяется только к расчётам и сравнению. При обратной отправке JSON снова превращаем значение в десятичную строку: обычный JSON.stringify не сериализует BigInt как привычное число. Цена в интерфейсе остаётся отображением серверного заказа. Переданная обратно клиентом строка не даёт ему права установить собственную стоимость курса.

Разбираем изменение суммы

Пусть читатель оплатил сначала 12 000, затем 18 000 сатоши. Сервер складывает целые значения и получает точную цену 30 000. Никакого допуска «плюс-минус из-за округления» здесь не требуется. Если сумма второго выхода равна 17 999, недостаёт одного сатоши; политика может обработать это отдельно, но арифметика не должна молча исправлять данные.

В условной транзакции из выхода 80 000 выделены 30 000 продавцу, 49 300 сдачи и 700 комиссии. Равенство целых чисел выполняется точно. Оно не доказывает реальную комиссию будущего узла, зато помогает заметить ошибку, когда сдачу прибавили к оплате или комиссию вычли из уже полученного выхода продавца.

Отделяем отображение от входных данных

Предположим, дизайнер захотел показать цену как «30 000 сат.» и выделить последние цифры цветом. Для экрана это допустимо, но такая строка не должна становиться денежным полем API. Отображаемая подпись содержит пробел и слово, тогда как контракт передачи требует только десятичные цифры. Сервер не снимает форматирование с красивого текста эвристикой: он читает собственное сохранённое целое значение.

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

Что сохраним в проекте

Денежный контракт теперь можно описать коротко: база и сервер используют целые сатоши, HTTP использует строки сатоши, платёжная ссылка содержит десятичную сумму BTC. На каждой границе выполняется отдельная проверка единицы, диапазона и точности. Локализованное представление на экране не попадает обратно в расчёт.

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

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