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

Синхронизация балансов

Вы держите у себя копию балансов и хотите, чтобы она сходилась с Pert. Эта страница описывает надёжную схему: как обновлять балансы в реальном времени, как их сверять и как восполнять то, что могло не дойти.

Схема состоит из трёх частей:

Часть Инструмент Роль
Живое обновление webhook-события транзакций поддерживать балансы кошельков актуальными
Сверка GET /asset/balances/currencies источник истины: сравнить ваш суммарный баланс с нашим
Восполнение GET /transactions/changes получить изменения, которые webhook не доставил

Ключевой принцип

Значение баланса авторитетно только из запроса балансов. Поток транзакций (webhook + журнал изменений) поддерживает вашу копию актуальной, но истину для сверки берите из GET /asset/balances/currencies, а не пересчётом из транзакций.

Синхронизация балансов у интегратора... ......Pert APIВаш сервисВаш сервисPert APIPert APIПервичная загрузка1GET /asset/balances/currencies2балансы B, cursor C, min_cursor Mсохранить B как истину, C как позициюЖивой поток3webhook transaction.*(from/to.balance + tx.balance_version)баланс кошелька = balance(перезаписывать по большему balance_version)Периодическая сверка4GET /asset/balances/currencies5B2, C2, Malt[B2 совпал с вашим суммарным балансом]всё сошлось[расхождение / вы отстали]alt[C < M]сброс — принять B2 как истинуloop[пока has_more]6GET /transactions/changes?since_cursor=C7items, next_cursor, has_moreсохранить по id, обновить балансы, C = next_cursor
Синхронизация балансов у интегратора... ......Pert APIВаш сервисВаш сервисPert APIPert APIПервичная загрузка1GET /asset/balances/currencies2балансы B, cursor C, min_cursor Mсохранить B как истину, C как позициюЖивой поток3webhook transaction.*(from/to.balance + tx.balance_version)баланс кошелька = balance(перезаписывать по большему balance_version)Периодическая сверка4GET /asset/balances/currencies5B2, C2, Malt[B2 совпал с вашим суммарным балансом]всё сошлось[расхождение / вы отстали]alt[C < M]сброс — принять B2 как истинуloop[пока has_more]6GET /transactions/changes?since_cursor=C7items, next_cursor, has_moreсохранить по id, обновить балансы, C = next_cursor

1. Живое обновление из webhook

Подпишитесь на события транзакций (см. Типы событий). В каждой транзакции есть стороны from и to, и у каждой:

Поле Смысл
address адрес кошелька
balance абсолютный баланс этого кошелька после операции
block_height высота блока, в котором операция подтвердилась

Плюс у самой транзакции есть поле balance_version — версия снимков балансов этой транзакции. Она монотонно растёт по каждому адресу и служит ключом порядка.

Обновляйте баланс кошелька абсолютным значением из balance — это снимок, а не приращение:

balance[address] = side.balance      # НЕ += amount

Порядок применяйте по balance_version, а не по порядку доставки

Webhook доставляются как минимум один раз: одно событие может прийти повторно или не по порядку (см. Лучшие практики). Если применять по порядку получения, поздно пришедший старый снимок затрёт свежий баланс.

Ключ порядка — balance_version транзакции: он монотонно растёт по каждому адресу, поэтому различает даже несколько операций в одном блоке (чего block_height не умеет). Храните последний применённый balance_version по каждому адресу и перезаписывайте только когда входящий не меньше:

v = tx.balance_version
for side in (from, to):
    if v >= version[side.address]:
        balance[side.address] = side.balance
        version[side.address] = v
# иначе игнорировать — это устаревший снимок

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

Повторы событий отсеивайте по X-Webhook-Event-Id, повторы транзакций — по их id. Такое обновление идемпотентно: повтор того же снимка ничего не меняет.

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

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

GET /asset/balances/currencies

Ответ — согласованный снимок:

Поле Смысл
currency_balances баланс по каждой валюте (это и есть истина для сверки)
cursor ваша позиция в журнале изменений на момент снимка
min_cursor нижняя граница журнала изменений (см. ниже)
snapshot_at серверное время, на которое снят снимок (RFC 3339) — ориентир «когда»; позицией синхронизации остаётся cursor

