24 KiB
Модуль синхронизации shine_users из Solana
Назначение
Этот модуль нужен для локальной серверной синхронизации всех пользовательских PDA программы shine_users.
Цель модуля:
- при старте получить актуальное состояние всех пользовательских PDA;
- дальше держать локальную копию в актуальном состоянии;
- хранить историю просмотренных транзакций синхронизации;
- хранить историю всех версий пользовательских PDA;
- уметь после рестарта продолжать синхронизацию без потери изменений;
- в будущем без большого переписывания встраиваться в основной SHiNE-сервер и блокировать дальнейший startup до входа в состояние
READY.
На текущем этапе модуль должен запускаться как отдельный Java-процесс со своим main, но внутренняя структура должна быть такой, чтобы потом его можно было перенести в сервер как обычный lifecycle-сервис.
Базовая идея
Модуль использует два источника данных:
- realtime-поток через Solana WebSocket
programSubscribeдля программыshine_users; - историю транзакций через
getSignaturesForAddress(economy_config_pda).
Ключевая договорённость:
- каждая транзакция, которая создаёт или обновляет пользовательский
user_pda, обязательно читаетusers_economy_config_pda; - значит, история по
users_economy_config_pdaявляется полным журналом всех релевантныхcreate/update user_pdaтранзакций; - дополнительные транзакции, которые тоже читают или меняют
users_economy_config_pda, допустимы и не мешают: модуль должен уметь распознавать, что такая транзакция не относится к изменению пользовательского PDA.
users_economy_config_pda вычисляется из:
program_id = SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6- seed =
shine_users_economy_config
Источник в коде контракта:
settings::USERS_ECONOMY_CONFIG_SEED = b"shine_users_economy_config"create_user_pdaиupdate_user_pdaпринимаютusers_economy_config_pdaкак обязательный аккаунт.
Что считается состоянием готовности
Модуль должен иметь внутреннее состояние готовности READY.
В READY он входит только после того, как:
- установлено websocket-подключение;
- выполнена начальная актуализация истории;
- локальная таблица актуальных PDA приведена в консистентное состояние;
- таблица состояния синхронизации обновлена.
Пока модуль не вошёл в READY, будущий сервер при встраивании не должен продолжать собственный startup.
Для этого модуль должен поддерживать:
start()awaitReady()isReady()close()
Для отдельного процесса main допустимо также печатать явный лог о входе в READY, но главным механизмом для будущего сервера должен быть Java API, а не парсинг логов.
Почему нельзя опираться только на WebSocket
WebSocket programSubscribe хорош для realtime, но недостаточен как единственный источник истины:
- если websocket временно оборвался, часть событий может быть пропущена;
- если соединение формально живо, но какое-то событие было потеряно, это не всегда можно заметить сразу;
- если в интервале не было транзакций, websocket по определению ничего не пришлёт.
Поэтому нужен второй защитный контур:
- периодический аудит истории по
users_economy_config_pda; - запуск такого аудита раз в 5 минут.
Общий lifecycle модуля
1. Старт процесса
При старте процесса модуль:
- читает конфиг;
- инициализирует локальную PostgreSQL БД;
- создаёт RPC-клиент;
- создаёт WebSocket-клиент;
- создаёт coordinator/service слой;
- запускает realtime-подписку;
- после подтверждённой подписки выполняет начальную актуализацию истории;
- после успешной актуализации выставляет
READY.
2. Работа в фоне
После входа в READY модуль одновременно:
- принимает realtime-обновления по websocket;
- раз в 5 минут запускает проверку истории через RPC;
- пишет журнал транзакций;
- пишет историю версий пользовательских PDA;
- обновляет актуальный снимок пользовательских PDA.
3. Реконнект
Если websocket оборвался:
- запускается reconnect;
- после успешного переподключения выполняется повторная актуализация истории;
- модуль снова возвращается в нормальный режим.
4. Остановка
При остановке:
- закрывается websocket;
- останавливаются фоновые scheduler/worker потоки;
- закрывается RPC-клиент;
- закрывается БД.
Источник истории: только по сигнатурам
Основной checkpoint модуля должен строиться по сигнатурам транзакций, а не по времени.
Хранить нужно:
- последнюю просмотренную сигнатуру;
- последний просмотренный слот;
- последнюю релевантную сигнатуру;
- время последней успешной актуализации.
Время хранится только как вспомогательная диагностика. Продолжение истории должно идти по сигнатурам и слотам.
Поведение начальной актуализации
Сценарий A: локальная БД пустая
Если локальная БД ещё не содержит синхронизированных данных:
- выполняется первичная загрузка пользовательских PDA;
- после этого фиксируется начальная точка истории;
- модуль переходит в
READY.
Предпочтительный вариант:
- использовать историю транзакций по
users_economy_config_pdaкак основной механизм синхронизации; - full snapshot всех program accounts остаётся аварийным fallback, а не штатным путём.
Сценарий B: локальная БД уже есть
Если БД не пуста:
- берётся последняя обработанная сигнатура;
- через
getSignaturesForAddress(users_economy_config_pda)вытягивается история после неё; - новые транзакции разбираются и применяются;
- после этого модуль входит в
READY.
Сценарий C: новых транзакций не было
Если после последней сигнатуры новых транзакций нет:
- это не ошибка;
- модуль всё равно обновляет
last_poll_atиlast_successful_poll_at; - фиксирует, что актуализация успешно проверена;
- может переходить в
READY.
Периодическая актуализация раз в 5 минут
Каждые 5 минут модуль должен:
- вызвать
getSignaturesForAddress(users_economy_config_pda); - получить новые сигнатуры после последней сохранённой точки;
- сохранить все найденные транзакции в журнал истории;
- выделить релевантные транзакции
create/update user_pda; - для релевантных транзакций извлечь адрес PDA и логин;
- подтянуть актуальное состояние затронутых PDA;
- обновить:
solana_user_pda_currentsolana_user_pda_historysolana_sync_state
Если новых транзакций нет:
- модуль ничего не меняет в зеркале PDA;
- но помечает, что polling выполнен успешно и состояние истории актуализировано.
Что делать с нерелевантными транзакциями
История должна храниться полностью, включая нерелевантные транзакции.
Примеры нерелевантных транзакций:
update_users_economy_config- служебные транзакции, где
economy_config_pdaприсутствовал, но пользовательскийuser_pdaне менялся
Такие транзакции:
- сохраняются в
solana_sync_tx_history; - помечаются
is_relevant = 0; - не приводят к обновлению пользовательских PDA.
Это важно для аудита и отладки.
Как распознавать тип транзакции
Для каждой транзакции из истории нужно определить её тип.
Минимальный набор типов:
create_user_pdaupdate_user_pdaupdate_users_economy_configinit_users_economy_configother
Также для каждой транзакции нужно определять:
is_relevant = 1, если транзакция создаёт или обновляет пользовательскийuser_pda;is_relevant = 0, если это транзакция истории, но она не меняет пользовательские PDA.
Для релевантных транзакций нужно дополнительно извлекать:
affected_pda_addressaffected_login
Если транзакция затрагивает несколько пользовательских PDA, архитектура должна не запрещать хранить несколько связей, но на первом этапе можно исходить из одной пользовательской записи на одну транзакцию, если это соответствует текущему контракту.
Локальные таблицы
Модуль должен использовать три основные таблицы.
1. solana_sync_state
Одна строка состояния синхронизации.
Назначение:
- хранить текущий checkpoint истории;
- хранить время последней успешной актуализации;
- хранить технический статус синка.
Пример полей:
id INTEGER PRIMARY KEY CHECK (id = 1)status TEXT NOT NULLready INTEGER NOT NULL DEFAULT 0last_poll_at_ms INTEGERlast_successful_poll_at_ms INTEGERlast_seen_signature TEXTlast_seen_slot INTEGERlast_relevant_signature TEXTlast_relevant_slot INTEGERlast_error TEXTupdated_at_ms INTEGER NOT NULL
2. solana_sync_tx_history
Append-only журнал всех просмотренных транзакций по users_economy_config_pda.
Назначение:
- хранить полную историю опроса;
- фиксировать, какие tx были релевантны;
- хранить связь tx -> пользовательский PDA / login;
- упрощать аудит и диагностику.
Пример полей:
signature TEXT PRIMARY KEYslot INTEGER NOT NULLblock_time INTEGERtx_kind TEXT NOT NULLis_relevant INTEGER NOT NULLaffected_pda_address TEXTaffected_login TEXTprocessed_at_ms INTEGER NOT NULLraw_summary_json TEXT NOT NULL
Индексы:
- по
slot - по
is_relevant - по
affected_login - по
affected_pda_address
3. solana_user_pda_current
Текущее актуальное состояние каждого пользовательского PDA.
Назначение:
- быстрый lookup текущих данных пользователя;
- будущая интеграция с сервером;
- опорная таблица для поиска текущих ключей и полей PDA.
Пример полей:
pda_address TEXT PRIMARY KEYlogin TEXT NOT NULLrecord_number INTEGER NOT NULLslot INTEGER NOT NULLlast_tx_signature TEXT NOT NULLblockchain_name TEXT NOT NULLblockchain_key TEXT NOT NULLclient_key TEXT NOT NULLpaid_limit_bytes INTEGER NOT NULLused_bytes INTEGER NOT NULLlast_block_number INTEGER NOT NULLlast_block_hash TEXTarweave_tx_id TEXTis_server INTEGER NOT NULL DEFAULT 0server_address TEXTsync_servers_json TEXT NOT NULL DEFAULT '[]'access_servers_json TEXT NOT NULL DEFAULT '[]'sessions_json TEXT NOT NULL DEFAULT '[]'trusted_count INTEGER NOT NULL DEFAULT 0created_at_ms INTEGER NOT NULLupdated_at_ms INTEGER NOT NULLraw_data_base64 TEXT NOT NULLfirst_seen_at_ms INTEGER NOT NULLlast_synced_at_ms INTEGER NOT NULL
Индексы:
- уникальный индекс на
login - индекс на
slot - индекс на
last_tx_signature
4. solana_user_pda_history
Append-only история всех версий пользовательских PDA.
Назначение:
- хранить все старые публичные ключи и прочие поля прошлых версий;
- позволять видеть, когда и какая версия записи была актуальна;
- позволять разбирать изменения пользователя во времени.
Пример полей:
id INTEGER PRIMARY KEYtx_signature TEXT NOT NULLslot INTEGER NOT NULLblock_time INTEGERpda_address TEXT NOT NULLlogin TEXT NOT NULLrecord_number INTEGER NOT NULLblockchain_name TEXT NOT NULLblockchain_key TEXT NOT NULLclient_key TEXT NOT NULLpaid_limit_bytes INTEGER NOT NULLused_bytes INTEGER NOT NULLlast_block_number INTEGER NOT NULLlast_block_hash TEXTarweave_tx_id TEXTis_server INTEGER NOT NULL DEFAULT 0server_address TEXTsync_servers_json TEXT NOT NULL DEFAULT '[]'access_servers_json TEXT NOT NULL DEFAULT '[]'sessions_json TEXT NOT NULL DEFAULT '[]'trusted_count INTEGER NOT NULL DEFAULT 0created_at_ms INTEGER NOT NULLupdated_at_ms INTEGER NOT NULLraw_data_base64 TEXT NOT NULLsaved_at_ms INTEGER NOT NULL
Индексы:
- уникальный индекс на
(pda_address, record_number) - индекс на
login - индекс на
slot - индекс на
tx_signature
Зачем нужны и current, и history
Нужны обе таблицы:
solana_user_pda_currentхранит только последнюю актуальную версию и удобна для быстрых запросов;solana_user_pda_historyхранит все версии и нужна для расследований и просмотра старых публичных ключей.
При обработке новой релевантной транзакции:
- новая версия всегда добавляется в
solana_user_pda_history; - актуальная строка в
solana_user_pda_currentвставляется или обновляется.
Что именно считается “историей пользователя”
История нужна не для секретных паролей, а для публичных данных PDA.
В частности, история должна позволять видеть старые значения:
client_keyblockchain_keysessionsaccess_serverssync_serversserver_address- других публичных полей PDA
Секретные приватные ключи или настоящие пользовательские пароли этот модуль не хранит и хранить не должен.
Логирование
Модуль должен использовать нормальный logger, а не System.out.println.
Требования к логированию:
- совместимость с будущей интеграцией в общие логи сервера;
- явные уровни
INFO,WARN,ERROR,DEBUG; - короткие, но диагностичные сообщения;
- каждый важный переход состояния должен логироваться.
Что обязательно логировать:
- старт процесса;
- чтение конфига;
- вычисление
users_economy_config_pda; - старт websocket-подписки;
- успешную подписку;
- начало initial sync;
- окончание initial sync;
- вход в
READY; - periodic poll;
- число найденных сигнатур;
- число релевантных tx;
- число обновлённых PDA;
- реконнекты websocket;
- ошибки RPC/WS;
- переход в
FAILED, если он будет.
Конфигурация
В конфиге должны остаться только обязательные для постоянной эксплуатации параметры:
SOLANA_RPC_URL=
SOLANA_WS_URL=
SOLANA_PROGRAM_ID=SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6
DATABASE_URL=jdbc:postgresql://127.0.0.1:5432/shine_server_db
PGUSER=shine_server
PGPASSWORD=
SYNC_POLL_INTERVAL_SECONDS=300
Дополнительно:
SOLANA_COMMITMENTне нужен как параметр;- commitment должен быть зафиксирован в коде как
confirmed; HELIUS_API_KEYне нужен;SOLANA_NETWORKне нужен.
Поведение при ошибках
Ошибка periodic poll
Если periodic poll не удался:
- это логируется как
WARNилиERROR; solana_sync_state.last_errorобновляется;- модуль не должен сразу завершаться, если reconnect/retry ещё возможны.
Ошибка initial sync
Если initial sync не удался:
- модуль не должен выставлять
READY; - отдельный процесс должен завершаться с ошибкой или оставаться в
FAILED, в зависимости от выбранного runtime-режима; - при встраивании в сервер основной startup должен считаться неуспешным.
Аварийный fallback
Даже если основная логика опирается на историю по users_economy_config_pda, аварийный full snapshot всех user_pda можно оставить как последний защитный fallback на случай повреждённого или неполного RPC-ответа.
Но это должен быть именно крайний защитный сценарий, а не штатный рабочий путь.
Почему хранить всю tx-историю полезно
Полный журнал solana_sync_tx_history нужен не только для самого синка, но и для эксплуатации:
- видно, что именно вернул RPC;
- видно, какие tx были признаны релевантными;
- видно, какие tx были проигнорированы и почему;
- можно поднимать старые кейсы без повторного запроса в Solana;
- упрощается аудит и отладка после инцидентов.
Что должно получиться в итоге
В результате модуль должен работать так:
- стартует как отдельный Java-процесс;
- подписывается на realtime-обновления
shine_users; - делает начальную актуализацию через историю
users_economy_config_pda; - входит в
READY; - раз в 5 минут делает дополнительный audit истории;
- хранит:
- состояние синка;
- полную историю просмотренных транзакций;
- актуальное зеркало всех пользовательских PDA;
- историю всех версий пользовательских PDA;
- после будущего переноса в SHiNE-сервер может использоваться как блокирующий startup-модуль:
- сначала синхронизируется Solana;
- потом сервер продолжает запуск остальных подсистем.
Следующий шаг реализации
После утверждения этого документа модуль нужно доработать в коде:
- упростить конфиг;
- заменить текущее логирование на logger;
- выделить lifecycle-сервис с
awaitReady(); - реализовать вычисление
users_economy_config_pda; - реализовать polling истории по сигнатурам;
- добавить новые SQLite-таблицы;
- добавить запись в
currentиhistory; - добавить periodic guard раз в 5 минут;
- сохранить отдельный
mainдля запуска как процесса.