Храните суммы в криптовалюте как целые минорные единицы

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

Храните суммы в криптовалюте как целые минорные единицы

Короткий ответ

Храните любую сумму платежа целым числом в наименьшей единице актива. Никогда не храните её как float. Дробное 0.1 нельзя точно представить в двоичном виде, поэтому арифметика над ним уходит в сторону на крошечные доли, а крошечные доли денег это всё равно деньги. Лечится скучно и навсегда: держите сумму целым числом минорных единиц (сатоши, wei, базовая единица токена), считайте на этом целом, а в человеческую десятичную строку переводите только на краю, при показе.

Это всё правило. Дальше в гайде о том, как применить его и не обжечься.

Что такое минорная единица

У каждого актива есть точность в десятичных знаках. Сумма, которую видит человек, это целое число, делённое на десять в степени этой точности.

  • У биткоина 8 знаков. 1 BTC это 100000000 (сто миллионов) минорных единиц, они же сатоши. 0.001 BTC это 100000.
  • Токен в стиле USDT с 6 знаками: 1 USDT это 1000000 минорных единиц. 12.50 USDT это 12500000.
  • Актив с 18 знаками: 1.0 это 1000000000000000000. Это число не помещается в 32-битное и 64-битное целое, и это следующая ловушка.

Значит, с каждой суммой едут два факта: целое значение и число знаков для этого актива. Храните оба. Не зашивайте 8, 6 или 18 в одном месте, считая, что так везде.

Почему float теряет деньги

Float это двоичное приближение. 0.1 + 0.2 в большинстве языков не равно 0.3, а равно 0.30000000000000004. Прогоните это через тысячи инвойсов и получите балансы, которые не сходятся, выплаты, мимо на волосок, и аудит, который не закрыть.

Три конкретных сбоя встречаются снова и снова:

  1. Уход при округлении. Вы складываете дробные позиции, округляете для показа, и сумма округлённых частей перестаёт равняться округлённой сумме. Итог инвойса и реестр расходятся.
  2. Потеря точности на больших числах. number в JavaScript это 64-битный float, и выше 2^53 он теряет целочисленную точность. Сумма с 18 знаками улетает за этот предел мгновенно. Разобранная как number, она молча роняет младшие цифры.
  3. Ложное равенство. if (paid == invoiced) на float может оказаться ложным даже когда цепочка прислала ровно ту сумму, потому что одна сторона прошла через float и сдвинулась.

У целых чисел этих бед нет. Сложение, вычитание и сравнение целых точны по определению.

Как это хранить

Берите целочисленный тип, достаточно широкий, чтобы никогда не переполниться, или тип с фиксированной точкой, который база хранит точно.

  • В базе: NUMERIC(38, 0) (целое на 38 цифр без дробной части) или столбец big-integer. Не FLOAT, не DOUBLE, не MONEY.
  • В коде: большое целое. BigInt в JavaScript/TypeScript, int в Python (там произвольная точность), BigInteger в Java, u128/i128 или крейт bigint в Rust, math/big в Go. Не берите нативные 64-битные целые для активов с 18 знаками.
  • В транспорте (JSON): отдавайте сумму строкой, а не числом JSON. Числа JSON у многих клиентов разбираются как float, и баг, который вы только что починили, возвращается. Строка вроде "12500000" доезжает целой.

Когда получаете сумму из нашего платёжного API, обращайтесь с ней так же: это целое количество минорных единиц вместе с числом знаков актива. Сравнивайте оплаченное с выставленным как целые. Равно значит равно.

Переводите на краю и только для показа

Единственное место, где уместна десятичная точка, это экран. Переводите целое в строку при отрисовке и разбирайте введённую человеком десятичную обратно в целое в тот же миг, как она входит в систему.

Показ (целое в строку), для 6 знаков:

amount = 12500000
whole    = amount / 1_000_000        // 12
fraction = amount % 1_000_000        // 500000, дополнить слева до 6 -> "500000"
display  = "12.500000"               // хвостовые нули можно срезать: "12.5"

Ввод (строка в целое), для 6 знаков:

человек ввёл "12.5"
разбить по "."  -> целое "12", дробь "5"
дополнить дробь справа до 6 -> "500000"
amount = 12 * 1_000_000 + 500000 = 12500000

Делайте это строковыми операциями или десятичной библиотекой, а не умножением float на 10**знаков. 0.1 * 10**18 в плавающей точке чистого целого не даст.

Частые ошибки

  • Разбор суммы как числа JSON. Читайте строкой и переводите осознанно. number теряет точность ещё до вашего кода.
  • Одно число знаков на все активы. У токена с 6 знаками и монеты с 8 знаками разный делитель. Везите точность актива вместе с суммой повсюду.
  • Округление посреди расчёта. Округляйте один раз, на показе, и никогда не подавайте округлённое обратно в вычисления. Источник истины это целое.
  • Нативные 64-битные целые для активов с 18 знаками. Переполнятся. Большое целое от начала до конца.
  • Сравнение платежей через float. Сравнивайте целые. paid_minor == invoiced_minor единственная честная проверка.
  • Форматирование с разделителем тысяч до записи. "1,000" это показ. На входе срезайте разделители, в базу не кладите.

Куда это встаёт

Всё, что отдаёт наш платёжный API, уже приходит целыми минорными единицами с числом знаков актива, поэтому безопасный путь это так и вести их через вашу базу и реестр, а переводить только когда на сумму смотрит человек. Публичный контракт интеграции SwapSS Pay построен вокруг этого. Число знаков по активам и сетям перечислено в разделе поддерживаемые сети. Если баланс вдруг не сходится, а float вы уже исключили, наш живой саппорт @swappsy поможет проследить, где утекло.

Принцип старше крипты: считай в наименьшей единице, храни целым числом, а точку ставь в самый последний момент. Сделаете так, и книги сойдутся до последнего сатоши.