← К содержанию навыка

Внутренний учёт и леджер

40 минут
Раздел 6 из 9

Внутренний учёт и леджер

В разделах 4 и 5 мы разобрались, как биржа исполняет ордер и какой путь он проходит от кнопки в приложении до matching engine. На каждом шаге этого пути всплывало слово ledger: там резервируют баланс, туда попадают сделки, оттуда читают инварианты. Пора, наконец, заглянуть внутрь этой коробки и увидеть, как она устроена. И здесь нас ждёт неожиданный для многих разработчиков поворот: самая надёжная криптобиржа в мире устроена ровно так же, как банк XIX века. Внутри у неё — классический двойной учёт, тот самый, который в 1494 году описал Лука Пачоли в своём трактате о счетоводстве. Пять веков спустя ничего лучше не придумали.

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

Почему именно двойная запись

Давайте подумаем с нуля. Что должно делать «учётное ядро» биржи?

  • Знать баланс каждого пользователя в каждой валюте, в каждую микросекунду.
  • Атомарно изменять сразу два баланса (покупатель получает BTC, продавец получает USDT). Если одна половина прошла, а другая нет — это катастрофа.
  • Отличать свои деньги от чужих (fee income, техсчета, суспенс-счета).
  • Позволять сверить всё это с on-chain резервами в любой момент.
  • Оставлять аудит-след: кто когда что сделал, чтобы можно было реконструировать любую строку баланса.

Самая простая мысль — «сделаем поле balance у пользователя и будем его обновлять». Атомарность тут не проблема: любая приличная база умеет обновить сразу четыре строки в одной транзакции. Проблема в другом — в том, что у такой схемы нет истории. Через месяц работы на вопрос «почему у этого пользователя баланс именно такой» ответа нет: в таблице лежит одно число, а как оно там получилось — неизвестно. Бухгалтер в такой системе слеп, аудитор — бесполезен, а вы сами при первом же баге в расчёте комиссии не сможете даже определить, у кого и на сколько баланс разошёлся с реальностью.

Решение столетиями известно: не обновлять балансы, а записывать проводки. Баланс — это производная величина, сумма всех проводок на счёте. Каждая операция порождает пару записей: одна в дебет, другая в кредит, на одну и ту же сумму. Инвариант «Σ дебетов = Σ кредитов» должен выполняться после каждой операции.

Сценарий: Maker продаёт 0.1 BTC по цене 68 000 USDT. Комиссия maker — 0.10%, taker — 0.20%. Биржа удерживает обе комиссии.
Maker · BTC
счёт продавца
Дебет
Кредит
Продажа 0.1 BTC
-0.1 BTC
Taker · BTC
счёт покупателя
Дебет
Кредит
Покупка 0.1 BTC
+0.1 BTC
Maker · USDT
счёт продавца
Дебет
Кредит
Получение выручки
+6 800 USDT
Комиссия maker
-6.80 USDT
Taker · USDT
счёт покупателя
Дебет
Кредит
Оплата покупки
-6 800 USDT
Комиссия taker
-13.60 USDT
Fee account · USDT
доход биржи
Дебет
Кредит
Комиссия maker
+6.80 USDT
Комиссия taker
+13.60 USDT
Главный инвариант:
Σ дебетов = Σ кредитов  (в рамках одной валюты)
BTC: +0.1 − 0.1 = 0. USDT: (+6 800 + 6.80 + 13.60) − (6 800 + 6.80 + 13.60) = 0. Любая проводка, которая нарушает это равенство — это баг, а не бизнес-правило.
Классические T-счета для одной сделки: maker, taker, счета комиссий. Двойная запись делает ошибки бухгалтерии невозможными.

Обратите внимание: в этой схеме нет ни одной строки, которую можно «забыть». Если вы удалите любую — инвариант нарушится, и это мгновенно увидит система сверки. Вы не можете «потерять» комиссию. Вы не можете «забыть» списать BTC у продавца. Каждая копейка учтена дважды и подтверждает сама себя.

Два инварианта, на которых стоит биржа

