Back to Blog

Deep Dives / H03

Двойная запись в блокчейне: архитектура учёта MERX

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

Когда мы разрабатывали систему учёта для MERX — платформы, которая обрабатывает депозиты TRX, покупки energy, расчеты с поставщиками и вывод средств — мы выбрали архитектуру двойной записи без дебатов. Альтернатива, однозначная запись с обновлением текущего баланса, — это то, как большинство крипто-платформ ведут учёт. Это также то, как большинство крипто-платформ в итоге сталкиваются с необъяснимыми расхождениями в балансах.

Эта статья объясняет архитектуру главной книги MERX: почему двойная запись важна для блокчейн-платформ, как спроектирована таблица главной книги, почему записи неизменяемы, как взаимодействуют дебетовые и кредитовые счета и как мы проводим сверку балансов с использованием SELECT FOR UPDATE.

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

Система однозначной записи работает как выписка в банке: один столбец, один итоговый результат. Когда пользователь вносит 100 TRX, вы добавляете 100 к его балансу. Когда он тратит 5 TRX, вы вычитаете 5. Просто.

Проблемы системы однозначной записи появляются под нагрузкой:

Одновременные изменения. Два заказа выполняются одновременно. Оба читают баланс пользователя как 100 TRX. Оба вычитают 5 TRX. Оба записывают 95 TRX. Пользователю было взимается один раз вместо двух. Или ещё хуже: одна запись перезаписывает другую, и платформа полностью теряет отслеживание транзакции.

Отсутствие аудит-следа. Баланс пользователя составляет 47,3 TRX. Как он туда попал? С однозначной записью вам нужно восстановить баланс из отдельных записей о транзакциях — которые могут быть неполными, и у которых нет встроенной проверки целостности.

Неудача при сверке. Сумма всех балансов пользователей должна равняться холдингам TRX платформы. С однозначной записью проверка этого требует агрегирования баланса каждого пользователя и сравнения его с казной. Если числа не совпадают, нет систематического способа найти расхождение.

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

Таблица главной книги

Главная книга MERX — это одна таблица PostgreSQL:

CREATE TABLE ledger (
  id            BIGSERIAL PRIMARY KEY,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  account_id    UUID NOT NULL REFERENCES accounts(id),
  entry_type    TEXT NOT NULL,
  amount_sun    BIGINT NOT NULL,
  direction     TEXT NOT NULL CHECK (direction IN ('DEBIT', 'CREDIT')),
  reference_id  UUID,
  reference_type TEXT,
  description   TEXT,
  idempotency_key TEXT UNIQUE
);

CREATE INDEX idx_ledger_account ON ledger(account_id);
CREATE INDEX idx_ledger_reference ON ledger(reference_id);
CREATE INDEX idx_ledger_created ON ledger(created_at);

Проектирование столбцов

amount_sun: Все суммы хранятся в SUN (1 TRX = 1 000 000 SUN). Использование наименьшей единицы полностью исключает арифметику с плавающей точкой. Нет десятичных сумм, нет ошибок округления, нет потери точности. Каждый расчёт — это целочисленная арифметика.

direction: Either DEBIT или CREDIT. Смысл зависит от типа счета:

entry_type: Категоризирует запись в главной книге. Примеры:

DEPOSIT              Пользователь вносит TRX на свой счёт MERX
WITHDRAWAL           Пользователь выводит TRX со своего счёта MERX
ORDER_PAYMENT        Пользователь платит за заказ energy
ORDER_REFUND         Заказ не выполнен, платёж возвращен пользователю
PROVIDER_SETTLEMENT  Платёж поставщику energy за выполненный заказ
X402_PAYMENT         Платёж в цепи получен через протокол x402

reference_id и reference_type: Связывают запись в главной книге с бизнес-объектом, который её вызвал (заказ, депозит, вывод). Это создаёт двусторонний аудит-след: из записи в главной книге вы можете найти заказ, и из заказа вы можете найти его записи в главной книге.

idempotency_key: Предотвращает дублирующиеся записи. Если одна и та же операция обрабатывается дважды (из-за повтора, тайм-аута сети, дублирующегося вебхука), ограничение уникальности на idempotency_key гарантирует, что будет создана только одна запись.

