01
Ключ идемпотентности делает повтор безопасным
Повтор — да. А вот какие проверки при этом можно пропустить, ключ не говорит. Это решает тот, кто писал ветку.
У нас она пропускала проверку баланса. Логика была простая и на вид разумная: раз это повтор, значит запрос уже один раз прошёл все проверки, зачем гонять их второй раз. Поэтому ветка и стояла первой — раньше сверки типа, счёта, суммы и средств.
А ключ удержания мы собирали склейкой: external_ref + ":hold". То есть придумывал его не сервер, а тот, кто к нам пришёл.
Дальше всё просто. Переводишь себе копейку и пишешь в external_ref строку X:hold. Потом создаёшь вывод с external_ref = X. Сервер строит для удержания ключ X + :hold — ровно тот, под которым уже лежит твоя копейка. Удержание находит её, говорит «а, это повтор, всё уже сделано» — и не резервирует ничего. Монеты уходят в сеть. Со счёта не списывается.
Полностью это работало на TRC20 и TON. Код отправки у каждой сети свой — мы зовём их рельсами, — и на биткойновом с эфирным баланс сверялся ещё раз, уже там; не прошло бы. Но узнали мы об этом потом, разбирая, а не потому, что так задумывали.
Идемпотентные ключи описаны давно, и описан счастливый путь. Несчастливый разобран недавно: в посте «Idempotency is easy until the second request is different» есть и сверка канонической команды, и порядок относительно авторизации, и мутабельное состояние вроде баланса. Чего мне не попалось нигде, включая его, — производного ключа, который сервер синтезирует из присланного клиентом поля. Про область действия ключа пишут все, про то, из чего его склеили, — никто.
Что должно быть верно
Повтор должен доказать, что это та же операция, а не просто та же строка: сверяй тип, счёт и сумму до того, как отдашь сохранённый ответ. И ключ для производного шага собирай на сервере, из самой заявки, а не приклеивай суффикс к тому, что прислал клиент. У нас на это TestHold_PlantedTransactionUnderSameRef_IsRefused.
02
Если монитор депозитов работает, он ничего не теряет
Наоборот. Сломанный не теряет ничего, а исправный теряет всё.
Монитор шёл по блокам и двигал чекпойнт за каждым просканированным. Транзакцию, у которой не набралось подтверждений, он пропускал — писал в лог «ждём подтверждений» и шёл дальше. Логично же: подберём следующим проходом.
Следующего прохода не было. Блок к тому моменту уже помечен обработанным, и перечитывать его никто не станет никогда.
А теперь про инверсию. Сканировал он до самого кончика цепи, интервал на Ethereum — пятнадцать секунд. Значит свежую транзакцию видел через пару блоков после появления, то есть всегда раньше, чем набирались подтверждения. Терялся любой депозит, пришедший при работающем мониторе. А если монитор полежал час и потом догонял — он видел блоки уже с подтверждениями и зачислял всё как надо.
У крипты для этого нет имени. В стриминге есть: это коммит оффсета до обработки, и документация Kafka предупреждает о нём прямым текстом. Разница только в том, что там нет понятия глубины подтверждений, поэтому инверсия «здоровый теряет, отставший нет» там невозможна.
Что должно быть верно
Сканировать не до кончика, а до tip − MinConfirmations. После этого ветка «ждём подтверждений» в норме недостижима. Само число подтверждений при этом — не инвариант, а допущение: на proof-of-stake правильная граница это финальность у консенсуса, а не счётчик блоков. Тест у нас проверяет положение курсора — что он не ушёл выше tip − MinConfirmations, — и это слабее, чем хотелось бы: сама ветка из кода никуда не делась.
03
Проверить сумму — дёшево
Разобрать {"amount":"1e1000000000"} — действительно дёшево. Мантисса — единица, экспонента — миллиард, двенадцать байт в теле. Разворачивается число не при разборе, а при первом же арифметическом действии.
Первое арифметическое действие — это наша проверка. «Сумма положительная», «сумма не больше лимита». Сравнение с нулём выглядит самой дешёвой операцией на свете, а оно приводит операнды к общей экспоненте.
Замерил, чтобы не гадать о порядке. Go 1.26, arm64, shopspring/decimal; засекается ровно одно GreaterThan(Zero) после разбора:
1000.50 0,0 мс +0 МБ
1e100000 0,6 мс +0 МБ
1e1000000 20 мс +3 МБ
1e5000000 251 мс +21 МБ
Разбор во всех четырёх случаях бесплатен. Платит сравнение.
Отдельная подлость: граница стояла в шести методах, но путь депозита с ненулевой комиссией через проверенный метод не проходил. То есть проверка включалась и выключалась настройкой тарифа.
В Java этот класс известен и закрыт — CVE у Johnzon буквально про 1e20000000, и закрылся он ограничением scale у BigDecimal. А отраслевой дефолт, jacksonовский maxNumberLength=1000, ограничивает длину литерала: ловит длинную мантиссу и пропускает короткую экспоненту. Два проекта, два разных ограничения, и ни одно не про экспоненту. У shopspring/decimal в OSV ноль advisory вовсе.
Что должно быть верно
Проверять экспоненту и число значащих цифр — Exponent() и NumDigits() мантиссу не разворачивают, поэтому отказ обходится даром. Длина литерала эту форму не ловит: наши двенадцать байт проходят любой такой предел. Резать по длине всё равно надо: длинную мантиссу ловит как раз она, и стоять эта проверка должна первой — до того как число построено. И помнить, что отказ обязан быть дешёвым: валидатор, отвергающий число ценой гигабайта, не закрыл ничего. В Go это особенно важно — выход по нехватке памяти фатален, падает процесс, а не запрос, и middleware его не перехватит.
04
Входящий перевод на адрес пользователя — это депозит
Обычно да. Кроме случая, когда отправитель — ты сам.
Свип — сбор денег с депозит-адресов на горячий кошелёк — на EVM невозможен без газа на самом адресе. Значит платформа шлёт ETH на депозит-адрес клиента, чтобы потом забрать оттуда токены. По адресам этот перевод неотличим от настоящего депозита: и там, и там деньги идут с горячего кошелька на адрес пользователя.
Монитор видел его как обычный входящий и кредитовал. Дефолт долива — 0,003 ETH, и столько же из ниоткуда прирастало каждый цикл. Обязательства росли, хотя ончейн-средства всё это время принадлежали платформе, — и сверка резервов расходилась вместе с ними.
Фильтр по адресу тут не работает принципиально. Единственный источник истины — собственный реестр своих же хешей, который подметальщик наполняет, а монитор читает.
Виталик писал, что биржи могут гонять залог между собой и изображать платёжеспособность. Это надувание активов, и оно намеренное. Тут — надувание обязательств, и оно случайное, своим же служебным трафиком.
Что должно быть верно
Реестр внутренних переводов и проверка монитора против него. И заодно смотреть на единицы: тем же коммитом чинилась путаница, где баланс приходил в wei, стоимость газа вычиталась в ETH, а результат домножался на 10¹⁸ ещё раз — подметальщик пытался отправить сумму в квинтиллион раз больше баланса.
05
Ошибка отправки значит, что ничего не ушло
У нас удержание освобождала любая ошибка отправки, кроме нехватки на горячем кошельке. Безусловно, на всех рельсах: не отправилось — верни деньги на счёт.
А рядом, на биткойновом рельсе, жил список nonRetryablePatterns с комментарием «fundamental issues with the transaction», и в нём среди прочего стояло txn-mempool-conflict. Он решал, прекращать ли повторы.
Обе конструкции исходят из одного: раз отправка вернула ошибку, значит ничего не ушло.
Из ошибки это не следует. Классифицировать надо не по её тексту, а по стадии. Сборка, разбор и подпись происходят до обращения к сети — их отказ доказывает, что транзакции нет. Всё, что упало после сетевого вызова, доказывает только незнание. Исходов три, а не два: ушло, точно не ушло, неизвестно.
У платёжников эту задачу решили заголовком: у Stripe отсутствие Stripe-Should-Retry в ответе означает «мы не можем определить, можно ли повторять». В блокчейне такого заголовка нет, поэтому все матчат строки — хотя сами ноды классифицируют по классу, а не по формулировке. В go-ethereum ErrAlreadyKnown документирован как «the transactions is already contained within the pool», у Bitcoin Core есть TX_CONFLICT с комментарием «Tx already in mempool or conflicts with a tx in the chain».
И тут же видно, почему строки — плохой источник истины. txn-mempool-conflict у Bitcoin Core означает конфликт с транзакцией в мемпуле, то есть двойную трату того же входа. Это не доказывает, что уйдёт ваша. Мы в своё время прочитали её как «всё хорошо, она уже там» — и это было такой же догадкой по строке, как и всё остальное.
Что должно быть верно
Удержание снимать только по доказанному отсутствию. Во всех остальных случаях — оставлять и звать человека. Зависший вывод разбирается руками; вторая выплата невозвратными монетами не разбирается ничем.
06
Колонка подходящего вида — это ключ
Пачку выплат человек загружает файлом, и в этом файле обычно есть своя колонка с ключами идемпотентности. Заголовков в выгрузке может не быть вовсе, а если есть — они на языке клиента, поэтому колонку мы угадываем по содержимому.
А колонка является ключом, только если значения в ней различны. Без этой проверки в ключи уехал бы, скажем, столбец периода — во всех строках «2026-08», — и вторая строка получила бы отказ по занятому: человек не заплатил бы получателю из-за нашей догадки. Так не случилось: распознавание роли и условие уникальности мы завели одним коммитом. А вот обратная сторона случилась.
Человек вставлял выгрузку со своими ключами, автоопределение их не узнавало, ключи молча исчезали, и пачка уходила с нашими придуманными. Вторая загрузка того же файла давала другие придуманные — то есть второй платёж. Ровно там, где ключ и должен был его остановить.
Автоопределение колонок выглядит удобством. На деле оно решает, будет ли работать главная защита продукта, и ошибается в обе стороны.
Что должно быть верно
Проверка уникальности значений: без неё «ключ» это просто первая колонка подходящего вида. У нас она смотрит первые двадцать непустых строк, и это компромисс, а не решение: файл, где повторы начинаются с двадцать первой, пройдёт. И правило про догадки: не узнали ничего — верни исходный порядок, а не угадывай половину. Половина угаданного хуже неугаданного, потому что человек видит «мы разобрались» и перестаёт проверять.
07
Регистр адреса — свойство сети
Свойство формата.
bech32 можно записать целиком заглавными: в BIP-173 такой тест-вектор есть, и декодер обязан принять его наравне со строчным. Запрещён там только смешанный регистр. Мы сравнивали санкционный список с учётом регистра, разводя правила по сетям, — и тот же самый адрес, присланный заглавными, не совпадал. Проверка отвечала «чисто».
Base58 при этом регистрозависим по-настоящему, там регистр несёт информацию. То есть правило не может быть одно на сеть: в одной цепи живут форматы с противоположными требованиями, и нормализовать надо по формату, а не по рельсу.
В NVD по bech32 ноль записей. По EIP-55 — ноль. То есть у класса нет имени, хотя обе спецификации прямым текстом описывают ту неоднозначность, из которой он растёт: ERC-55 даже называет совместимость с парсерами, принимающими смешанный регистр, достоинством.
Что должно быть верно
Нормализация по формату — рядом с нормализацией по сети, в том же компараторе. Заменять одно другим нельзя: правило по сети продолжает работать там, где регистр действительно свойство рельса. И тест на то, что адрес заглавными опознаётся тем же санкционным списком: TestScreen_Bech32UppercaseStillMatches.
08
Политика подписи ограничивает, сколько уйдёт
Она ограничивает сумму перевода. Сколько уйдёт со счёта — не ограничивает никак.
Комиссия — вторая касса горячего кошелька, и на EVM её называет вызывающий: gas × gas_price, оба поля его. Перевод на копейку с комиссией во весь остаток проходил по правилам: потолок суммы соблюдён, а денег на кошельке больше нет.
На Bitcoin хуже, и это уже не наша недоработка. Комиссия там равна входы минус выходы, а сумм входов в подписываемой транзакции для legacy-входов нет вовсе. Изолированный подписант, который по устройству не ходит в цепь, посчитать её не может физически. Принять заявленной от вызывающего бессмысленно — вызывающий это ровно та сторона, которой здесь не доверяют.
Это и есть мотивация BIP-143, сказанная в нём прямым текстом: для офлайнового устройства незнание суммы входа делает невозможным вычисление комиссии. Только говорит он это про холодный кошелёк, offline signing device. На кастодиальную политику подписи мне не попалось ни одной формулировки, которая бы на это ссылалась, — хотя сторона там ровно та же: подписант, которому нельзя верить вызывающему.
И два условия, без которых потолок не потолок. Первое: лимит на одну транзакцию не ограничивает ничего, пока запросов может быть сколько угодно одновременно — экспозиция равна потолку, умноженному на число параллельных подписей. Второе: сумма выходов считается с проверкой переполнения. Четыре выхода по 2⁶² дают ровно ноль, пятый в сто сатоши делает итог маленьким и положительным — политика видит сто сатоши там, где транзакция платит восемнадцать квинтиллионов.
Что должно быть верно
До сегвита — ограничение на недоверенной стороне, как отношение комиссии к сумме вывода. После — комиссия становится проверяемой у самого подписанта, и проверять надо там.
09
Предохранитель платёжеспособности защищает деньги
Он их замораживает.
Сверка резервов сравнивает обязательства с тем, что лежит в цепи по нашим адресам. Перечислитель адресов фильтровал is_active = TRUE — казалось разумным, ведь деактивируем мы только неиспользованные.
Довод был верен для инварианта приложения и неверен для базы. Код и правда деактивирует только неиспользованные — он это проверяет. А вот миграции пишут строки, которые ни один код-путь породить не может, и среди них те, на которых деньги лежат. На стенде так выпали 1,522 ETH при обязательствах 1,5246: сверка видела по адресам из базы ноль, то есть недостачу размером почти со все обязательства. Под RECONCILIATION_ENFORCE это остановка выводов по ETH — при том, что деньги на месте.
Режим отказа тут вывернутый. Обычный предохранитель опасен тем, что пропустит. Этот опасен тем, что сработает — и сработает ровно тогда, когда всё в порядке.
И цена ложной тревоги здесь сопоставима с ценой пропуска: остановленные выводы у биржи — это не неудобство, это инцидент. Значит входные данные предохранителя лежат на критическом пути, а не во вспомогательном запросе.
Вся публичная дискуссия про доказательства резервов при этом — о том, что биржа может спрятать адреса или одолжить средства. То есть про умысел. Про то, что перечислитель может потерять свои же адреса без всякого умысла, мне не попалось ничего.
Что должно быть верно
Развести два разных вопроса, которые выглядят одним: «какие адреса наши» и «куда мы сейчас принимаем депозиты». Второй фильтруется по активности, первый — никогда.
10
Ошибка в пользу клиента не страшна
Страшна тем, что выключает канал, которым её находят.
У актива два числа рядом. precision — сколько знаков мы считаем. decimals — сколько знаков у него на рельсе. Сверялось с цепочкой только второе.
Притом precision у нас одно на актив, а decimals у одного и того же актива на разных рельсах разные. У USDT стояла точность 2 при шести знаках на ETH, TRC20 и TON — и восемнадцати на BSC. У USDC — те же 2 при шести. У ETH — 8 при восемнадцати.
Комиссия округляется вниз по precision. Значит комиссия меньше цента превращалась в ноль. Недобор, о котором никто не знал.
И не мог узнать. Денежный дефект округления находят быстро — потому что кто-то приходит и жалуется. Здесь жаловаться было не на что: ошибка шла в пользу клиента, и обратная связь, на которую инженер неявно рассчитывает, оказалась выключена направлением ошибки.
Второе последствие смотрело наружу. То же число уезжало клиенту в /assets как точность ввода. Экран, построивший маску по нему, не принял бы у человека 0.000001 USDT — сумму, которую сеть проводит без единого вопроса.
Что должно быть верно
Выводить точность запросом как минимум знаков по рельсам актива, а не держать списком руками. И общее правило, ради которого весь пункт: сверки и алерты обязаны быть двусторонними. Односторонняя ошибка не породит тикета.