Двойная запись говорит, как вносить проводки. Но она ничего не гарантирует про результат — если в коде ошибка и одна из двух записей потерялась, двойная запись сама себя не защитит. Поэтому поверх механики проводок биржа держит инварианты — утверждения вида «сумма X всегда равна сумме Y», которые проверяются после каждой операции и отдельно в фоне. Сломался инвариант — значит, где-то в коде или в данных что-то поехало, и торги нужно останавливать до выяснения причины.

У внутреннего леджера всего два инварианта. Пока они выполняются — биржа здорова. Когда хотя бы один нарушается — включается алерт и запускается расследование.
#1Double-entry balance
Σ дебетов = Σ кредитов
Любая проводка состоит из пары записей: одна сторона увеличивает, другая уменьшает на ту же сумму. Нарушение означает, что где-то потерялась или размножилась копейка.
Σ дебетов
6 820.40 USDT
Σ кредитов
6 820.40 USDT
0.00 USDT ✓
#2Solvency
Σ балансов пользователей + Σ внутренних счетов ≤ on-chain резервы
Сумма всего, что биржа должна пользователям и сама себе, не может превышать то, что действительно лежит в кошельках. Разница — «страховочная подушка» биржи.
Пользовательские балансы41 280.00 BTC
Суспенс-счета (депозиты/выводы в пути)54.00 BTC
Fee-income и прочие внутренние12.00 BTC
Σ обязательств41 346.00 BTC
Σ on-chain резервов41 390.00 BTC
Разница+44.00 BTC
Обязательства покрыты на 100% ✓
Если второй инвариант нарушен даже на копейку — это означает, что либо сканер что-то пропустил, либо приватный ключ увели, либо кто-то провёл проводку «в долг». На любом из этих сценариев биржа обязана остановить работу и разобраться ДО возобновления торгов.
Две проверки, которые запускаются непрерывно: double-entry balance и solvency. Нарушение любой из них — стоп торгов.

Инвариант #1: двойная запись. Для каждой валюты и каждой транзакции суммарные дебеты равны суммарным кредитам. Это легко проверить одним SQL-запросом в момент коммита. Это внутренний инвариант: он гарантирует, что внутри ваших собственных книг всё сходится.

Инвариант #2: solvency. Сумма обязательств (всех пользовательских балансов плюс суспенс-счетов плюс внутренних счетов) не превышает того, что реально лежит в кошельках биржи. Это внешний инвариант: он связывает ваши книги с реальностью — с on-chain-кошельками.

Разница между этими двумя инвариантами огромна: первый — чисто программная проверка, второй — точка стыковки с миром. Именно на втором биржи сгорают. Если ваш код безупречен, но кто-то украл приватный ключ с hot-wallet, инвариант #1 будет по-прежнему выполняться, а инвариант #2 — нет. И это вы должны заметить за секунды, а не за недели.

SQL, который делает всю работу

Пора перестать рисовать схемы и посмотреть на реальный код. Ниже — минимальная схема и проводка, достаточная, чтобы понять устройство.