Правило неизменяемости

Записи в главной книге никогда не обновляются. Они никогда не удаляются. Это обеспечивается на уровне базы данных:

-- Предотвратить обновление записей главной книги
CREATE OR REPLACE FUNCTION prevent_ledger_update()
RETURNS TRIGGER AS $$
BEGIN
  RAISE EXCEPTION 'Ledger records cannot be updated';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER no_ledger_update
  BEFORE UPDATE ON ledger
  FOR EACH ROW
  EXECUTE FUNCTION prevent_ledger_update();

-- Предотвратить удаление из главной книги
CREATE OR REPLACE FUNCTION prevent_ledger_delete()
RETURNS TRIGGER AS $$
BEGIN
  RAISE EXCEPTION 'Ledger records cannot be deleted';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER no_ledger_delete
  BEFORE DELETE ON ledger
  FOR EACH ROW
  EXECUTE FUNCTION prevent_ledger_delete();

Эти триггеры делают правило неизменяемости неразрывным на уровне базы данных. Даже администратор, запускающий прямой SQL, не может изменить или удалить запись в главной книге, не отключив предварительно триггер — операцию, которая будет видна в журналах аудита базы данных.

Почему неизменяемость важна

Целостность аудита. Если записи в главной книге можно изменять, злоумышленник (или ошибка) может изменить финансовую историю платформы. Неизменяемые записи означают, что история постоянна и защищена от подделок.

Соответствие нормативным требованиям. Правила ведения финансовой отчётности универсально требуют сохранения записей о транзакциях. Их удаление или изменение является нарушением соответствия.

Отладка. Когда что-то пойдёт не так — а в системе, обрабатывающей реальные деньги, что-то обязательно пойдёт не так — неизменяемые записи предоставляют полную, неизменённую временную шкалу событий. Вы можете воспроизвести историю ровно так, как она произошла.

Исправления и возвраты

Если запись в главной книге должна быть «исправлена» (например, возврат денег), вы не обновляете исходную запись. Вы создаёте новую запись с противоположным направлением:

-- Исходный платёж по заказу
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id)
VALUES ($user_id, 'ORDER_PAYMENT', 1820000, 'DEBIT', $order_id);

INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id)
VALUES ($provider_settlement, 'ORDER_PAYMENT', 1820000, 'CREDIT', $order_id);

-- Заказ не выполнен, издан возврат (новые записи, исходные остаются)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id)
VALUES ($user_id, 'ORDER_REFUND', 1820000, 'CREDIT', $order_id);

INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id)
VALUES ($provider_settlement, 'ORDER_REFUND', 1820000, 'DEBIT', $order_id);

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

Дебетовые и кредитовые счета

MERX использует несколько типов счетов, каждый с собственной ролью в системе двойной записи:

Счета пользователей

Каждый пользователь MERX имеет счет. Их баланс рассчитывается из записей в главной книге:

SELECT
  COALESCE(SUM(CASE WHEN direction = 'CREDIT' THEN amount_sun ELSE 0 END), 0) -
  COALESCE(SUM(CASE WHEN direction = 'DEBIT' THEN amount_sun ELSE 0 END), 0)
  AS balance_sun
FROM ledger
WHERE account_id = $user_id;

Кредиты увеличивают баланс (депозиты, возвраты). Дебеты уменьшают его (платежи по заказам, выводы).

Счет расчетов с поставщиком

Когда пользователь покупает energy, платёж должен достичь поставщика. Счет расчетов с поставщиком отслеживает, сколько MERX должна каждому поставщику:

Пользователь платит за заказ:
  Счет пользователя:                DEBIT  1 820 000 SUN
  Счет расчётов поставщика (Feee): CREDIT 1 820 000 SUN

MERX рассчитывается с поставщиком:
  Счет расчётов поставщика (Feee): DEBIT  1 820 000 SUN
  Казна:                            CREDIT 1 820 000 SUN

В любой момент баланс счета расчетов с поставщиком показывает общую сумму, которую MERX должна этому поставщику и ещё не рассчиталась.

Казна

Счет казны представляет холдинги TRX MERX в цепи. Депозиты кредитуют казну (TRX получены). Выводы и расчеты с поставщиками дебетуют казну (TRX отправлены).