Сравните currency_balances со своим суммарным балансом по валютам. Сошлось — готово. Разошлись — переходите к восполнению.

Живой баланс может немного опережать cursor

Снимок балансов может уже учитывать самые свежие операции, которые ещё не попали в cursor. Это нормально и в безопасную сторону: при восполнении вы в худшем случае повторно примените уже учтённую операцию (идемпотентно), но ничего не потеряете.

3. Восполнение пропущенного

Чтобы получить изменения, которые webhook не доставил, листайте журнал изменений начиная с вашей сохранённой позиции:

GET /transactions/changes?since_cursor={cursor}&limit=100
Поле ответа Смысл
items транзакции (те же объекты, что и в GET /transactions), по одной записи на транзакцию в её актуальном состоянии
next_cursor позиция после этой страницы — передайте её в следующий запрос
has_more есть ли ещё страницы
min_cursor нижняя граница журнала

Листайте, пока has_more == true, и на каждой странице:

  1. сохраните каждую транзакцию по её id (создание или замена);
  2. обновите балансы кошельков из from/to — так же абсолютно и по balance_version, как в п.1;
  3. сдвиньте позицию: since_cursor = next_cursor.

limit — по умолчанию 100, максимум 1000.

C = сохранённая_позиция
loop:
    page = GET /transactions/changes?since_cursor=C&limit=500
    for item in page.items:
        store(item.id, item)
        applyBalance(item.from); applyBalance(item.to)   # абсолютно, по balance_version
    C = page.next_cursor
    save(C)
    if not page.has_more: break
пересчитать суммарный баланс; повторить сверку из п.2

Курсор

cursorнепрозрачное монотонное значение. Храните его и возвращайте как есть, не разбирайте и не считайте номером. Курсор из ответа балансов и курсор журнала изменений — одно и то же пространство: позиция, полученная от балансов, всегда достижима через журнал.

min_cursor — защита от «слишком далеко отстал». Если ваша сохранённая позиция меньше min_cursor, журнал уже не сможет восстановить всё пропущенное. В этом случае не пытайтесь восполнять — сбросьте состояние: возьмите свежий снимок GET /asset/balances/currencies как истину и продолжайте с его cursor.

cursor и balance_version — это разные ключи, не путайте

Два монотонных значения решают разные задачи:

  • cursor — ваша позиция в журнале изменений: что получить, где вы в синхронизации. Один на всё рабочее пространство, для постраничного обхода журнала.
  • balance_version — ключ порядка баланса конкретного адреса: какой снимок from/to.balance новее. Монотонен по адресу, для правила «побеждает последняя запись» при обновлении кошелька.

Одним предложением: cursor двигает синхронизацию (полнота — какие транзакции), balance_version разрешает порядок значений баланса (корректность — какое число). Сравнивать их между собой бессмысленно — разные шкалы.

Гарантии и важные моменты

Механизм Что даёт
webhook как минимум одна доставка: может задвоиться, потеряться или прийти не по порядку — это живой сигнал, не источник истины
журнал изменений упорядочен и не пропускает изменения; надёжно восполняет пропущенное
запрос балансов авторитетное значение баланса; на нём — сверка и разрешение расхождений
  • Истина значения — запрос балансов. При расхождении принимайте её, а не результат пересчёта из транзакций. Ответ баланса разбивает средства на замороженные (frozen) и удержанные (held, суммы исходящих транзакций «в полёте»), и available уже вычтен из подтверждённого баланса — эти поправки из потока транзакций сам клиент надёжно не выведет.
  • Идемпотентность. Запись по id транзакции и абсолютное обновление баланса по balance_version делают повторную доставку и повторное применение безопасными.
  • Не сошлось после восполнения — перезапросите позже: временное расхождение от свежей активности исчезнет само, а реальное расхождение останется и разрешится при следующей сверке против балансов.

Итоговый цикл

  1. Первичная загрузка: GET /asset/balances/currencies → сохранить балансы как истину и cursor.
  2. Живой поток: обновлять балансы кошельков из webhook (from/to.balance, по balance_version).
  3. Сверка: периодически GET /asset/balances/currencies, сравнить суммарный баланс.
  4. Восполнение: при расхождении листать GET /transactions/changes?since_cursor=… до has_more == false; при cursor < min_cursor — сброс к свежему снимку.