-- Счета (один пользовательский баланс — это одна строка здесь)
CREATE TABLE accounts (
  id            BIGINT PRIMARY KEY,
  owner_type    TEXT NOT NULL,    -- 'user' | 'fee' | 'suspense' | 'internal'
  owner_id      TEXT,             -- id пользователя или имя внутреннего счёта
  asset         TEXT NOT NULL,    -- 'BTC', 'USDT', ...
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Журнал проводок. Запись НИКОГДА не обновляется и не удаляется.
CREATE TABLE ledger_entries (
  id            BIGINT PRIMARY KEY,
  tx_id         BIGINT NOT NULL,        -- группирует пары дебет/кредит одной операции
  account_id    BIGINT NOT NULL REFERENCES accounts(id),
  asset         TEXT NOT NULL,
  amount        NUMERIC(38, 18) NOT NULL, -- положительное число
  direction     CHAR(1) NOT NULL CHECK (direction IN ('D','C')),
  posted_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_ledger_tx    ON ledger_entries (tx_id);
CREATE INDEX idx_ledger_acct  ON ledger_entries (account_id, posted_at);

Запись «сделка: maker отдаёт 0.1 BTC, taker отдаёт 6 800 USDT» в транзакционном смысле выглядит так:

BEGIN;

INSERT INTO ledger_entries (tx_id, account_id, asset, amount, direction)
VALUES
  (:tx, :maker_btc_acct,  'BTC',  0.1,   'C'),  -- maker отдал BTC
  (:tx, :taker_btc_acct,  'BTC',  0.1,   'D'),  -- taker получил BTC
  (:tx, :taker_usdt_acct, 'USDT', 6800,  'C'),  -- taker отдал USDT
  (:tx, :maker_usdt_acct, 'USDT', 6800,  'D'); -- maker получил USDT

-- Проверка инварианта #1 прямо в транзакции, по каждой валюте
WITH sums AS (
  SELECT asset,
         SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END) AS d,
         SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END) AS c
  FROM ledger_entries
  WHERE tx_id = :tx
  GROUP BY asset
)
SELECT 1 / CASE WHEN EXISTS (SELECT 1 FROM sums WHERE d <> c) THEN 0 ELSE 1 END;
-- Делением на ноль ломаем коммит, если нашлась валюта с несовпадением.

COMMIT;

Последний селект — это классический «предохранитель»: если хотя бы в одной валюте суммы не совпадают, он падает с division by zero, откатывая всю транзакцию. Проще этой проверки ничего нет, и именно поэтому её ничем не обойти. Проводка либо атомарно применяется целиком, либо не применяется вовсе.

Баланс пользователя вычисляется уже из журнала:

SELECT asset,
       SUM(CASE WHEN direction = 'D' THEN amount ELSE -amount END) AS balance
FROM ledger_entries
WHERE account_id = :acct
GROUP BY asset;

В учебном виде это честный и правильный запрос. В проде его так никогда не выполняют. Причина очевидна: журнал проводок у активной биржи растёт на десятки миллионов записей в сутки, и сканировать его целиком каждый раз, когда кто-то открыл приложение и хочет увидеть свой баланс, — это способ положить базу за минуту. Нужен трюк, который сохранил бы семантику «баланс = сумма журнала», но не требовал бы физически складывать миллиарды строк на каждый запрос.

Трюк пришёл прямо из бухгалтерии и называется закрытие периода. Биржа раз в сутки (или чаще) фиксирует для каждого счёта его баланс на конкретный момент времени и записывает результат в отдельную таблицу account_snapshots(account_id, asset, balance, as_of_seq). Поле as_of_seq — это позиция в журнале, на которой снапшот был снят: всё, что было до неё, уже учтено в поле balance, всё, что после, — ещё нет. Дальше расчёт текущего баланса превращается в «возьми последний снапшот и прибавь к нему только те проводки, у которых id > as_of_seq». Сканировать нужно не весь журнал, а хвост длиной в несколько часов — это единицы миллисекунд даже на загруженной паре.

Важно понимать, что снапшот — это не кэш и не денормализация в плохом смысле. Снапшот выводится из журнала — если его удалить, его можно восстановить ровно той же SUM-проекцией. Журнал остаётся единственным источником правды: при любом сомнении («этот снапшот точно корректный?») мы просто перегоняем проводки с начала месяца и сравниваем. А значит, ошибка в коде, который считает снапшот, не разрушает данные — в худшем случае её исправляют и пересчитывают снапшоты заново.

Это общая картина: журнал — истина, снапшоты — производная для быстрого чтения. К event sourcing и к этой же идее проекций мы ещё вернёмся в конце раздела.

Ledger Posting Explorer: пощупать руками

Разобравшись с устройством, полезно пощупать проводки интерактивно. Каждая кнопка ниже — одна операция, и после клика вы увидите пару проводок и обновившиеся балансы.