Фундаментальное уравнение

Всегда:

Сумма всех КРЕДИТОВ = Сумма всех ДЕБЕТОВ

Если это уравнение не выполняется, в системе есть ошибка. MERX периодически запускает проверку сверки:

SELECT
  SUM(CASE WHEN direction = 'CREDIT' THEN amount_sun ELSE 0 END) AS total_credits,
  SUM(CASE WHEN direction = 'DEBIT' THEN amount_sun ELSE 0 END) AS total_debits
FROM ledger;

-- total_credits ДОЛЖНА равняться total_debits
-- Если нет, немедленно alert

Полный поток транзакций

Вот как типичная покупка energy проходит через главную книгу:

1. Пользователь вносит TRX

Монитор депозитов обнаруживает входящий трансфер TRX на адрес депозита MERX:

BEGIN;

-- Кредитовать счет пользователя (баланс увеличивается)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id, reference_type, idempotency_key)
VALUES ($user_id, 'DEPOSIT', 100000000, 'CREDIT', $deposit_id, 'DEPOSIT', $tx_hash);

-- Дебетовать казну (TRX получена, казна признаёт ответственность)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id, reference_type, idempotency_key)
VALUES ($treasury_id, 'DEPOSIT', 100000000, 'DEBIT', $deposit_id, 'DEPOSIT', $tx_hash || '_treasury');

COMMIT;

2. Пользователь покупает energy

Пользователь размещает заказ на 65 000 energy по 28 SUN за единицу:

BEGIN;

-- Проверить баланс с блокировкой строки
SELECT balance_sun FROM account_balances
WHERE account_id = $user_id
FOR UPDATE;

-- Проверить достаточность баланса
-- 65 000 * 28 = 1 820 000 SUN = 1.82 TRX

-- Дебетовать счет пользователя (баланс уменьшается)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id, reference_type, idempotency_key)
VALUES ($user_id, 'ORDER_PAYMENT', 1820000, 'DEBIT', $order_id, 'ORDER', $idempotency_key);

-- Кредитовать счет расчетов (MERX теперь должна поставщику)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id, reference_type, idempotency_key)
VALUES ($provider_settlement_id, 'ORDER_PAYMENT', 1820000, 'CREDIT', $order_id, 'ORDER', $idempotency_key || '_settlement');

COMMIT;

3. Заказ не выполнен (возврат)

Если поставщик не может делегировать energy, заказ возвращается:

BEGIN;

-- Кредитовать счет пользователя (баланс восстановлен)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id, reference_type)
VALUES ($user_id, 'ORDER_REFUND', 1820000, 'CREDIT', $order_id, 'ORDER');

-- Дебетовать счет расчетов (MERX больше не должна поставщику)
INSERT INTO ledger (account_id, entry_type, amount_sun, direction, reference_id, reference_type)
VALUES ($provider_settlement_id, 'ORDER_REFUND', 1820000, 'DEBIT', $order_id, 'ORDER');

COMMIT;

Исходные записи платежа остаются. Записи возврата — новые записи. Чистое изменение баланса пользователя для этого заказа равно нулю: 1 820 000 SUN дебетировано, затем 1 820 000 SUN кредитировано.

Сверка баланса с SELECT FOR UPDATE

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

Состояние гонки

Поток A: SELECT balance WHERE user_id = 1  -> 10 TRX
Поток B: SELECT balance WHERE user_id = 1  -> 10 TRX
Поток A: Вычесть 8 TRX, новый баланс = 2 TRX
Поток B: Вычесть 8 TRX, новый баланс = 2 TRX
Результат: Пользователь имел 10 TRX, потратил 16 TRX, баланс показывает 2 TRX

Платформа только что потеряла 6 TRX.

Исправление: SELECT FOR UPDATE

BEGIN;

-- Заблокировать строку. Любая другая транзакция, пытающаяся прочитать эту строку
-- с FOR UPDATE, будет заблокирована до завершения этой транзакции.
SELECT balance_sun FROM account_balances
WHERE account_id = $user_id
FOR UPDATE;

-- Теперь у нас есть монопольная блокировка. Проверить баланс безопасно.
-- Если недостаточно, ROLLBACK.
-- Если достаточно, продолжить с записями в главной книге.

