Синхронизация балансов
Вы держите у себя копию балансов и хотите, чтобы она сходилась с Pert. Эта страница описывает надёжную схему: как обновлять балансы в реальном времени, как их сверять и как восполнять то, что могло не дойти.
Схема состоит из трёх частей:
| Часть | Инструмент | Роль |
|---|---|---|
| Живое обновление | webhook-события транзакций | поддерживать балансы кошельков актуальными |
| Сверка | GET /asset/balances/currencies |
источник истины: сравнить ваш суммарный баланс с нашим |
| Восполнение | GET /transactions/changes |
получить изменения, которые webhook не доставил |
Ключевой принцип
Значение баланса авторитетно только из запроса балансов. Поток транзакций (webhook +
журнал изменений) поддерживает вашу копию актуальной, но истину для сверки берите из
GET /asset/balances/currencies, а не пересчётом из транзакций.
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, и на каждой странице:
- сохраните каждую транзакцию по её
id(создание или замена); - обновите балансы кошельков из
from/to— так же абсолютно и поbalance_version, как в п.1; - сдвиньте позицию:
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делают повторную доставку и повторное применение безопасными. - Не сошлось после восполнения — перезапросите позже: временное расхождение от свежей активности исчезнет само, а реальное расхождение останется и разрешится при следующей сверке против балансов.
Итоговый цикл
- Первичная загрузка:
GET /asset/balances/currencies→ сохранить балансы как истину иcursor. - Живой поток: обновлять балансы кошельков из webhook (
from/to.balance, поbalance_version). - Сверка: периодически
GET /asset/balances/currencies, сравнить суммарный баланс. - Восполнение: при расхождении листать
GET /transactions/changes?since_cursor=…доhas_more == false; приcursor < min_cursor— сброс к свежему снимку.