Последняя проводка
Выберите операцию сверху
Балансы после проводки
СчётБаланс
fee_income.USDT0
suspense.BTC1
user_001.BTC0
user_001.USDT10 000
user_002.BTC1
user_002.USDT0
Строки, затронутые последней проводкой, подсвечены.
Интерактивный проводчик: выбирайте операцию, смотрите, какая пара дебет/кредит её реализует и как меняются балансы.

Обратите внимание на операцию «Вывод 0.5 BTC»: баланс пользователя сразу уменьшается, но суспенс-счёт одновременно растёт. Эти две проводки — единая пара. Суспенс будет закрыт позже, когда on-chain транзакция финализируется.

Суспенс-счета и мосты между on-chain и off-chain

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

Проблема в том, что деньги в блокчейне движутся не мгновенно и, что ещё хуже, не безоговорочно. Между моментом, когда транзакция впервые попала в блок, и моментом, когда её можно считать окончательно подтверждённой, проходят минуты, а иногда и часы. Всё это время транзакция висит в промежуточном состоянии: она уже случилась, но ещё может быть отменена — из-за реорганизации сети, из-за того, что её вытеснили более выгодные транзакции, или просто потому что так решил майнер. Внутри леджера у нас нет такой опции, как «операция почти прошла», а снаружи — есть. И это несоответствие приходится как-то обслуживать.

Простое и неправильное решение выглядело бы так: увидели транзакцию в блокчейне — сразу зачислили пользователю баланс. Проблема станет очевидна в первый же день. Представьте: сканер увидел депозит на 1 BTC, пользователь открыл приложение, обрадовался, продал этот BTC за USDT, кто-то на другой стороне сделки эти USDT вывел на другую биржу. А потом блок, в котором пришёл исходный депозит, оказался в отколовшейся ветке блокчейна. Исходного биткоина у биржи нет. USDT уже ушли. Дыра.

Счета-активы: другая половина леджера

Прежде чем показывать правильное решение, нужно добавить к нашей картине мира ещё один тип счёта. До этого момента мы обсуждали только счета, которые представляют обязательства биржи: баланс пользователя — это то, что биржа ему должна, fee-income — это то, что биржа должна сама себе как прибыль, суспенс — тоже обязательство, просто ещё не закреплённое за конкретным адресатом. Все они живут исключительно внутри базы данных, и источник истины по ним — сам леджер. Никто и нигде снаружи не может сказать «на самом деле у этого пользователя баланс другой».

Но депозит и вывод — это не внутренняя операция. Они трогают реальный on-chain кошелёк биржи. Чтобы двойная запись сошлась, у каждой проводки должна быть вторая сторона, и этой второй стороной должен быть какой-то счёт, который представляет физическое содержимое кошелька. Такой счёт в классической бухгалтерии называется счётом-активом (asset account): он отражает не то, что биржа должна, а то, чем биржа реально владеет. Для криптобиржи это выглядит как ряд обычных строк в той же таблице accounts с owner_type = 'internal':

hot_wallet.BTC
warm_wallet.BTC
cold_wallet.BTC
hot_wallet.USDT
...

Никакой отдельной машинерии, никаких новых сущностей — просто ещё несколько именованных счетов. Но у этих счетов есть одно принципиальное отличие от всех остальных: их «правильное» значение определяется не леджером, а блокчейном снаружи. Баланс пользователя в леджере верен по определению — это сумма проводок, никто не может с этим спорить. А вот hot_wallet.BTC в леджере — это всего лишь модель того, что реально лежит в кошельке. За правильностью этой модели кто-то должен следить; именно этим занимается сервис сверки, к которому мы скоро перейдём.

С этим различием инвариант платёжеспособности можно наконец сформулировать в точных терминах леджера. Верхняя часть — сумма всех обязательств биржи, нижняя — сумма всех её активов:

Σ(user balances) + Σ(suspense) + Σ(fee income)
    ≤
Σ(hot_wallet) + Σ(warm_wallet) + Σ(cold_wallet)

Обе суммы считаются одним SQL-запросом к таблице проводок. А то, что правая часть должна одновременно совпадать с реальным содержимым on-chain-кошельков, проверяется отдельно — это уже работа сервиса сверки.

