Files
SHiNE-server/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md
T
AidarKC 8a0275d962 Убрали старый sync и обновили bundle
Что сделано: вычистили неиспользуемый user-settings sync/DM sync хвост, сохранили сборку, обновили bundle.sh так, чтобы gradle-wrapper.jar всегда попадал в архив.

Проверено: compileJava и deploy на t2 (server + UI).
Не проверяли: полные интеграционные сценарии, ручные UI-флоу и продовый деплой.
2026-08-28 14:51:47 +04:00

570 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Модуль синхронизации `shine_users` из Solana
## Назначение
Этот модуль нужен для локальной серверной синхронизации всех пользовательских PDA программы `shine_users`.
Цель модуля:
- при старте получить актуальное состояние всех пользовательских PDA;
- дальше держать локальную копию в актуальном состоянии;
- хранить историю просмотренных транзакций синхронизации;
- хранить историю всех версий пользовательских PDA;
- уметь после рестарта продолжать синхронизацию без потери изменений;
- в будущем без большого переписывания встраиваться в основной SHiNE-сервер и блокировать дальнейший startup до входа в состояние `READY`.
На текущем этапе модуль должен запускаться как отдельный Java-процесс со своим `main`, но внутренняя структура должна быть такой, чтобы потом его можно было перенести в сервер как обычный lifecycle-сервис.
На дату `2026-07-24` в основном сервере SHiNE уже есть серверная интеграция startup-уровня:
- сервер запускает sync-модуль до продолжения собственного startup;
- server startup ждёт входа модуля в состояние `READY`;
- актуальный источник истины по пользователям для нового PostgreSQL runtime-слоя: `solana_user_pda_current`.
- для быстрого локального lookup единственного сервера доступа поддерживается
вторичная проекция `user_access_servers_current`, которая автоматически
пересобирается только из первого элемента `access_servers[0]` в
`solana_user_pda_current`.
---
## Базовая идея
Модуль использует два источника данных:
1. realtime-поток через Solana WebSocket `programSubscribe` для программы `shine_users`;
2. историю транзакций через `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` он входит только после того, как:
1. установлено websocket-подключение;
2. выполнена начальная актуализация истории;
3. локальная таблица актуальных PDA приведена в консистентное состояние;
4. таблица состояния синхронизации обновлена.
Пока модуль не вошёл в `READY`, будущий сервер при встраивании не должен продолжать собственный startup.
Для этого модуль должен поддерживать:
- `start()`
- `awaitReady()`
- `isReady()`
- `close()`
Для отдельного процесса `main` допустимо также печатать явный лог о входе в `READY`, но главным механизмом для будущего сервера должен быть Java API, а не парсинг логов.
---
## Почему нельзя опираться только на WebSocket
WebSocket `programSubscribe` хорош для realtime, но недостаточен как единственный источник истины:
- если websocket временно оборвался, часть событий может быть пропущена;
- если соединение формально живо, но какое-то событие было потеряно, это не всегда можно заметить сразу;
- если в интервале не было транзакций, websocket по определению ничего не пришлёт.
Поэтому нужен второй защитный контур:
- периодический аудит истории по `users_economy_config_pda`;
- запуск такого аудита раз в 5 минут.
---
## Общий lifecycle модуля
### 1. Старт процесса
При старте процесса модуль:
1. читает конфиг;
2. инициализирует локальную PostgreSQL БД;
3. создаёт RPC-клиент;
4. создаёт WebSocket-клиент;
5. создаёт coordinator/service слой;
6. запускает realtime-подписку;
7. после подтверждённой подписки выполняет начальную актуализацию истории;
8. после успешной актуализации выставляет `READY`.
### 2. Работа в фоне
После входа в `READY` модуль одновременно:
- принимает realtime-обновления по websocket;
- раз в 5 минут запускает проверку истории через RPC;
- пишет журнал транзакций;
- пишет историю версий пользовательских PDA;
- обновляет актуальный снимок пользовательских PDA.
### 3. Реконнект
Если websocket оборвался:
1. запускается reconnect;
2. после успешного переподключения выполняется повторная актуализация истории;
3. модуль снова возвращается в нормальный режим.
### 4. Остановка
При остановке:
- закрывается websocket;
- останавливаются фоновые scheduler/worker потоки;
- закрывается RPC-клиент;
- закрывается БД.
---
## Источник истории: только по сигнатурам
Основной checkpoint модуля должен строиться по сигнатурам транзакций, а не по времени.
Хранить нужно:
- последнюю просмотренную сигнатуру;
- последний просмотренный слот;
- последнюю релевантную сигнатуру;
- время последней успешной актуализации.
Время хранится только как вспомогательная диагностика. Продолжение истории должно идти по сигнатурам и слотам.
---
## Поведение начальной актуализации
### Сценарий A: локальная БД пустая
Если локальная БД ещё не содержит синхронизированных данных:
1. выполняется первичная загрузка пользовательских PDA;
2. после этого фиксируется начальная точка истории;
3. модуль переходит в `READY`.
Предпочтительный вариант:
- использовать историю транзакций по `users_economy_config_pda` как основной механизм синхронизации;
- full snapshot всех program accounts остаётся аварийным fallback, а не штатным путём.
### Сценарий B: локальная БД уже есть
Если БД не пуста:
1. берётся последняя обработанная сигнатура;
2. через `getSignaturesForAddress(users_economy_config_pda)` вытягивается история после неё;
3. новые транзакции разбираются и применяются;
4. после этого модуль входит в `READY`.
### Сценарий C: новых транзакций не было
Если после последней сигнатуры новых транзакций нет:
- это не ошибка;
- модуль всё равно обновляет `last_poll_at` и `last_successful_poll_at`;
- фиксирует, что актуализация успешно проверена;
- может переходить в `READY`.
---
## Периодическая актуализация раз в 5 минут
Каждые 5 минут модуль должен:
1. вызвать `getSignaturesForAddress(users_economy_config_pda)`;
2. получить новые сигнатуры после последней сохранённой точки;
3. сохранить все найденные транзакции в журнал истории;
4. выделить релевантные транзакции `create/update user_pda`;
5. для релевантных транзакций извлечь адрес PDA и логин;
6. подтянуть актуальное состояние затронутых PDA;
7. обновить:
- `solana_user_pda_current`
- `user_access_servers_current`
- `solana_user_pda_history`
- `solana_sync_state`
Если новых транзакций нет:
- модуль ничего не меняет в зеркале PDA;
- но помечает, что polling выполнен успешно и состояние истории актуализировано.
---
## Что делать с нерелевантными транзакциями
История должна храниться полностью, включая нерелевантные транзакции.
Примеры нерелевантных транзакций:
- `update_users_economy_config`
- служебные транзакции, где `economy_config_pda` присутствовал, но пользовательский `user_pda` не менялся
Такие транзакции:
- сохраняются в `solana_sync_tx_history`;
- помечаются `is_relevant = 0`;
- не приводят к обновлению пользовательских PDA.
Это важно для аудита и отладки.
---
## Как распознавать тип транзакции
Для каждой транзакции из истории нужно определить её тип.
Минимальный набор типов:
- `create_user_pda`
- `update_user_pda`
- `update_users_economy_config`
- `init_users_economy_config`
- `other`
Также для каждой транзакции нужно определять:
- `is_relevant = 1`, если транзакция создаёт или обновляет пользовательский `user_pda`;
- `is_relevant = 0`, если это транзакция истории, но она не меняет пользовательские PDA.
Для релевантных транзакций нужно дополнительно извлекать:
- `affected_pda_address`
- `affected_login`
Если транзакция затрагивает несколько пользовательских PDA, архитектура должна не запрещать хранить несколько связей, но на первом этапе можно исходить из одной пользовательской записи на одну транзакцию, если это соответствует текущему контракту.
---
## Локальные таблицы
Модуль должен использовать три основные таблицы.
### 1. `solana_sync_state`
Одна строка состояния синхронизации.
Назначение:
- хранить текущий checkpoint истории;
- хранить время последней успешной актуализации;
- хранить технический статус синка.
Пример полей:
- `id INTEGER PRIMARY KEY CHECK (id = 1)`
- `status TEXT NOT NULL`
- `ready INTEGER NOT NULL DEFAULT 0`
- `last_poll_at_ms INTEGER`
- `last_successful_poll_at_ms INTEGER`
- `last_seen_signature TEXT`
- `last_seen_slot INTEGER`
- `last_relevant_signature TEXT`
- `last_relevant_slot INTEGER`
- `last_error TEXT`
- `updated_at_ms INTEGER NOT NULL`
### 2. `solana_sync_tx_history`
Append-only журнал всех просмотренных транзакций по `users_economy_config_pda`.
Назначение:
- хранить полную историю опроса;
- фиксировать, какие tx были релевантны;
- хранить связь tx -> пользовательский PDA / login;
- упрощать аудит и диагностику.
Пример полей:
- `signature TEXT PRIMARY KEY`
- `slot INTEGER NOT NULL`
- `block_time INTEGER`
- `tx_kind TEXT NOT NULL`
- `is_relevant INTEGER NOT NULL`
- `affected_pda_address TEXT`
- `affected_login TEXT`
- `processed_at_ms INTEGER NOT NULL`
- `raw_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 KEY`
- `login TEXT NOT NULL`
- `normalized_login TEXT NOT NULL`
- `record_number INTEGER NOT NULL`
- `slot INTEGER NOT NULL`
- `last_tx_signature TEXT NOT NULL`
- `blockchain_name TEXT NOT NULL`
- `blockchain_key TEXT NOT NULL`
- `client_key TEXT NOT NULL`
- `paid_limit_bytes INTEGER NOT NULL`
- `used_bytes INTEGER NOT NULL`
- `last_block_number INTEGER NOT NULL`
- `last_block_hash TEXT`
- `arweave_tx_id TEXT`
- `is_server INTEGER NOT NULL DEFAULT 0`
- `server_address TEXT`
- `sync_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 0`
- `created_at_ms INTEGER NOT NULL`
- `updated_at_ms INTEGER NOT NULL`
- `raw_data_base64 TEXT NOT NULL`
- `first_seen_at_ms INTEGER NOT NULL`
- `last_synced_at_ms INTEGER NOT NULL`
Индексы:
- уникальный индекс на `login`
- уникальный индекс на `normalized_login`
- индекс на `slot`
- индекс на `last_tx_signature`
Правило использования:
- `login` хранит display-логин ровно в том регистре, как он записан в PDA;
- `normalized_login` хранит канонический lower-case логин;
- server runtime может использовать `normalized_login` для lookup и FK там, где внутренние записи живут в canonical lower-case.
### 4. `solana_user_pda_history`
Append-only история всех версий пользовательских PDA.
Назначение:
- хранить все старые публичные ключи и прочие поля прошлых версий;
- позволять видеть, когда и какая версия записи была актуальна;
- позволять разбирать изменения пользователя во времени.
Пример полей:
- `id INTEGER PRIMARY KEY`
- `tx_signature TEXT NOT NULL`
- `slot INTEGER NOT NULL`
- `block_time INTEGER`
- `pda_address TEXT NOT NULL`
- `login TEXT NOT NULL`
- `record_number INTEGER NOT NULL`
- `blockchain_name TEXT NOT NULL`
- `blockchain_key TEXT NOT NULL`
- `client_key TEXT NOT NULL`
- `paid_limit_bytes INTEGER NOT NULL`
- `used_bytes INTEGER NOT NULL`
- `last_block_number INTEGER NOT NULL`
- `last_block_hash TEXT`
- `arweave_tx_id TEXT`
- `is_server INTEGER NOT NULL DEFAULT 0`
- `server_address TEXT`
- `sync_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 0`
- `created_at_ms INTEGER NOT NULL`
- `updated_at_ms INTEGER NOT NULL`
- `raw_data_base64 TEXT NOT NULL`
- `saved_at_ms INTEGER NOT NULL`
Индексы:
- уникальный индекс на `(pda_address, record_number)`
- индекс на `login`
- индекс на `slot`
- индекс на `tx_signature`
---
## Зачем нужны и `current`, и `history`
Нужны обе таблицы:
- `solana_user_pda_current` хранит только последнюю актуальную версию и удобна для быстрых запросов;
- `solana_user_pda_history` хранит все версии и нужна для расследований и просмотра старых публичных ключей.
При обработке новой релевантной транзакции:
1. новая версия всегда добавляется в `solana_user_pda_history`;
2. актуальная строка в `solana_user_pda_current` вставляется или обновляется.
---
## Что именно считается “историей пользователя”
История нужна не для секретных паролей, а для публичных данных PDA.
В частности, история должна позволять видеть старые значения:
- `client_key`
- `blockchain_key`
- `sessions`
- `access_servers`
- `sync_servers`
- `server_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`, если он будет.
---
## Конфигурация
В конфиге должны остаться только обязательные для постоянной эксплуатации параметры:
```text
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;
- упрощается аудит и отладка после инцидентов.
---
## Что должно получиться в итоге
В результате модуль должен работать так:
1. стартует как отдельный Java-процесс;
2. подписывается на realtime-обновления `shine_users`;
3. делает начальную актуализацию через историю `users_economy_config_pda`;
4. входит в `READY`;
5. раз в 5 минут делает дополнительный audit истории;
6. хранит:
- состояние синка;
- полную историю просмотренных транзакций;
- актуальное зеркало всех пользовательских PDA;
- историю всех версий пользовательских PDA;
7. после будущего переноса в SHiNE-сервер может использоваться как блокирующий startup-модуль:
- сначала синхронизируется Solana;
- потом сервер продолжает запуск остальных подсистем.
---
## Следующий шаг реализации
После утверждения этого документа модуль нужно доработать в коде:
1. упростить конфиг;
2. заменить текущее логирование на logger;
3. выделить lifecycle-сервис с `awaitReady()`;
4. реализовать вычисление `users_economy_config_pda`;
5. реализовать polling истории по сигнатурам;
6. добавить runtime-таблицы PostgreSQL;
7. добавить запись в `current` и `history`;
8. добавить periodic guard раз в 5 минут;
9. сохранить отдельный `main` для запуска как процесса.