INSERT INTO ledger ...;

COMMIT;
-- Блокировка освобождена. Следующая ожидающая транзакция может продолжить.

С FOR UPDATE сценарий становится:

Поток A: SELECT ... FOR UPDATE  -> 10 TRX (строка заблокирована)
Поток B: SELECT ... FOR UPDATE  -> ЗАБЛОКИРОВАН (ожидание блокировки A)
Поток A: Вычесть 8 TRX, COMMIT  -> баланс = 2 TRX (блокировка освобождена)
Поток B: SELECT ... FOR UPDATE  -> 2 TRX (блокировка получена)
Поток B: Вычесть 8 TRX?         -> НЕДОСТАТОЧНЫЙ БАЛАНС, ROLLBACK

Никакого перерасхода. Никаких потерянных средств. Гарантия сериализации SELECT FOR UPDATE гарантирует, что проверки баланса и вычитания являются атомарными.

Влияние на производительность

SELECT FOR UPDATE сериализирует транзакции для каждого счета. Два пользователя могут выполнять транзакции одновременно без блокировки друг друга (они блокируют разные строки). Но два одновременных заказа одного пользователя должны ждать в очереди.

На практике это не узкое место. Отдельные пользователи редко подают действительно одновременные запросы. Когда они это делают (например, неправильно настроенный бот), сериализация является правильным поведением — вам нужна обработка этих запросов последовательно, а не параллельно.

Периодическая сверка

Помимо целостности каждой транзакции, MERX запускает периодическую сверку, которая проверяет всю главную книгу:

-- 1. Проверить глобальное уравнение баланса
SELECT
  SUM(CASE WHEN direction = 'CREDIT' THEN amount_sun ELSE 0 END) AS credits,
  SUM(CASE WHEN direction = 'DEBIT' THEN amount_sun ELSE 0 END) AS debits
FROM ledger;
-- credits ДОЛЖНЫ равняться debits

-- 2. Проверить балансы на счете против состояния в цепи
-- Сумма всех балансов пользователей должна равняться холдингам казны
-- минус ожидающие расчеты с поставщиками

-- 3. Проверить на потерянные ссылки
-- Каждый reference_id должен указывать на действительный заказ, депозит или вывод
SELECT l.reference_id, l.reference_type
FROM ledger l
LEFT JOIN orders o ON l.reference_id = o.id AND l.reference_type = 'ORDER'
LEFT JOIN deposits d ON l.reference_id = d.id AND l.reference_type = 'DEPOSIT'
WHERE o.id IS NULL AND d.id IS NULL AND l.reference_type IS NOT NULL;

Если какая-либо проверка не пройдена, система немедленно оповещает. Ответ — это расследование и исправление (через новые записи в главной книге), никогда не изменение существующих записей.

Резюме

Главная книга двойной записи MERX обеспечивает:

  1. Целостность: Каждая транзакция — это сбалансированная пара. Дисбалансы обнаруживаются немедленно.
  2. Неизменяемость: Записи не могут быть изменены или удалены. История постоянна.
  3. Безопасность при параллелизме: SELECT FOR UPDATE предотвращает состояния гонки при проверке баланса.
  4. Проверяемость: Полная финансовая история с двусторонними ссылками.
  5. Сверка: Периодические проверки верифицируют состояние всей системы.

Это не ново. Это 700-летняя техника учета, применяемая к блокчейн-платформе. Новизна в том, что большинство крипто-платформ её пропускают — и платят цену потерянными средствами, необъяснимыми расхождениями и кошмарами аудиторов.

Платформа: https://merx.exchange

Документация: https://merx.exchange/docs

Попробуйте сейчас с AI

Добавьте MERX в Claude Desktop или любой MCP-совместимый клиент — нулевая установка, без API ключа для инструментов, доступных только для чтения:

{
  "mcpServers": {
    "merx": {
      "url": "https://merx.exchange/mcp/sse"
    }
  }
}

Спросите вашего AI-помощника: "What is the cheapest TRON energy right now?" и получите живые цены от всех подключенных поставщиков.

Полная документация MCP: merx.exchange/docs/tools/mcp-server


All Articles