Почему всё-таки нужен суспенс

Теперь, когда в нашей модели есть и счета-обязательства, и счета-активы, можно вернуться к исходному вопросу. Двойная запись требует, чтобы у каждой проводки была вторая сторона. Для вывода 0.5 BTC эта вторая сторона напрашивается: снять с user_001.BTC и списать с hot_wallet.BTC. Одна проводка — и всё. Но вот беда: в момент нажатия кнопки «вывести» пользователь уже не должен иметь доступа к этим 0.5 BTC (иначе он успеет ими поторговать, пока on-chain транзакция ещё не ушла), а hot_wallet.BTC мы тронуть не имеем права — физически монеты ещё в кошельке. Вариантов ровно три:

  1. Соврать: списать с hot_wallet.BTC сразу, как будто монеты уже ушли. Двойная запись сойдётся, но леджер начнёт показывать неправильное содержимое hot-wallet на всё время, пока on-chain транзакция не пройдёт. Сервис сверки тут же поднимет тревогу — он увидит, что модель и реальность разошлись.
  2. Не посылать проводку, а просто поставить флаг reserved = true у пользователя. Двойная запись даже не нарушится, потому что проводки никакой не было. Но теперь balance = SUM(postings) перестаёт работать: у пользователя в поле balance одно, а в таблице флагов — другое. Ровно та самая проблема, ради решения которой мы всю эту систему и строим.
  3. Провести операцию через промежуточный счёт, который специально существует для состояния «эти деньги уже не пользователя, но ещё и не ушли из кошелька». Это и есть суспенс-счёт (от suspense account — «счёт временного хранения»; термин пришёл из классического бухучёта задолго до появления криптовалют). По формальной классификации это счёт-обязательство: биржа кому-то эти деньги должна, просто пока неясно, кому именно — пользователю как возврат или внешнему адресу как исходящая транзакция.

Первые два варианта ломают либо модель кошелька, либо правило «баланс — это сумма проводок». Третий — единственный, который сохраняет оба. Поэтому любая операция, пересекающая границу между блокчейном и леджером, делается не в один шаг, а в два, и промежуточную точку удерживает именно суспенс.

On-chain транзакция не становится «деньгами пользователя» одним шагом. Она проходит через суспенс-счёт — промежуточное звено, которое удерживает средства, пока они ещё не могут считаться полноценным балансом.
Депозитon-chain → suspense → user balance
1
Сканер увидел tx в блоке
Дебет
Кредит
suspense.BTC+1.0 BTC
На этом этапе баланс пользователя ещё не растёт — деньги в ожидании подтверждений.
2
Набран порог подтверждений
Дебет
suspense.BTC−1.0 BTC
Кредит
user_001.BTC+1.0 BTC
Сузпенс-счёт закрывается, пользователь получает торгуемый баланс. Ровно одна пара проводок.
Выводuser balance → suspense → on-chain
1
Пользователь подтвердил вывод
Дебет
suspense.BTC+0.5 BTC
Кредит
user_002.BTC−0.5 BTC
Деньги списаны с торгового баланса, но ещё не ушли в сеть — висят в суспенс-счёте.
2
On-chain транзакция финализирована
Дебет
Кредит
suspense.BTC−0.5 BTC
Сузпенс закрывается. Если транзакция отменилась в mempool — возврат, и баланс пользователя восстанавливается.
Зачем нужен суспенс? Чтобы инвариант «Σ обязательств ≤ on-chain резервы» НЕ нарушался ни в одну из микросекунд, даже в момент, когда деньги «в пути». Суспенс-счёт делает промежуточное состояние наблюдаемым и обратимым.
Депозит и вывод — это двухшаговые мосты через суспенс-счёт. Инвариант платёжеспособности не нарушается ни в одной микросекунде.

Депозит по шагам

Пользователь отправляет 1 BTC на свой депозитный адрес на бирже. Сканер кошельков биржи видит эту транзакцию в новом блоке, но для биржи сделка пока «условная»: блок может откатиться. Тем не менее 1 BTC уже физически пришёл в кошелёк, и это нужно отразить в леджере — иначе hot_wallet.BTC разойдётся с реальностью и сервис сверки будет в недоумении. Поэтому биржа делает первую проводку: плюс 1 BTC на hot_wallet.BTC (актив вырос — монеты реально в кошельке) и плюс 1 BTC на suspense.BTC (обязательство тоже выросло, но пока безымянное — мы ещё не готовы закрепить эти монеты за конкретным пользователем, потому что блок может откатиться). Пользователь в приложении при этом ничего не видит: его баланс не изменился.

Дальше сканер ждёт несколько блоков (обычно 3–6 для биткоина, в зависимости от суммы). Когда порог подтверждений набран, риск реорганизации падает до практически нулевого, и биржа делает вторую проводку, которая уже целиком внутренняя: минус 1 BTC на suspense.BTC и плюс 1 BTC на user_001.BTC. Обязательство переехало с безымянного суспенса на конкретного пользователя. В этот момент пользователь наконец видит депозит у себя в приложении и может торговать. Активная сторона (hot_wallet.BTC) при этом не меняется — монеты никуда не двигались, просто внутри леджера изменилась их «бирка».

Вывод по шагам

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

Теперь биржа формирует и подписывает on-chain-транзакцию и отправляет её в сеть. Дальше возможны два сценария.

В хорошем сценарии транзакция спокойно подтверждается. Биржа делает вторую проводку: минус 0.5 BTC на suspense.BTC и минус 0.5 BTC на hot_wallet.BTC. Обязательство снимается одновременно с уменьшением актива. И обязательства, и активы уменьшились ровно на одну и ту же сумму — обе стороны инварианта платёжеспособности изменились синхронно. Операция завершена.

В плохом сценарии транзакция зависла в mempool, комиссия оказалась недостаточной, пользователь нажал «отменить» или внутренний сервис решил пересобрать её с более высокой комиссией. Тогда биржа делает компенсирующую проводку, зеркальную первой: минус 0.5 BTC с suspense.BTC и плюс 0.5 BTC обратно на user_001.BTC. Обязательство вернулось к тому же пользователю, пользователь видит возврат, и его торгуемый баланс восстанавливается. Обратите внимание: ни в одной из этих цепочек мы ни разу не нарушили инвариант двойной записи — каждый шаг это ровно пара дебет/кредит на одну и ту же сумму.

А нельзя ли обойтись без суспенса для депозита?

Внимательный читатель заметит, что в депозитном случае проблемы с выводом нет. Можно было бы просто дождаться, пока наберётся нужное число подтверждений, и только тогда одной проводкой зачислить пользователю баланс с корреспондирующим увеличением hot_wallet.BTC. Двойная запись сойдётся, инвариант платёжеспособности удержится, суспенс в явном виде не понадобится.

Формально — да, так можно. Но вы всё равно получите суспенс, просто неявный. Пока сканер ждёт подтверждений, монеты уже физически лежат в hot-wallet, а в леджере их нет. Сервис сверки, сравнивающий модель кошелька с реальностью, будет непрерывно видеть расхождение on-chain > ledger и непрерывно поднимать тревогу. Чтобы не спамить дежурного инженера, придётся вести отдельный список «вот эти N BTC — это неподтверждённые депозиты, их можно не считать расхождением». Такой список — это и есть суспенс, просто сделанный руками, вне таблицы проводок, без аудит-следа. Проще один раз завести явный суспенс-счёт и получить ту же информацию через SELECT SUM(amount) FROM ledger_entries WHERE account_id = 'suspense.BTC'.

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

Непрерывная сверка

Два инварианта не проверяют себя сами. За них отвечает отдельный воркер — reconciliation service, — который крутится в цикле и сверяет внутренние суммы с on-chain данными.

Инварианты не проверяются «раз в квартал на аудите». Их проверяет отдельный воркер в непрерывном цикле — обычно раз в несколько секунд для hot и раз в час для cold.
Внутренний леджер
off-chain SQL
users.BTC41 280.00 BTC
suspense.BTC54.00 BTC
fee_income.BTC12.00 BTC
Σ обязательств
41 346.00 BTC
≈ ?
Wallet scanner
on-chain balances
hot wallet1 240.00 BTC
warm wallet4 100.00 BTC
cold wallet36 050.00 BTC
Σ резервов
41 390.00 BTC
Разница совпадает с политикой
+44.00 BTC · healthy
Фиксируем снапшот и засыпаем до следующего тика.
Alert branch (если что-то не сходится)
|Δ| > tolerance → freeze
Замораживаются выводы, дежурный получает уведомление, эскалация до CISO.
Reconciliation крутится в непрерывном цикле: сумма обязательств в леджере сравнивается с балансами сканера кошельков.

Типичные частоты:

  • Hot-кошельки и суспенс-счета — каждые 5–30 секунд. Это самые подвижные части, и именно здесь чаще всего случаются баги.
  • Warm-кошельки — раз в минуту-пять.
  • Cold-кошельки — раз в час, плюс принудительно перед публикацией Proof of Reserves.

Если расхождение выходит за допустимые пределы (а допустимое — это буквально несколько сатоши, на уровне сетевой комиссии и погрешности округления), воркер ничего не пытается подкрутить сам. Он сразу останавливает выводы, будит дежурного инженера и передаёт разбор ему. На этом моменте стоит задержаться, потому что соблазн написать по-другому очень велик. Кажется логичным: если расхождение маленькое — просто подправим леджер, чтобы сходилось, и живём дальше. Код становится короче, алерты замолкают, жизнь выглядит спокойнее. На самом деле именно такой код и хоронит биржи. Пока расхождения автоматически «подравниваются», никто не замечает, что где-то идёт медленная утечка: из-за бага в сканере, из-за неправильного округления комиссий, или из-за того, что кто-то тихо уводит с hot-wallet по копейке за раз. Правильный порядок действий ровно обратный: остановиться, разобраться в причине, исправить её в коде или в данных руками и только после этого возобновлять торги и выводы.

Кейс: FTX и то, как НЕ надо

А теперь плохая история. 11 ноября 2022 года FTX — вторая по объёму криптобиржа мира — подала заявление о банкротстве. Через несколько дней новый CEO John J. Ray III, человек, который в своё время разгребал банкротство Enron, опубликовал ставшую знаменитой фразу:

«Никогда в своей карьере я не видел такого полного отсутствия корпоративного контроля и такого полного отсутствия надёжной финансовой информации, как здесь».

— First Day Declaration, In re FTX Trading Ltd., Case No. 22-11068 (Bankr. D. Del. Nov. 17, 2022)

Что именно было не так? По материалам First Day Declaration и последующим показаниям в Конгрессе:

  • Не было отдельного леджера для клиентских средств. FTX и Alameda Research, родственная торговая фирма, делили одну и ту же бухгалтерию. Клиентские депозиты сливались с торговым капиталом Alameda. Инвариант #2 не нарушался только потому, что инварианта #2 попросту не проверяли.
  • Не было aging-отчётов и сверки по счетам. Ray писал, что часть операций фиксировалась сообщениями в Slack и записями в QuickBooks, без нормального бухгалтерского учёта. Он не смог сразу получить даже базовый список банковских счетов компании.
  • Секретные исключения в коде. В коде биржи было «жёстко вшито», что счёт Alameda не подчинялся обычным правилам маржин-коллов. Это технически ломало инвариант solvency, но никаких внешних проверок не было.
  • Никакого Proof of Reserves. FTX никогда не публиковала криптографических доказательств резервов, хотя это уже было возможно.

Приговоры получились внушительные. Сэм Бэнкмен-Фрид получил 25 лет лишения свободы и $11 млрд штрафа. Кэролайн Эллисон — 2 года. Остальные фигуранты — сроки где-то между этими двумя значениями. Клиенты вернут, по текущим оценкам конкурсного управляющего, бо́льшую часть номинальных сумм благодаря росту криптоактивов — но это не заслуга FTX, а случайный подарок рынка.

Главный урок FTX — не «криптобиржи опасны». Главный урок FTX — инварианты должны быть записаны в код и проверяться снаружи. Если solvency-проверка запускается каждые 10 секунд и её результат публикуется — сотрудник биржи физически не может «временно одолжить» средства клиентов. Именно поэтому вся индустрия после FTX ускорила внедрение Proof of Reserves, а хорошо устроенные биржи честно публикуют расхождения, даже крошечные, — потому что это доказывает, что сверка действительно работает.

Event sourcing: когда журнал становится источником правды

С этим паттерном мы уже встречались в разделе про жизненный цикл ордера: именно благодаря append-only журналу событий от matching engine ledger после падения может «довосстановить» баланс, перечитав журнал с нужной позиции. Там event sourcing был инструментом устойчивости к сбоям. Здесь, в учётном ядре, он становится чем-то большим — основным способом хранить состояние.

Ещё раз по существу: мы с самого начала сказали, что балансы — производные, а журнал проводок — источник правды. Это ровно тот паттерн, который Мартин Фаулер описал как Event Sourcing — состояние системы хранится не как «текущее значение», а как последовательность событий, из которых это состояние вычислимо. Для учётных систем это естественно: журнал проводок — это и есть лента событий, а балансы, отчёты, выписки — это проекции.

Плюсы такого подхода для биржи:

  • Полная история. Вы можете в любой момент реконструировать состояние системы на любую секунду в прошлом.
  • Детерминированный реплей. Если вы нашли баг в расчёте балансов — исправьте функцию проекции и перегоните журнал заново.
  • Снапшоты. Быстрые чтения делаются из материализованных «закрытых периодов» плюс дельта с последнего снапшота.
  • Аудит-след получаем даром. Ничего специально «не логируется» — вся история уже лежит на диске, потому что состояние ПО ОПРЕДЕЛЕНИЮ хранится именно так.

Минус у event sourcing тоже один, но серьёзный: сложность. Простой CRUD с таблицей balances занимает в коде 20 строк. Полноценный event-sourced ledger — это отдельная дисциплина, отдельные тесты, отдельные инварианты. Поэтому он оправдан там, где цена ошибки высока. Для биржи эта цена — существование компании.

Что запомнить

  • Балансы — это производная. Источник правды — журнал проводок. Никогда не обновляйте поле balance напрямую.
  • Каждая операция — пара проводок. «Σ дебетов = Σ кредитов» проверяется внутри той же транзакции, в которой проводки вставляются.
  • Два инварианта, два уровня. Double-entry balance — внутренняя проверка. Solvency — стыковка с on-chain резервами.
  • Суспенс-счета делают «деньги в пути» наблюдаемыми. Без них невозможно соблюдать solvency во время движения on-chain.
  • Reconciliation крутится непрерывно. Hot — секунды, warm — минуты, cold — часы. Рассинхронизация = стоп выводов + эскалация, НЕ «автокоррекция».
  • FTX — это не уникальный злодей, а предсказуемое следствие отсутствия этих правил. Когда инварианты не записаны в код, нет никакой причины, по которой они должны соблюдаться.
  • Event sourcing хорошо ложится на учёт. Журнал и проекции — это прямая реализация идеи Фаулера, только с поправкой на криптоспецифику (суспенс, on-chain reconciliation, multi-asset).

Дополнительное чтение

  • Martin Fowler, «Event Sourcing». martinfowler.com/eaaDev/EventSourcing.html — каноническое объяснение паттерна. Обязательное чтение, прежде чем проектировать собственный леджер.
  • Coinbase Engineering, «How Coinbase builds reliable software». coinbase.com/blog/landing/engineering — раздел с инженерными постами Coinbase, включая материалы о внутреннем учёте и надёжности.
  • In re FTX Trading Ltd., First Day Declaration of John J. Ray III. Chapter 11 filings, Case No. 22-11068 (Bankr. D. Del.) — официальный портал конкурсного управляющего Kroll с документами банкротства FTX, включая декларацию первого дня.