Compare commits

...
Author SHA256 Message Date
AidarKC 9be4c4d3a5 Исправить переход смены ключей при пустых датах 2026-09-27 16:50:21 +03:00
AidarKC f5698771e4 Исправить повтор смены ключей после публикации candidate-цепочки 2026-09-27 16:35:27 +03:00
AidarKC 151a2c1754 GPT: сгенерено и не проверено (смена ключей пользователя) 2026-09-27 15:50:27 +03:00
AidarKC 13693a0a53 Merge remote-tracking branch 'origin/main' into ui-channels-redesign
# Conflicts:
#	VERSION.properties
#	shine-UI/js/pages/registration-faq-view.js
#	shine-UI/js/services/shine-user-pda-service.js
2026-09-26 20:43:47 +03:00
AidarKC 451280edd2 Добавил больше цветовых схем 2026-09-26 20:32:37 +03:00
AidarKC c997057a37 Новый протокол Solana PDA 1.2 2026-09-26 11:01:14 +03:00
qandClaude Opus 5.5 f8900e531a UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов,
  цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли.
- Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие —
  редактор цветов с экспортом/импортом.
- Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале»,
  создание канала с адресом из названия; лента открывается на свежих постах.
- Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке.
- Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка;
  настройки, кошелёк, сеансы — меню-списки.
- Нижняя панель: иконки без подписей, бейджи на иконках.
- confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу.
- docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:05:26 +03:00
AidarKC 6e5b57fd7c Новая схема работы Arweave: каждое сообщение отдельным блоком и через Turbo 2026-09-25 13:20:00 +03:00
AidarKC 408e474130 Новая схема работы Arweave через Turbo 2026-09-25 13:19:24 +03:00
AidarKC ef707ec217 Добавить тестовые каналы и Arweave-синхронизацию 2026-09-23 11:14:02 +03:00
AidarKC 8b12760dde Дизайн Артёма 2026-09-22 14:09:02 +03:00
AidarKC cae64639bd Добавить Arweave viewer v2 2026-09-22 00:29:20 +03:00
AidarKC be696d116b Аввив 2.0 - работает, заливка!! 2026-09-21 12:49:33 +03:00
AidarKC 23969121e5 cripto utils 2026-09-20 11:20:32 +03:00
AidarKC 4da2d7129a 1 2026-09-19 10:18:45 +03:00
AidarKC 2be5ad0b7d Выровнять верхние панели UI 2026-09-18 00:00:42 +03:00
AidarKC f44ab75c64 Улучшить аватары и граф связей 2026-09-17 23:35:53 +03:00
AidarKC 7aa05dc609 Добавить страницу донатов канала 2026-09-17 22:43:49 +03:00
AidarKC 7b81d140ec Исправить read-state каналов и импорт Solana PDA 2026-09-17 21:10:48 +03:00
AidarKC 1be1d56599 Добавить импорт архивов с доверенных серверов 2026-09-12 16:59:35 +03:00
AidarKC 2df0a78eb2 мелкое Изменения формулировки в документах 2026-09-12 16:49:47 +03:00
AidarKC c3253dceac Архивация в Arweave 2026-09-12 10:17:23 +03:00
AidarKC ef068eca81 Доработать вложенные файлы и архив проекта 2026-09-11 14:33:21 +03:00
AidarKC f9ae811702 Первая версия вложенных файлов работает 2026-09-11 02:38:02 +03:00
AidarKC ea0098d705 Доработать граф связей 2026-09-11 00:43:39 +03:00
AidarKC 4991a146d5 Связи - интересная версия 2026-09-10 19:12:11 +03:00
AidarKC 849250bfa8 Связи - интересно но надо дорабатывать 2026-09-10 14:52:20 +03:00
AidarKC d83f1d4cce Исправить скрытие разделителя новых DM 2026-09-10 12:41:33 +03:00
AidarKC 4c7e71f21f Исправить синхронизацию прочитанных каналов 2026-09-10 12:33:24 +03:00
AidarKC 107f85b818 Исправить обновление непрочитанных в UI 2026-09-10 12:18:39 +03:00
AidarKC 3a466b4c38 Исправить подпись состояния чтения канала 2026-09-10 11:08:30 +03:00
AidarKC 08663fa339 Исправить статус сохранения чтения канала 2026-09-09 19:06:43 +03:00
AidarKC 8e86872aa7 Добавить состояние чтения каналов 2026-09-09 18:59:54 +03:00
AidarKC 2d059e9ff5 Добавить просмотр лайков сообщений канала 2026-09-09 17:01:13 +03:00
AidarKC 9f77c54955 Обновить каналы и перенести черновики 2026-09-09 15:17:31 +03:00
AidarKC f827c3e493 Исправить повторную авторизацию при входе 2026-09-09 10:23:36 +03:00
AidarKC ae2f2fac41 Доработать UI и счётчики 2026-09-08 22:33:06 +04:00
AidarKC 1f70d36e74 Убрать нижнюю кнопку в каналах 2026-09-08 17:54:34 +04:00
tram 7d27bfdcaf Обновить UI профиля и навигации 2026-09-08 16:13:04 +03:00
tram 023d61a1e9 Обновить профиль пользователя 2026-09-03 15:37:28 +03:00
AidarKC 85d90a7f95 Исправить синхронизацию прочтения каналов 2026-09-03 14:36:44 +04:00
414 changed files with 56474 additions and 23001 deletions
@@ -0,0 +1,30 @@
-proc:none
-d
/tmp/javac-check
-sourcepath
SHiNE-server/shine-server-geo/src/main/java:SHiNE-server/shine-server-log/src/main/java:SHiNE-server/shine-server-solana-users-sync/src/main/java:SHiNE-server/shine-server-db/src/main/java:SHiNE-server/src/main/java:SHiNE-server/shine-server-archive/src/main/java:SHiNE-server/shine-server-blockchain/src/main/java:SHiNE-server/shine-server-crypto/src/main/java:SHiNE-server/shine-server-net-protocol/src/main/java:SHiNE-server/shine-server-config/src/main/java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java
/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/ws/WsServer.java
/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java
/mnt/data/shine_patch_final_work/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java
/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java
+1
View File
@@ -105,6 +105,7 @@ ESP32/**/*.a
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
deploy/backup/archive/**
!deploy/backup/archive/.gitkeep
SHiNE-bundle-*.zip
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
.agents/
+3 -2
View File
@@ -43,8 +43,9 @@
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
- `docs/Solana_Architecture/README.md`
- Документ формата пользовательской PDA-записи `shine_users` находится в:
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
- Документы формата пользовательской PDA-записи `shine_users`:
- текущий: `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md`;
- legacy: `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`.
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
+29
View File
@@ -0,0 +1,29 @@
# SHiNE ANS-104 `test5590` patch
Этот архив содержит **только новые/изменённые файлы** плюс `DELETE_FILES.txt`.
## Что меняется
- Frame v1 без поддержки старого формата.
- Один пользовательский block = готовый Ed25519 ANS-104 DataItem.
- Test tag: `App=test5590`; channel tag: `c=<canonical slug>`.
- SHiNE `prevHash` остаётся `SHA-256(FrameV1Bytes)` и не зависит от Arweave ID.
- PostgreSQL — единственное локальное хранилище пользовательских blocks.
- Старый custom SHINE-ARCHIVE и filesystem blockchain storage удаляются.
- Каждые ~15 минут локальные pending DataItems собираются в standard ANS-104 bundle и идут через существующий native Arweave L1 transport.
- Другие серверы находят `App=test5590`, импортируют и не публикуют повторно.
- `ESP32/**` **не изменён**.
## Состав
- изменено/добавлено файлов: 46
- удалить файлов: 41
Применение и проверки: `docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
## Проверки при сборке patch
- `node --check` для изменённых production JS: OK.
- `diff -qr` каталога `ESP32` относительно исходного архива: без изменений.
- Active Java refs на удалённые SHINE-ARCHIVE/file-recovery classes: отсутствуют.
- `./gradlew testClasses --offline --no-daemon`: не запущен до компиляции, потому что wrapper требует скачать Gradle 8.14 с `services.gradle.org`, а рабочая среда не имеет внешнего сетевого доступа. Полный Gradle test обязателен после применения patch в dev-среде.
@@ -52,113 +52,84 @@ function readStrU8(bytes, cursorRef) {
function parseServerFieldsFromUserPda(dataBytes) {
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []);
if (bytes.length < 5) throw new Error('Некорректный формат PDA');
if (bytes.length < 9) throw new Error('Некорректный формат PDA');
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const cursorRef = { value: 0 };
const readU16 = () => { if (cursorRef.value + 2 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getUint16(cursorRef.value, true); cursorRef.value += 2; return v; };
const readU32 = () => { if (cursorRef.value + 4 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getUint32(cursorRef.value, true); cursorRef.value += 4; return v; };
const readU64 = () => { if (cursorRef.value + 8 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getBigUint64(cursorRef.value, true); cursorRef.value += 8; return v; };
const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5));
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
cursorRef.value += 1; // format_major
cursorRef.value += 1; // format_minor
cursorRef.value += 2; // record_len
const formatMajor = readU8(bytes, cursorRef);
const formatMinor = readU8(bytes, cursorRef);
if (formatMajor !== 1 || formatMinor !== 2) throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`);
const recordLen = readU16();
if (recordLen < 73 || recordLen > bytes.length) throw new Error('Некорректный record_len PDA');
cursorRef.value += 8; // created_at_ms
cursorRef.value += 8; // updated_at_ms
cursorRef.value += 4; // record_number
cursorRef.value += 32; // prev_record_hash
readStrU8(bytes, cursorRef); // login
const login = readStrU8(bytes, cursorRef);
const blocksCount = readU8(bytes, cursorRef);
let isServer = false;
let serverAddress = '';
let accessServers = [];
let recoveryKey32 = null;
let rootKey32 = null;
let clientKey32 = null;
let blockchainKey32 = null;
let blockchainName = '';
let homeserverSessions = [];
let forkCount = 0;
for (let i = 0; i < blocksCount; i += 1) {
const blockType = readU8(bytes, cursorRef);
cursorRef.value += 1; // block_version
if (blockType === 0 || blockType === 1 || blockType === 2) {
const blockVersion = readU8(bytes, cursorRef);
if (blockVersion !== 0) throw new Error('Неподдерживаемая версия блока PDA');
if (blockType === 1 || blockType === 2) {
const key32 = readBytes(bytes, cursorRef, 32);
if (blockType === 0) recoveryKey32 = key32;
if (blockType === 1) rootKey32 = key32;
if (blockType === 2) clientKey32 = key32;
else clientKey32 = key32;
continue;
}
const payloadLen = readU16();
const payloadEnd = cursorRef.value + payloadLen;
if (payloadEnd > recordLen - 64) throw new Error('Повреждённый variable block PDA');
if (blockType === 3) {
const count = readU8(bytes, cursorRef);
for (let j = 0; j < count; j += 1) {
cursorRef.value += 1;
const currentBlockchainName = readStrU8(bytes, cursorRef);
const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32);
if (!blockchainKey32) {
blockchainKey32 = currentBlockchainKey32;
blockchainName = currentBlockchainName;
}
cursorRef.value += 8 + 8 + 4 + 32 + 64;
const arPresent = readU8(bytes, cursorRef);
if (arPresent === 1) readStrU8(bytes, cursorRef);
forkCount = readU16();
if (forkCount < 1) throw new Error('BlockchainRegistry пуст');
for (let j = 0; j < forkCount; j += 1) {
blockchainKey32 = readBytes(bytes, cursorRef, 32);
readU64(); // created_at_ms fork
readU32(); // paid_limit_bytes
}
continue;
}
if (blockType === 30) {
isServer = readU8(bytes, cursorRef) === 1;
if (isServer) {
cursorRef.value += 1; // address_format_type
cursorRef.value += 1; // address_format_version
serverAddress = readStrU8(bytes, cursorRef);
const syncCount = readU8(bytes, cursorRef);
for (let j = 0; j < syncCount; j += 1) readStrU8(bytes, cursorRef);
}
continue;
}
if (blockType === 40) {
} else if (blockType === 30) {
const addressCount = readU8(bytes, cursorRef);
if (addressCount !== 1) throw new Error('PDA 1.2 допускает ровно один адрес сервера');
readU8(bytes, cursorRef); // address_format_type
readU8(bytes, cursorRef); // address_format_version
serverAddress = readStrU8(bytes, cursorRef);
} else if (blockType === 40) {
const accessCount = readU8(bytes, cursorRef);
if (accessCount > 1) throw new Error('PDA 1.2 допускает максимум один access server');
accessServers = [];
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
continue;
}
if (blockType === 50) {
cursorRef.value += 1;
const sessionsCount = readU8(bytes, cursorRef);
for (let j = 0; j < sessionsCount; j += 1) {
const sessionType = readU8(bytes, cursorRef);
const sessionVersion = readU8(bytes, cursorRef);
const sessionName = readStrU8(bytes, cursorRef);
const sessionPubKey32 = readBytes(bytes, cursorRef, 32);
if (sessionType === 100) {
homeserverSessions.push({
sessionType,
sessionVersion,
sessionName,
sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(),
sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`,
});
}
}
continue;
}
if (blockType === 70) {
cursorRef.value += 1;
continue;
}
throw new Error(`Неизвестный блок PDA: ${blockType}`);
cursorRef.value = payloadEnd;
}
if (!rootKey32 || !clientKey32 || !blockchainKey32) throw new Error('PDA 1.2 не содержит обязательные ключи');
return {
isServer,
isServer: Boolean(serverAddress),
serverAddress: normalizeHostLike(serverAddress),
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
publicKeys: {
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
blockchainName,
recoveryKeyBase58: '',
rootKeyBase58: new PublicKey(rootKey32).toBase58(),
clientKeyBase58: new PublicKey(clientKey32).toBase58(),
blockchainKeyBase58: new PublicKey(blockchainKey32).toBase58(),
blockchainName: `${normalizeServerLogin(login)}-${String(forkCount).padStart(3, '0')}`,
},
homeserverSessions,
homeserverSessions: [],
};
}
+7 -15
View File
@@ -18,15 +18,11 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
## Настройка сервера в Solana (Solana PDA)
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
В PDA хранятся:
Серверный аккаунт SHiNE регистрируется как обычный `user_pda` формата 1.2 с присутствующим `ServerProfileBlock`. Отдельного `is_server` в PDA нет: наличие server profile означает, что аккаунт объявляет серверный endpoint.
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
с которыми синхронизируются пользовательские блокчейны;
- **корневой ключ** сервера (`root_key`).
PDA 1.2 сейчас допускает **один** адрес сервера (`address_format_type + address_format_version + address`). Бинарный блок использует count/array, чтобы будущая версия протокола могла увеличить лимит без смены структуры блока. Прямой список `sync_servers` удалён: пользовательские blockchain синхронизируются через Arweave.
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
Клиенты читают server PDA из Solana, получают опубликованный endpoint и подключаются к нему. Межсерверная доставка DM через access-server routing остаётся отдельным механизмом.
**Управление серверной PDA выполняется через Web-панель администратора:**
@@ -36,10 +32,9 @@ shine-UI/server-ui.html
Страницы:
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers.
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса сервера.
Для регистрации нужен полный keyBundle (root + device + blockchain).
Для обновления — только root + device (blockchain-ключ не нужен).
PDA 1.2 использует три постоянные роли ключей: `root` (cold recovery), `blockchain` (обычный authority и подпись блоков) и `client` (клиент/кошелёк). Обычный update авторизуется активным последним blockchain key; root используется для recovery и может менять root.
Актуальные адреса программ Solana (devnet):
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
@@ -47,12 +42,9 @@ shine-UI/server-ui.html
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
## Синхронизация с партнёрскими серверами
## Синхронизация пользовательских blockchain
Сервер должен синхронизировать блоки пользовательских блокчейнов с
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
они доставляются только на первый access-сервер получателя.
Детали: `docs/Blockchain/sync-between-servers.md`
Прямой peer-to-peer blockchain sync через `sync_servers` отключён. Сервер восстанавливает и синхронизирует пользовательские записи через Arweave. Код старого peer-sync пока может оставаться как inert legacy, но не должен включаться в runtime.
## Деплой
@@ -0,0 +1,31 @@
plugins {
id 'java'
}
group = 'shine'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories { mavenCentral() }
dependencies {
implementation project(':shine-server-config')
implementation project(':shine-server-db')
implementation project(':shine-server-crypto')
implementation project(':shine-server-blockchain')
implementation project(':shine-server-net-protocol')
implementation project(':shine-server-solana-users-sync')
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
implementation 'org.slf4j:slf4j-api:2.0.16'
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
}
test { useJUnitPlatform() }
@@ -0,0 +1,103 @@
package server.archive;
import blockchain.Ans104DataItem;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/** Minimal ANS-104 binary bundle writer/reader. */
public final class Ans104Bundle {
public record Entry(byte[] id32, byte[] rawDataItem) {}
private Ans104Bundle() {}
public static byte[] write(List<byte[]> rawItems) {
if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty");
if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items");
List<Ans104DataItem> parsed = new ArrayList<>(rawItems.size());
long total = 32L + 64L * rawItems.size();
for (byte[] raw : rawItems) {
Ans104DataItem item = new Ans104DataItem(raw);
if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature");
parsed.add(item);
total = Math.addExact(total, raw.length);
}
if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total);
ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
out.writeBytes(uint256LE(rawItems.size()));
for (int i = 0; i < rawItems.size(); i++) {
out.writeBytes(uint256LE(rawItems.get(i).length));
out.writeBytes(parsed.get(i).id32());
}
for (byte[] raw : rawItems) out.writeBytes(raw);
return out.toByteArray();
}
public static List<Entry> read(byte[] bundle, int maxItems) {
if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short");
long countLong = readUint256AsLong(bundle, 0);
if (countLong <= 0 || countLong > Math.max(1, maxItems)) {
throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong);
}
int count = (int) countLong;
long headerLong = 32L + 64L * count;
if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated");
int header = (int) headerLong;
long[] sizes = new long[count];
byte[][] ids = new byte[count][];
int hp = 32;
long payloadBytes = 0;
for (int i = 0; i < count; i++) {
sizes[i] = readUint256AsLong(bundle, hp); hp += 32;
ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32;
if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]);
payloadBytes = Math.addExact(payloadBytes, sizes[i]);
}
if (headerLong + payloadBytes != bundle.length) {
throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length);
}
List<Entry> out = new ArrayList<>(count);
int p = header;
for (int i = 0; i < count; i++) {
int len = (int) sizes[i];
byte[] raw = Arrays.copyOfRange(bundle, p, p + len);
p += len;
Ans104DataItem item = new Ans104DataItem(raw);
if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i);
out.add(new Entry(ids[i], raw));
}
return out;
}
public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) {
if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes");
for (Entry e : read(bundle, maxItems)) {
if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem();
}
return null;
}
/** ANS-104 bundle integers are 32-byte unsigned little-endian values. */
static byte[] uint256LE(long value) {
if (value < 0) throw new IllegalArgumentException("negative uint256");
byte[] out = new byte[32];
long v = value;
for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; }
return out;
}
static long readUint256AsLong(byte[] data, int offset) {
if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated");
for (int i = offset + 8; i < offset + 32; i++) {
if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
}
long v = 0;
for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL);
if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
return v;
}
}
@@ -0,0 +1,37 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Fixed-delay publisher. Empty cycles are no-ops. */
public final class ArweaveBlockPublisherScheduler {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArweaveBlockPublisherScheduler() {}
public static void startOrLog() {
ArweaveBlocksConfig cfg;
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled (mode=none)"); return; }
if (!STARTED.compareAndSet(false,true)) return;
try {
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "shine-arweave-block-publisher"); t.setDaemon(true); return t;
});
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
log.info("Arweave user-block publisher enabled: mode={} interval={}m", cfg.publishMode(), cfg.publishIntervalMinutes());
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); }
}
public static void close() { ScheduledExecutorService e=executor; if(e!=null)e.shutdownNow(); }
}
@@ -0,0 +1,174 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import java.util.ArrayList;
import java.util.List;
/** Publishes locally-created signed user DataItems through the configured transport. */
public final class ArweaveBlockPublisherService {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
private final ArweaveBlocksConfig cfg;
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
private final ArweaveL1Uploader arweaveUploader;
private final TurboDataItemUploader turboUploader;
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
this.cfg = cfg;
this.arweaveUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.ARWEAVE ? new ArweaveL1Uploader(cfg) : null;
this.turboUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.TURBO ? new TurboDataItemUploader(cfg) : null;
}
public int runCycle() throws Exception {
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
int maxItems = cfg.publishMaxItems();
int attempted = 0;
int published = 0;
// Ротация приоритетна: пользователь уже заблокирован для обычной записи и ждёт завершения копирования.
List<KeyRotationCandidateBlockEntry> rotationCandidates = candidateBlocksDAO.listPendingArweave(maxItems);
if (!rotationCandidates.isEmpty()) {
attempted += rotationCandidates.size();
published += switch (cfg.publishMode()) {
case TURBO -> publishTurboRotation(rotationCandidates);
case ARWEAVE -> publishDirectArweaveRotation(rotationCandidates);
case NONE -> 0;
};
}
int remaining = Math.max(0, maxItems - attempted);
if (remaining == 0) return published;
List<BlockEntry> candidates = blocksDAO.listPendingArweave(remaining);
if (candidates.isEmpty()) return published;
published += switch (cfg.publishMode()) {
case TURBO -> publishTurbo(candidates);
case ARWEAVE -> publishDirectArweave(candidates);
case NONE -> 0;
};
return published;
}
private int publishTurboRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
int published = 0;
Exception firstFailure = null;
for (KeyRotationCandidateBlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
try {
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
candidateBlocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
published++;
log.debug("Turbo published key-rotation DataItem {} chain={} block={}",
result.dataItemId(), e.getCandidateBlockchainName(), e.getBlockNumber());
} catch (Exception ex) {
if (firstFailure == null) firstFailure = ex;
log.warn("Turbo key-rotation publish failed: chain={} block={} bytes={} error={}",
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length, ex.getMessage());
}
}
if (published > 0) log.info("Published {} SHiNE key-rotation DataItems through Turbo", published);
if (published == 0 && firstFailure != null) throw firstFailure;
return published;
}
private int publishTurbo(List<BlockEntry> candidates) throws Exception {
int published = 0;
Exception firstFailure = null;
for (BlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
try {
TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id);
blocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis());
published++;
log.debug("Turbo published SHiNE DataItem {} chain={} block={}", result.dataItemId(), e.getBchName(), e.getBlockNumber());
} catch (Exception ex) {
if (firstFailure == null) firstFailure = ex;
log.warn("Turbo publish failed: chain={} block={} bytes={} error={}",
e.getBchName(), e.getBlockNumber(), raw.length, ex.getMessage());
}
}
if (published > 0) log.info("Published {} SHiNE test DataItems through Turbo", published);
if (published == 0 && firstFailure != null) throw firstFailure;
return published;
}
private int publishDirectArweaveRotation(List<KeyRotationCandidateBlockEntry> candidates) throws Exception {
List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>();
long estimated = 32;
for (KeyRotationCandidateBlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
long next = estimated + 64L + raw.length;
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
if (next > cfg.publishMaxBundleBytes()) {
log.error("One key-rotation DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
e.getCandidateBlockchainName(), e.getBlockNumber(), raw.length);
continue;
}
items.add(raw);
ids.add(id);
estimated = next;
}
if (items.isEmpty()) return 0;
byte[] bundle = Ans104Bundle.write(items);
List<ArweaveL1Uploader.Tag> rootTags = List.of(
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
);
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
candidateBlocksDAO.markArweavePublished(ids, System.currentTimeMillis());
log.info("Published {} SHiNE key-rotation DataItems in direct Arweave root tx {} (bundle={} bytes)",
items.size(), result.txId(), bundle.length);
return items.size();
}
private int publishDirectArweave(List<BlockEntry> candidates) throws Exception {
List<byte[]> items = new ArrayList<>();
List<byte[]> ids = new ArrayList<>();
long estimated = 32;
for (BlockEntry e : candidates) {
byte[] raw = e.getBlockBytes();
byte[] id = e.getDataItemId();
if (raw == null || raw.length == 0 || id == null || id.length != 32) continue;
long next = estimated + 64L + raw.length;
if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break;
if (next > cfg.publishMaxBundleBytes()) {
log.error("One DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}",
e.getBchName(), e.getBlockNumber(), raw.length);
continue;
}
items.add(raw);
ids.add(id);
estimated = next;
}
if (items.isEmpty()) return 0;
byte[] bundle = Ans104Bundle.write(items);
List<ArweaveL1Uploader.Tag> rootTags = List.of(
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
);
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
blocksDAO.markArweavePublished(ids, System.currentTimeMillis());
log.info("Published {} SHiNE test DataItems in direct Arweave root tx {} (bundle={} bytes)",
items.size(), result.txId(), bundle.length);
return items.size();
}
}
@@ -0,0 +1,32 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class ArweaveBlockSyncScheduler {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArweaveBlockSyncScheduler() {}
public static void startOrLog() {
ArweaveBlocksConfig cfg;
try { cfg = ArweaveBlocksConfig.load(); }
catch (Exception e) { log.error("Cannot read Arweave block sync config", e); return; }
if (!cfg.syncEnabled()) { log.info("Arweave user-block sync disabled"); return; }
if (!STARTED.compareAndSet(false,true)) return;
try {
ArweaveBlockSyncService service = new ArweaveBlockSyncService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t=new Thread(r,"shine-arweave-block-sync");t.setDaemon(true);return t; });
Runnable task=()->{try{service.runCycle();}catch(Exception e){log.error("Arweave block sync cycle failed",e);}};
executor.scheduleWithFixedDelay(task,0,cfg.syncIntervalMinutes(),TimeUnit.MINUTES);
log.info("Arweave user-block sync enabled: interval={}m gateway={}",cfg.syncIntervalMinutes(),cfg.syncGateway());
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block sync failed to start",e); }
}
public static void close(){ScheduledExecutorService e=executor;if(e!=null)e.shutdownNow();}
}
@@ -0,0 +1,252 @@
package server.archive;
import blockchain.Ans104DataItem;
import blockchain.BchBlockEntry;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import shine.db.dao.ArweaveBlockImportDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.SolanaUserPdaCurrentDAO;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.*;
/**
* Discovers individual App=test5590 DataItems and imports the exact signed ANS-104 bytes.
* The importer is transport-agnostic: a DataItem may have reached Arweave through Turbo
* or inside a direct server-created ANS-104 bundle.
*/
public final class ArweaveBlockSyncService {
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
private final ArweaveBlocksConfig cfg;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance();
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
public ArweaveBlockSyncService(ArweaveBlocksConfig cfg) { this.cfg = cfg; }
public void runCycle() throws Exception {
discover();
drainQueue();
}
private void discover() throws Exception {
long stored = importDAO.getLastBlockHeight();
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
String cursor = null;
long maxHeight = minHeight;
long lowestRetryHeight = Long.MAX_VALUE;
int discovered = 0;
do {
JsonNode response = graphQlPage(minHeight, cursor);
JsonNode txs = response.path("data").path("transactions");
if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors"));
JsonNode edges = txs.path("edges");
if (!edges.isArray()) throw new IOException("Arweave GraphQL returned no transactions.edges");
String nextCursor = null;
for (JsonNode edge : edges) {
nextCursor = edge.path("cursor").asText(null);
JsonNode node = edge.path("node");
String dataItemId = node.path("id").asText("").trim();
long height = node.path("block").path("height").asLong(-1L);
if (dataItemId.isBlank() || height < 0) continue;
maxHeight = Math.max(maxHeight, height);
byte[] id32;
try {
id32 = B64URL.decode(dataItemId);
if (id32.length != 32) throw new IllegalArgumentException("id length=" + id32.length);
} catch (Exception e) {
log.warn("Ignoring malformed Arweave DataItem id {}: {}", dataItemId, e.getMessage());
continue;
}
if (blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
try {
byte[] rawDataItem = downloadSignedDataItem(dataItemId);
if (importDAO.enqueueIfMissing(id32, height, rawDataItem, System.currentTimeMillis())) discovered++;
} catch (Exception e) {
lowestRetryHeight = Math.min(lowestRetryHeight, height);
log.warn("Cannot retrieve signed DataItem {} at height {} yet: {}", dataItemId, height, e.getMessage());
}
}
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
cursor = hasNext ? nextCursor : null;
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
} while (cursor != null);
// Keep inclusive overlap. If a gateway has indexed GraphQL before offsets, do not advance past that item.
long checkpoint = lowestRetryHeight == Long.MAX_VALUE ? maxHeight : Math.min(maxHeight, lowestRetryHeight);
importDAO.setLastBlockHeight(checkpoint, System.currentTimeMillis());
if (discovered > 0) {
log.info("Arweave discovery queued {} new SHiNE test DataItems; checkpoint={}", discovered, checkpoint);
}
}
private void drainQueue() throws Exception {
int passes = 0;
int importedTotal = 0;
boolean progress;
do {
progress = false;
List<ArweaveBlockImportDAO.QueueItem> pending = importDAO.listPending(cfg.syncQueueBatchSize());
if (pending.isEmpty()) break;
for (ArweaveBlockImportDAO.QueueItem q : pending) {
Ans104DataItem item;
try {
item = new Ans104DataItem(q.rawDataItem());
if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch");
if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag");
if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature");
new BchBlockEntry(q.rawDataItem());
} catch (Exception e) {
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
continue;
}
SolanaUserPdaCurrentEntry user = usersDAO.getByBlockchainKey(item.owner32());
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
importDAO.setPendingError(q.dataItemId(), "unknown_owner", System.currentTimeMillis());
continue;
}
ensureState(user);
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(user.getBlockchainName(), q.rawDataItem());
if (result.ok()) {
importDAO.delete(q.dataItemId());
importedTotal++; progress = true;
continue;
}
String reason = result.reasonCode();
if ("bad_block_number".equals(reason) || "bad_prev_hash".equals(reason)
|| "prev_line_block_not_found".equals(reason) || "channel_not_found".equals(reason)
|| "chain_resync_in_progress".equals(reason)) {
importDAO.setPendingError(q.dataItemId(), reason, System.currentTimeMillis());
} else {
importDAO.reject(q.dataItemId(), reason, System.currentTimeMillis());
}
}
passes++;
} while (progress && passes < 8);
if (importedTotal > 0) log.info("Imported {} SHiNE test DataItems from Arweave", importedTotal);
}
private void ensureState(SolanaUserPdaCurrentEntry user) throws Exception {
BlockchainStateEntry existing = stateDAO.getByBlockchainName(user.getBlockchainName());
if (existing != null) return;
BlockchainStateEntry s = new BlockchainStateEntry();
s.setBlockchainName(user.getBlockchainName()); s.setLogin(user.getLogin()); s.setBlockchainKey(user.getBlockchainKey());
s.setSizeLimit(user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
s.setFileSizeBytes(0L); s.setLastBlockNumber(-1); s.setLastBlockHash(null); s.setUpdatedAtMs(System.currentTimeMillis());
stateDAO.insertIfMissing(s);
}
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
String query = "query { transactions(tags:[{name:\"" + ArweaveBlocksConfig.TEST_TAG_NAME + "\",values:[\""
+ ArweaveBlocksConfig.TEST_TAG_VALUE + "\"]}], block:{min:" + minHeight + "}, first:"
+ cfg.syncPageSize() + ", after:" + after
+ ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }";
String body = MAPPER.writeValueAsString(Map.of("query", query));
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("GraphQL HTTP " + resp.statusCode());
return MAPPER.readTree(resp.body());
}
/**
* Gateways normally expose only the payload at /{dataItemId}. SHiNE needs the complete signed
* DataItem, so obtain its exact offset/size inside the root L1 transaction and range-read it.
*/
private byte[] downloadSignedDataItem(String dataItemId) throws Exception {
JsonNode offsets = getOffsets(dataItemId);
String rootTxId = offsets.path("rootTxId").asText("").trim();
long rootOffset = offsets.path("rootOffset").asLong(-1L);
long size = offsets.path("size").asLong(-1L);
if (rootTxId.isBlank() || rootOffset < 0 || size <= 0) {
throw new IOException("Bad AR.IO offsets for " + dataItemId + ": " + offsets);
}
if (size > cfg.syncMaxDataItemBytes()) {
throw new IOException("DataItem exceeds syncMaxDataItemBytes: " + size);
}
if (rootOffset > Long.MAX_VALUE - size) throw new IOException("DataItem offset overflow");
long endInclusive = rootOffset + size - 1;
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/raw/" + rootTxId))
.timeout(Duration.ofMinutes(2))
.header("Range", "bytes=" + rootOffset + "-" + endInclusive)
.header("Accept", "application/octet-stream")
.GET().build();
HttpResponse<InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream in = resp.body()) {
if (resp.statusCode() != 206) {
throw new IOException("Gateway ignored root range for DataItem " + dataItemId + ": HTTP " + resp.statusCode());
}
byte[] bytes = readExactlyBounded(in, (int) size);
if (bytes.length != size) throw new IOException("Truncated DataItem range: expected=" + size + " got=" + bytes.length);
Ans104DataItem parsed = new Ans104DataItem(bytes);
byte[] expectedId = B64URL.decode(dataItemId);
if (!Arrays.equals(parsed.id32(), expectedId)) {
throw new IOException("Range returned another ANS-104 DataItem for " + dataItemId);
}
return bytes;
}
}
private JsonNode getOffsets(String dataItemId) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/ar-io/offsets/" + dataItemId))
.timeout(Duration.ofSeconds(30)).header("Accept","application/json").GET().build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (resp.statusCode() == 404) throw new IOException("AR.IO offsets not indexed yet");
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
throw new IOException("AR.IO offsets HTTP " + resp.statusCode() + ": " + safe(resp.body()));
}
return MAPPER.readTree(resp.body());
}
private static byte[] readExactlyBounded(InputStream in, int expected) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(expected);
byte[] buf = new byte[Math.min(64 * 1024, Math.max(1024, expected))];
int remaining = expected;
while (remaining > 0) {
int n = in.read(buf, 0, Math.min(buf.length, remaining));
if (n < 0) break;
out.write(buf, 0, n);
remaining -= n;
}
return out.toByteArray();
}
private static String safe(String value) {
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
return v.length() <= 500 ? v : v.substring(0, 500);
}
private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
}
@@ -0,0 +1,99 @@
package server.archive;
import blockchain.Ans104DataItem;
import utils.config.AppConfig;
import java.nio.file.Path;
import java.util.Locale;
/** Configuration of per-user-block ANS-104 Arweave/Turbo transport. */
public record ArweaveBlocksConfig(
PublishMode publishMode,
int publishIntervalMinutes,
int publishMaxItems,
long publishMaxBundleBytes,
String publishGateway,
Path walletJwkPath,
int minConfirmations,
int confirmPollSeconds,
int confirmTimeoutMinutes,
String turboUploadUrl,
String turboPaidByAddress,
Path turboWalletJwkPath,
boolean syncEnabled,
int syncIntervalMinutes,
int syncPageSize,
int syncQueueBatchSize,
long syncStartBlockHeight,
long syncMaxDataItemBytes,
String syncGateway
) {
public enum PublishMode {
TURBO,
ARWEAVE,
NONE;
static PublishMode parse(String value) {
String normalized = value == null ? "none" : value.trim().toLowerCase(Locale.ROOT);
return switch (normalized) {
case "turbo" -> TURBO;
case "arweave" -> ARWEAVE;
case "none", "" -> NONE;
default -> throw new IllegalArgumentException(
"arweave.blocks.publish.mode must be turbo, arweave or none; got: " + value);
};
}
}
public static final String TEST_TAG_NAME = "App";
public static final String TEST_TAG_VALUE = "test5590";
public static final String CHANNEL_TAG_NAME = "c_test5590";
public static ArweaveBlocksConfig load() {
AppConfig c = AppConfig.getInstance();
long maxDataItemBytes = parseLong(c.getParam("arweave.blocks.sync.maxDataItemBytes"), Ans104DataItem.MAX_DATA_ITEM_BYTES);
if (maxDataItemBytes > Ans104DataItem.MAX_DATA_ITEM_BYTES) maxDataItemBytes = Ans104DataItem.MAX_DATA_ITEM_BYTES;
return new ArweaveBlocksConfig(
PublishMode.parse(c.getParam("arweave.blocks.publish.mode")),
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
orDefault(c.getParam("arweave.blocks.publish.gateway"), "https://arweave.net"),
optionalPath(c.getParam("arweave.blocks.publish.walletJwkPath")),
nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"),
positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"),
positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"),
orDefault(c.getParam("arweave.blocks.publish.turbo.uploadUrl"), "https://turbo.ardrive.io/tx"),
blankToNull(c.getParam("arweave.blocks.publish.turbo.paidByAddress")),
optionalPath(c.getParam("arweave.blocks.publish.turbo.walletJwkPath")),
c.getBoolean("arweave.blocks.sync.enabled", false),
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
positiveLong(maxDataItemBytes, "sync.maxDataItemBytes"),
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
);
}
public boolean publishEnabled() { return publishMode != PublishMode.NONE; }
public void validatePublisher() {
if (publishMode == PublishMode.ARWEAVE && walletJwkPath == null) {
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required for mode=arweave");
}
if (publishMode == PublishMode.TURBO && (turboUploadUrl == null || turboUploadUrl.isBlank())) {
throw new IllegalArgumentException("arweave.blocks.publish.turbo.uploadUrl is required for mode=turbo");
}
}
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v.trim(); }
private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); }
private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); }
private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
private static int nonNegative(int v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
private static long positiveLong(long v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
private static long nonNegativeLong(long v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;}
private static int clamp(int v,int min,int max){return Math.max(min,Math.min(max,v));}
}
@@ -0,0 +1,141 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.*;
import java.security.spec.MGF1ParameterSpec;
import java.security.spec.PSSParameterSpec;
import java.security.spec.RSAPrivateCrtKeySpec;
import java.time.Duration;
import java.util.*;
/** Existing native Arweave L1 transport adapted to upload in-memory ANS-104 bundles. */
public final class ArweaveL1Uploader {
private static final Logger log = LoggerFactory.getLogger(ArweaveL1Uploader.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
public record Tag(String name, String value) {}
public record UploadResult(String txId, byte[] txIdBytes) {}
private final ArweaveBlocksConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private volatile Wallet wallet;
public ArweaveL1Uploader(ArweaveBlocksConfig cfg) { this.cfg = Objects.requireNonNull(cfg); }
public UploadResult upload(byte[] data, List<Tag> tags) throws Exception {
if (data == null || data.length == 0) throw new IllegalArgumentException("Arweave upload data is empty");
Wallet w = wallet();
String gateway = trimGateway(cfg.publishGateway());
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(data);
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + data.length);
if (!reward.matches("^\\d+$")) throw new IOException("Bad Arweave price: " + reward);
verifyBalance(gateway, w.address(), reward);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String,String>> jsonTags = new ArrayList<>();
for (Tag tag : tags == null ? List.<Tag>of() : tags) addTag(jsonTags, tagList, tag.name(), tag.value());
byte[] signaturePayload = deepHash(List.of(
utf8("2"), b64Decode(w.owner()), new byte[0], utf8("0"), utf8(reward),
b64Decode(anchor), tagList, utf8(Integer.toString(data.length)), merkle.dataRoot()
));
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
byte[] txIdBytes = sha256(rawSignature);
String txId = b64(txIdBytes);
Map<String,Object> tx = new LinkedHashMap<>();
tx.put("format",2); tx.put("id",txId); tx.put("last_tx",anchor); tx.put("owner",w.owner());
tx.put("tags",jsonTags); tx.put("target",""); tx.put("quantity","0");
tx.put("data_root",b64(merkle.dataRoot())); tx.put("data_size",Integer.toString(data.length)); tx.put("data","");
tx.put("reward",reward); tx.put("signature",b64(rawSignature));
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
for (int i=0;i<merkle.chunks().size();i++) {
ArweaveMerkle.Chunk meta=merkle.chunks().get(i); ArweaveMerkle.Proof proof=merkle.proofs().get(i);
Map<String,Object> body=new LinkedHashMap<>();
body.put("data_root",b64(merkle.dataRoot())); body.put("data_size",Integer.toString(data.length));
body.put("data_path",b64(proof.proof())); body.put("offset",Long.toString(proof.offset()));
body.put("chunk",b64(ArweaveMerkle.readChunk(data,meta)));
postChunkWithRetry(gateway,body,i,merkle.chunks().size());
}
if (cfg.minConfirmations() > 0) waitForConfirmations(txId);
log.info("ANS-104 bundle uploaded to Arweave: tx={} bytes={} items-transported",txId,data.length);
return new UploadResult(txId,txIdBytes);
}
private int waitForConfirmations(String txId) throws Exception {
String gateway=trimGateway(cfg.publishGateway());
long deadline=System.currentTimeMillis()+cfg.confirmTimeoutMinutes()*60_000L; int last=0;
while(System.currentTimeMillis()<deadline){
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+"/tx/"+txId+"/status")).timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if(resp.statusCode()==200){JsonNode j=MAPPER.readTree(resp.body());last=j.path("number_of_confirmations").asInt(0);if(last>=cfg.minConfirmations())return last;}
else if(resp.statusCode()!=202&&resp.statusCode()!=404)log.warn("Arweave status tx={} HTTP {}",txId,resp.statusCode());
Thread.sleep(cfg.confirmPollSeconds()*1000L);
}
throw new IOException("Arweave confirmation timeout for "+txId+", last="+last);
}
private void postChunkWithRetry(String gateway,Map<String,Object> body,int index,int total)throws Exception{
Exception last=null;
for(int attempt=1;attempt<=12;attempt++)try{postJsonExpect2xx(gateway+"/chunk",body,Duration.ofSeconds(60));return;}catch(Exception e){last=e;if(attempt==12)break;Thread.sleep(5000L);}
throw new IOException("Failed Arweave chunk "+(index+1)+"/"+total,last);
}
private void postJsonExpect2xx(String url,Object body,Duration timeout)throws Exception{
HttpRequest req=HttpRequest.newBuilder(URI.create(url)).timeout(timeout).header("Content-Type","application/json").header("Accept","application/json")
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body),StandardCharsets.UTF_8)).build();
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("HTTP "+resp.statusCode()+" "+safe(resp.body()));
}
private void verifyBalance(String gateway,String address,String reward)throws Exception{
String balance=getRequiredText(gateway,"/wallet/"+address+"/balance");
if(balance.matches("^\\d+$")&&new BigInteger(balance).compareTo(new BigInteger(reward))<0)throw new IllegalStateException("Not enough AR: balance="+balance+" reward="+reward);
}
private String getRequiredText(String gateway,String path)throws Exception{
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+path)).timeout(Duration.ofSeconds(20)).GET().build();
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("Arweave HTTP "+resp.statusCode()+" for "+path+": "+safe(resp.body()));
String s=resp.body()==null?"":resp.body().trim();if(s.isBlank())throw new IOException("Empty Arweave response for "+path);return s;
}
private Wallet wallet()throws Exception{
Wallet local=wallet;if(local!=null)return local;synchronized(this){if(wallet!=null)return wallet;
JsonNode jwk=MAPPER.readTree(Files.readString(cfg.walletJwkPath(),StandardCharsets.UTF_8));String owner=required(jwk,"n");
PrivateKey pk=buildPrivateKey(jwk);String address=b64(sha256(b64Decode(owner)));return wallet=new Wallet(owner,address,pk);}
}
private static PrivateKey buildPrivateKey(JsonNode jwk)throws GeneralSecurityException{
RSAPrivateCrtKeySpec spec=new RSAPrivateCrtKeySpec(big(required(jwk,"n")),big(required(jwk,"e")),big(required(jwk,"d")),big(required(jwk,"p")),big(required(jwk,"q")),big(required(jwk,"dp")),big(required(jwk,"dq")),big(required(jwk,"qi")));
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
private static byte[] signPayload(PrivateKey key,byte[] payload)throws GeneralSecurityException{
Signature s=Signature.getInstance("RSASSA-PSS");s.setParameter(new PSSParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,32,1));s.initSign(key);s.update(payload);return s.sign();
}
private static byte[] deepHash(Object data)throws GeneralSecurityException{
if(data instanceof List<?> list){byte[] acc=sha384(utf8("list"+list.size()));for(Object item:list)acc=sha384(concat(acc,deepHash(item)));return acc;}
if(!(data instanceof byte[] bytes))throw new IllegalArgumentException("deepHash byte[]/List expected");return sha384(concat(sha384(utf8("blob"+bytes.length)),sha384(bytes)));
}
private static void addTag(List<Map<String,String>> json,List<List<byte[]>> raw,String name,String value){byte[]n=utf8(name),v=utf8(value);Map<String,String>m=new LinkedHashMap<>();m.put("name",b64(n));m.put("value",b64(v));json.add(m);raw.add(List.of(n,v));}
private static String required(JsonNode n,String f){String s=n==null?"":n.path(f).asText("").trim();if(s.isBlank())throw new IllegalStateException("Arweave JWK missing "+f);return s;}
private static BigInteger big(String s){return new BigInteger(1,b64Decode(s));}
private static String trimGateway(String s){return(s==null?"https://arweave.net":s.trim()).replaceAll("/+$","");}
private static byte[] utf8(String s){return String.valueOf(s==null?"":s).getBytes(StandardCharsets.UTF_8);}
private static byte[] b64Decode(String s){return B64D.decode(s);}private static String b64(byte[]b){return B64.encodeToString(b);}
private static byte[] sha256(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-256").digest(b);}private static byte[] sha384(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-384").digest(b);}
private static byte[] concat(byte[]...a){int n=0;for(byte[]x:a)n=Math.addExact(n,x.length);byte[]o=new byte[n];int p=0;for(byte[]x:a){System.arraycopy(x,0,o,p,x.length);p+=x.length;}return o;}
private static String safe(String s){s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return s.length()<=250?s:s.substring(0,250)+"...";}
private record Wallet(String owner,String address,PrivateKey privateKey){}
}
@@ -0,0 +1,206 @@
package server.archive;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts.
*/
final class ArweaveMerkle {
static final int MAX_CHUNK_SIZE = 256 * 1024;
static final int MIN_CHUNK_SIZE = 32 * 1024;
record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) {
long size() { return maxByteRange - minByteRange; }
}
record Proof(long offset, byte[] proof) {}
record Prepared(byte[] dataRoot, List<Chunk> chunks, List<Proof> proofs) {}
private sealed interface Node permits Leaf, Branch {
byte[] id();
long maxByteRange();
}
private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {}
private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {}
private ArweaveMerkle() {}
static Prepared prepare(byte[] data) throws IOException {
if (data == null || data.length == 0) throw new IOException("Arweave data is empty");
List<Chunk> chunks = chunkBytes(data);
List<Node> leaves = new ArrayList<>(chunks.size());
for (Chunk c : chunks) {
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
}
Node root = buildLayers(leaves);
List<Proof> proofs = new ArrayList<>();
resolveProofs(root, new byte[0], proofs);
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
chunks = new ArrayList<>(chunks);
chunks.remove(chunks.size() - 1);
proofs.remove(proofs.size() - 1);
}
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
}
private static List<Chunk> chunkBytes(byte[] data) {
List<Chunk> out = new ArrayList<>();
long remaining = data.length;
long cursor = 0;
int p = 0;
while (remaining >= MAX_CHUNK_SIZE) {
long chunkSize = MAX_CHUNK_SIZE;
long next = remaining - MAX_CHUNK_SIZE;
if (next > 0 && next < MIN_CHUNK_SIZE) chunkSize = (remaining + 1) / 2;
byte[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize);
out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize));
p += (int) chunkSize;
cursor += chunkSize;
remaining -= chunkSize;
}
byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining);
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
return out;
}
static byte[] readChunk(byte[] data, Chunk c) throws IOException {
long len = c.size();
if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) {
throw new IOException("Invalid Arweave chunk range");
}
return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange());
}
static Prepared prepare(Path file) throws IOException {
long fileSize = Files.size(file);
List<Chunk> chunks = chunkFile(file, fileSize);
List<Node> leaves = new ArrayList<>(chunks.size());
for (Chunk c : chunks) {
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
}
if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves");
Node root = buildLayers(leaves);
List<Proof> proofs = new ArrayList<>();
resolveProofs(root, new byte[0], proofs);
// arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB,
// но сам нулевой chunk/proof не загружает.
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
chunks = new ArrayList<>(chunks);
chunks.remove(chunks.size() - 1);
proofs.remove(proofs.size() - 1);
}
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
}
private static List<Chunk> chunkFile(Path file, long fileSize) throws IOException {
List<Chunk> out = new ArrayList<>();
long remaining = fileSize;
long cursor = 0;
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
while (remaining >= MAX_CHUNK_SIZE) {
long chunkSize = MAX_CHUNK_SIZE;
long next = remaining - MAX_CHUNK_SIZE;
if (next > 0 && next < MIN_CHUNK_SIZE) {
chunkSize = (remaining + 1) / 2;
}
byte[] data = readExact(in, (int)chunkSize);
out.add(new Chunk(sha256(data), cursor, cursor + chunkSize));
cursor += chunkSize;
remaining -= chunkSize;
}
byte[] rest = readExact(in, (int)remaining);
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
}
return out;
}
private static Node buildLayers(List<Node> nodes) {
if (nodes.size() < 2) return nodes.get(0);
List<Node> next = new ArrayList<>((nodes.size() + 1) / 2);
for (int i = 0; i < nodes.size(); i += 2) {
Node left = nodes.get(i);
Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null;
if (right == null) {
next.add(left);
continue;
}
long byteRange = left.maxByteRange();
byte[] id = sha256(concat(
sha256(left.id()),
sha256(right.id()),
sha256(note(byteRange))
));
next.add(new Branch(id, byteRange, right.maxByteRange(), left, right));
}
return buildLayers(next);
}
private static void resolveProofs(Node node, byte[] prefix, List<Proof> out) {
if (node instanceof Leaf leaf) {
out.add(new Proof(leaf.maxByteRange() - 1,
concat(prefix, leaf.dataHash(), note(leaf.maxByteRange()))));
return;
}
Branch b = (Branch) node;
byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange()));
resolveProofs(b.left(), partial, out);
resolveProofs(b.right(), partial, out);
}
static byte[] readChunk(Path file, Chunk c) throws IOException {
long len = c.size();
if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len);
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(c.minByteRange());
byte[] data = new byte[(int)len];
raf.readFully(data);
return data;
}
}
private static byte[] readExact(InputStream in, int len) throws IOException {
byte[] out = new byte[len];
int pos = 0;
while (pos < len) {
int n = in.read(out, pos, len - pos);
if (n < 0) throw new EOFException("Неожиданный EOF");
pos += n;
}
return out;
}
private static byte[] note(long value) {
if (value < 0) throw new IllegalArgumentException("negative note");
byte[] out = new byte[32];
long v = value;
for (int i = 31; i >= 0 && v != 0; i--) {
out[i] = (byte)(v & 0xff);
v >>>= 8;
}
return out;
}
private static byte[] sha256(byte[] data) {
try { return MessageDigest.getInstance("SHA-256").digest(data); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static byte[] concat(byte[]... arrays) {
int len = 0;
for (byte[] a : arrays) len = Math.addExact(len, a.length);
byte[] out = new byte[len];
int p=0;
for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; }
return out;
}
}
@@ -0,0 +1,106 @@
package server.archive;
import blockchain.Ans104DataItem;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.time.Duration;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
/** Uploads an already user-signed ANS-104 DataItem to Turbo without modifying it. */
public final class TurboDataItemUploader {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder();
public record UploadResult(String dataItemId, String owner) {}
private final ArweaveBlocksConfig cfg;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private volatile String resolvedPaidByAddress;
public TurboDataItemUploader(ArweaveBlocksConfig cfg) {
this.cfg = Objects.requireNonNull(cfg);
}
public UploadResult upload(byte[] rawDataItem, byte[] expectedId32) throws Exception {
if (rawDataItem == null || rawDataItem.length == 0) throw new IllegalArgumentException("Turbo DataItem is empty");
Ans104DataItem item = new Ans104DataItem(rawDataItem);
if (!item.verifySignature()) throw new IllegalArgumentException("Turbo DataItem has bad ANS-104 signature");
if (expectedId32 != null && !Arrays.equals(item.id32(), expectedId32)) {
throw new IllegalArgumentException("Turbo DataItem id does not match blocks.data_item_id");
}
String expectedId = B64URL.encodeToString(item.id32());
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(cfg.turboUploadUrl()))
.timeout(Duration.ofMinutes(2))
.header("Content-Type", "application/octet-stream")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofByteArray(rawDataItem));
String paidBy = paidByAddress();
if (paidBy != null) request.header("x-paid-by", paidBy);
HttpResponse<String> response = http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
String body = response.body() == null ? "" : response.body().trim();
if (response.statusCode() == 409 && body.toLowerCase().contains("data item exists")) {
return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
}
if (response.statusCode() < 200 || response.statusCode() >= 300) {
String hint = response.statusCode() == 402
? " (Turbo payment required: check server credits / Credit Share Approval / x-paid-by)"
: "";
throw new IOException("Turbo HTTP " + response.statusCode() + hint + ": " + safe(body));
}
if (body.isBlank()) return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
JsonNode json;
try { json = MAPPER.readTree(body); }
catch (Exception ignored) { return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); }
String returnedId = json.path("id").asText("").trim();
if (!returnedId.isBlank() && !returnedId.equals(expectedId)) {
throw new IOException("Turbo returned another DataItem id: expected=" + expectedId + " got=" + returnedId);
}
return new UploadResult(expectedId, json.path("owner").asText(""));
}
/**
* x-paid-by contains the payer's public native address, never the private key.
* A configured Arweave JWK is used only to derive that public address.
*/
private String paidByAddress() throws Exception {
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
synchronized (this) {
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
String explicit = cfg.turboPaidByAddress();
if (explicit != null && !explicit.isBlank()) return resolvedPaidByAddress = explicit.trim();
if (cfg.turboWalletJwkPath() == null) {
resolvedPaidByAddress = "";
return null;
}
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.turboWalletJwkPath(), StandardCharsets.UTF_8));
String modulus = jwk.path("n").asText("").trim();
if (modulus.isBlank()) throw new IllegalStateException("Turbo payer JWK missing n");
byte[] owner = B64URL_DECODER.decode(modulus);
resolvedPaidByAddress = B64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(owner));
return resolvedPaidByAddress;
}
}
private static String safe(String value) {
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
return v.length() <= 500 ? v : v.substring(0, 500);
}
}
@@ -0,0 +1,36 @@
package server.archive;
import blockchain.Ans104DataItem;
import org.junit.jupiter.api.Test;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class Ans104BundleTest {
@Test
void writeReadAndFindPreserveExactDataItems() {
byte[] a = item("a");
byte[] b = item("b");
byte[] bundle = Ans104Bundle.write(List.of(a, b));
List<Ans104Bundle.Entry> entries = Ans104Bundle.read(bundle, 10);
assertEquals(2, entries.size());
assertArrayEquals(a, entries.get(0).rawDataItem());
assertArrayEquals(b, entries.get(1).rawDataItem());
byte[] bId = new Ans104DataItem(b).id32();
assertArrayEquals(b, Ans104Bundle.find(bundle, bId, 10));
}
private static byte[] item(String payload) {
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("bundle-" + payload);
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
byte[] data = payload.getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
return Ans104DataItem.serializeEd25519(signature, owner, tags, data);
}
}
@@ -0,0 +1,363 @@
package blockchain;
import utils.crypto.Ed25519Util;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* Minimal ANS-104 DataItem parser/verifier used by SHiNE.
*
* SHiNE accepts only generic Ed25519 signature type 2:
* uint16le signatureType
* signature[64]
* owner[32]
* target_present[1] + target[32] when present
* anchor_present[1] + anchor[32] when present
* tags_count uint64le
* tags_size uint64le
* avro_tags[tags_size]
* data[*]
*
* The signing message matches the deployed arbundles/Turbo DataItem implementation:
* deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]).
*/
public final class Ans104DataItem {
public static final int SIGNATURE_TYPE_ED25519 = 2;
public static final int SIGNATURE_LEN = 64;
public static final int OWNER_LEN = 32;
public static final int OPTIONAL_FIELD_LEN = 32;
public static final int ID_LEN = 32;
public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024;
public static final int MAX_TAGS_BYTES = 64 * 1024;
public static final long MAX_TAGS_COUNT = 128;
public record Tag(String name, String value) {}
private final int signatureType;
private final byte[] signature;
private final byte[] owner;
private final byte[] target;
private final byte[] anchor;
private final long tagsCount;
private final byte[] rawTags;
private final List<Tag> tags;
private final byte[] data;
private final byte[] rawBytes;
private final byte[] id;
public Ans104DataItem(byte[] bytes) {
Objects.requireNonNull(bytes, "bytes == null");
if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) {
throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length);
}
if (bytes.length > MAX_DATA_ITEM_BYTES) {
throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length);
}
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
this.signatureType = Short.toUnsignedInt(bb.getShort());
if (signatureType != SIGNATURE_TYPE_ED25519) {
throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)");
}
this.signature = readExact(bb, SIGNATURE_LEN, "signature");
this.owner = readExact(bb, OWNER_LEN, "owner");
this.target = readOptional32(bb, "target");
this.anchor = readOptional32(bb, "anchor");
this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count");
long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size");
if (tagsCount > MAX_TAGS_COUNT) {
throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount);
}
if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) {
throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining());
}
this.rawTags = readExact(bb, (int) tagsSizeLong, "tags");
this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount));
this.data = new byte[bb.remaining()];
bb.get(this.data);
this.rawBytes = Arrays.copyOf(bytes, bytes.length);
this.id = sha256(signature);
}
public int signatureType() { return signatureType; }
public byte[] signature64() { return Arrays.copyOf(signature, signature.length); }
public byte[] owner32() { return Arrays.copyOf(owner, owner.length); }
public byte[] target() { return Arrays.copyOf(target, target.length); }
public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); }
public long tagsCount() { return tagsCount; }
public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); }
public List<Tag> tags() { return tags; }
public byte[] data() { return Arrays.copyOf(data, data.length); }
public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); }
public byte[] id32() { return Arrays.copyOf(id, id.length); }
public String tagValue(String name) {
if (name == null) return null;
for (Tag tag : tags) {
if (name.equals(tag.name())) return tag.value();
}
return null;
}
public boolean hasTag(String name, String value) {
for (Tag tag : tags) {
if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true;
}
return false;
}
public boolean verifySignature() {
return Ed25519Util.verify(signingMessage(), signature, owner);
}
public boolean ownerEquals(byte[] expectedOwner32) {
return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32);
}
public byte[] signingMessage() {
return buildSigningMessage(owner, target, anchor, tags, data);
}
/**
* Builds the exact ANS-104 signing message for generic Ed25519 DataItems.
* Useful for SHiNE clients/tests that must sign a DataItem before serializing it.
*/
public static byte[] buildSigningMessage(byte[] owner32, List<Tag> tags, byte[] data) {
return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data);
}
public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List<Tag> tags, byte[] data) {
requireLen(owner32, OWNER_LEN, "owner");
byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length);
byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length);
if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) {
throw new IllegalArgumentException("target must be empty or 32 bytes");
}
if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) {
throw new IllegalArgumentException("anchor must be empty or 32 bytes");
}
List<Tag> safeTags = validateTags(tags);
byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length);
byte[] rawTags = serializeAvroTags(safeTags);
return deepHash(List.of(
utf8("dataitem"),
utf8("1"),
utf8(Integer.toString(SIGNATURE_TYPE_ED25519)),
Arrays.copyOf(owner32, owner32.length),
safeTarget,
safeAnchor,
rawTags,
safeData
));
}
/** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */
public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List<Tag> tags, byte[] data) {
requireLen(signature64, SIGNATURE_LEN, "signature");
requireLen(owner32, OWNER_LEN, "owner");
List<Tag> safeTags = validateTags(tags);
byte[] rawTags = serializeAvroTags(safeTags);
byte[] safeData = data == null ? new byte[0] : data;
ByteArrayOutputStream out = new ByteArrayOutputStream(
2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length);
writeU16LE(out, SIGNATURE_TYPE_ED25519);
out.writeBytes(signature64);
out.writeBytes(owner32);
out.write(0); // target absent
out.write(0); // anchor absent
writeU64LE(out, safeTags.size());
writeU64LE(out, rawTags.length);
out.writeBytes(rawTags);
out.writeBytes(safeData);
return out.toByteArray();
}
private static byte[] readOptional32(ByteBuffer bb, String name) {
if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte");
int present = Byte.toUnsignedInt(bb.get());
if (present == 0) return new byte[0];
if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present);
return readExact(bb, OPTIONAL_FIELD_LEN, name);
}
private static byte[] readExact(ByteBuffer bb, int len, String field) {
if (len < 0 || bb.remaining() < len) {
throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining());
}
byte[] out = new byte[len];
bb.get(out);
return out;
}
private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) {
if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field);
long v = bb.getLong();
if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range");
return v;
}
private static List<Tag> validateTags(List<Tag> tags) {
List<Tag> safe = tags == null ? List.of() : List.copyOf(tags);
if (safe.size() > MAX_TAGS_COUNT) {
throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size());
}
for (Tag tag : safe) {
if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) {
throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
}
if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
}
return safe;
}
private static byte[] serializeAvroTags(List<Tag> tags) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
if (!tags.isEmpty()) {
writeAvroLong(out, tags.size());
for (Tag tag : tags) {
writeAvroString(out, tag.name());
writeAvroString(out, tag.value());
}
}
writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array
byte[] raw = out.toByteArray();
if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes");
return raw;
}
private static void writeAvroString(ByteArrayOutputStream out, String value) {
byte[] bytes = utf8(value);
writeAvroLong(out, bytes.length);
out.writeBytes(bytes);
}
private static void writeAvroLong(ByteArrayOutputStream out, long value) {
long n = (value << 1) ^ (value >> 63);
while ((n & ~0x7fL) != 0) {
out.write((int) ((n & 0x7f) | 0x80));
n >>>= 7;
}
out.write((int) n);
}
private static void writeU16LE(ByteArrayOutputStream out, int value) {
out.write(value & 0xff);
out.write((value >>> 8) & 0xff);
}
private static void writeU64LE(ByteArrayOutputStream out, long value) {
for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff));
}
private static void requireLen(byte[] bytes, int expected, String name) {
if (bytes == null || bytes.length != expected) {
throw new IllegalArgumentException(name + " must be " + expected + " bytes");
}
}
private static List<Tag> parseAvroTags(byte[] raw, long declaredCount) {
AvroCursor c = new AvroCursor(raw);
List<Tag> out = new ArrayList<>();
while (!c.eof()) {
long count = c.readLong();
if (count == 0) break;
if (count < 0) {
count = -count;
long blockBytes = c.readLong();
if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size");
}
if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) {
throw new IllegalArgumentException("Too many Avro tags");
}
for (long i = 0; i < count; i++) {
String name = c.readString();
String value = c.readString();
if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
out.add(new Tag(name, value));
}
}
if (!c.eof()) {
throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags");
}
if (out.size() != declaredCount) {
throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size());
}
return out;
}
private static final class AvroCursor {
private final byte[] data;
private int p;
private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; }
boolean eof() { return p == data.length; }
long readLong() {
long raw = 0;
int shift = 0;
while (true) {
if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long");
int b = data[p++] & 0xff;
raw |= (long) (b & 0x7f) << shift;
if ((b & 0x80) == 0) break;
shift += 7;
if (shift > 63) throw new IllegalArgumentException("Avro long too large");
}
return (raw >>> 1) ^ -(raw & 1L);
}
String readString() {
long len = readLong();
if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) {
throw new IllegalArgumentException("Bad Avro string length: " + len);
}
String s = new String(data, p, (int) len, StandardCharsets.UTF_8);
p += (int) len;
return s;
}
}
private static byte[] deepHash(Object item) {
if (item instanceof byte[] bytes) {
return deepHashBlob(bytes);
}
if (item instanceof List<?> items) {
byte[] acc = sha384(utf8("list" + items.size()));
for (Object child : items) {
acc = sha384(concat(acc, deepHash(child)));
}
return acc;
}
throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass()));
}
private static byte[] deepHashBlob(byte[] bytes) {
byte[] tag = utf8("blob" + bytes.length);
return sha384(concat(sha384(tag), sha384(bytes)));
}
private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); }
private static byte[] sha256(byte[] b) { return digest("SHA-256", b); }
private static byte[] sha384(byte[] b) { return digest("SHA-384", b); }
private static byte[] digest(String alg, byte[] b) {
try { return MessageDigest.getInstance(alg).digest(b); }
catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); }
}
private static byte[] concat(byte[] a, byte[] b) {
ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length);
out.writeBytes(a); out.writeBytes(b); return out.toByteArray();
}
}
@@ -9,363 +9,115 @@ import java.util.Arrays;
import java.util.Objects;
/**
* BchBlockEntry — универсальный блок формата SHiNE (Frame v0).
* SHiNE user block, Frame v1, carried as the data payload of an ANS-104 DataItem.
*
* =========================================================================
* FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
* =========================================================================
* The constructor accepts the complete serialized ANS-104 DataItem because this is
* exactly what is stored in PostgreSQL and exchanged between servers.
*
* Все числа BigEndian.
* SHiNE Frame v1 (all integer fields BigEndian):
* [2] frameCode = 0x0001
* [32] prevHash32 = SHA-256(Frame v1 bytes of previous SHiNE block)
* [4] blockSize = exact Frame v1 byte length
* [4] blockNumber
* [8] timestamp unix seconds
* [2] type
* [2] subType
* [2] version
* [N] bodyBytes
*
* PREIMAGE (входит в blockSize, подписывается):
* [2] frameCode (uint16) код/версия рамки:
* - 0x0000 = Frame v0 (текущий)
* [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка)
* [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode,
* НО БЕЗ sigMarker и БЕЗ signature64
* [4] blockNumber (int32) глобальный номер блока (>=0)
* [8] timestamp (int64) unix seconds
* [2] type (uint16) тип сообщения
* [2] subType (uint16) подтип сообщения
* [2] version (uint16) версия формата сообщения
* [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version)
*
* TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0):
* [2] sigMarker (uint16) маркер подписи:
* - 0x0100 (256) = далее подпись Ed25519 64 байта
* [64] signature64 (bytes) Ed25519 signature над hash32
*
* hash32 НЕ хранится в блоке.
* hash32 вычисляется при парсинге:
* preimage = первые blockSize байт
* hash32 = SHA-256(preimage)
*
* Правила MVP-парсера (Frame v0):
* - frameCode должен быть строго 0x0000, иначе REJECT.
* - sigMarker должен быть строго 0x0100, иначе REJECT.
* - подпись обязана присутствовать всегда (sigMarker+signature64).
* - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”.
*
* Важно по безопасности:
* - sigMarker в v0 не входит в подписываемые байты → его можно подменить,
* поэтому единственная безопасная логика: "если не 0x0100 — reject".
* =========================================================================
* The user signature is NOT stored in this frame. It is the single Ed25519/Solana
* signature of the surrounding ANS-104 DataItem.
*/
public final class BchBlockEntry {
public static final int SIGNATURE_LEN = 64;
public static final int HASH_LEN = 32;
public static final int FRAME_CODE_LEN = 2;
public static final int SIG_MARKER_LEN = 2;
/** Frame v0 */
public static final int FRAME_CODE_V0 = 0x0000;
/** sigMarker: 256 = 0x0100 */
public static final int SIG_MARKER_ED25519 = 0x0100;
/**
* Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature),
* чтобы не уложить сервер по памяти/диску.
*/
public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024;
/**
* Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов).
* Если timestamp больше now + 60 сек — блок считаем неверным.
*/
public static final int FRAME_CODE_V1 = 0x0001;
public static final int MAX_BLOCK_FULL_BYTES = Ans104DataItem.MAX_DATA_ITEM_BYTES;
public static final long MAX_FUTURE_SECONDS = 60;
public static final int FRAME_HEADER_SIZE = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2;
/**
* Размер фиксированной части PREIMAGE (без bodyBytes).
*
* PREIMAGE header:
* frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8)
* + type(2) + subType(2) + version(2)
*/
public static final int PREIMAGE_HEADER_SIZE =
2 // frameCode
+ 32 // prevHash32
+ 4 // blockSize
+ 4 // blockNumber
+ 8 // timestamp
+ 2 // type
+ 2 // subType
+ 2; // version
/** Минимальный полный размер блока (без bodyBytes). */
public static final int MIN_FULL_BYTES =
PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN;
// --- HEADER (PREIMAGE) ---
public final int frameCode; // uint16 (v0=0)
public final byte[] prevHash32; // 32
public final int blockSize; // preimage size (включая frameCode)
public final int blockNumber; // >=0
public final int frameCode;
public final byte[] prevHash32;
public final int blockSize;
public final int blockNumber;
public final long timestamp;
public final short type;
public final short subType;
public final short version;
// --- BODY (PREIMAGE) ---
public final byte[] bodyBytes;
/** Распарсенное тело (создаётся сразу при парсинге блока). */
public final BodyRecord body;
// --- TAIL ---
public final int sigMarker; // uint16 (v0: 0x0100)
private final byte[] signature64; // 64
private final Ans104DataItem dataItem;
private final byte[] frameBytes;
private final byte[] hash32;
// --- derived ---
private final byte[] hash32; // 32, computed
private final byte[] preimage; // blockSize bytes
private final byte[] fullBytes; // preimage + sigMarker + signature
/* ===================================================================== */
/* ====================== Конструктор из байт ========================== */
/* ===================================================================== */
public BchBlockEntry(byte[] fullBytes) {
Objects.requireNonNull(fullBytes, "fullBytes == null");
if (fullBytes.length < MIN_FULL_BYTES) {
throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES);
}
if (fullBytes.length > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES);
/** Parse a complete serialized ANS-104 DataItem containing one SHiNE Frame v1. */
public BchBlockEntry(byte[] fullDataItemBytes) {
Objects.requireNonNull(fullDataItemBytes, "fullDataItemBytes == null");
this.dataItem = new Ans104DataItem(fullDataItemBytes);
this.frameBytes = dataItem.data();
if (frameBytes.length < FRAME_HEADER_SIZE) {
throw new IllegalArgumentException("SHiNE Frame v1 too short: " + frameBytes.length);
}
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
// [2] frameCode
ByteBuffer bb = ByteBuffer.wrap(frameBytes).order(ByteOrder.BIG_ENDIAN);
this.frameCode = Short.toUnsignedInt(bb.getShort());
if (this.frameCode != FRAME_CODE_V0) {
throw new IllegalArgumentException(String.format(
"Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0
));
if (frameCode != FRAME_CODE_V1) {
throw new IllegalArgumentException(String.format("Bad SHiNE frameCode: 0x%04X (expected 0x%04X)", frameCode, FRAME_CODE_V1));
}
// [32] prevHash32
this.prevHash32 = new byte[32];
bb.get(this.prevHash32);
// [4] blockSize
bb.get(prevHash32);
this.blockSize = bb.getInt();
if (blockSize < PREIMAGE_HEADER_SIZE) {
throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
if (blockSize != frameBytes.length || blockSize < FRAME_HEADER_SIZE) {
throw new IllegalArgumentException("SHiNE blockSize mismatch: declared=" + blockSize + " actual=" + frameBytes.length);
}
// fullLen must match exactly: blockSize + sigMarker(2) + signature(64)
int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
if (expectedFullLen != fullBytes.length) {
throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize
+ " expectedFullLen=" + expectedFullLen
+ " fullLen=" + fullBytes.length);
}
if (expectedFullLen > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES);
}
// [4] blockNumber
this.blockNumber = bb.getInt();
if (this.blockNumber < 0) {
throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber);
}
// [8] timestamp
if (blockNumber < 0) throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
this.timestamp = bb.getLong();
// запрет “в будущее” больше чем на 1 минуту
long now = Instant.now().getEpochSecond();
if (this.timestamp > now + MAX_FUTURE_SECONDS) {
throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
if (timestamp > now + MAX_FUTURE_SECONDS) {
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now);
}
// [2][2][2] type/subType/version
this.type = bb.getShort();
this.subType = bb.getShort();
this.version = bb.getShort();
// [N] bodyBytes
int bodyLen = blockSize - PREIMAGE_HEADER_SIZE;
if (bodyLen < 0) {
throw new IllegalArgumentException("Invalid body length: " + bodyLen);
}
this.bodyBytes = new byte[bodyLen];
bb.get(this.bodyBytes);
// TAIL: [2] sigMarker
this.sigMarker = Short.toUnsignedInt(bb.getShort());
if (this.sigMarker != SIG_MARKER_ED25519) {
throw new IllegalArgumentException(String.format(
"Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519
));
}
// TAIL: [64] signature64
this.signature64 = new byte[SIGNATURE_LEN];
bb.get(this.signature64);
// preimage = первые blockSize байт (включая frameCode)
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
// hash32 = sha256(preimage)
this.hash32 = BchCryptoVerifier.sha256(preimage);
// parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check()
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);
if (bb.remaining() != 0) {
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
this.bodyBytes = new byte[bb.remaining()];
bb.get(bodyBytes);
this.body = BodyRecordParser.parse(type, subType, version, bodyBytes);
this.hash32 = BchCryptoVerifier.sha256(frameBytes);
}
/* ===================================================================== */
/* ====================== Конструктор сборки ============================ */
/* ===================================================================== */
public BchBlockEntry(byte[] prevHash32,
int blockNumber,
long timestamp,
short type,
short subType,
short version,
byte[] bodyBytes,
byte[] signature64) {
Objects.requireNonNull(prevHash32, "prevHash32 == null");
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
Objects.requireNonNull(signature64, "signature64 == null");
if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
if (blockNumber < 0) {
throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
}
// запрет “в будущее” больше чем на 1 минуту
long now = Instant.now().getEpochSecond();
if (timestamp > now + MAX_FUTURE_SECONDS) {
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
}
this.frameCode = FRAME_CODE_V0;
this.prevHash32 = Arrays.copyOf(prevHash32, 32);
this.blockNumber = blockNumber;
this.timestamp = timestamp;
this.type = type;
this.subType = subType;
this.version = version;
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
// blockSize = размер preimage (включая frameCode)
this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length;
int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
if (fullLen > MAX_BLOCK_FULL_BYTES) {
throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES);
}
// parse body по header + ОБЯЗАТЕЛЬНЫЙ check()
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
// tail marker фиксирован
this.sigMarker = SIG_MARKER_ED25519;
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
// build preimage
ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF));
pre.put(this.prevHash32);
pre.putInt(this.blockSize);
pre.putInt(this.blockNumber);
pre.putLong(this.timestamp);
pre.putShort(this.type);
pre.putShort(this.subType);
pre.putShort(this.version);
pre.put(this.bodyBytes);
this.preimage = pre.array();
this.hash32 = BchCryptoVerifier.sha256(preimage);
// build fullBytes: preimage + sigMarker + signature64
ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
full.put(this.preimage);
full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF));
full.put(this.signature64);
this.fullBytes = full.array();
}
/* ===================================================================== */
/* ============================ Getters ================================= */
/* ===================================================================== */
public byte[] getPreimageBytes() {
return Arrays.copyOf(preimage, preimage.length);
}
/** Возвращает подпись Ed25519 (64 байта). */
public byte[] getSignature64() {
return Arrays.copyOf(signature64, SIGNATURE_LEN);
}
/** Возвращает hash32 = SHA-256(preimage). */
public byte[] getHash32() {
return Arrays.copyOf(hash32, HASH_LEN);
}
/** Возвращает полный блок: preimage + sigMarker + signature. */
public byte[] toBytes() {
return Arrays.copyOf(fullBytes, fullBytes.length);
}
public Ans104DataItem getDataItem() { return dataItem; }
public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); }
/** Kept as an alias for code/docs that called the old field preimage. */
public byte[] getPreimageBytes() { return getFrameBytes(); }
public byte[] getSignature64() { return dataItem.signature64(); }
public byte[] getOwner32() { return dataItem.owner32(); }
public byte[] getDataItemId32() { return dataItem.id32(); }
public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); }
/** Full serialized ANS-104 DataItem. */
public byte[] toBytes() { return dataItem.rawBytes(); }
@Override
public String toString() {
String timeIso;
try {
timeIso = Instant.ofEpochSecond(timestamp).toString();
} catch (Exception e) {
timeIso = "некорректныйTimestamp";
}
return "BchBlockEntry{"
+ "FRAME{frameCode=0x" + hex4(frameCode)
+ "}, HDR{"
+ "blockSize=" + blockSize
+ ", blockNumber=" + blockNumber
+ ", timestamp=" + timestamp + " (" + timeIso + ")"
+ ", type=" + (type & 0xFFFF)
+ ", subType=" + (subType & 0xFFFF)
+ ", version=" + (version & 0xFFFF)
+ ", prevHash32(hex)=" + toHex(prevHash32)
+ "}"
+ ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
+ ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}"
+ ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
+ "}";
}
private static String hex4(int v) {
String s = Integer.toHexString(v & 0xFFFF);
while (s.length() < 4) s = "0" + s;
return s;
return "BchBlockEntry{" +
"frameCode=0x" + Integer.toHexString(frameCode) +
", blockNumber=" + blockNumber +
", timestamp=" + timestamp +
", type=" + (type & 0xffff) +
", subType=" + (subType & 0xffff) +
", version=" + (version & 0xffff) +
", bodyLen=" + bodyBytes.length +
", dataItemId=" + toHex(getDataItemId32()) +
", blockHash=" + toHex(hash32) +
'}';
}
private static String toHex(byte[] bytes) {
if (bytes == null) return "null";
char[] HEX = "0123456789abcdef".toCharArray();
char[] h = "0123456789abcdef".toCharArray();
char[] out = new char[bytes.length * 2];
for (int i = 0; i < bytes.length; i++) {
int vv = bytes[i] & 0xFF;
out[i * 2] = HEX[vv >>> 4];
out[i * 2 + 1] = HEX[vv & 0x0F];
int v = bytes[i] & 0xff;
out[i * 2] = h[v >>> 4]; out[i * 2 + 1] = h[v & 15];
}
return new String(out);
}
@@ -1,42 +1,27 @@
package blockchain;
import utils.crypto.Ed25519Util;
import java.security.MessageDigest;
import java.util.Objects;
/**
* Верификатор SHiNE (Frame v0):
*
* preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000),
* = всё до TAIL (sigMarker+signature).
*
* hash32 = SHA-256(preimage)
* verify = Ed25519.verify(hash32, signature64, pubKey32)
*/
/** Cryptographic checks for SHiNE Frame v1 + surrounding ANS-104 DataItem. */
public final class BchCryptoVerifier {
private BchCryptoVerifier() {}
public static byte[] sha256(byte[] data) {
Objects.requireNonNull(data, "data == null");
try {
MessageDigest d = MessageDigest.getInstance("SHA-256");
return d.digest(data);
} catch (Exception e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
try { return MessageDigest.getInstance("SHA-256").digest(data); }
catch (Exception e) { throw new IllegalStateException("SHA-256 unavailable", e); }
}
/**
* Verifies that the ANS-104 owner is the current blockchain key and that the
* ANS-104 Ed25519 signature is valid. The SHiNE block hash itself remains
* SHA-256(Frame v1 bytes) and is intentionally independent of Arweave.
*/
public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) {
Objects.requireNonNull(block, "block == null");
Objects.requireNonNull(publicKey32, "publicKey32 == null");
if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32");
byte[] hash32 = block.getHash32();
byte[] sig64 = block.getSignature64();
return Ed25519Util.verify(hash32, sig64, publicKey32);
return block.getDataItem().ownerEquals(publicKey32) && block.getDataItem().verifySignature();
}
}
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
return new CreateChannelBody(subType, version, bodyBytes).check();
}
if (st == (ForkBody.SUBTYPE & 0xFFFF)
&& (v == (ForkBody.VER & 0xFFFF))) {
return new ForkBody(subType, version, bodyBytes).check();
}
throw new IllegalArgumentException(
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
);
@@ -35,6 +35,8 @@ public final class MsgSubType {
/** HeaderBody: subType всегда 0 (compat). */
public static final short HEADER_COMPAT = 0;
public static final short TECH_CREATE_CHANNEL = 1;
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
public static final short TECH_FORK = 2;
/* ===================== TEXT (msg_type=1) ===================== */
@@ -53,7 +55,7 @@ public final class MsgSubType {
/**
* REPLY — ответ на сообщение.
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
* НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
*/
public static final short TEXT_REPLY = 20;
@@ -69,7 +71,7 @@ public final class MsgSubType {
/**
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
* Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
*/
public static final short TEXT_REPOST = 50;
@@ -1,29 +1,30 @@
package blockchain.body;
import utils.blockchain.BlockchainNameUtil;
/**
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
* BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
*
* Новое правило:
* - toLogin НЕ храним в байтах блока.
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
* Актуальное правило target:
* - в подписываемых байтах хранится login цели;
* - номер fork/blockchainName в target не хранится;
* - идентичность цели задаётся как login + blockNumber + blockHash.
*
* Все методы могут возвращать null.
* Это позволяет одной и той же логической записи сохранять ссылки после fork,
* если её номер и SHA-256 hash остались прежними.
*/
public interface BodyHasTarget {
/** login цели (nullable). Вычисляется из toBchName(). */
default String toLogin() {
String bch = toBchName();
if (bch == null) return null;
return BlockchainNameUtil.loginFromBlockchainName(bch);
}
/** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
default String toLogin() { return null; }
/** blockchainName цели (nullable). */
String toBchName();
/**
* Legacy source-level accessor. В новом подписанном target blockchainName не хранится.
* Оставлен временно только чтобы старые вспомогательные классы компилировались;
* runtime-логика не должна использовать его для новых блоков.
*/
@Deprecated
default String toBchName() { return null; }
/** globalNumber цели (nullable). */
/** globalNumber цели (nullable только если конкретный subtype не содержит target). */
Integer toBlockGlobalNumber();
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
@@ -1,7 +1,6 @@
package blockchain.body;
import blockchain.MsgSubType;
import utils.blockchain.BlockchainNameUtil;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@@ -12,96 +11,38 @@ import java.util.Objects;
/**
* ConnectionBody — type=3, ver=1 (в заголовке блока).
*
* subType (в заголовке блока) как MsgSubType:
* FRIEND=10, UNFRIEND=11
* CONTACT=20, UNCONTACT=21
* FOLLOW=30, UNFOLLOW=31
* SPOUSE=40, UNSPOUSE=41
* PARENT=50, UNPARENT=51
* CHILD=52, UNCHILD=53
* SIBLING=54, UNSIBLING=55
* FRIEND=14, UNFRIEND=15
* KNOWN_PERSON=60, UNKNOWN_PERSON=61 (legacy; accepted but not used by current UI)
* SHINE_CONFIRMED=70, SHINE_UNCONFIRMED=71
* SHINE_SEEN=74, SHINE_UNSEEN=75 (currently not used by UI)
* OFFICIAL_ACCOUNT_CONFIRMED=80, OFFICIAL_ACCOUNT_UNCONFIRMED=81
*
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
* bodyBytes (BigEndian):
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
*
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [4] toBlockGlobalNumber (int32)
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* toLogin вычисляется автоматически из toBlockchainName:
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
* Номер fork/blockchainName в target не хранится.
*/
/**
* =========================================================================
* ПРАВИЛО TARGET/ROOT ДЛЯ КАНАЛОВ И СВЯЗЕЙ (важно для подписок/друзей/контактов)
* =========================================================================
*
* Термины:
* - ROOT линии/канала = блок, который "начинает" линию:
* * для канала "0" root = HEADER (blockNumber=0)
* * для канала "X" root = CREATE_CHANNEL (blockNumber этого блока)
*
* 1) СВЯЗИ МЕЖДУ ПОЛЬЗОВАТЕЛЯМИ (CONNECTION_*):
* FRIEND / CONTACT -> цель ВСЕГДА HEADER пользователя:
* toBlockNumber = 0
* toBlockHash32 = hash32(HEADER цели)
*
* 2) ПОДПИСКИ НА КОНТЕНТ (FOLLOW/SUBSCRIBE):
* FOLLOW пользователя (в целом) -> цель = ROOT дефолтного канала "0" (то есть HEADER):
* toBlockNumber = 0
* toBlockHash32 = hash32(HEADER цели)
*
* FOLLOW/подписка на конкретный канал пользователя ->
* цель = ROOT этого канала:
* - канал "0": toBlockNumber=0, toBlockHash32=hash32(HEADER)
* - канал "X": toBlockNumber=blockNumber(CREATE_CHANNEL),
* toBlockHash32=hash32(CREATE_CHANNEL)
*
* 3) ЗАПРЕТЫ ВАЛИДАЦИИ (желательно на сервере/в БД):
* - CONNECTION_CLOSE_FRIEND/CONTACT не могут ссылаться на не-HEADER (toBlockNumber != 0 запрещено).
* - FOLLOW на канал "X" не может ссылаться на произвольный пост внутри канала:
* разрешено ТОЛЬКО на ROOT (HEADER или CREATE_CHANNEL).
*
* Зачем так:
* - связи и подписки всегда стабильны и не ломаются при новых постах,
* - один понятный инвариант: "подписка всегда указывает на root линии".
* =========================================================================
*/
public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
public static final short TYPE = 3;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // из header
public final short subType;
public final short version;
// line
public final int lineCode;
public final int prevLineNumber;
public final byte[] prevLineHash32;
public final int thisLineNumber;
// payload
public final String toBlockchainName;
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
if (!isValidSubType(this.subType)) {
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
}
// минимум:
// lineCode(4) + line(4+32+4) + toBchLen[1]+toBch[1] + global[4] + hash[32]
if (bodyBytes.length < 4 + (4 + 32 + 4) + 1 + 1 + 4 + 32) {
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
throw new IllegalArgumentException("ConnectionBody too short");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.lineCode = bb.getInt();
this.prevLineNumber = bb.getInt();
this.prevLineHash32 = new byte[32];
bb.get(this.prevLineHash32);
this.thisLineNumber = bb.getInt();
int bchLen = Byte.toUnsignedInt(bb.get());
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
byte[] bchBytes = new byte[bchLen];
bb.get(bchBytes);
this.toBlockchainName = new String(bchBytes, StandardCharsets.UTF_8);
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
byte[] prevLineHash32,
int thisLineNumber,
short subType,
String toBlockchainName,
String toLogin,
int toBlockGlobalNumber,
byte[] toBlockHash32) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
// Железное правило формата: bchName -> login + "-NNN"
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null) {
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
}
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.lineCode = lineCode;
this.prevLineNumber = prevLineNumber;
this.prevLineHash32 = (prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
this.prevLineHash32 = prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32);
this.thisLineNumber = thisLineNumber;
this.subType = subType;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
}
@Override
public ConnectionBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else if (prevLineHash32 == null || prevLineHash32.length != 32) {
throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
return this;
}
@Override
public byte[] toBytes() {
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
int cap = 4 + 4 + 32 + 4 + 1 + loginBytes.length + 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
private static boolean isValidSubType(short st) {
int v = st & 0xFFFF;
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
@@ -211,76 +172,18 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|| v == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
}
@Override
public ConnectionBody check() {
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
// line rule (как было)
if (prevLineNumber == -1) {
if (!isAllZero32(prevLineHash32)) throw new IllegalArgumentException("prevLineHash32 must be zero when prevLineNumber=-1");
if (thisLineNumber != -1) throw new IllegalArgumentException("thisLineNumber must be -1 when prevLineNumber=-1");
} else {
if (prevLineHash32 == null || prevLineHash32.length != 32) throw new IllegalArgumentException("prevLineHash32 invalid");
}
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("toBlockchainName is blank");
// гарантируем вычислимый toLogin (иначе target “битый” по стандарту)
if (BlockchainNameUtil.loginFromBlockchainName(toBlockchainName) == null)
throw new IllegalArgumentException("toBlockchainName must match login+\"-NNN\": " + toBlockchainName);
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
return this;
}
@Override
public byte[] toBytes() {
byte[] bchBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (bchBytes.length == 0 || bchBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 != 32");
int cap = 4 + (4 + 32 + 4)
+ 1 + bchBytes.length
+ 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.putInt(lineCode);
bb.putInt(prevLineNumber);
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
bb.put((byte) bchBytes.length);
bb.put(bchBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
private static boolean isAllZero32(byte[] b) {
if (b == null || b.length != 32) return true;
for (int i = 0; i < 32; i++) if (b[i] != 0) return false;
for (byte value : b) if (value != 0) return false;
return true;
}
/* ====================== BodyHasLine ====================== */
@Override public int lineCode() { return lineCode; }
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
@Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
}
@@ -0,0 +1,194 @@
package blockchain.body;
import blockchain.MsgSubType;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
/**
* TECH_FORK body (type=0, subType=2, version=1).
*
* Записывается первым новым блоком после точной перепубликации выбранного
* префикса старой цепочки новым blockchain key.
*
* body bytes (BigEndian):
* [32] parentBlockchainKey
* [4] forkPointBlockNumber
* [32] forkPointBlockHash32
* [8] forkPointTimestampMs
* [4] parentTipBlockNumber
* [32] parentTipBlockHash32
* [8] parentTipTimestampMs
* [4] discardedBlocksCount
* [1] reasonCode
* [2] commentUtf8Length
* [N] comment UTF-8 (0..1024 bytes)
*/
public final class ForkBody implements BodyRecord {
public static final short TYPE = 0;
public static final short VER = 1;
public static final short SUBTYPE = MsgSubType.TECH_FORK;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public static final int REASON_ROUTINE_ROTATION = 1;
public static final int REASON_POSSIBLE_COMPROMISE = 2;
public static final int REASON_CONFIRMED_COMPROMISE_ROLLBACK = 3;
public static final int REASON_RECOVERY = 4;
public static final int MAX_COMMENT_UTF8_LEN = 1024;
private static final int FIXED_LEN = 32 + 4 + 32 + 8 + 4 + 32 + 8 + 4 + 1 + 2;
public final short subType;
public final short version;
public final byte[] parentBlockchainKey32;
public final int forkPointBlockNumber;
public final byte[] forkPointBlockHash32;
public final long forkPointTimestampMs;
public final int parentTipBlockNumber;
public final byte[] parentTipBlockHash32;
public final long parentTipTimestampMs;
public final int discardedBlocksCount;
public final int reasonCode;
public final String comment;
public ForkBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
}
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody version must be 1");
}
if (bodyBytes.length < FIXED_LEN) {
throw new IllegalArgumentException("ForkBody too short");
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
this.parentBlockchainKey32 = new byte[32];
bb.get(this.parentBlockchainKey32);
this.forkPointBlockNumber = bb.getInt();
this.forkPointBlockHash32 = new byte[32];
bb.get(this.forkPointBlockHash32);
this.forkPointTimestampMs = bb.getLong();
this.parentTipBlockNumber = bb.getInt();
this.parentTipBlockHash32 = new byte[32];
bb.get(this.parentTipBlockHash32);
this.parentTipTimestampMs = bb.getLong();
this.discardedBlocksCount = bb.getInt();
this.reasonCode = Byte.toUnsignedInt(bb.get());
int commentLen = Short.toUnsignedInt(bb.getShort());
if (commentLen > MAX_COMMENT_UTF8_LEN) {
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
}
if (bb.remaining() != commentLen) {
throw new IllegalArgumentException("ForkBody tail mismatch: remaining=" + bb.remaining() + " commentLen=" + commentLen);
}
byte[] commentBytes = new byte[commentLen];
bb.get(commentBytes);
this.comment = new String(commentBytes, StandardCharsets.UTF_8);
}
public ForkBody(byte[] parentBlockchainKey32,
int forkPointBlockNumber,
byte[] forkPointBlockHash32,
long forkPointTimestampMs,
int parentTipBlockNumber,
byte[] parentTipBlockHash32,
long parentTipTimestampMs,
int discardedBlocksCount,
int reasonCode,
String comment) {
this.subType = SUBTYPE;
this.version = VER;
this.parentBlockchainKey32 = copy32(parentBlockchainKey32, "parentBlockchainKey32");
this.forkPointBlockNumber = forkPointBlockNumber;
this.forkPointBlockHash32 = copy32(forkPointBlockHash32, "forkPointBlockHash32");
this.forkPointTimestampMs = forkPointTimestampMs;
this.parentTipBlockNumber = parentTipBlockNumber;
this.parentTipBlockHash32 = copy32(parentTipBlockHash32, "parentTipBlockHash32");
this.parentTipTimestampMs = parentTipTimestampMs;
this.discardedBlocksCount = discardedBlocksCount;
this.reasonCode = reasonCode;
this.comment = normalizeComment(comment);
}
@Override
public ForkBody check() {
if ((subType & 0xFFFF) != (SUBTYPE & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody subType must be TECH_FORK(2)");
}
if ((version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("ForkBody version must be 1");
}
require32(parentBlockchainKey32, "parentBlockchainKey32");
require32(forkPointBlockHash32, "forkPointBlockHash32");
require32(parentTipBlockHash32, "parentTipBlockHash32");
if (forkPointBlockNumber < 0) throw new IllegalArgumentException("forkPointBlockNumber < 0");
if (parentTipBlockNumber < forkPointBlockNumber) {
throw new IllegalArgumentException("parentTipBlockNumber < forkPointBlockNumber");
}
if (forkPointTimestampMs < 0) throw new IllegalArgumentException("forkPointTimestampMs < 0");
if (parentTipTimestampMs < 0) throw new IllegalArgumentException("parentTipTimestampMs < 0");
int expectedDiscarded = parentTipBlockNumber - forkPointBlockNumber;
if (discardedBlocksCount != expectedDiscarded) {
throw new IllegalArgumentException("discardedBlocksCount must equal parentTipBlockNumber - forkPointBlockNumber");
}
if (!isReasonSupported(reasonCode)) {
throw new IllegalArgumentException("Unsupported fork reasonCode=" + reasonCode);
}
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
if (commentUtf8.length > MAX_COMMENT_UTF8_LEN) {
throw new IllegalArgumentException("ForkBody comment utf8 len must be <=1024");
}
return this;
}
@Override
public byte[] toBytes() {
check();
byte[] commentUtf8 = normalizeComment(comment).getBytes(StandardCharsets.UTF_8);
ByteBuffer bb = ByteBuffer.allocate(FIXED_LEN + commentUtf8.length).order(ByteOrder.BIG_ENDIAN);
bb.put(parentBlockchainKey32);
bb.putInt(forkPointBlockNumber);
bb.put(forkPointBlockHash32);
bb.putLong(forkPointTimestampMs);
bb.putInt(parentTipBlockNumber);
bb.put(parentTipBlockHash32);
bb.putLong(parentTipTimestampMs);
bb.putInt(discardedBlocksCount);
bb.put((byte) reasonCode);
bb.putShort((short) commentUtf8.length);
bb.put(commentUtf8);
return bb.array();
}
public static boolean isReasonSupported(int code) {
return code == REASON_ROUTINE_ROTATION
|| code == REASON_POSSIBLE_COMPROMISE
|| code == REASON_CONFIRMED_COMPROMISE_ROLLBACK
|| code == REASON_RECOVERY;
}
private static byte[] copy32(byte[] value, String name) {
require32(value, name);
return Arrays.copyOf(value, 32);
}
private static void require32(byte[] value, String name) {
if (value == null || value.length != 32) {
throw new IllegalArgumentException(name + " must be 32 bytes");
}
}
private static String normalizeComment(String value) {
if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
}
@@ -15,12 +15,13 @@ import java.util.Objects;
* 1 = LIKE
* 2 = UNLIKE
*
* bodyBytes (BigEndian), новый формат:
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* bodyBytes (BigEndian):
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32 (raw 32 bytes)
*
* Номер fork/blockchainName в target не хранится.
* ЛИНИИ НЕТ.
*/
public final class ReactionBody implements BodyRecord, BodyHasTarget {
@@ -30,10 +31,10 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // из header
public final short subType;
public final short version;
public final String toBlockchainName;
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
if (!isSupportedSubType(this.subType)) {
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
}
// минимум: nameLen[1]+name[1]+global[4]+hash[32]
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
}
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
this.subType = MsgSubType.REACTION_LIKE;
this.version = VER;
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
}
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
public ReactionBody check() {
if (!isSupportedSubType(subType))
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("toBlockchainName is blank");
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0)
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid");
return this;
}
@Override
public byte[] toBytes() {
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameBytes.length == 0 || nameBytes.length > 255)
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginBytes.length == 0 || loginBytes.length > 255)
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
int cap = 1 + nameBytes.length + 4 + 32;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameBytes.length);
bb.put(nameBytes);
ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginBytes.length);
bb.put(loginBytes);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
return bb.array();
}
/* ====================== BodyHasTarget ====================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -13,11 +13,11 @@ import java.util.Objects;
/**
* StatusActionBody — type=5, ver=1.
*
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
* Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
public final short subType;
public final short version;
public final String toBlockchainName;
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
ensureNoTail(bb, "StatusActionBody");
}
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.subType = subType;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
@Override
public StatusActionBody check() {
if (!isSupportedSubType(subType)) {
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
}
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
if (message == null) throw new IllegalArgumentException("message is null");
return this;
}
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..255");
}
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("STATUS_ACTION toLogin utf8 len must be 1..255");
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) return "";
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException e) {
@@ -33,23 +33,13 @@ import java.util.Objects;
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* EDIT_POST:
* EDIT_POST / REPOST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
*
* REPOST:
* [4] lineCode
* [4] prevLineNumber
* [32] prevLineHash32
* [4] thisLineNumber
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber (int32)
* [32] toBlockHash32
* [2] textLenBytes (uint16)
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
public final int thisLineNumber;
// target (для EDIT_POST / REPOST)
public final String toBlockchainName; // nullable для POST/EDIT_POST
public final String toLogin; // nullable для сообщений без target
public final Integer toBlockGlobalNumber; // nullable для POST
public final byte[] toBlockHash32; // nullable для POST
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.thisLineNumber = bb.getInt();
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// нужен target
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
int tgtNum = bb.getInt();
byte[] tgtHash = new byte[32];
bb.get(tgtHash);
this.toBlockchainName = null;
this.toBlockGlobalNumber = tgtNum;
this.toBlockHash32 = tgtHash;
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPOST missing target");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("REPOST toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "REPOST payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
} else {
this.toBlockchainName = null;
this.toLogin = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
short subType,
Integer toBlockGlobalNumber,
byte[] toBlockHash32,
String toBlockchainName,
String toLogin,
String message) {
Objects.requireNonNull(message, "message == null");
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
this.thisLineNumber = thisLineNumber;
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = null;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
Objects.requireNonNull(toLogin, "toLogin == null");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
} else {
this.toBlockchainName = null;
this.toLogin = null;
this.toBlockGlobalNumber = null;
this.toBlockHash32 = null;
}
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("EDIT_POST toLogin is blank");
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
if (message == null || message.isBlank())
throw new IllegalArgumentException("REPOST message is blank");
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
if (toLogin == null || toLogin.isBlank())
throw new IllegalArgumentException("REPOST toLogin is blank");
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
if (toBlockHash32 == null || toBlockHash32.length != 32)
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
} else if (message == null) {
throw new IllegalArgumentException("Text message is null");
}
if (toBlockchainName != null || toBlockGlobalNumber != null || toBlockHash32 != null)
if (toLogin != null || toBlockGlobalNumber != null || toBlockHash32 != null)
throw new IllegalArgumentException("POST/CHANNEL_META must not contain target fields");
}
@@ -284,19 +266,14 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|| st == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
} else if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
// EDIT_POST
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("EDIT_POST missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("EDIT_POST toBlockHash32 != 32");
cap = (4 + 4 + 32 + 4) + (4 + 32) + 2 + msgUtf8.length;
} else {
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("REPOST toBlockchainName utf8 len must be 1..255");
throw new IllegalArgumentException("target toLogin utf8 len must be 1..255");
}
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("REPOST missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
if (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("target toBlockHash32 != 32");
cap = (4 + 4 + 32 + 4) + (1 + nameUtf8.length + 4 + 32) + 2 + msgUtf8.length;
}
@@ -307,13 +284,10 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
bb.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
bb.putInt(thisLineNumber);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
}
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
@Override public int lineSeq() { return thisLineNumber; }
/* ====================== BodyHasTarget ===================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -11,14 +11,11 @@ import java.util.Arrays;
import java.util.Objects;
/**
* TextRatingBody — type=1, ver=1.
*
* subType:
* - RATING (30)
* TextRatingBody — type=1, ver=1, subType=30.
*
* Формат bodyBytes (BigEndian):
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
public final short subType;
public final short version;
public final String toBlockchainName;
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32;
public final String message;
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
ensureNoTail(bb, "TextRatingBody");
}
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
Objects.requireNonNull(toLogin, "toLogin == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(message, "message == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
this.subType = MsgSubType.TEXT_RATING;
this.version = VER;
this.toBlockchainName = toBlockchainName;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
@Override
public TextRatingBody check() {
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
}
if (toBlockchainName == null || toBlockchainName.isBlank()) {
throw new IllegalArgumentException("RATING toBlockchainName is blank");
}
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
throw new IllegalArgumentException("Bad RATING subType");
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) {
throw new IllegalArgumentException("toBlockHash32 invalid");
}
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
return this;
}
@@ -106,15 +97,14 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
if (msgUtf8.length == 0) throw new IllegalArgumentException("Text payload is empty");
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..255");
}
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("RATING toLogin utf8 len must be 1..255");
ByteBuffer bb = ByteBuffer.allocate(1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length)
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
return bb.array();
}
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
@@ -17,84 +17,56 @@ import java.util.Objects;
* - REPLY (20)
* - EDIT_REPLY (21)
*
* Форматы bodyBytes (BigEndian):
*
* REPLY:
* [1] toBlockchainNameLen (uint8)
* [N] toBlockchainName UTF-8
* Оба subtype используют одинаковый target-формат:
* [1] toLoginLen (uint8)
* [N] toLogin UTF-8
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [M] text UTF-8
*
* EDIT_REPLY:
* [4] toBlockGlobalNumber
* [32] toBlockHash32
* [2] textLenBytes (uint16)
* [N] text UTF-8
* Для EDIT_REPLY text может быть пустым (логическое удаление).
*/
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public static final short TYPE = 1;
public static final short VER = 1;
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
public final short subType; // из header
public final short version; // (=1)
public final short subType;
public final short version;
// target
public final String toBlockchainName; // nullable для EDIT_REPLY
public final String toLogin;
public final int toBlockGlobalNumber;
public final byte[] toBlockHash32; // 32
// text
public final byte[] toBlockHash32;
public final String message;
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
this.subType = subType;
this.version = version;
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
}
int st = this.subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
}
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TextReplyBody too short");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
int loginLen = Byte.toUnsignedInt(bb.get());
if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
int nameLen = Byte.toUnsignedInt(bb.get());
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
byte[] nameBytes = new byte[nameLen];
bb.get(nameBytes);
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
} else {
// EDIT_REPLY: target без имени
ensureMin(bb, (4 + 32) + 2, "EDIT_REPLY too short");
this.toBlockchainName = null;
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
}
byte[] loginBytes = new byte[loginLen];
bb.get(loginBytes);
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
this.toBlockGlobalNumber = bb.getInt();
this.toBlockHash32 = new byte[32];
bb.get(this.toBlockHash32);
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
ensureNoTail(bb, "TextReplyBody");
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public TextReplyBody(short subType,
int toBlockGlobalNumber,
byte[] toBlockHash32,
String toBlockchainName,
String toLogin,
String message) {
Objects.requireNonNull(message, "message == null");
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
Objects.requireNonNull(toLogin, "toLogin == null");
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF)) {
@@ -116,25 +88,15 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
throw new IllegalArgumentException("message is blank");
}
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
this.toBlockchainName = toBlockchainName;
} else {
// EDIT_REPLY: имя не хранить
this.toBlockchainName = null;
}
this.subType = subType;
this.version = VER;
this.toLogin = toLogin;
this.toBlockGlobalNumber = toBlockGlobalNumber;
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
this.message = message;
}
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
int st = subType & 0xFFFF;
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
if (toBlockGlobalNumber < 0)
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32)
throw new IllegalArgumentException("toBlockHash32 invalid");
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (message == null || message.isBlank())
throw new IllegalArgumentException("Text message is blank");
if (toBlockchainName == null || toBlockchainName.isBlank())
throw new IllegalArgumentException("REPLY toBlockchainName is blank");
} else {
if (message == null) throw new IllegalArgumentException("EDIT_REPLY message is null");
if (toBlockchainName != null)
throw new IllegalArgumentException("EDIT_REPLY must not contain toBlockchainName");
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
} else if (message == null) {
throw new IllegalArgumentException("EDIT_REPLY message is null");
}
return this;
}
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
public byte[] toBytes() {
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
int st = subType & 0xFFFF;
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
throw new IllegalArgumentException("Text payload is empty");
}
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
if (loginUtf8.length == 0 || loginUtf8.length > 255)
throw new IllegalArgumentException("TextReplyBody toLogin utf8 len must be 1..255");
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
if (nameUtf8.length == 0 || nameUtf8.length > 255)
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
int cap = 1 + nameUtf8.length + 4 + 32 + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
bb.put((byte) nameUtf8.length);
bb.put(nameUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
// EDIT_REPLY
int cap = (4 + 32) + 2 + msgUtf8.length;
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
.order(ByteOrder.BIG_ENDIAN);
bb.put((byte) loginUtf8.length);
bb.put(loginUtf8);
bb.putInt(toBlockGlobalNumber);
bb.put(toBlockHash32);
bb.putShort((short) msgUtf8.length);
bb.put(msgUtf8);
return bb.array();
}
/* ====================== BodyHasTarget ====================== */
@Override public String toBchName() { return toBlockchainName; }
@Override public String toLogin() { return toLogin; }
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
@@ -215,8 +148,6 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
}
/* ====================== helpers ====================== */
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
int len = Short.toUnsignedInt(bb.getShort());
if (len == 0) {
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
throw new IllegalArgumentException(fieldName + " is empty");
}
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
byte[] bytes = new byte[len];
bb.get(bytes);
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
@@ -1,297 +0,0 @@
package utils.files;
import java.io.IOException;
import java.nio.file.*;
import java.util.Objects;
/**
* ===============================================================
* FileStoreUtil — утилита работы с файлами в папке data/.
*
* Теперь поддерживает:
* - основной файл блокчейна: <blockchainName>.bch
* - временный файл блокчейна: <blockchainName>.tmp_bch
* - sidecar-файл проверки записи: <blockchainName>.write_check
* - marker-файл записи: <blockchainName>.write_pending
*
* Важное:
* - validateSimpleFileName() запрещает path traversal.
* - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает),
* иначе делает обычный REPLACE_EXISTING move.
* ===============================================================
*/
public final class FileStoreUtil {
/** Базовая папка для хранения всех файлов (создаётся автоматически). */
public static final String DATA_DIR_NAME = "data";
/** Расширение основного файла блокчейна. */
public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch";
/** Расширение временного файла (старое+новое). */
public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch";
/** Маркер того, что chain сейчас в процессе полного resync. */
public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending";
/** Marker того, что обычный AddBlock находится в опасной фазе записи. */
public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending";
/** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */
public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check";
private static final FileStoreUtil INSTANCE = new FileStoreUtil();
private final Path dataDirPath;
private FileStoreUtil() {
this.dataDirPath = Paths.get(DATA_DIR_NAME);
ensureDataDirExists();
}
public static FileStoreUtil getInstance() {
return INSTANCE;
}
/* ===================================================================== */
/* ======================== Базовые операции =========================== */
/* ===================================================================== */
public void newFile(String fileName, byte[] data) {
Objects.requireNonNull(data, "data == null");
Path target = resolveSafe(fileName);
try {
Files.write(target, data,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE);
} catch (IOException e) {
throw new IllegalStateException("Не удалось записать файл: " + target, e);
}
}
public void addDataToFile(String fileName, byte[] data) {
Objects.requireNonNull(data, "data == null");
Path target = resolveSafe(fileName);
try {
Files.write(target, data,
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
StandardOpenOption.APPEND);
} catch (IOException e) {
throw new IllegalStateException("Не удалось дописать файл: " + target, e);
}
}
public byte[] readAllDataFromFile(String fileName) {
Path target = resolveSafe(fileName);
if (!Files.exists(target)) {
throw new IllegalStateException("Файл не найден: " + target);
}
try {
return Files.readAllBytes(target);
} catch (IOException e) {
throw new IllegalStateException("Не удалось прочитать файл: " + target, e);
}
}
public boolean exists(String fileName) {
Path target = resolveSafe(fileName);
return Files.exists(target);
}
public long size(String fileName) {
Path target = resolveSafe(fileName);
try {
return Files.size(target);
} catch (IOException e) {
throw new IllegalStateException("Не удалось получить размер файла: " + target, e);
}
}
/* ===================================================================== */
/* ===================== Блокчейн-файлы по имени ======================= */
/* ===================================================================== */
/** <blockchainName>.bch */
public String buildBlockchainFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_FILE_EXTENSION;
}
/** <blockchainName>.tmp_bch */
public String buildBlockchainTmpFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_TMP_EXTENSION;
}
public Path resolveBlockchainPath(String blockchainName) {
return resolveSafe(buildBlockchainFileName(blockchainName));
}
public Path resolveBlockchainTmpPath(String blockchainName) {
return resolveSafe(buildBlockchainTmpFileName(blockchainName));
}
public byte[] readBlockchain(String blockchainName) {
return readAllDataFromFile(buildBlockchainFileName(blockchainName));
}
public void writeBlockchainTmp(String blockchainName, byte[] data) {
newFile(buildBlockchainTmpFileName(blockchainName), data);
}
/** <blockchainName>.write_check */
public String buildBlockchainWriteCheckFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION;
}
public Path resolveBlockchainWriteCheckPath(String blockchainName) {
return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName));
}
public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) {
StringBuilder sb = new StringBuilder(128);
sb.append("blockNumber=").append(blockNumber).append('\n');
sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n');
newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
/** <blockchainName>.write_pending */
public String buildBlockchainWritePendingMarkerFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION;
}
public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) {
return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName));
}
public void writeBlockchainWritePendingMarker(String blockchainName) {
newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]);
}
/** <blockchainName>.resync_pending */
public String buildBlockchainResyncMarkerFileName(String blockchainName) {
validateSimpleFileName(blockchainName);
return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION;
}
public Path resolveBlockchainResyncMarkerPath(String blockchainName) {
return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName));
}
public void writeBlockchainResyncMarker(String blockchainName, String markerContent) {
byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8);
newFile(buildBlockchainResyncMarkerFileName(blockchainName), data);
}
public void deleteIfExists(Path path) {
if (path == null) {
return;
}
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new IllegalStateException("Не удалось удалить файл: " + path, e);
}
}
public void deleteBlockchainFileIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainPath(blockchainName));
}
public void deleteBlockchainTmpFileIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainTmpPath(blockchainName));
}
public void deleteBlockchainWriteCheckIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName));
}
public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName));
}
public void deleteBlockchainResyncMarkerIfExists(String blockchainName) {
deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName));
}
public boolean existsBlockchainResyncMarker(String blockchainName) {
return exists(buildBlockchainResyncMarkerFileName(blockchainName));
}
/**
* Атомарно заменить основной файл блокчейна временным:
* <name>.tmp_bch -> <name>.bch
*
* Стратегия:
* 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING)
* 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности
*
* Важный нюанс:
* - атомарность гарантируется только в пределах одной файловой системы.
*/
public void atomicReplaceBlockchainFile(String blockchainName) {
Path tmp = resolveBlockchainTmpPath(blockchainName);
Path main = resolveBlockchainPath(blockchainName);
if (!Files.exists(tmp)) {
throw new IllegalStateException("TMP-файл не найден: " + tmp);
}
try {
// 1) Пытаемся атомарный move
Files.move(tmp, main,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
// 2) Если ФС не поддерживает атомарный move — делаем обычный replace
try {
Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex);
}
} catch (IOException e) {
throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e);
}
}
/* ===================================================================== */
/* ============================ Helpers ================================= */
/* ===================================================================== */
private void ensureDataDirExists() {
try {
if (!Files.exists(dataDirPath)) {
Files.createDirectories(dataDirPath);
}
} catch (IOException e) {
throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e);
}
}
private Path resolveSafe(String fileName) {
validateSimpleFileName(fileName);
return dataDirPath.resolve(fileName);
}
/**
* Валидация "простого имени":
* - запрещаем слэши, обратные слэши, ".."
* - запрещаем пустоту
*
* Важно: сюда у нас попадает и blockchainName (как часть имени файла),
* поэтому blockchainName должен быть "простым": без путей.
*/
private void validateSimpleFileName(String fileName) {
Objects.requireNonNull(fileName, "fileName == null");
if (fileName.isBlank()) {
throw new IllegalArgumentException("Имя файла не должно быть пустым");
}
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
throw new IllegalArgumentException("Недопустимое имя файла: " + fileName);
}
}
}
@@ -0,0 +1,74 @@
package blockchain;
import org.junit.jupiter.api.Test;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
final class Ans104DataItemTest {
@Test
void ed25519RoundTripWithTestAndChannelTags() {
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-test-key");
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of(
new Ans104DataItem.Tag("App", "test5590"),
new Ans104DataItem.Tag("c_test5590", "books")
);
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
byte[] signature = Ed25519Util.sign(message, privateKey);
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
Ans104DataItem parsed = new Ans104DataItem(raw);
assertEquals(2, parsed.signatureType());
assertArrayEquals(owner, parsed.owner32());
assertArrayEquals(data, parsed.data());
assertTrue(parsed.hasTag("App", "test5590"));
assertEquals("books", parsed.tagValue("c_test5590"));
assertTrue(parsed.verifySignature());
assertEquals(32, parsed.id32().length);
}
@Test
void mutationBreaksSignature() {
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-mutation-key");
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
byte[] data = "original".getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data);
raw[raw.length - 1] ^= 1;
assertFalse(new Ans104DataItem(raw).verifySignature());
}
@Test
void signingMessageMatchesArbundlesTurboCompatibilityVector() {
byte[] owner = new byte[32];
for (int i = 0; i < owner.length; i++) owner[i] = (byte) i;
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
List<Ans104DataItem.Tag> tags = List.of(
new Ans104DataItem.Tag("App", "test5590"),
new Ans104DataItem.Tag("c_test5590", "books")
);
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
byte[] expected = hex("1abe0371d12268b34be32ca5ebf3d3d2189f9d311d9004c142538ca8a4312faa329d1a4873df72dd01b632a2ecce6b87");
assertArrayEquals(expected, actual);
}
private static byte[] hex(String value) {
if ((value.length() & 1) != 0) throw new IllegalArgumentException("odd hex length");
byte[] out = new byte[value.length() / 2];
for (int i = 0; i < out.length; i++) {
int hi = Character.digit(value.charAt(i * 2), 16);
int lo = Character.digit(value.charAt(i * 2 + 1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex");
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
}
@@ -0,0 +1,85 @@
package blockchain;
import blockchain.body.ForkBody;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
final class ForkBodyTest {
@Test
void roundTripThroughBodyRecordParser() {
byte[] parentKey = filled(32, 0x11);
byte[] forkHash = filled(32, 0x22);
byte[] tipHash = filled(32, 0x33);
ForkBody source = new ForkBody(
parentKey,
120,
forkHash,
1_700_000_000_000L,
137,
tipHash,
1_700_100_000_000L,
17,
ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK,
"Удаляю неизвестные записи"
).check();
ForkBody parsed = assertInstanceOf(
ForkBody.class,
BodyRecordParser.parse(ForkBody.TYPE, ForkBody.SUBTYPE, ForkBody.VER, source.toBytes())
);
assertArrayEquals(parentKey, parsed.parentBlockchainKey32);
assertEquals(120, parsed.forkPointBlockNumber);
assertArrayEquals(forkHash, parsed.forkPointBlockHash32);
assertEquals(137, parsed.parentTipBlockNumber);
assertArrayEquals(tipHash, parsed.parentTipBlockHash32);
assertEquals(17, parsed.discardedBlocksCount);
assertEquals(ForkBody.REASON_CONFIRMED_COMPROMISE_ROLLBACK, parsed.reasonCode);
assertEquals("Удаляю неизвестные записи", parsed.comment);
}
@Test
void routineRotationMayDiscardNothing() {
ForkBody body = new ForkBody(
filled(32, 1),
10,
filled(32, 2),
1000,
10,
filled(32, 2),
1000,
0,
ForkBody.REASON_ROUTINE_ROTATION,
""
);
assertDoesNotThrow(body::check);
}
@Test
void rejectedWhenDiscardCountDoesNotMatchForkPointAndTip() {
ForkBody body = new ForkBody(
filled(32, 1),
10,
filled(32, 2),
1000,
12,
filled(32, 3),
2000,
99,
ForkBody.REASON_POSSIBLE_COMPROMISE,
""
);
assertThrows(IllegalArgumentException.class, body::check);
}
private static byte[] filled(int size, int value) {
byte[] out = new byte[size];
Arrays.fill(out, (byte) value);
return out;
}
}
@@ -0,0 +1,73 @@
package blockchain;
import blockchain.body.ConnectionBody;
import blockchain.body.ReactionBody;
import blockchain.body.StatusActionBody;
import blockchain.body.TextLineBody;
import blockchain.body.TextReplyBody;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
final class TargetLoginFormatTest {
private static final byte[] HASH = hash(7);
@Test
void reactionRoundTripStoresLoginNotBlockchainName() {
ReactionBody source = new ReactionBody("alice", 42, HASH);
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.toLogin());
assertEquals(42, parsed.toBlockGlobalNumber());
assertArrayEquals(HASH, parsed.toBlockHashBytes());
assertFalse(new String(source.toBytes()).contains("alice-001"));
}
@Test
void replyAndEditReplyUseSameLoginTargetShape() {
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply");
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
assertEquals("alice", parsedReply.toLogin());
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", "");
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
assertEquals("alice", parsedEdit.toLogin());
assertEquals("", parsedEdit.message);
}
@Test
void editPostAndConnectionCarryLoginTarget() {
TextLineBody editPost = new TextLineBody(
0, -1, new byte[32], -1,
MsgSubType.TEXT_EDIT_POST,
5, HASH, "alice", "edited"
);
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
assertEquals("alice", parsedEditPost.toLogin());
ConnectionBody connection = new ConnectionBody(
0, -1, new byte[32], -1,
MsgSubType.CONNECTION_FOLLOW,
"alice", 0, HASH
);
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
assertEquals("alice", parsedConnection.toLogin());
}
@Test
void statusActionCarriesLoginTarget() {
StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, "");
StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check();
assertEquals("alice", parsed.toLogin());
assertEquals(77, parsed.toBlockGlobalNumber());
}
private static byte[] hash(int seed) {
byte[] out = new byte[32];
Arrays.fill(out, (byte) seed);
return out;
}
}
@@ -66,19 +66,20 @@ public final class AppConfig {
/** Вернёт строку или пустую строку, если параметр не найден. */
public String getStringOrEmpty(String name) {
String value = properties.getProperty(name);
String value = getParam(name);
return value == null ? "" : value.trim();
}
/** Можно добавить методы для удобства */
/** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */
public int getInt(String name, int defaultValue) {
String v = properties.getProperty(name);
return v == null ? defaultValue : Integer.parseInt(v);
String v = getParam(name);
return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim());
}
/** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */
public boolean getBoolean(String name, boolean defaultValue) {
String v = properties.getProperty(name);
return v == null ? defaultValue : Boolean.parseBoolean(v);
String v = getParam(name);
return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim());
}
private static String toEnvName(String name) {
@@ -17,6 +17,9 @@ public final class ShineSignatureConstants {
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
@@ -1,7 +1,7 @@
# utils.crypto
Пакет отвечает за криптографию — подписи и хэши блоков.
Используется при создании и проверке целостности `.bch`-блоков.
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
---
@@ -37,6 +37,13 @@ public final class DatabaseInitializer {
public static final int SCHEMA_VERSION_18 = 18;
public static final int SCHEMA_VERSION_19 = 19;
public static final int SCHEMA_VERSION_20 = 20;
public static final int SCHEMA_VERSION_21 = 21;
public static final int SCHEMA_VERSION_22 = 22;
public static final int SCHEMA_VERSION_23 = 23;
public static final int SCHEMA_VERSION_24 = 24;
public static final int SCHEMA_VERSION_25 = 25;
public static final int SCHEMA_VERSION_26 = 26;
public static final int SCHEMA_VERSION_27 = 27;
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
@@ -57,6 +64,13 @@ public final class DatabaseInitializer {
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
private DatabaseInitializer() {}
@@ -206,6 +220,34 @@ public final class DatabaseInitializer {
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
currentVersion = SCHEMA_VERSION_20;
}
if (currentVersion < SCHEMA_VERSION_21) {
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
currentVersion = SCHEMA_VERSION_21;
}
if (currentVersion < SCHEMA_VERSION_22) {
runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE);
currentVersion = SCHEMA_VERSION_22;
}
if (currentVersion < SCHEMA_VERSION_23) {
runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE);
currentVersion = SCHEMA_VERSION_23;
}
if (currentVersion < SCHEMA_VERSION_24) {
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
currentVersion = SCHEMA_VERSION_24;
}
if (currentVersion < SCHEMA_VERSION_25) {
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
currentVersion = SCHEMA_VERSION_25;
}
if (currentVersion < SCHEMA_VERSION_26) {
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
currentVersion = SCHEMA_VERSION_26;
}
if (currentVersion < SCHEMA_VERSION_27) {
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
currentVersion = SCHEMA_VERSION_27;
}
}
}
@@ -23,6 +23,46 @@ public final class KeyEncodingUtil {
private KeyEncodingUtil() {}
public static String encodeBase58(byte[] input) {
if (input == null || input.length == 0) {
return "";
}
byte[] copy = java.util.Arrays.copyOf(input, input.length);
int zeros = 0;
while (zeros < copy.length && copy[zeros] == 0) {
zeros++;
}
byte[] encoded = new byte[copy.length * 2];
int outputStart = encoded.length;
int inputStart = zeros;
while (inputStart < copy.length) {
int remainder = divmod58(copy, inputStart);
if (copy[inputStart] == 0) {
inputStart++;
}
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(remainder);
}
while (outputStart < encoded.length && encoded[outputStart] == (byte) BASE58_ALPHABET.charAt(0)) {
outputStart++;
}
while (--zeros >= 0) {
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(0);
}
return new String(encoded, outputStart, encoded.length - outputStart, java.nio.charset.StandardCharsets.US_ASCII);
}
public static String base58KeyToBase64_32(String rawKey) {
if (rawKey == null) {
return null;
}
String value = rawKey.trim();
if (value.isEmpty()) {
return value;
}
byte[] decoded = tryDecodeBase58_32(value);
return decoded == null ? value : Base64.getEncoder().encodeToString(decoded);
}
public static String normalizeKeyToBase64_32(String rawKey) {
if (rawKey == null) {
return null;
@@ -102,6 +142,17 @@ public final class KeyEncodingUtil {
return result;
}
private static int divmod58(byte[] number, int startAt) {
int remainder = 0;
for (int i = startAt; i < number.length; i++) {
int digit256 = number[i] & 0xFF;
int temp = remainder * 256 + digit256;
number[i] = (byte) (temp / 58);
remainder = temp % 58;
}
return remainder;
}
private static int divmod256(byte[] number58, int startAt) {
int remainder = 0;
for (int i = startAt; i < number58.length; i++) {
@@ -0,0 +1,137 @@
package shine.db.dao;
import shine.db.DbController;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */
public final class ArweaveBlockImportDAO {
public static final String STATUS_PENDING = "PENDING";
public static final String STATUS_REJECTED = "REJECTED";
public record QueueItem(byte[] dataItemId, long blockHeight,
byte[] rawDataItem, String status, String lastError,
long firstSeenAtMs, long updatedAtMs) {}
private static volatile ArweaveBlockImportDAO instance;
private final DbController db = DbController.getInstance();
private ArweaveBlockImportDAO() {}
public static ArweaveBlockImportDAO getInstance() {
if (instance == null) {
synchronized (ArweaveBlockImportDAO.class) {
if (instance == null) instance = new ArweaveBlockImportDAO();
}
}
return instance;
}
public long getLastBlockHeight() throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1");
ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getLong(1) : 0L;
}
}
public void setLastBlockHeight(long height, long nowMs) throws SQLException {
String sql = """
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
VALUES(1,?,?)
ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, Math.max(0L, height));
ps.setLong(2, nowMs);
ps.executeUpdate();
}
}
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs)
throws SQLException {
String sql = """
INSERT INTO arweave_block_import_queue(
data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
) VALUES(?,?,?,?, '',?,?)
ON CONFLICT(data_item_id) DO NOTHING
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setBytes(1, dataItemId);
ps.setLong(2, blockHeight);
ps.setBytes(3, rawDataItem);
ps.setString(4, STATUS_PENDING);
ps.setLong(5, nowMs);
ps.setLong(6, nowMs);
return ps.executeUpdate() > 0;
}
}
public boolean exists(byte[] dataItemId) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) {
ps.setBytes(1, dataItemId);
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
}
}
public List<QueueItem> listPending(int limit) throws SQLException {
int safeLimit = Math.max(1, Math.min(limit, 100_000));
String sql = """
SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
FROM arweave_block_import_queue
WHERE status=?
ORDER BY block_height ASC, first_seen_at_ms ASC
LIMIT ?
""";
List<QueueItem> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, STATUS_PENDING);
ps.setInt(2, safeLimit);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
out.add(new QueueItem(
rs.getBytes("data_item_id"), rs.getLong("block_height"),
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
}
}
}
return out;
}
public void setPendingError(byte[] id, String error, long nowMs) throws SQLException {
updateStatus(id, STATUS_PENDING, error, nowMs);
}
public void reject(byte[] id, String error, long nowMs) throws SQLException {
updateStatus(id, STATUS_REJECTED, error, nowMs);
}
public void delete(byte[] id) throws SQLException {
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
"DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) {
ps.setBytes(1, id);
ps.executeUpdate();
}
}
private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException {
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
"UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) {
ps.setString(1, status);
ps.setString(2, trimError(error));
ps.setLong(3, nowMs);
ps.setBytes(4, id);
ps.executeUpdate();
}
}
private static String trimError(String s) {
String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim();
return v.length() <= 500 ? v : v.substring(0,500);
}
}
@@ -23,7 +23,7 @@ import java.sql.SQLException;
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
* - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only.
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
* отдельным recovery/resync-слоем после успешного commit.
@@ -71,7 +71,7 @@ public final class BlockchainResyncCleanupDAO {
* - если blockchain_state уже отсутствует, login берём из blockchainName.
*
* Отдельно важно:
* - здесь НЕТ удаления .bch/.tmp_bch;
* - filesystem blockchain storage отсутствует;
* - здесь НЕТ повторной загрузки цепочки;
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
*/
@@ -127,6 +127,100 @@ public final class BlockchainResyncCleanupDAO {
}
}
/**
* После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок.
* Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value
* не меняет смысл ссылки и не требует переподписывать чужие блоки.
*/
public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException {
if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) {
throw new IllegalArgumentException("login/newBlockchainName are required");
}
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
updateTargetCacheByLogin(c, "blocks", login, newBlockchainName);
updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName);
updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName);
updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName);
if (oldBlockchainName != null && !oldBlockchainName.isBlank()) {
try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) {
ps.setString(1, newBlockchainName);
ps.setString(2, oldBlockchainName);
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) {
ps.setString(1, newBlockchainName);
ps.setString(2, oldBlockchainName);
ps.executeUpdate();
}
}
rebuildInboundMessageCounters(c, login, newBlockchainName);
rebuildStatsState(c);
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
/**
* После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы
* других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем
* агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache.
*/
private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
UPDATE message_stats ms
SET likes_count = (
SELECT COUNT(*)::INTEGER
FROM reactions_state rs
WHERE rs.reaction_type = ?
AND rs.last_sub_type = ?
AND LOWER(rs.to_login) = LOWER(ms.to_login)
AND rs.to_bch_name = ms.to_bch_name
AND rs.to_block_number = ms.to_block_number
AND rs.to_block_hash = ms.to_block_hash
),
replies_count = (
SELECT COUNT(*)::INTEGER
FROM blocks b
WHERE b.msg_type = 1
AND b.msg_sub_type = ?
AND LOWER(b.to_login) = LOWER(ms.to_login)
AND b.to_bch_name = ms.to_bch_name
AND b.to_block_number = ms.to_block_number
AND b.to_block_hash = ms.to_block_hash
)
WHERE LOWER(ms.to_login) = LOWER(?)
AND ms.to_bch_name = ?
""")) {
ps.setInt(1, DatabaseInitializer.REACTION_LIKE);
ps.setInt(2, DatabaseInitializer.REACTION_LIKE);
ps.setInt(3, DatabaseInitializer.TEXT_REPLY);
ps.setString(4, login);
ps.setString(5, newBlockchainName);
ps.executeUpdate();
}
}
private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException {
String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, newBlockchainName);
ps.setString(2, login);
ps.setString(3, newBlockchainName);
ps.executeUpdate();
}
}
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT login
@@ -9,286 +9,146 @@ import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* DAO для таблицы blocks (новый формат).
*
* Правило:
* - методы с Connection НЕ закрывают соединение
* - методы без Connection сами открывают и закрывают соединение
*
* Ключ:
* - (bch_name, block_number) — уникальная пара в рамках общей БД сервера.
*/
/** DAO for SHiNE Frame v1 blocks. block_bytes contains the complete ANS-104 DataItem. */
public final class BlocksDAO {
private static volatile BlocksDAO instance;
private final DbController db = DbController.getInstance();
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
private BlocksDAO() { }
public static BlocksDAO getInstance() {
if (instance == null) {
synchronized (BlocksDAO.class) {
if (instance == null) instance = new BlocksDAO();
}
}
if (instance == null) synchronized (BlocksDAO.class) { if (instance == null) instance = new BlocksDAO(); }
return instance;
}
// -------------------- INSERT --------------------
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
public void insert(Connection c, BlockEntry e) throws SQLException {
log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}",
e.getMsgType(), e.getMsgSubType(),
e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(),
e.getPrevLineHash() == null ? null : e.getPrevLineHash().length
);
String sql = """
INSERT INTO blocks (
login,
bch_name,
block_number,
msg_type,
msg_sub_type,
block_bytes,
to_login,
to_bch_name,
to_block_number,
to_block_hash,
block_hash,
block_signature,
edited_by_block_number,
line_code,
prev_line_number,
prev_line_hash,
this_line_number
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,
block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
ps.setString(i++, e.getLogin());
ps.setString(i++, e.getBchName());
ps.setInt(i++, e.getBlockNumber());
ps.setInt(i++, e.getMsgType());
ps.setInt(i++, e.getMsgSubType());
ps.setBytes(i++, e.getBlockBytes());
if (e.getToLogin() != null) ps.setString(i++, e.getToLogin());
else ps.setNull(i++, Types.VARCHAR);
if (e.getToBchName() != null) ps.setString(i++, e.getToBchName());
else ps.setNull(i++, Types.VARCHAR);
if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber());
else ps.setNull(i++, Types.INTEGER);
if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash());
else ps.setNull(i++, Types.BINARY);
setNullableString(ps, i++, e.getToLogin());
setNullableString(ps, i++, e.getToBchName());
setNullableInt(ps, i++, e.getToBlockNumber());
setNullableBytes(ps, i++, e.getToBlockHash());
ps.setBytes(i++, e.getBlockHash());
ps.setBytes(i++, e.getBlockSignature());
if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber());
else ps.setNull(i++, Types.INTEGER);
// NEW: line_code
if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode());
else ps.setNull(i++, Types.INTEGER);
if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber());
else ps.setNull(i++, Types.INTEGER);
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
else ps.setNull(i++, Types.BINARY);
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
else ps.setNull(i++, Types.INTEGER);
ps.setBytes(i++, e.getDataItemId());
ps.setBoolean(i++, e.isArweavePublishPending());
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
setNullableInt(ps, i++, e.getEditedByBlockNumber());
setNullableInt(ps, i++, e.getLineCode());
setNullableInt(ps, i++, e.getPrevLineNumber());
setNullableBytes(ps, i++, e.getPrevLineHash());
setNullableInt(ps, i++, e.getThisLineNumber());
ps.executeUpdate();
}
}
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
public void insert(BlockEntry e) throws SQLException {
try (Connection c = db.getConnection()) {
insert(c, e);
public void insert(BlockEntry e) throws SQLException { try (Connection c = db.getConnection()) { insert(c, e); } }
public boolean existsByDataItemId(byte[] dataItemId) throws SQLException {
if (dataItemId == null || dataItemId.length != 32) return false;
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM blocks WHERE data_item_id=? LIMIT 1")) {
ps.setBytes(1, dataItemId);
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
}
}
// -------------------- SELECT: HASH BY NUMBER --------------------
/** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
public List<BlockEntry> listPendingArweave(int limit) throws SQLException {
int safeLimit = Math.max(1, Math.min(limit, 100_000));
String sql = """
SELECT block_hash
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
FROM blocks
WHERE bch_name = ? AND block_number = ?
LIMIT 1
WHERE arweave_publish_pending = TRUE
ORDER BY bch_name ASC, block_number ASC
LIMIT ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bchName);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return rs.getBytes("block_hash");
}
List<BlockEntry> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, safeLimit);
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
}
return out;
}
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException {
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
if (dataItemIds == null || dataItemIds.isEmpty()) return;
String sql = """
UPDATE blocks
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?
WHERE data_item_id=?
""";
try (Connection c = db.getConnection()) {
return getHashByNumber(c, bchName, blockNumber);
c.setAutoCommit(false);
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (byte[] id : dataItemIds) {
ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch();
}
ps.executeBatch(); c.commit();
} catch (Exception e) { c.rollback(); throw e; }
finally { c.setAutoCommit(true); }
}
}
// -------------------- SELECT: FULL ENTRY --------------------
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1")) {
ps.setString(1,bchName); ps.setInt(2,blockNumber);
try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; }
}
}
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} }
public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
String sql = """
SELECT
login,
bch_name,
block_number,
msg_type,
msg_sub_type,
block_bytes,
to_login,
to_bch_name,
to_block_number,
to_block_hash,
block_hash,
block_signature,
edited_by_block_number,
line_code,
prev_line_number,
prev_line_hash,
this_line_number
FROM blocks
WHERE bch_name = ? AND block_number = ?
LIMIT 1
""";
String sql = baseSelect()+" WHERE bch_name=? AND block_number=? LIMIT 1";
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
}
public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} }
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bchName);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{
String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
List<BlockEntry> out=new ArrayList<>();
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
return out;
}
public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException {
try (Connection c = db.getConnection()) {
return getByNumber(c, bchName, blockNumber);
}
}
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
try (Connection c = db.getConnection()) {
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
}
}
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
String sql = """
SELECT
login,
bch_name,
block_number,
msg_type,
msg_sub_type,
block_bytes,
to_login,
to_bch_name,
to_block_number,
to_block_hash,
block_hash,
block_signature,
edited_by_block_number,
line_code,
prev_line_number,
prev_line_hash,
this_line_number
FROM blocks
WHERE bch_name = ?
AND block_number >= ?
AND block_number <= ?
ORDER BY block_number ASC
""";
List<BlockEntry> result = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, bchName);
ps.setInt(2, fromBlockNumberInclusive);
ps.setInt(3, toBlockNumberInclusive);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
result.add(mapRow(rs));
}
}
}
return result;
}
// -------------------- INTERNAL --------------------
private BlockEntry mapRow(ResultSet rs) throws SQLException {
BlockEntry e = new BlockEntry();
e.setLogin(rs.getString("login"));
e.setBchName(rs.getString("bch_name"));
e.setBlockNumber(rs.getInt("block_number"));
e.setMsgType(rs.getInt("msg_type"));
e.setMsgSubType(rs.getInt("msg_sub_type"));
e.setBlockBytes(rs.getBytes("block_bytes"));
String toLogin = rs.getString("to_login");
if (rs.wasNull()) toLogin = null;
e.setToLogin(toLogin);
String toBchName = rs.getString("to_bch_name");
if (rs.wasNull()) toBchName = null;
e.setToBchName(toBchName);
Integer toBlockNumber = (Integer) rs.getObject("to_block_number");
e.setToBlockNumber(toBlockNumber);
byte[] toHash = rs.getBytes("to_block_hash");
if (rs.wasNull()) toHash = null;
e.setToBlockHash(toHash);
e.setBlockHash(rs.getBytes("block_hash"));
e.setBlockSignature(rs.getBytes("block_signature"));
Integer editedBy = (Integer) rs.getObject("edited_by_block_number");
e.setEditedByBlockNumber(editedBy);
// NEW: line_code
Integer lineCode = (Integer) rs.getObject("line_code");
e.setLineCode(lineCode);
Integer prevLn = (Integer) rs.getObject("prev_line_number");
e.setPrevLineNumber(prevLn);
byte[] prevLh = rs.getBytes("prev_line_hash");
if (rs.wasNull()) prevLh = null;
e.setPrevLineHash(prevLh);
Integer thisLn = (Integer) rs.getObject("this_line_number");
e.setThisLineNumber(thisLn);
private static String baseSelect(){return """
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
arweave_publish_pending,arweave_published_at_ms,
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
FROM blocks
""";}
private BlockEntry mapRow(ResultSet rs)throws SQLException{
BlockEntry e=new BlockEntry();
e.setLogin(rs.getString("login")); e.setBchName(rs.getString("bch_name")); e.setBlockNumber(rs.getInt("block_number"));
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms"));
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
return e;
}
private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);}
private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);}
private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);}
}
@@ -120,4 +120,15 @@ public final class ChannelNameStateDAO {
insert(c, entry);
}
}
public String getSlugByOwnerAndRoot(String ownerBchName, int rootBlockNumber) throws SQLException {
String sql = "SELECT slug FROM channel_names_state WHERE owner_bch_name=? AND channel_root_block_number=? LIMIT 1";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBchName);
ps.setInt(2, rootBlockNumber);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString(1) : null;
}
}
}
}
@@ -0,0 +1,298 @@
package shine.db.dao;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
/**
* Server projection for per-user channel read/unread state.
*
* Important rules:
* - rows are created only by a signed client SetChannelReadState;
* - channel publications increment unread_count only for rows that already exist;
* - read_count is monotonic and unread_count is reduced by the accepted read delta;
* - unread_count is never part of the client signature and is server-owned.
*/
public final class ChannelReadStateDAO {
private static volatile ChannelReadStateDAO instance;
private ChannelReadStateDAO() {}
public static ChannelReadStateDAO getInstance() {
if (instance == null) {
synchronized (ChannelReadStateDAO.class) {
if (instance == null) instance = new ChannelReadStateDAO();
}
}
return instance;
}
public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs,
String clientKey, String readSignature) {}
public record UpsertResult(boolean applied, long readCount, long unreadCount) {}
public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
String sql = """
SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature
FROM channel_read_state
WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=?
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBchName);
ps.setString(3, channelName);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return new ReadState(
Math.max(0L, rs.getLong("read_count")),
Math.max(0L, rs.getLong("unread_count")),
rs.getLong("read_updated_at_ms"),
rs.getString("client_key"),
rs.getString("read_signature"));
}
}
}
public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
ReadState state = get(c, viewerLogin, ownerBchName, channelName);
return state == null ? 0L : state.unreadCount();
}
public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException {
String sql = """
SELECT COALESCE(SUM(crs.unread_count),0)
FROM channel_read_state crs
WHERE LOWER(crs.viewer_login)=LOWER(?)
AND EXISTS (
SELECT 1
FROM channel_names_state cns
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(crs.viewer_login)
AND cs.rel_type=30
AND cs.to_bch_name=cns.owner_bch_name
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=crs.owner_bch_name
AND cns.slug=crs.channel_name
AND cns.channel_type_code=1
)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
}
}
}
/**
* Validates that the requested public channel exists and that viewer currently follows it.
*/
public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName,
int followRelType) throws SQLException {
String sql = """
SELECT 1
FROM channel_names_state cns
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(?)
AND cs.rel_type=?
AND cs.to_bch_name=cns.owner_bch_name
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=?
AND cns.slug=?
AND cns.channel_type_code=1
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setInt(2, followRelType);
ps.setString(3, ownerBchName);
ps.setString(4, channelName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next();
}
}
}
public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException {
String sql = """
SELECT slug
FROM channel_names_state
WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBchName);
ps.setInt(2, rootBlockNumber);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("slug") : null;
}
}
}
public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
String sql = """
SELECT channel_root_block_number
FROM channel_names_state
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBchName);
ps.setString(2, channelName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt("channel_root_block_number") : null;
}
}
}
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
String sql = """
SELECT owner_login
FROM channel_names_state
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
LIMIT 1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBchName);
ps.setString(2, channelName);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("owner_login") : null;
}
}
}
/**
* Inserts the first client baseline with unread=0, or advances an existing read watermark.
* Older timestamps and backwards read_count values are ignored.
*/
public UpsertResult upsertSignedReadIfNewer(Connection c,
String viewerLogin,
String ownerLogin,
String ownerBchName,
String channelName,
long readCount,
long readUpdatedAtMs,
String clientKey,
String readSignature,
long nowMs) throws SQLException {
String sql = """
INSERT INTO channel_read_state (
viewer_login, owner_login, owner_bch_name, channel_name,
read_count, unread_count,
read_updated_at_ms, client_key, read_signature, updated_at_ms
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
ON CONFLICT (viewer_login, owner_bch_name, channel_name)
DO UPDATE SET
owner_login = EXCLUDED.owner_login,
unread_count = GREATEST(
0,
channel_read_state.unread_count -
GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count)
),
read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count),
read_updated_at_ms = EXCLUDED.read_updated_at_ms,
client_key = EXCLUDED.client_key,
read_signature = EXCLUDED.read_signature,
updated_at_ms = EXCLUDED.updated_at_ms
WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms
AND EXCLUDED.read_count >= channel_read_state.read_count
RETURNING read_count, unread_count
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerLogin);
ps.setString(3, ownerBchName);
ps.setString(4, channelName);
ps.setLong(5, Math.max(0L, readCount));
ps.setLong(6, readUpdatedAtMs);
ps.setString(7, clientKey);
ps.setString(8, readSignature);
ps.setLong(9, nowMs);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return new UpsertResult(true,
Math.max(0L, rs.getLong("read_count")),
Math.max(0L, rs.getLong("unread_count")));
}
}
}
ReadState current = get(c, viewerLogin, ownerBchName, channelName);
return new UpsertResult(false,
current == null ? 0L : current.readCount(),
current == null ? 0L : current.unreadCount());
}
/**
* Increments existing rows for current subscribers of one public channel.
* Missing channel_read_state rows are deliberately NOT created.
* Returns affected viewer logins for post-commit counter pushes.
*/
public List<String> incrementUnreadForSubscribers(Connection c,
String ownerBchName,
String channelName,
int followRelType,
long nowMs) throws SQLException {
String sql = """
UPDATE channel_read_state crs
SET unread_count = crs.unread_count + 1,
updated_at_ms = ?
WHERE crs.owner_bch_name = ?
AND crs.channel_name = ?
AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login)
AND EXISTS (
SELECT 1
FROM channel_names_state cns
JOIN connections_state cs
ON LOWER(cs.login)=LOWER(crs.viewer_login)
AND cs.rel_type=?
AND cs.to_bch_name=cns.owner_bch_name
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
AND cs.to_block_hash=cns.channel_root_block_hash
WHERE cns.owner_bch_name=crs.owner_bch_name
AND cns.slug=crs.channel_name
AND cns.channel_type_code=1
)
RETURNING viewer_login
""";
List<String> affected = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, nowMs);
ps.setString(2, ownerBchName);
ps.setString(3, channelName);
ps.setInt(4, followRelType);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) affected.add(rs.getString("viewer_login"));
}
}
return affected;
}
/** Delete the projection row when a channel FOLLOW is explicitly removed. */
public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName,
int rootBlockNumber) throws SQLException {
String sql = """
DELETE FROM channel_read_state crs
USING channel_names_state cns
WHERE LOWER(crs.viewer_login)=LOWER(?)
AND crs.owner_bch_name=?
AND cns.owner_bch_name=crs.owner_bch_name
AND cns.slug=crs.channel_name
AND cns.channel_root_block_number=?
AND cns.channel_type_code=1
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setString(2, ownerBchName);
ps.setInt(3, rootBlockNumber);
return ps.executeUpdate() > 0;
}
}
}
@@ -0,0 +1,236 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/** Хранилище candidate-блоков будущего fork во время ротации ключей. */
public final class KeyRotationCandidateBlocksDAO {
private static volatile KeyRotationCandidateBlocksDAO instance;
private final DbController db = DbController.getInstance();
private KeyRotationCandidateBlocksDAO() { }
public static KeyRotationCandidateBlocksDAO getInstance() {
if (instance == null) {
synchronized (KeyRotationCandidateBlocksDAO.class) {
if (instance == null) instance = new KeyRotationCandidateBlocksDAO();
}
}
return instance;
}
/**
* Идемпотентно сохраняет один candidate-блок.
* Повтор того же blockNumber допустим только при полном совпадении hash и DataItem id.
*/
public KeyRotationCandidateBlockEntry insertOrGet(Connection c, KeyRotationCandidateBlockEntry entry) throws SQLException {
KeyRotationCandidateBlockEntry existing = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
if (existing != null) {
if (Arrays.equals(existing.getBlockHash(), entry.getBlockHash())
&& Arrays.equals(existing.getDataItemId(), entry.getDataItemId())) {
return existing;
}
throw new SQLException("Candidate block conflict at number=" + entry.getBlockNumber());
}
String sql = """
INSERT INTO key_rotation_candidate_blocks (
rotation_session_id, login, candidate_blockchain_name,
block_number, block_hash, data_item_id, block_bytes,
arweave_publish_pending, arweave_published_at_ms, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, TRUE, NULL, ?)
""";
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
int i = 1;
ps.setLong(i++, entry.getRotationSessionId());
ps.setString(i++, entry.getLogin());
ps.setString(i++, entry.getCandidateBlockchainName());
ps.setInt(i++, entry.getBlockNumber());
ps.setBytes(i++, entry.getBlockHash());
ps.setBytes(i++, entry.getDataItemId());
ps.setBytes(i++, entry.getBlockBytes());
ps.setLong(i++, entry.getCreatedAtMs());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) throw new SQLException("candidate block insert returned no id");
entry.setId(keys.getLong(1));
}
} catch (SQLException insertFailure) {
// Конкурирующий повтор из другой сессии мог успеть вставиться между SELECT и INSERT.
KeyRotationCandidateBlockEntry raced = getByNumber(c, entry.getRotationSessionId(), entry.getBlockNumber());
if (raced != null
&& Arrays.equals(raced.getBlockHash(), entry.getBlockHash())
&& Arrays.equals(raced.getDataItemId(), entry.getDataItemId())) {
return raced;
}
throw insertFailure;
}
entry.setArweavePublishPending(true);
return entry;
}
public KeyRotationCandidateBlockEntry getByNumber(Connection c, long sessionId, int blockNumber) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ? AND block_number = ?
""")) {
ps.setLong(1, sessionId);
ps.setInt(2, blockNumber);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? map(rs) : null;
}
}
}
public int countStored(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT COUNT(*) FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?
""")) {
ps.setLong(1, sessionId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getInt(1);
}
}
}
public int countPublished(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT COUNT(*) FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ? AND arweave_publish_pending = FALSE
""")) {
ps.setLong(1, sessionId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getInt(1);
}
}
}
/** Все candidate-блоки одной ротации в строгом порядке block_number. */
public List<KeyRotationCandidateBlockEntry> listBySession(Connection c, long sessionId) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE rotation_session_id = ?
ORDER BY block_number
""")) {
ps.setLong(1, sessionId);
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(map(rs));
}
return out;
}
}
/** Candidate-блоки имеют приоритет перед обычной очередью publisher-а. */
public List<KeyRotationCandidateBlockEntry> listPendingArweave(int limit) throws SQLException {
if (limit <= 0) return List.of();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT * FROM key_rotation_candidate_blocks
WHERE arweave_publish_pending = TRUE
ORDER BY id
LIMIT ?
""")) {
ps.setInt(1, limit);
List<KeyRotationCandidateBlockEntry> out = new ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(map(rs));
}
return out;
}
}
/**
* Помечает DataItem опубликованными и синхронизирует progress_current ротации
* с реальным количеством DataItem, подтверждённых publisher-ом.
*/
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
if (dataItemIds == null || dataItemIds.isEmpty()) return;
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
java.util.Set<Long> sessions = new java.util.HashSet<>();
try (PreparedStatement find = c.prepareStatement("""
SELECT rotation_session_id
FROM key_rotation_candidate_blocks
WHERE data_item_id = ?
""")) {
for (byte[] id : dataItemIds) {
find.setBytes(1, id);
try (ResultSet rs = find.executeQuery()) {
if (rs.next()) sessions.add(rs.getLong(1));
}
}
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_candidate_blocks
SET arweave_publish_pending = FALSE,
arweave_published_at_ms = COALESCE(arweave_published_at_ms, ?)
WHERE data_item_id = ?
""")) {
for (byte[] id : dataItemIds) {
ps.setLong(1, publishedAtMs);
ps.setBytes(2, id);
ps.addBatch();
}
ps.executeBatch();
}
try (PreparedStatement progress = c.prepareStatement("""
UPDATE key_rotation_sessions s
SET progress_current = LEAST(s.progress_total, (
SELECT COUNT(*)::INTEGER
FROM key_rotation_candidate_blocks b
WHERE b.rotation_session_id = s.id
AND b.arweave_publish_pending = FALSE
)),
updated_at_ms = ?
WHERE s.id = ? AND s.status = 'COPYING_CHAIN'
""")) {
for (Long sessionId : sessions) {
progress.setLong(1, publishedAtMs);
progress.setLong(2, sessionId);
progress.addBatch();
}
progress.executeBatch();
}
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to mark candidate DataItems published", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
private static KeyRotationCandidateBlockEntry map(ResultSet rs) throws SQLException {
KeyRotationCandidateBlockEntry e = new KeyRotationCandidateBlockEntry();
e.setId(rs.getLong("id"));
e.setRotationSessionId(rs.getLong("rotation_session_id"));
e.setLogin(rs.getString("login"));
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
e.setBlockNumber(rs.getInt("block_number"));
e.setBlockHash(rs.getBytes("block_hash"));
e.setDataItemId(rs.getBytes("data_item_id"));
e.setBlockBytes(rs.getBytes("block_bytes"));
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending"));
e.setArweavePublishedAtMs((Long) rs.getObject("arweave_published_at_ms"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
return e;
}
}
@@ -0,0 +1,612 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
/**
* DAO для серверной машины смены ключей.
*
* Все методы изменения статуса синхронно обновляют и key_rotation_sessions,
* и solana_user_pda_current.rotation_status / rotation_session_id.
*/
public final class KeyRotationSessionsDAO {
private static volatile KeyRotationSessionsDAO instance;
private final DbController db = DbController.getInstance();
private KeyRotationSessionsDAO() { }
public static KeyRotationSessionsDAO getInstance() {
if (instance == null) {
synchronized (KeyRotationSessionsDAO.class) {
if (instance == null) instance = new KeyRotationSessionsDAO();
}
}
return instance;
}
/**
* Атомарно создаёт первую серверную запись ротации сразу в COPYING_CHAIN.
* PREPARING в БД отсутствует: до этого момента всё является локальным UI-состоянием.
*/
public KeyRotationSessionEntry createCopyingSession(KeyRotationSessionEntry entry) throws SQLException {
validateNewSession(entry);
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry created = createCopyingSession(c, entry);
c.commit();
return created;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to create key rotation session", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
public KeyRotationSessionEntry createCopyingSession(Connection c, KeyRotationSessionEntry entry) throws SQLException {
validateNewSession(entry);
String currentStatus;
try (PreparedStatement ps = c.prepareStatement("""
SELECT rotation_status
FROM solana_user_pda_current
WHERE login = ?
FOR UPDATE
""")) {
ps.setString(1, entry.getLogin());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Unknown login for key rotation: " + entry.getLogin());
currentStatus = rs.getString(1);
}
}
if (!KeyRotationStatus.NONE.name().equals(currentStatus)) {
throw new SQLException("Key rotation already active for login=" + entry.getLogin() + ", status=" + currentStatus);
}
long now = entry.getCreatedAtMs() > 0 ? entry.getCreatedAtMs() : System.currentTimeMillis();
entry.setCreatedAtMs(now);
entry.setUpdatedAtMs(now);
entry.setStatus(KeyRotationStatus.COPYING_CHAIN);
if (entry.getComment() == null) entry.setComment("");
if (entry.getWalletMigrationStatus() == null) entry.setWalletMigrationStatus("PENDING");
if (entry.getMessageMigrationStatus() == null) entry.setMessageMigrationStatus("PENDING");
String sql = """
INSERT INTO key_rotation_sessions (
login, status,
source_blockchain_name, candidate_blockchain_name,
old_root_key, old_blockchain_key, old_client_key,
new_root_key, new_blockchain_key, new_client_key,
fork_from_block, fork_from_hash,
source_tip_block, source_tip_hash,
reason_code, comment,
progress_current, progress_total,
pda_rotation_signature,
wallet_migration_status, message_migration_status,
last_error, last_error_at_ms, retry_count,
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
""";
long id;
try (PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
int i = 1;
ps.setString(i++, entry.getLogin());
ps.setString(i++, entry.getStatus().name());
ps.setString(i++, entry.getSourceBlockchainName());
ps.setString(i++, entry.getCandidateBlockchainName());
ps.setString(i++, entry.getOldRootKey());
ps.setString(i++, entry.getOldBlockchainKey());
ps.setString(i++, entry.getOldClientKey());
ps.setString(i++, entry.getNewRootKey());
ps.setString(i++, entry.getNewBlockchainKey());
ps.setString(i++, entry.getNewClientKey());
ps.setInt(i++, entry.getForkFromBlock());
ps.setBytes(i++, entry.getForkFromHash());
ps.setInt(i++, entry.getSourceTipBlock());
ps.setBytes(i++, entry.getSourceTipHash());
ps.setShort(i++, entry.getReasonCode());
ps.setString(i++, entry.getComment());
ps.setInt(i++, entry.getProgressCurrent());
ps.setInt(i++, entry.getProgressTotal());
ps.setString(i++, entry.getPdaRotationSignature());
ps.setString(i++, entry.getWalletMigrationStatus());
ps.setString(i++, entry.getMessageMigrationStatus());
ps.setString(i++, entry.getLastError());
if (entry.getLastErrorAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getLastErrorAtMs());
ps.setInt(i++, entry.getRetryCount());
ps.setLong(i++, entry.getCreatedAtMs());
ps.setLong(i++, entry.getUpdatedAtMs());
if (entry.getCompletedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getCompletedAtMs());
if (entry.getAbortedAtMs() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, entry.getAbortedAtMs());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (!keys.next()) throw new SQLException("key_rotation_sessions insert returned no id");
id = keys.getLong(1);
}
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = ?, rotation_session_id = ?
WHERE login = ? AND rotation_status = 'NONE'
""")) {
ps.setString(1, KeyRotationStatus.COPYING_CHAIN.name());
ps.setLong(2, id);
ps.setString(3, entry.getLogin());
if (ps.executeUpdate() != 1) {
throw new SQLException("Failed to attach key rotation session to login=" + entry.getLogin());
}
}
entry.setId(id);
return entry;
}
public KeyRotationSessionEntry getById(long id) throws SQLException {
try (Connection c = db.getConnection()) {
return getById(c, id);
}
}
public KeyRotationSessionEntry getById(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
public KeyRotationSessionEntry getActiveByLogin(String login) throws SQLException {
try (Connection c = db.getConnection()) {
return getActiveByLogin(c, login);
}
}
public KeyRotationSessionEntry getActiveByLogin(Connection c, String login) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectBase() + """
WHERE login = ?
AND status NOT IN ('COMPLETE', 'ABORTED')
ORDER BY id DESC
LIMIT 1
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
/** Возвращает несколько сессий одного состояния для фоновых workers. */
public java.util.List<KeyRotationSessionEntry> listByStatus(KeyRotationStatus status, int limit) throws SQLException {
if (status == null || status == KeyRotationStatus.NONE) return java.util.List.of();
int safeLimit = Math.max(1, Math.min(limit, 100));
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(selectBase() + """
WHERE status = ?
ORDER BY updated_at_ms, id
LIMIT ?
""")) {
ps.setString(1, status.name());
ps.setInt(2, safeLimit);
java.util.List<KeyRotationSessionEntry> out = new java.util.ArrayList<>();
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapRow(rs));
}
return out;
}
}
/**
* Атомарный переход state machine. Смена статуса выполняется только из expected.
*/
public KeyRotationSessionEntry transition(long id,
KeyRotationStatus expected,
KeyRotationStatus next) throws SQLException {
if (expected == null || next == null || !expected.canTransitionTo(next)) {
throw new IllegalArgumentException("Forbidden key rotation transition: " + expected + " -> " + next);
}
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
if (current.getStatus() != expected) {
throw new SQLException("Key rotation status mismatch: expected=" + expected + ", actual=" + current.getStatus());
}
long now = System.currentTimeMillis();
Long completed = current.getCompletedAtMs();
if (next == KeyRotationStatus.COMPLETE) completed = now;
Long aborted = current.getAbortedAtMs();
if (next == KeyRotationStatus.ABORTED) aborted = now;
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = ?, updated_at_ms = ?, completed_at_ms = ?, aborted_at_ms = ?
WHERE id = ? AND status = ?
""")) {
ps.setString(1, next.name());
ps.setLong(2, now);
if (completed == null) ps.setNull(3, java.sql.Types.BIGINT); else ps.setLong(3, completed);
if (aborted == null) ps.setNull(4, java.sql.Types.BIGINT); else ps.setLong(4, aborted);
ps.setLong(5, id);
ps.setString(6, expected.name());
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent key rotation transition for id=" + id);
}
KeyRotationStatus userStatus = next.userStatus();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = ?, rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ?
""")) {
ps.setString(1, userStatus.name());
if (userStatus == KeyRotationStatus.NONE) ps.setNull(2, java.sql.Types.BIGINT); else ps.setLong(2, id);
ps.setString(3, current.getLogin());
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
}
// Candidate-копии нужны только пока ротация активна. В Arweave они уже опубликованы,
// а в PostgreSQL после COMPLETE/ABORTED временные строки больше не нужны.
if (next.isTerminalSessionStatus()) {
try (PreparedStatement ps = c.prepareStatement(
"DELETE FROM key_rotation_candidate_blocks WHERE rotation_session_id = ?")) {
ps.setLong(1, id);
ps.executeUpdate();
}
}
c.commit();
current.setStatus(next);
current.setUpdatedAtMs(now);
current.setCompletedAtMs(completed);
current.setAbortedAtMs(aborted);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed key rotation transition", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
public void updateProgress(long id, int current, int total) throws SQLException {
if (current < 0 || total < 0 || current > total) {
throw new IllegalArgumentException("Invalid rotation progress: " + current + "/" + total);
}
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET progress_current = ?, progress_total = ?, updated_at_ms = ?
WHERE id = ? AND status = 'COPYING_CHAIN'
""")) {
ps.setInt(1, current);
ps.setInt(2, total);
ps.setLong(3, System.currentTimeMillis());
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation progress can only be updated in COPYING_CHAIN, id=" + id);
}
}
/**
* Атомарно фиксирует tx signature и переводит CHAIN_READY -> ROTATING_PDA.
* Это точка, после которой Abort запрещён: отправленная Solana-транзакция может подтвердиться позже.
*/
public KeyRotationSessionEntry beginPdaRotation(long id, String signature) throws SQLException {
if (signature == null || signature.isBlank()) throw new IllegalArgumentException("signature is empty");
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
if (current == null) throw new SQLException("Unknown key rotation session id=" + id);
if (current.getStatus() != KeyRotationStatus.CHAIN_READY) {
throw new SQLException("PDA rotation requires CHAIN_READY, actual=" + current.getStatus());
}
long now = System.currentTimeMillis();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = 'ROTATING_PDA', pda_rotation_signature = ?, updated_at_ms = ?,
last_error = NULL, last_error_at_ms = NULL
WHERE id = ? AND status = 'CHAIN_READY'
""")) {
ps.setString(1, signature);
ps.setLong(2, now);
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation start for id=" + id);
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = 'ROTATING_PDA', rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'CHAIN_READY'
""")) {
ps.setLong(1, id);
ps.setString(2, current.getLogin());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to login=" + current.getLogin());
}
c.commit();
current.setStatus(KeyRotationStatus.ROTATING_PDA);
current.setPdaRotationSignature(signature);
current.setUpdatedAtMs(now);
current.setLastError(null);
current.setLastErrorAtMs(null);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to begin PDA rotation", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
/**
* Если Solana sync уже записал в current-PDA весь ожидаемый новый набор ключей,
* атомарно переводит ROTATING_PDA -> PDA_ROTATED. Иначе возвращает null.
*/
public KeyRotationSessionEntry tryMarkPdaRotatedFromCurrentState(long id) throws SQLException {
try (Connection c = db.getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
KeyRotationSessionEntry current = getByIdForUpdate(c, id);
if (current == null || current.getStatus() != KeyRotationStatus.ROTATING_PDA) {
c.rollback();
return current != null && current.getStatus() == KeyRotationStatus.PDA_ROTATED ? current : null;
}
String root;
String blockchain;
String client;
String blockchainName;
try (PreparedStatement ps = c.prepareStatement("""
SELECT root_key, blockchain_key, client_key, blockchain_name
FROM solana_user_pda_current
WHERE login = ?
FOR UPDATE
""")) {
ps.setString(1, current.getLogin());
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) { c.rollback(); return null; }
root = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("root_key"));
blockchain = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"));
client = shine.db.KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("client_key"));
blockchainName = rs.getString("blockchain_name");
}
}
if (!current.getNewRootKey().equals(root)
|| !current.getNewBlockchainKey().equals(blockchain)
|| !current.getNewClientKey().equals(client)
|| !current.getCandidateBlockchainName().equals(blockchainName)) {
c.rollback();
return null;
}
long now = System.currentTimeMillis();
try (PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET status = 'PDA_ROTATED', updated_at_ms = ?, last_error = NULL, last_error_at_ms = NULL
WHERE id = ? AND status = 'ROTATING_PDA'
""")) {
ps.setLong(1, now);
ps.setLong(2, id);
if (ps.executeUpdate() != 1) throw new SQLException("Concurrent PDA rotation confirm for id=" + id);
}
try (PreparedStatement ps = c.prepareStatement("""
UPDATE solana_user_pda_current
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
WHERE login = ? AND rotation_session_id = ? AND rotation_status = 'ROTATING_PDA'
""")) {
ps.setLong(1, id);
ps.setString(2, current.getLogin());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Rotation session is not attached to current PDA login=" + current.getLogin());
}
c.commit();
current.setStatus(KeyRotationStatus.PDA_ROTATED);
current.setUpdatedAtMs(now);
current.setLastError(null);
current.setLastErrorAtMs(null);
return current;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException sql) throw sql;
throw new SQLException("Failed to confirm rotated PDA", e);
} finally {
c.setAutoCommit(oldAutoCommit);
}
}
}
public void setPdaRotationSignature(long id, String signature) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET pda_rotation_signature = ?, updated_at_ms = ?
WHERE id = ? AND status = 'ROTATING_PDA'
""")) {
ps.setString(1, signature);
ps.setLong(2, System.currentTimeMillis());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("PDA signature requires ROTATING_PDA, id=" + id);
}
}
/**
* Фиксирует результат необязательного этапа wallet/DM.
* Имя колонки выбирается только из закрытого списка — внешние значения в SQL не подставляются.
*/
public void setMigrationSubStatus(long id, String column, String value) throws SQLException {
String safeColumn = switch (String.valueOf(column)) {
case "wallet_migration_status" -> "wallet_migration_status";
case "message_migration_status" -> "message_migration_status";
default -> throw new IllegalArgumentException("Unsupported migration status column: " + column);
};
String safeValue = switch (String.valueOf(value)) {
case "PENDING", "COMPLETE", "SKIPPED", "NOT_IMPLEMENTED" -> String.valueOf(value);
default -> throw new IllegalArgumentException("Unsupported migration status value: " + value);
};
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(
"UPDATE key_rotation_sessions SET " + safeColumn + " = ?, updated_at_ms = ? WHERE id = ?")) {
ps.setString(1, safeValue);
ps.setLong(2, System.currentTimeMillis());
ps.setLong(3, id);
if (ps.executeUpdate() != 1) throw new SQLException("Unknown key rotation session id=" + id);
}
}
public void recordError(long id, String error) throws SQLException {
long now = System.currentTimeMillis();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET last_error = ?, last_error_at_ms = ?, retry_count = retry_count + 1, updated_at_ms = ?
WHERE id = ? AND status NOT IN ('COMPLETE', 'ABORTED')
""")) {
ps.setString(1, error);
ps.setLong(2, now);
ps.setLong(3, now);
ps.setLong(4, id);
if (ps.executeUpdate() != 1) throw new SQLException("Cannot record error for inactive rotation id=" + id);
}
}
public void clearError(long id) throws SQLException {
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement("""
UPDATE key_rotation_sessions
SET last_error = NULL, last_error_at_ms = NULL, updated_at_ms = ?
WHERE id = ?
""")) {
ps.setLong(1, System.currentTimeMillis());
ps.setLong(2, id);
ps.executeUpdate();
}
}
private KeyRotationSessionEntry getByIdForUpdate(Connection c, long id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(selectBase() + " WHERE id = ? FOR UPDATE")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
private static String selectBase() {
return """
SELECT
id, login, status,
source_blockchain_name, candidate_blockchain_name,
old_root_key, old_blockchain_key, old_client_key,
new_root_key, new_blockchain_key, new_client_key,
fork_from_block, fork_from_hash,
source_tip_block, source_tip_hash,
reason_code, comment,
progress_current, progress_total,
pda_rotation_signature,
wallet_migration_status, message_migration_status,
last_error, last_error_at_ms, retry_count,
created_at_ms, updated_at_ms, completed_at_ms, aborted_at_ms
FROM key_rotation_sessions
""";
}
private static KeyRotationSessionEntry mapRow(ResultSet rs) throws SQLException {
KeyRotationSessionEntry e = new KeyRotationSessionEntry();
e.setId(rs.getLong("id"));
e.setLogin(rs.getString("login"));
e.setStatus(KeyRotationStatus.valueOf(rs.getString("status")));
e.setSourceBlockchainName(rs.getString("source_blockchain_name"));
e.setCandidateBlockchainName(rs.getString("candidate_blockchain_name"));
e.setOldRootKey(rs.getString("old_root_key"));
e.setOldBlockchainKey(rs.getString("old_blockchain_key"));
e.setOldClientKey(rs.getString("old_client_key"));
e.setNewRootKey(rs.getString("new_root_key"));
e.setNewBlockchainKey(rs.getString("new_blockchain_key"));
e.setNewClientKey(rs.getString("new_client_key"));
e.setForkFromBlock(rs.getInt("fork_from_block"));
e.setForkFromHash(rs.getBytes("fork_from_hash"));
e.setSourceTipBlock(rs.getInt("source_tip_block"));
e.setSourceTipHash(rs.getBytes("source_tip_hash"));
e.setReasonCode(rs.getShort("reason_code"));
e.setComment(rs.getString("comment"));
e.setProgressCurrent(rs.getInt("progress_current"));
e.setProgressTotal(rs.getInt("progress_total"));
e.setPdaRotationSignature(rs.getString("pda_rotation_signature"));
e.setWalletMigrationStatus(rs.getString("wallet_migration_status"));
e.setMessageMigrationStatus(rs.getString("message_migration_status"));
e.setLastError(rs.getString("last_error"));
long lastErrorAt = rs.getLong("last_error_at_ms");
e.setLastErrorAtMs(rs.wasNull() ? null : lastErrorAt);
e.setRetryCount(rs.getInt("retry_count"));
e.setCreatedAtMs(rs.getLong("created_at_ms"));
e.setUpdatedAtMs(rs.getLong("updated_at_ms"));
long completed = rs.getLong("completed_at_ms");
e.setCompletedAtMs(rs.wasNull() ? null : completed);
long aborted = rs.getLong("aborted_at_ms");
e.setAbortedAtMs(rs.wasNull() ? null : aborted);
return e;
}
private static void validateNewSession(KeyRotationSessionEntry e) {
if (e == null) throw new IllegalArgumentException("entry is null");
if (blank(e.getLogin())) throw new IllegalArgumentException("login is required");
if (blank(e.getSourceBlockchainName()) || blank(e.getCandidateBlockchainName())) {
throw new IllegalArgumentException("source/candidate blockchain names are required");
}
if (blank(e.getOldRootKey()) || blank(e.getOldBlockchainKey()) || blank(e.getOldClientKey())
|| blank(e.getNewRootKey()) || blank(e.getNewBlockchainKey()) || blank(e.getNewClientKey())) {
throw new IllegalArgumentException("all old/new public keys are required");
}
if (e.getForkFromHash() == null || e.getForkFromHash().length != 32) {
throw new IllegalArgumentException("forkFromHash must be 32 bytes");
}
if (e.getSourceTipHash() == null || e.getSourceTipHash().length != 32) {
throw new IllegalArgumentException("sourceTipHash must be 32 bytes");
}
if (e.getForkFromBlock() < 0 || e.getSourceTipBlock() < e.getForkFromBlock()) {
throw new IllegalArgumentException("invalid fork/source tip block numbers");
}
if (e.getReasonCode() < 1 || e.getReasonCode() > 4) {
throw new IllegalArgumentException("reasonCode must be 1..4");
}
String comment = e.getComment() == null ? "" : e.getComment();
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
throw new IllegalArgumentException("comment must be <= 1024 UTF-8 bytes");
}
if (e.getProgressCurrent() < 0 || e.getProgressTotal() < e.getProgressCurrent()) {
throw new IllegalArgumentException("invalid initial progress");
}
}
private static boolean blank(String value) {
return value == null || value.isBlank();
}
}
@@ -37,6 +37,27 @@ public final class SolanaUserPdaCurrentDAO {
}
}
public SolanaUserPdaCurrentEntry getByLogin(String login) throws SQLException {
String sql = """
SELECT
login,
blockchain_name,
blockchain_key,
paid_limit_bytes
FROM solana_user_pda_current
WHERE LOWER(login) = LOWER(?)
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT
@@ -60,11 +81,35 @@ public final class SolanaUserPdaCurrentDAO {
}
}
/** Resolve a user/fork by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException {
if (owner32 == null || owner32.length != 32) return null;
String wanted = KeyEncodingUtil.encodeBase58(owner32);
String sql = """
SELECT
u.login,
u.login || '-' || LPAD(((fork_item.value ->> 'forkIndex')::integer + 1)::text, 3, '0') AS blockchain_name,
fork_item.value ->> 'blockchainKey' AS blockchain_key,
(fork_item.value ->> 'paidLimitBytes')::bigint AS paid_limit_bytes
FROM solana_user_pda_current u
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(NULLIF(u.blockchain_forks_json, ''), '[]')::jsonb) AS fork_item(value)
WHERE fork_item.value ->> 'blockchainKey' = ?
LIMIT 1
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, wanted);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapRow(rs);
}
}
}
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
entry.setLogin(rs.getString("login"));
entry.setBlockchainName(rs.getString("blockchain_name"));
entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key")));
entry.setBlockchainKey(KeyEncodingUtil.base58KeyToBase64_32(rs.getString("blockchain_key")));
entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes"));
return entry;
}
@@ -37,6 +37,9 @@ public class BlockEntry {
private byte[] blockHash;
private byte[] blockSignature;
private byte[] dataItemId;
private boolean arweavePublishPending;
private Long arweavePublishedAtMs;
private Integer editedByBlockNumber;
@@ -85,6 +88,13 @@ public class BlockEntry {
public byte[] getBlockSignature() { return blockSignature; }
public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; }
public byte[] getDataItemId() { return dataItemId; }
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
public boolean isArweavePublishPending() { return arweavePublishPending; }
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; }
@@ -0,0 +1,45 @@
package shine.db.entities;
/**
* Candidate-блок будущего fork во время смены ключей.
*
* Эти записи намеренно не попадают в обычную таблицу blocks и поэтому
* не влияют на лайки, ответы, каналы и другие materialized state до
* финального переключения активного fork.
*/
public final class KeyRotationCandidateBlockEntry {
private long id;
private long rotationSessionId;
private String login;
private String candidateBlockchainName;
private int blockNumber;
private byte[] blockHash;
private byte[] dataItemId;
private byte[] blockBytes;
private boolean arweavePublishPending;
private Long arweavePublishedAtMs;
private long createdAtMs;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public long getRotationSessionId() { return rotationSessionId; }
public void setRotationSessionId(long rotationSessionId) { this.rotationSessionId = rotationSessionId; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getCandidateBlockchainName() { return candidateBlockchainName; }
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public byte[] getBlockHash() { return blockHash; }
public void setBlockHash(byte[] blockHash) { this.blockHash = blockHash; }
public byte[] getDataItemId() { return dataItemId; }
public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; }
public byte[] getBlockBytes() { return blockBytes; }
public void setBlockBytes(byte[] blockBytes) { this.blockBytes = blockBytes; }
public boolean isArweavePublishPending() { return arweavePublishPending; }
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
}
@@ -0,0 +1,96 @@
package shine.db.entities;
/**
* Публичное/runtime-состояние одной смены ключей.
* Приватные ключи и пароли в этой сущности не хранятся никогда.
*/
public final class KeyRotationSessionEntry {
private long id;
private String login;
private KeyRotationStatus status;
private String sourceBlockchainName;
private String candidateBlockchainName;
private String oldRootKey;
private String oldBlockchainKey;
private String oldClientKey;
private String newRootKey;
private String newBlockchainKey;
private String newClientKey;
private int forkFromBlock;
private byte[] forkFromHash;
private int sourceTipBlock;
private byte[] sourceTipHash;
private short reasonCode;
private String comment;
private int progressCurrent;
private int progressTotal;
private String pdaRotationSignature;
private String walletMigrationStatus;
private String messageMigrationStatus;
private String lastError;
private Long lastErrorAtMs;
private int retryCount;
private long createdAtMs;
private long updatedAtMs;
private Long completedAtMs;
private Long abortedAtMs;
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public KeyRotationStatus getStatus() { return status; }
public void setStatus(KeyRotationStatus status) { this.status = status; }
public String getSourceBlockchainName() { return sourceBlockchainName; }
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
public String getCandidateBlockchainName() { return candidateBlockchainName; }
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
public String getOldRootKey() { return oldRootKey; }
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
public String getOldBlockchainKey() { return oldBlockchainKey; }
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
public String getOldClientKey() { return oldClientKey; }
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
public String getNewRootKey() { return newRootKey; }
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
public String getNewBlockchainKey() { return newBlockchainKey; }
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
public String getNewClientKey() { return newClientKey; }
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
public int getForkFromBlock() { return forkFromBlock; }
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
public byte[] getForkFromHash() { return forkFromHash; }
public void setForkFromHash(byte[] forkFromHash) { this.forkFromHash = forkFromHash; }
public int getSourceTipBlock() { return sourceTipBlock; }
public void setSourceTipBlock(int sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
public byte[] getSourceTipHash() { return sourceTipHash; }
public void setSourceTipHash(byte[] sourceTipHash) { this.sourceTipHash = sourceTipHash; }
public short getReasonCode() { return reasonCode; }
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public int getProgressCurrent() { return progressCurrent; }
public void setProgressCurrent(int progressCurrent) { this.progressCurrent = progressCurrent; }
public int getProgressTotal() { return progressTotal; }
public void setProgressTotal(int progressTotal) { this.progressTotal = progressTotal; }
public String getPdaRotationSignature() { return pdaRotationSignature; }
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
public String getWalletMigrationStatus() { return walletMigrationStatus; }
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
public String getMessageMigrationStatus() { return messageMigrationStatus; }
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public Long getLastErrorAtMs() { return lastErrorAtMs; }
public void setLastErrorAtMs(Long lastErrorAtMs) { this.lastErrorAtMs = lastErrorAtMs; }
public int getRetryCount() { return retryCount; }
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }
public long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
public long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
public Long getCompletedAtMs() { return completedAtMs; }
public void setCompletedAtMs(Long completedAtMs) { this.completedAtMs = completedAtMs; }
public Long getAbortedAtMs() { return abortedAtMs; }
public void setAbortedAtMs(Long abortedAtMs) { this.abortedAtMs = abortedAtMs; }
}
@@ -0,0 +1,71 @@
package shine.db.entities;
import java.util.EnumSet;
import java.util.Set;
/**
* Состояния серверной машины смены ключей.
*
* NONE используется только в solana_user_pda_current.rotation_status.
* COMPLETE/ABORTED остаются в истории key_rotation_sessions, после чего
* пользователь снова получает rotation_status=NONE.
*/
public enum KeyRotationStatus {
NONE,
COPYING_CHAIN,
CHAIN_READY,
ROTATING_PDA,
PDA_ROTATED,
REBUILDING_SERVER,
WALLET_MIGRATION,
MESSAGE_MIGRATION,
FINALIZING,
COMPLETE,
ABORTED;
public boolean isTerminalSessionStatus() {
return this == COMPLETE || this == ABORTED;
}
public boolean isUserActiveStatus() {
return this != NONE && !isTerminalSessionStatus();
}
/**
* Допустимые переходы одной ротации.
* Abort сознательно разрешён только до начала ROTATING_PDA: после отправки
* Solana-транзакции нельзя надёжно знать, не будет ли она подтверждена позже.
*/
public boolean canTransitionTo(KeyRotationStatus next) {
if (next == null) return false;
return switch (this) {
case COPYING_CHAIN -> EnumSet.of(CHAIN_READY, ABORTED).contains(next);
case CHAIN_READY -> EnumSet.of(ROTATING_PDA, ABORTED).contains(next);
case ROTATING_PDA -> next == PDA_ROTATED;
case PDA_ROTATED -> next == REBUILDING_SERVER;
case REBUILDING_SERVER -> next == WALLET_MIGRATION;
case WALLET_MIGRATION -> next == MESSAGE_MIGRATION;
case MESSAGE_MIGRATION -> next == FINALIZING;
case FINALIZING -> next == COMPLETE;
case NONE, COMPLETE, ABORTED -> false;
};
}
public static Set<KeyRotationStatus> activeStatuses() {
return EnumSet.of(
COPYING_CHAIN,
CHAIN_READY,
ROTATING_PDA,
PDA_ROTATED,
REBUILDING_SERVER,
WALLET_MIGRATION,
MESSAGE_MIGRATION,
FINALIZING
);
}
/** Статус, который должен храниться у пользователя для данной session-state. */
public KeyRotationStatus userStatus() {
return isTerminalSessionStatus() ? NONE : this;
}
}
@@ -0,0 +1,25 @@
-- Channel unread/read projection.
-- The row is created only by a signed SetChannelReadState from the client.
-- New channel publications only increment existing rows.
CREATE TABLE IF NOT EXISTS channel_read_state (
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
channel_name TEXT NOT NULL,
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
read_updated_at_ms BIGINT NOT NULL,
client_key TEXT NOT NULL,
read_signature TEXT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
);
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
ON channel_read_state(viewer_login);
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
ON channel_read_state(owner_bch_name, channel_name);
UPDATE db_schema_version SET schema_version = 21 WHERE id = 1;
@@ -0,0 +1,56 @@
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
blockchain_name TEXT PRIMARY KEY,
last_archived_source_block_number BIGINT NOT NULL,
last_archived_source_block_hash BYTEA NOT NULL,
last_archive_big_block_number BIGINT NOT NULL,
last_archive_big_block_hash BYTEA NOT NULL,
last_chunk_offset BIGINT NOT NULL,
last_chunk_size BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS archive_publish_job (
id BIGSERIAL PRIMARY KEY,
big_block_number BIGINT NOT NULL,
status TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
local_archive_path TEXT,
archive_hash BYTEA,
arweave_tx_id BYTEA,
arweave_confirmations INTEGER,
solana_signature TEXT,
error_text TEXT,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
ON archive_publish_job(status);
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
ON archive_publish_job(big_block_number);
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
blockchain_name TEXT NOT NULL,
from_source_block_number BIGINT NOT NULL,
to_source_block_number BIGINT NOT NULL,
previous_source_block_hash BYTEA NOT NULL,
last_source_block_hash BYTEA NOT NULL,
previous_archive_big_block_number BIGINT,
previous_archive_big_block_hash BYTEA,
previous_chunk_offset BIGINT,
previous_chunk_size BIGINT,
new_chunk_offset BIGINT,
new_chunk_size BIGINT,
PRIMARY KEY (job_id, blockchain_name)
);
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_history
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_history
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
UPDATE db_schema_version SET schema_version = 22, updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id = 1;
@@ -0,0 +1,32 @@
-- v23: trusted SHINE-ARCHIVE import state + last chunk location index.
-- Solana archive head itself is already copied by the normal Solana users sync (v22).
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
ON solana_user_pda_current(is_server, archive_imported)
WHERE archive_head_tx_id <> '';
CREATE TABLE IF NOT EXISTS archive_blockchain_location (
blockchain_name TEXT PRIMARY KEY,
publisher_login TEXT NOT NULL,
arweave_tx_id TEXT NOT NULL,
archive_hash BYTEA NOT NULL,
big_block_number BIGINT NOT NULL,
chunk_offset BIGINT NOT NULL,
chunk_size BIGINT NOT NULL,
source_last_block_number BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher
ON archive_blockchain_location(publisher_login);
UPDATE db_schema_version
SET schema_version = 23,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)
WHERE id = 1;
@@ -0,0 +1,56 @@
-- Frame v1 intentionally has no backward compatibility with the old signed block format.
-- Refuse to migrate a populated blockchain database: test/dev data must be reset first.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM blocks LIMIT 1)
OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN
RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading';
END IF;
END $$;
-- Старый SHINE-ARCHIVE v1 больше не используется.
DROP TABLE IF EXISTS archive_publish_job_chain CASCADE;
DROP TABLE IF EXISTS archive_publish_job CASCADE;
DROP TABLE IF EXISTS archive_chain_cursor CASCADE;
DROP TABLE IF EXISTS archive_blockchain_location CASCADE;
-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required.
ALTER TABLE blocks
ADD COLUMN IF NOT EXISTS data_item_id BYTEA;
ALTER TABLE blocks
ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE blocks
ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT;
ALTER TABLE blocks
ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
ON blocks(data_item_id)
WHERE data_item_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
ON blocks(block_number)
WHERE arweave_publish_pending = TRUE;
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
id SMALLINT PRIMARY KEY CHECK (id = 1),
last_block_height BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL DEFAULT 0
);
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
VALUES(1,0,0) ON CONFLICT(id) DO NOTHING;
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
data_item_id BYTEA PRIMARY KEY,
root_tx_id TEXT NOT NULL,
block_height BIGINT NOT NULL,
raw_data_item BYTEA NOT NULL,
status TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
first_seen_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
ON arweave_block_import_queue(block_height,first_seen_at_ms)
WHERE status='PENDING';
UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1;
@@ -0,0 +1,8 @@
-- PDA 1.2: keep the complete append-only blockchain-key/fork history alongside the active compatibility columns.
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS blockchain_forks_json TEXT NOT NULL DEFAULT '[]';
UPDATE db_schema_version
SET schema_version = 25,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE id = 1;
@@ -0,0 +1,123 @@
-- v26: серверная state machine смены ключей пользователя.
--
-- Важно:
-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера,
-- а не полями Solana PDA;
-- - Solana sync не должен перезаписывать эти колонки при upsert PDA;
-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации.
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
rotation_status IN (
'NONE',
'COPYING_CHAIN',
'CHAIN_READY',
'ROTATING_PDA',
'PDA_ROTATED',
'REBUILDING_SERVER',
'WALLET_MIGRATION',
'MESSAGE_MIGRATION',
'FINALIZING'
)
);
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
ON solana_user_pda_current(rotation_status)
WHERE rotation_status <> 'NONE';
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
id BIGSERIAL PRIMARY KEY,
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (
status IN (
'COPYING_CHAIN',
'CHAIN_READY',
'ROTATING_PDA',
'PDA_ROTATED',
'REBUILDING_SERVER',
'WALLET_MIGRATION',
'MESSAGE_MIGRATION',
'FINALIZING',
'COMPLETE',
'ABORTED'
)
),
source_blockchain_name TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
old_root_key TEXT NOT NULL,
old_blockchain_key TEXT NOT NULL,
old_client_key TEXT NOT NULL,
new_root_key TEXT NOT NULL,
new_blockchain_key TEXT NOT NULL,
new_client_key TEXT NOT NULL,
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
pda_rotation_signature TEXT,
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
),
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
),
last_error TEXT,
last_error_at_ms BIGINT,
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
completed_at_ms BIGINT,
aborted_at_ms BIGINT,
CHECK (progress_current <= progress_total),
CHECK (fork_from_block <= source_tip_block)
);
-- У одного login одновременно может существовать только одна незавершённая ротация.
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
ON key_rotation_sessions(login)
WHERE status NOT IN ('COMPLETE', 'ABORTED');
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
ON key_rotation_sessions(login, created_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
ON key_rotation_sessions(status);
-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию.
-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок.
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
FOREIGN KEY (rotation_session_id)
REFERENCES key_rotation_sessions(id)
ON DELETE SET NULL;
UPDATE db_schema_version
SET schema_version = 26,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE id = 1;
@@ -0,0 +1,31 @@
BEGIN;
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
id BIGSERIAL PRIMARY KEY,
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
login TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
block_number INTEGER NOT NULL CHECK (block_number >= 0),
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
block_bytes BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
arweave_published_at_ms BIGINT,
created_at_ms BIGINT NOT NULL,
UNIQUE (rotation_session_id, block_number),
UNIQUE (rotation_session_id, data_item_id)
);
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
ON key_rotation_candidate_blocks(id)
WHERE arweave_publish_pending = TRUE;
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
UPDATE db_schema_version
SET schema_version = 27,
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
WHERE id = 1;
COMMIT;
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
);
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
ON CONFLICT (id) DO UPDATE SET
schema_version = EXCLUDED.schema_version,
updated_at_ms = EXCLUDED.updated_at_ms;
@@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
blockchain_name TEXT NOT NULL,
blockchain_key TEXT NOT NULL,
paid_limit_bytes BIGINT NOT NULL,
blockchain_forks_json TEXT NOT NULL DEFAULT '[]',
used_bytes BIGINT NOT NULL,
last_block_number INTEGER NOT NULL,
last_block_hash TEXT NOT NULL,
@@ -291,12 +292,6 @@ AFTER TRUNCATE ON solana_user_pda_current
FOR EACH STATEMENT
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
id BIGSERIAL PRIMARY KEY,
tx_signature TEXT NOT NULL,
@@ -425,6 +420,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
ON user_settings(setting_type, setting_key);
CREATE TABLE IF NOT EXISTS channel_read_state (
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
owner_login TEXT NOT NULL,
owner_bch_name TEXT NOT NULL,
channel_name TEXT NOT NULL,
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
read_updated_at_ms BIGINT NOT NULL,
client_key TEXT NOT NULL,
read_signature TEXT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
);
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
ON channel_read_state(viewer_login);
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
ON channel_read_state(owner_bch_name, channel_name);
CREATE TABLE IF NOT EXISTS ip_geo_cache (
ip TEXT PRIMARY KEY,
geo TEXT,
@@ -483,6 +498,10 @@ CREATE TABLE IF NOT EXISTS blocks (
to_block_hash BYTEA,
block_hash BYTEA NOT NULL,
block_signature BYTEA NOT NULL,
data_item_id BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
arweave_published_at_ms BIGINT,
arweave_root_tx_id TEXT,
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
@@ -494,12 +513,41 @@ CREATE TABLE IF NOT EXISTS blocks (
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
ON blocks (bch_name, block_number);
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
ON blocks(data_item_id);
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
ON blocks(block_number)
WHERE arweave_publish_pending = TRUE;
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
ON blocks (to_login, to_bch_name, to_block_number);
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
ON blocks (bch_name, line_code, this_line_number);
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
id SMALLINT PRIMARY KEY CHECK (id = 1),
last_block_height BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL DEFAULT 0
);
INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms)
VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
data_item_id BYTEA PRIMARY KEY,
root_tx_id TEXT NOT NULL,
block_height BIGINT NOT NULL,
raw_data_item BYTEA NOT NULL,
status TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
first_seen_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
ON arweave_block_import_queue(block_height,first_seen_at_ms)
WHERE status='PENDING';
CREATE TABLE IF NOT EXISTS connections_state (
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
rel_type INTEGER NOT NULL,
@@ -1640,6 +1688,12 @@ AFTER INSERT ON blocks
FOR EACH ROW
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
CREATE TRIGGER trg_user_stats_state_ai
AFTER INSERT ON solana_user_pda_current
FOR EACH ROW
EXECUTE FUNCTION shine_user_stats_state_ai();
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
CREATE TRIGGER trg_channel_names_state_stats_ai
AFTER INSERT ON channel_names_state
@@ -2008,9 +2062,130 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
PRIMARY KEY (owner_login, category)
);
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_history
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_history
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
ON solana_user_pda_current(is_server, archive_imported)
WHERE archive_head_tx_id <> '';
-- Server-local key rotation state machine (schema v26).
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
ALTER TABLE solana_user_pda_current
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
rotation_status IN (
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
)
);
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
ON solana_user_pda_current(rotation_status)
WHERE rotation_status <> 'NONE';
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
id BIGSERIAL PRIMARY KEY,
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (
status IN (
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
'COMPLETE','ABORTED'
)
),
source_blockchain_name TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
old_root_key TEXT NOT NULL,
old_blockchain_key TEXT NOT NULL,
old_client_key TEXT NOT NULL,
new_root_key TEXT NOT NULL,
new_blockchain_key TEXT NOT NULL,
new_client_key TEXT NOT NULL,
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
pda_rotation_signature TEXT,
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
),
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
),
last_error TEXT,
last_error_at_ms BIGINT,
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
completed_at_ms BIGINT,
aborted_at_ms BIGINT,
CHECK (progress_current <= progress_total),
CHECK (fork_from_block <= source_tip_block)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
ON key_rotation_sessions(login)
WHERE status NOT IN ('COMPLETE','ABORTED');
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
ON key_rotation_sessions(login, created_at_ms DESC);
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
ON key_rotation_sessions(status);
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
id BIGSERIAL PRIMARY KEY,
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
login TEXT NOT NULL,
candidate_blockchain_name TEXT NOT NULL,
block_number INTEGER NOT NULL CHECK (block_number >= 0),
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
block_bytes BYTEA NOT NULL,
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
arweave_published_at_ms BIGINT,
created_at_ms BIGINT NOT NULL,
UNIQUE (rotation_session_id, block_number),
UNIQUE (rotation_session_id, data_item_id)
);
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
ON key_rotation_candidate_blocks(id)
WHERE arweave_publish_pending = TRUE;
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
ALTER TABLE solana_user_pda_current
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
ALTER TABLE solana_user_pda_current
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
FOREIGN KEY (rotation_session_id)
REFERENCES key_rotation_sessions(id)
ON DELETE SET NULL;
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
COMMIT;
@@ -42,15 +42,28 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
// --- NEW: SearchUsers ---
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
@@ -75,18 +88,24 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
@@ -159,8 +178,6 @@ public final class JsonHandlerRegistry {
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
Map.entry("GetUser", new Net_GetUser_Handler()),
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
// --- auth ---
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
// --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_Handler()),
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
// --- key rotation ---
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
// --- userParams ---
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
@@ -208,9 +235,12 @@ public final class JsonHandlerRegistry {
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()),
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
@@ -250,8 +280,6 @@ public final class JsonHandlerRegistry {
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
Map.entry("GetUser", Net_GetUser_Request.class),
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
// --- auth ---
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
@@ -282,6 +310,16 @@ public final class JsonHandlerRegistry {
// --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_Request.class),
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
// --- key rotation ---
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
// --- userParams ---
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
@@ -299,9 +337,12 @@ public final class JsonHandlerRegistry {
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class),
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class),
Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
@@ -54,8 +54,7 @@ public final class SolanaUserPdaImportService {
}
/**
* Чтение server PDA по логину сервера. Используется сервером при старте,
* чтобы получить актуальный server_address и список sync_servers.
* Чтение server PDA 1.2 по логину сервера. В 1.2 разрешён ровно один endpoint.
*/
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
String login = normalizeLogin(loginRaw);
@@ -155,9 +154,16 @@ public final class SolanaUserPdaImportService {
}
private static ParsedSolanaUser parseUserPda(byte[] raw) {
if (raw == null || raw.length < 128) return null;
if (raw == null || raw.length < 9) return null;
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
int major = u8(raw, 5);
int minor = u8(raw, 6);
if (major != 1 || minor != 2) return null;
return parseUserPdaV12(raw);
}
private static ParsedSolanaUser parseUserPdaV12(byte[] raw) {
if (raw == null || raw.length < 128) return null;
int recordLen = u16le(raw, 7);
if (recordLen < 73 || recordLen > raw.length) return null;
@@ -166,224 +172,166 @@ public final class SolanaUserPdaImportService {
c += 8; // updated_at_ms
c += 4; // record_number
c += 32; // prev_record_hash
if (c >= recordLen) return null;
int loginLen = u8(raw, c++);
if (loginLen <= 0 || c + loginLen > recordLen) return null;
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
c += loginLen;
if (c >= recordLen) return null;
int blocksCount = u8(raw, c++);
String blockchainName = null;
byte[] blockchainKey32 = null;
byte[] clientKey32 = null;
long paidLimitBytes = 0L;
List<ParsedSessionRecord> sessions = new ArrayList<>();
int forkCount = 0;
List<String> accessServers = new ArrayList<>();
for (int i = 0; i < blocksCount; i++) {
if (c + 2 > recordLen) return null;
int blockType = u8(raw, c++);
int blockVer = u8(raw, c++);
if (blockVer != 0) return null;
if (blockType == 0) {
c += 32; // recovery_key
} else if (blockType == 1) {
c += 32;
} else if (blockType == 2) {
if (blockType == 1) {
if (c + 32 > recordLen) return null;
c += 32; // root_key
continue;
}
if (blockType == 2) {
if (c + 32 > recordLen) return null;
clientKey32 = slice(raw, c, 32);
c += 32;
} else if (blockType == 3) {
int count = u8(raw, c++);
for (int j = 0; j < count; j++) {
c += 1; // blockchain_type
int bchLen = u8(raw, c++);
blockchainName = new String(raw, c, bchLen, StandardCharsets.UTF_8);
c += bchLen;
blockchainKey32 = slice(raw, c, 32);
c += 32;
paidLimitBytes = u64le(raw, c);
c += 8;
c += 8; // used_bytes
c += 4; // last_block_number
c += 32; // last_block_hash
c += 64; // last_block_signature
int arweavePresent = u8(raw, c++);
if (arweavePresent == 1) {
int arLen = u8(raw, c++);
c += arLen;
} else if (arweavePresent != 0) {
return null;
}
}
} else if (blockType == 30) {
int isServer = u8(raw, c++);
if (isServer == 1) {
c += 1; // address_format_type
c += 1; // address_format_version
int addrLen = u8(raw, c++);
c += addrLen;
int syncCount = u8(raw, c++);
for (int j = 0; j < syncCount; j++) {
int n = u8(raw, c++);
c += n;
}
} else if (isServer != 0) {
return null;
}
} else if (blockType == 40) {
int accessCount = u8(raw, c++);
for (int j = 0; j < accessCount; j++) {
int n = u8(raw, c++);
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
continue;
}
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
accessServers.add(normalizedAccessServerLogin);
}
}
} else if (blockType == 50) {
int sessionsMode = u8(raw, c++);
if (sessionsMode != 1 && sessionsMode != 10) return null;
int sessionsCount = u8(raw, c++);
if (sessionsCount > 64) return null;
for (int j = 0; j < sessionsCount; j++) {
int sessionType = u8(raw, c++);
int sessionVersion = u8(raw, c++);
int n = u8(raw, c++);
String sessionName = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
byte[] sessionPubKey32 = slice(raw, c, 32);
c += 32;
sessions.add(new ParsedSessionRecord(
sessionType,
sessionVersion,
sessionName,
sessionPubKey32
));
}
} else if (blockType == 70) {
c += 1;
} else {
return null;
continue;
}
if (c > recordLen) return null;
if (c + 2 > recordLen) return null;
int payloadLen = u16le(raw, c);
c += 2;
int payloadEnd = c + payloadLen;
if (payloadEnd < c || payloadEnd > recordLen) return null;
if (blockType == 3) {
if (c + 2 > payloadEnd) return null;
forkCount = u16le(raw, c);
c += 2;
if (forkCount <= 0) return null;
for (int j = 0; j < forkCount; j++) {
if (c + 44 > payloadEnd) return null;
blockchainKey32 = slice(raw, c, 32);
c += 32;
c += 8; // fork created_at_ms
paidLimitBytes = u32le(raw, c);
c += 4;
}
if (c != payloadEnd) return null;
} else if (blockType == 30) {
// Server profile is not needed for auth parsing; validate/skip its payload.
c = payloadEnd;
} else if (blockType == 40) {
if (c >= payloadEnd) return null;
int accessCount = u8(raw, c++);
if (accessCount > 1) return null;
for (int j = 0; j < accessCount; j++) {
if (c >= payloadEnd) return null;
int n = u8(raw, c++);
if (c + n > payloadEnd) return null;
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
String normalized = normalizeLogin(accessServerLogin);
if (normalized != null && !accessServers.contains(normalized)
&& accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
accessServers.add(normalized);
}
}
if (c != payloadEnd) return null;
} else {
// Unknown variable PDA 1.2 block: forward-compatible skip by payload_len.
c = payloadEnd;
}
c = payloadEnd;
}
if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null;
if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0) return null;
String blockchainName = login + "-" + String.format(Locale.ROOT, "%03d", forkCount);
return new ParsedSolanaUser(
login,
blockchainName,
Base64.getEncoder().encodeToString(blockchainKey32),
Base64.getEncoder().encodeToString(clientKey32),
paidLimitBytes,
sessions,
List.of(),
accessServers
);
}
private static ParsedServerProfile parseServerProfile(byte[] raw) {
if (raw == null || raw.length < 128) return null;
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
private static ParsedServerProfile parseServerProfile(byte[] raw) {
if (raw == null || raw.length < 9) return null;
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
int major = u8(raw, 5);
int minor = u8(raw, 6);
if (major != 1 || minor != 2) return null;
return parseServerProfileV12(raw);
}
private static ParsedServerProfile parseServerProfileV12(byte[] raw) {
if (raw == null || raw.length < 128) return null;
int recordLen = u16le(raw, 7);
if (recordLen < 73 || recordLen > raw.length) return null;
int c = 9;
c += 8; // created_at_ms
c += 8; // updated_at_ms
c += 4; // record_number
c += 32; // prev_record_hash
int c = 9 + 8 + 8 + 4 + 32;
if (c >= recordLen) return null;
int loginLen = u8(raw, c++);
if (loginLen <= 0 || c + loginLen > recordLen) return null;
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
c += loginLen;
if (c >= recordLen) return null;
int blocksCount = u8(raw, c++);
boolean isServer = false;
String serverAddress = "";
List<String> syncServers = new ArrayList<>();
for (int i = 0; i < blocksCount; i++) {
if (c + 2 > recordLen) return null;
int blockType = u8(raw, c++);
int blockVer = u8(raw, c++);
if (blockVer != 0) return null;
if (blockType == 0 || blockType == 1 || blockType == 2) {
if (blockType == 1 || blockType == 2) {
if (c + 32 > recordLen) return null;
c += 32;
} else if (blockType == 3) {
int count = u8(raw, c++);
for (int j = 0; j < count; j++) {
c += 1; // blockchain_type
int bchLen = u8(raw, c++);
c += bchLen;
c += 32; // blockchain pubkey
c += 8; // paid_limit_bytes
c += 8; // used_bytes
c += 4; // last_block_number
c += 32; // last_block_hash
c += 64; // last_block_signature
int arweavePresent = u8(raw, c++);
if (arweavePresent == 1) {
int arLen = u8(raw, c++);
c += arLen;
} else if (arweavePresent != 0) {
return null;
}
}
} else if (blockType == 30) {
int isServerValue = u8(raw, c++);
if (isServerValue == 1) {
isServer = true;
continue;
}
if (c + 2 > recordLen) return null;
int payloadLen = u16le(raw, c);
c += 2;
int payloadEnd = c + payloadLen;
if (payloadEnd < c || payloadEnd > recordLen) return null;
if (blockType == 30) {
if (c >= payloadEnd) return null;
int addressCount = u8(raw, c++);
if (addressCount != 1) return null;
for (int j = 0; j < addressCount; j++) {
if (c + 3 > payloadEnd) return null;
c += 1; // address_format_type
c += 1; // address_format_version
int addrLen = u8(raw, c++);
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
c += addrLen;
int syncCount = u8(raw, c++);
for (int j = 0; j < syncCount; j++) {
int n = u8(raw, c++);
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
syncServers.add(normalizeLogin(syncLogin));
int n = u8(raw, c++);
if (c + n > payloadEnd) return null;
String address = new String(raw, c, n, StandardCharsets.UTF_8);
c += n;
if (!isServer) {
isServer = true;
serverAddress = address;
}
} else if (isServerValue != 0) {
return null;
}
} else if (blockType == 40) {
int accessCount = u8(raw, c++);
for (int j = 0; j < accessCount; j++) {
int n = u8(raw, c++);
c += n;
}
} else if (blockType == 50) {
int sessionsMode = u8(raw, c++);
if (sessionsMode != 1 && sessionsMode != 10) return null;
int sessionsCount = u8(raw, c++);
if (sessionsCount > 64) return null;
for (int j = 0; j < sessionsCount; j++) {
c += 1; // session_type
c += 1; // session_version
int n = u8(raw, c++);
c += n;
c += 32;
}
} else if (blockType == 70) {
c += 1;
} else {
return null;
if (c != payloadEnd) return null;
}
if (c > recordLen) return null;
c = payloadEnd;
}
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
return new ParsedServerProfile(login, isServer, serverAddress, List.of());
}
private static String normalizeLogin(String login) {
if (login == null) return null;
String s = login.trim();
@@ -395,6 +343,12 @@ public final class SolanaUserPdaImportService {
private static int u16le(byte[] b, int o) {
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
}
private static long u32le(byte[] b, int o) {
return ((long) b[o] & 0xFFL)
| (((long) b[o + 1] & 0xFFL) << 8)
| (((long) b[o + 2] & 0xFFL) << 16)
| (((long) b[o + 3] & 0xFFL) << 24);
}
private static long u64le(byte[] b, int o) {
long out = 0L;
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
@@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.WireCodes;
import server.sync.AddBlockSyncService;
import shine.db.channels.ChannelNameRules;
@@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry;
import utils.blockchain.BlockchainNameUtil;
import java.util.Arrays;
import java.util.List;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
@@ -96,11 +98,25 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
lock.lock();
try {
try {
if (isKeyRotationActive(blockchainName)) {
BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName);
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
return error(req, 423, "key_rotation_in_progress", lastNum, lastHash);
}
} catch (Exception e) {
log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e);
return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, "");
}
AddBlockResult r = addBlock(
blockchainName,
req.getBlockNumber(), // старое поле, пока оставляем
req.getPrevBlockHash(), // старое поле, пока оставляем
req.getBlockBytesB64()
req.getBlockBytesB64(),
true,
true
);
// ✅ УСПЕХ: как раньше
@@ -148,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return resp;
}
private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException {
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
if (login == null || login.isBlank()) return false;
try (Connection c = shine.db.DbController.getInstance().getConnection();
PreparedStatement ps = c.prepareStatement("""
SELECT rotation_status
FROM solana_user_pda_current
WHERE normalized_login = LOWER(BTRIM(?))
LIMIT 1
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() && !"NONE".equals(rs.getString(1));
}
}
}
private static String humanMessage(String code) {
if (code == null) return "Ошибка добавления блока";
return switch (code) {
@@ -160,6 +193,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "limit_exceeded" -> "Превышен лимит размера блокчейна";
case "limit_check_failed" -> "Ошибка проверки лимита размера";
case "bad_block_format" -> "Некорректный формат блока";
case "bad_app_tag" -> "Некорректный тестовый тег ANS-104";
case "bad_channel_tag" -> "Некорректный тег канала ANS-104";
case "bad_block_body" -> "Некорректное тело блока";
case "bad_channel_name" -> "Некорректное название канала";
case "bad_block_number" -> "Некорректный номер блока";
@@ -178,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
default -> "Ошибка: " + code;
};
}
@@ -186,7 +223,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
String blockchainName,
int globalNumberFromReq,
String prevGlobalHashHexFromReq,
String blockBytesB64
String blockBytesB64,
boolean replicateAfterWrite,
boolean publishToArweave
) {
if (blockchainName == null || blockchainName.isBlank()) {
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
@@ -268,6 +307,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
}
if (!block.getDataItem().hasTag("App", "test5590")) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex);
}
// body.check()
try {
block.body.check();
@@ -397,6 +440,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
}
// Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=<slug>.
try {
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
String actualChannelSlug = block.getDataItem().tagValue("c_test5590");
if (expectedChannelSlug != null) {
if (!expectedChannelSlug.equals(actualChannelSlug)) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
}
} else if (actualChannelSlug != null) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
}
} catch (Exception e) {
log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
}
if ((block.type & 0xFFFF) == 1
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
try {
@@ -454,7 +513,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
}
// 6) подпись по hash32(preimage)
// 6) ANS-104 Ed25519 подпись и owner
boolean sigOk;
try {
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
@@ -512,7 +571,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
}
}
// 8) сформировать запись и записать (DB + state + файл)
// 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state
try {
BlockEntry be = new BlockEntry();
be.setLogin(login);
@@ -525,6 +584,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
be.setBlockBytes(block.toBytes());
be.setBlockHash(block.getHash32());
be.setBlockSignature(block.getSignature64());
be.setDataItemId(block.getDataItemId32());
be.setArweavePublishPending(publishToArweave);
// line columns (optional)
be.setLineCode(lineCode);
@@ -586,9 +647,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
}
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
dbWriter.appendBlockAndState(
List<String> counterChangedLogins = dbWriter.appendBlockAndState(
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
for (String changedLogin : counterChangedLogins) {
UserCountersSupport.pushChanged(changedLogin);
}
if (chat200CreateSeed != null) {
upsertChat200StateFromCreate(chat200CreateSeed);
}
@@ -607,15 +672,80 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
login, blockchainName, block.blockNumber, newHashHex);
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
if (replicateAfterWrite) {
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
}
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
}
/**
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
* но не запускает повторную межсерверную репликацию уже импортированного блока.
*/
public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) {
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
return new ArweaveImportResult(false, "chain_resync_in_progress", -1, "");
}
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
return new ArweaveImportResult(false, "empty_block", -1, "");
}
try {
BchBlockEntry probe = new BchBlockEntry(rawBlockBytes);
if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) {
return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32()));
}
} catch (Exception ignored) {
// Parsed again below to return the canonical bad_block_format result.
}
final BchBlockEntry parsed;
try {
parsed = new BchBlockEntry(rawBlockBytes);
} catch (Exception e) {
return new ArweaveImportResult(false, "bad_block_format", -1, "");
}
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
lock.lock();
try {
AddBlockResult r = addBlock(
blockchainName,
parsed.blockNumber,
toHex(parsed.prevHash32),
Base64Ws.encode(rawBlockBytes),
false,
false
);
return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
} finally {
lock.unlock();
}
}
public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
/* ===================================================================== */
/* ====================== Helpers ====================================== */
/* ===================================================================== */
private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception {
if (block.body instanceof CreateChannelBody) {
return justCreated == null ? null : justCreated.getSlug();
}
int type = block.type & 0xffff;
int sub = block.subType & 0xffff;
boolean channelBlock = type == 1 && (
sub == (MsgSubType.TEXT_POST & 0xffff)
|| sub == (MsgSubType.TEXT_REPOST & 0xffff)
|| sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff)
|| sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff)
|| sub == (MsgSubType.TEXT_EXERCISE & 0xffff)
|| sub == (MsgSubType.TEXT_SERVICE & 0xffff)
|| sub == (MsgSubType.TEXT_COURSE & 0xffff)
|| sub == (MsgSubType.TEXT_EDIT_POST & 0xffff));
if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null;
return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode());
}
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
@@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.ChannelNameStateDAO;
import shine.db.dao.ChannelReadStateDAO;
import shine.db.MsgSubType;
import shine.db.dao.UserParamsDAO;
import shine.db.dao.UserNotificationsStateDAO;
import shine.db.entities.BlockchainStateEntry;
@@ -11,40 +13,24 @@ import shine.db.entities.BlockEntry;
import shine.db.entities.ChannelNameStateEntry;
import shine.db.entities.UserParamEntry;
import shine.db.entities.UserNotificationEntry;
import utils.files.FileStoreUtil;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HexFormat;
import java.util.ArrayList;
import java.util.List;
/**
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
*
* Текущая схема:
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
* 3) создаём пустой marker <blockchainName>.write_pending;
* 4) выполняем одну SQL-транзакцию;
* 5) после commit атомарно заменяем основной .bch из tmp;
* 6) убираем временные файлы.
*
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
* - если marker отсутствует, а tmp/check остались, это мусор;
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL.
* Local .bch/tmp/marker files are intentionally not used.
*/
public final class BlockchainWriter {
private static final HexFormat HEX = HexFormat.of();
private final BlocksDAO blocksDAO;
private final BlockchainStateDAO stateDAO;
private final ChannelNameStateDAO channelNameStateDAO;
private final UserParamsDAO userParamsDAO;
private final UserNotificationsStateDAO userNotificationsStateDAO;
private final FileStoreUtil fs = FileStoreUtil.getInstance();
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
public BlockchainWriter(BlocksDAO blocksDAO,
BlockchainStateDAO stateDAO,
@@ -58,7 +44,11 @@ public final class BlockchainWriter {
this.userNotificationsStateDAO = userNotificationsStateDAO;
}
public void appendBlockAndState(String blockchainName,
/**
* Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction.
* There is intentionally no .bch/tmp/marker file layer anymore.
*/
public List<String> appendBlockAndState(String blockchainName,
BchBlockEntry block,
BlockchainStateEntry st,
BlockEntry be,
@@ -68,121 +58,64 @@ public final class BlockchainWriter {
UserNotificationEntry notificationEntry) throws SQLException {
long nowMs = System.currentTimeMillis();
byte[] blockBytes = block.toBytes();
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
String blockHashHex = HEX.formatHex(block.getHash32());
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
boolean committed = false;
byte[] rawDataItem = block.toBytes();
List<String> counterChangedLogins = new ArrayList<>();
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
c.setAutoCommit(false);
try {
// 1) insert block
blocksDAO.insert(c, be);
// 2) update state
st.setLastBlockNumber(block.blockNumber);
st.setLastBlockHash(block.getHash32());
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length);
st.setUpdatedAtMs(nowMs);
stateDAO.upsert(c, st);
// 2.1) Если блок — USER_PARAM, синхронизируем снимок в users_params в той же транзакции.
if (userParamEntry != null) {
userParamsDAO.upsertIfNewer(c, userParamEntry);
if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry);
if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry);
if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry);
int msgType = block.type & 0xFFFF;
int msgSubType = block.subType & 0xFFFF;
Integer lineCode = be.getLineCode();
if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) {
String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode);
if (channelName != null && !channelName.isBlank()) {
counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers(
c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs));
}
}
if (channelNameStateEntry != null) {
channelNameStateDAO.insert(c, channelNameStateEntry);
}
if (channelMetaUpdateEntry != null) {
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
}
// Notification projection is part of the same SQL transaction as the block.
// If notification indexing fails, the block/state write is rolled back as well.
if (notificationEntry != null) {
userNotificationsStateDAO.upsert(c, notificationEntry);
if (msgType == 3
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
&& be.getToBchName() != null && !be.getToBchName().isBlank()
&& be.getToBlockNumber() != null && be.getToBlockNumber() > 0) {
if (channelReadStateDAO.deleteForUnfollowTarget(
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
counterChangedLogins.add(be.getLogin());
}
}
c.commit();
committed = true;
} catch (Exception e) {
try {
c.rollback();
} catch (Exception ignored) {
}
if (!committed) {
cleanupWriteArtifactsBestEffort(blockchainName);
}
if (e instanceof SQLException se) {
throw se;
}
try { c.rollback(); } catch (Exception ignored) {}
if (e instanceof SQLException se) throw se;
throw new SQLException("appendBlockAndState failed", e);
} finally {
try {
c.setAutoCommit(true);
} catch (Exception ignored) {
}
try { c.setAutoCommit(true); } catch (Exception ignored) {}
}
}
// 3) После commit — атомарно подменяем основной файл.
try {
fs.atomicReplaceBlockchainFile(blockchainName);
} catch (RuntimeException e) {
// marker/check/tmp оставляем для startup-recovery
throw e;
}
// 4) После успешной подмены — чистим временные артефакты.
cleanupWriteArtifactsBestEffort(blockchainName);
return counterChangedLogins;
}
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
byte[] base;
try {
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
base = fs.readBlockchain(blockchainName);
} else {
base = new byte[0];
}
} catch (RuntimeException e) {
throw e;
}
byte[] out = new byte[base.length + blockBytes.length];
System.arraycopy(base, 0, out, 0, base.length);
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
return out;
}
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
fs.writeBlockchainTmp(blockchainName, candidateBytes);
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
fs.writeBlockchainWritePendingMarker(blockchainName);
}
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
}
private static void deleteQuietly(DeleteAction action) {
try {
action.run();
} catch (RuntimeException ignored) {
}
}
@FunctionalInterface
private interface DeleteAction {
void run();
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
if (msgType != 1) return false;
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
}
@@ -0,0 +1,98 @@
package server.logic.ws_protocol.JSON.handlers.blockchain;
import blockchain.BchBlockEntry;
import blockchain.body.BodyHasTarget;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.SolanaUserPdaCurrentDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.SolanaUserPdaCurrentEntry;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/** Authenticated, paginated current-chain view used by "Мой блокчейн" and key rotation. */
public final class Net_GetMyBlockchain_Handler implements JsonMessageHandler {
private final BlocksDAO blocks = BlocksDAO.getInstance();
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_GetMyBlockchain_Request req = (Net_GetMyBlockchain_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
String login = ctx.getLogin().trim();
SolanaUserPdaCurrentEntry user = users.getByLogin(login);
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "Текущий блокчейн пользователя не найден");
}
String bch = user.getBlockchainName();
BlockchainStateEntry state = states.getByBlockchainName(bch);
int tip = state == null ? -1 : state.getLastBlockNumber();
String tipHash = state == null ? null : hex(state.getLastBlockHash());
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(100, req.getLimit()));
int before = req.getBeforeBlock() == null || req.getBeforeBlock() < 0 ? tip : Math.min(tip, req.getBeforeBlock());
boolean includeBytes = Boolean.TRUE.equals(req.getIncludeBlockBytes());
List<BlockEntry> rows = before < 0
? List.of()
: blocks.listRangeByNumber(bch, Math.max(0, before - limit + 1), before);
Collections.reverse(rows);
List<Net_GetMyBlockchain_Response.BlockItem> items = new ArrayList<>(rows.size());
for (BlockEntry row : rows) {
BchBlockEntry parsed = new BchBlockEntry(row.getBlockBytes());
Net_GetMyBlockchain_Response.BlockItem item = new Net_GetMyBlockchain_Response.BlockItem();
item.setBlockNumber(row.getBlockNumber());
item.setBlockHash(hex(row.getBlockHash()));
item.setPrevBlockHash(hex(parsed.prevHash32));
item.setTimestampMs(parsed.timestamp * 1000L);
item.setMsgType(Short.toUnsignedInt(parsed.type));
item.setMsgSubType(Short.toUnsignedInt(parsed.subType));
item.setMsgVersion(Short.toUnsignedInt(parsed.version));
if (parsed.body instanceof BodyHasTarget target) {
item.setToLogin(target.toLogin());
item.setToBlockNumber(target.toBlockGlobalNumber());
item.setToBlockHash(hex(target.toBlockHashBytes()));
}
if (includeBytes) item.setBlockBytesB64(Base64Ws.encode(row.getBlockBytes()));
items.add(item);
}
Net_GetMyBlockchain_Response resp = new Net_GetMyBlockchain_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(login);
resp.setBlockchainName(bch);
resp.setTipBlockNumber(tip);
resp.setTipBlockHash(tipHash);
resp.setBlocks(items);
int lowest = rows.isEmpty() ? -1 : rows.get(rows.size() - 1).getBlockNumber();
resp.setNextBeforeBlock(lowest > 0 ? lowest - 1 : null);
return resp;
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "GET_MY_BLOCKCHAIN_FAILED", "Не удалось прочитать блокчейн пользователя");
}
}
private static String hex(byte[] bytes) {
if (bytes == null) return null;
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
return sb.toString();
}
}
@@ -0,0 +1,20 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Paginated read of the authenticated user's current materialized blockchain. */
public final class Net_GetMyBlockchain_Request extends Net_Request {
/** Inclusive highest block number to return. Null/<0 means current tip. */
private Integer beforeBlock;
/** 1..100, default 50. */
private Integer limit;
/** Include complete serialized ANS-104 DataItem for rotation/UI inspection. */
private Boolean includeBlockBytes;
public Integer getBeforeBlock() { return beforeBlock; }
public void setBeforeBlock(Integer beforeBlock) { this.beforeBlock = beforeBlock; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public Boolean getIncludeBlockBytes() { return includeBlockBytes; }
public void setIncludeBlockBytes(Boolean includeBlockBytes) { this.includeBlockBytes = includeBlockBytes; }
}
@@ -0,0 +1,64 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public final class Net_GetMyBlockchain_Response extends Net_Response {
private String login;
private String blockchainName;
private int tipBlockNumber;
private String tipBlockHash;
private Integer nextBeforeBlock;
private List<BlockItem> blocks = new ArrayList<>();
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public int getTipBlockNumber() { return tipBlockNumber; }
public void setTipBlockNumber(int tipBlockNumber) { this.tipBlockNumber = tipBlockNumber; }
public String getTipBlockHash() { return tipBlockHash; }
public void setTipBlockHash(String tipBlockHash) { this.tipBlockHash = tipBlockHash; }
public Integer getNextBeforeBlock() { return nextBeforeBlock; }
public void setNextBeforeBlock(Integer nextBeforeBlock) { this.nextBeforeBlock = nextBeforeBlock; }
public List<BlockItem> getBlocks() { return blocks; }
public void setBlocks(List<BlockItem> blocks) { this.blocks = blocks; }
public static final class BlockItem {
private int blockNumber;
private String blockHash;
private String prevBlockHash;
private long timestampMs;
private int msgType;
private int msgSubType;
private int msgVersion;
private String toLogin;
private Integer toBlockNumber;
private String toBlockHash;
private String blockBytesB64;
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
public String getPrevBlockHash() { return prevBlockHash; }
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
public long getTimestampMs() { return timestampMs; }
public void setTimestampMs(long timestampMs) { this.timestampMs = timestampMs; }
public int getMsgType() { return msgType; }
public void setMsgType(int msgType) { this.msgType = msgType; }
public int getMsgSubType() { return msgSubType; }
public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; }
public int getMsgVersion() { return msgVersion; }
public void setMsgVersion(int msgVersion) { this.msgVersion = msgVersion; }
public String getToLogin() { return toLogin; }
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
public Integer getToBlockNumber() { return toBlockNumber; }
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
public String getToBlockHash() { return toBlockHash; }
public void setToBlockHash(String toBlockHash) { this.toBlockHash = toBlockHash; }
public String getBlockBytesB64() { return blockBytesB64; }
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
}
}
@@ -144,38 +144,12 @@ final class ChannelsReadSupport {
}
}
static String userSettingsChannelKey(String ownerBch, String channelName) {
String bch = ownerBch == null ? "" : ownerBch.trim();
String name = channelName == null ? "" : channelName.trim();
return bch + "/" + name;
}
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
String key = userSettingsChannelKey(ownerBch, channelName);
String sql = """
SELECT value_num
FROM user_settings
WHERE LOWER(login) = LOWER(?)
AND setting_type = ?
AND setting_key = ?
LIMIT 1
""";
long lastSeen = messagesCount;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, viewerLogin);
ps.setInt(2, 1);
ps.setString(3, key);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
long value = rs.getLong("value_num");
if (!rs.wasNull()) lastSeen = value;
}
}
}
if (lastSeen < 0) lastSeen = 0;
if (lastSeen > messagesCount) return 0;
return Math.max(0, messagesCount - (int) lastSeen);
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
|| channelName == null || channelName.isBlank()) return 0;
long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
.getUnreadCount(c, viewerLogin, ownerBch, channelName);
return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
}
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
@@ -262,12 +236,13 @@ final class ChannelsReadSupport {
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
String order = asc ? "ASC" : "DESC";
boolean bounded = limit > 0;
String sql = """
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
FROM blocks
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
ORDER BY block_number
""" + order + " LIMIT ?";
""" + order + (bounded ? " LIMIT ?" : "");
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, ownerBch);
ps.setInt(2, MSG_TYPE_TEXT);
@@ -278,7 +253,7 @@ final class ChannelsReadSupport {
ps.setInt(7, MsgSubType.TEXT_SERVICE);
ps.setInt(8, MsgSubType.TEXT_COURSE);
ps.setInt(9, lineCode);
ps.setInt(10, limit);
if (bounded) ps.setInt(10, limit);
try (ResultSet rs = ps.executeQuery()) {
List<PostBlock> out = new ArrayList<>();
while (rs.next()) {
@@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
}
int limit = req.getLimit() == null ? 30 : req.getLimit();
if (limit <= 0 || limit > 1000) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
// limit is optional. null/0 means "return the whole channel".
// Positive values are kept for backwards-compatible callers that still want a bounded response.
int limit = req.getLimit() == null ? 0 : req.getLimit();
if (limit < 0) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit");
}
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
@@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
if (!asc) {
java.util.Collections.reverse(posts);
}
if (posts.size() > limit) {
if (limit > 0 && posts.size() > limit) {
posts = new ArrayList<>(posts.subList(0, limit));
}
}
@@ -0,0 +1,101 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.List;
public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest;
Net_GetMessageLikes_Request.MessageSelector message = req.getMessage();
if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank()
|| message.getBlockNumber() == null || message.getBlockNumber() < 0
|| message.getBlockHash() == null || message.getBlockHash().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
}
final byte[] blockHash;
try {
blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
}
if (blockHash == null || blockHash.length == 0) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
}
try (Connection c = DbController.getInstance().getConnection()) {
String sql = """
SELECT rs.from_login,
COALESCE(ups.first_name, '') AS first_name,
COALESCE(ups.last_name, '') AS last_name,
COALESCE(ups.ava_ar, '') AS avatar_ar,
COALESCE(ups.account_role, 'unknown') AS account_role,
COALESCE(ups.shine_status, 'unknown') AS shine_status
FROM reactions_state rs
LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login)
WHERE rs.reaction_type = 1
AND rs.last_sub_type = 1
AND rs.to_bch_name = ?
AND rs.to_block_number = ?
AND rs.to_block_hash = ?
ORDER BY LOWER(rs.from_login)
""";
List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>();
List<Net_GetMessageLikes_Response.UserItem> official = new ArrayList<>();
List<Net_GetMessageLikes_Response.UserItem> others = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, message.getBlockchainName().trim());
ps.setInt(2, message.getBlockNumber());
ps.setBytes(3, blockHash);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
user.setLogin(rs.getString("from_login"));
user.setFirstName(rs.getString("first_name"));
user.setLastName(rs.getString("last_name"));
user.setAvatarAr(rs.getString("avatar_ar"));
boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role"));
boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status"));
if (isOfficial && isShining) shining.add(user);
else if (isOfficial) official.add(user);
else others.add(user);
}
Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setShining(shining);
resp.setOfficial(official);
resp.setOthers(others);
resp.setTotal(shining.size() + official.size() + others.size());
resp.setTruncated(false);
return resp;
}
}
} catch (Exception e) {
log.error("GetMessageLikes failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
}
}
}
@@ -105,7 +105,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
String order = asc ? "ASC" : "DESC";
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
to_login, to_bch_name, to_block_number, to_block_hash
FROM blocks
WHERE login = ? AND msg_type = ?
ORDER BY block_number
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setLikedByMe(false);
item.setRepliesCount(0);
item.setRatingsCount(0);
item.setTargetBlockchainName(statusBody.toBchName());
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
item.setTargetBlockchainName(rs.getString("to_bch_name"));
item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
c,
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
item.setVersionsTotal(versions.size());
item.setText(versions.get(versions.size() - 1).getText());
fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes());
fillTargetDetails(c, item, rs.getString("to_bch_name"),
(Integer) rs.getObject("to_block_number"), rs.getBytes("to_block_hash"));
out.add(item);
}
}
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
return;
}
try (PreparedStatement ps = c.prepareStatement("""
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type
SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type,
to_login, to_bch_name, to_block_number, to_block_hash
FROM blocks
WHERE bch_name = ? AND block_number = ?
LIMIT 1
@@ -0,0 +1,46 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import java.sql.Connection;
public class Net_GetUserCounters_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String login = ctx.getLogin().trim();
try (Connection c = DbController.getInstance().getConnection()) {
UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login);
Net_GetUserCounters_Response r = new Net_GetUserCounters_Response();
r.setOp(req.getOp());
r.setRequestId(req.getRequestId());
r.setStatus(WireCodes.Status.OK);
r.setLogin(login);
r.setDmUnreadCount(s.dmUnreadCount());
r.setChannelsUnreadCount(s.channelsUnreadCount());
r.setNotificationsUnreadCount(s.notificationsUnreadCount());
r.setNotificationRepliesUnreadCount(s.repliesUnreadCount());
r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount());
r.setNotificationEventsUnreadCount(s.eventsUnreadCount());
return r;
} catch (Exception e) {
log.error("GetUserCounters failed for {}", login, e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики");
}
}
}
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.MsgSubType;
import shine.db.DbController;
import shine.db.dao.ChannelReadStateDAO;
import java.sql.Connection;
import java.sql.PreparedStatement;
@@ -81,8 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
channelRef.setChannelRoot(rootRef);
row.setChannel(channelRef);
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
row.setMessagesCount(messagesCount);
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
? null
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
row.setReadStateInitialized(readState != null);
row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount())));
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
if (lastPost != null) {
@@ -0,0 +1,145 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import shine.db.MsgSubType;
import shine.db.dao.ChannelReadStateDAO;
import utils.config.ShineSignatureConstants;
import utils.crypto.Ed25519Util;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
/** Accepts the signed client read watermark for one followed public channel. */
public final class Net_SetChannelReadState_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_SetChannelReadState_Handler.class);
private static final long MAX_FUTURE_SKEW_MS = 5 * 60_000L;
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_SetChannelReadState_Request req = (Net_SetChannelReadState_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
"NOT_AUTHENTICATED", "Требуется авторизация");
}
if (req.getLogin() == null || req.getLogin().isBlank()
|| req.getOwner_bch_name() == null || req.getOwner_bch_name().isBlank()
|| req.getChannel_name() == null || req.getChannel_name().isBlank()
|| req.getRead_count() == null || req.getRead_count() < 0
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|| req.getClient_key() == null || req.getClient_key().isBlank()
|| req.getSignature() == null || req.getSignature().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
"BAD_FIELDS", "Некорректные поля ChannelReadState");
}
String authenticatedLogin = String.valueOf(ctx.getCurrentUser().getLogin()).trim();
String login = req.getLogin().trim();
String ownerBch = req.getOwner_bch_name().trim();
String channelName = req.getChannel_name().trim();
long readCount = req.getRead_count();
long timeMs = req.getTime_ms();
String clientKeyB64 = req.getClient_key().trim();
String signatureB64 = req.getSignature().trim();
if (!authenticatedLogin.equalsIgnoreCase(login)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
"LOGIN_MISMATCH", "Состояние чтения принадлежит другому пользователю");
}
long nowMs = System.currentTimeMillis();
if (timeMs > nowMs + MAX_FUTURE_SKEW_MS) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
"BAD_TIME", "Некорректное время подписи");
}
try {
byte[] pubKey32;
byte[] sig64;
try {
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
"BAD_BASE64", "client_key/signature должны быть Base64");
}
String userClientKey = String.valueOf(ctx.getCurrentUser().getClientKey() == null
? "" : ctx.getCurrentUser().getClientKey()).trim();
if (userClientKey.isBlank() || !userClientKey.equals(clientKeyB64)) {
return NetExceptionResponseFactory.error(req, 403,
"DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
}
String signText = ShineSignatureConstants.CHANNEL_READ_STATE_PREFIX
+ escapePart(login) + '|'
+ escapePart(ownerBch) + '|'
+ escapePart(channelName) + '|'
+ timeMs + '|'
+ readCount;
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
"INVALID_SIGNATURE", "Подпись состояния чтения канала не прошла проверку");
}
ChannelReadStateDAO dao = ChannelReadStateDAO.getInstance();
ChannelReadStateDAO.UpsertResult result;
try (Connection c = DbController.getInstance().getConnection()) {
if (!dao.hasActiveSubscription(c, authenticatedLogin, ownerBch, channelName,
MsgSubType.CONNECTION_FOLLOW)) {
return NetExceptionResponseFactory.error(req, 409,
"CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал");
}
String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName);
Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName);
if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) {
return NetExceptionResponseFactory.error(req, 404,
"CHANNEL_NOT_FOUND", "Канал не найден");
}
// Never persist a watermark beyond the number of messages that really exists.
// Without this guard, one buggy/malicious oversized read_count would be monotonic
// and could make all future unread counters impossible to clear correctly.
long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot);
long effectiveReadCount = Math.min(readCount, actualMessagesCount);
result = dao.upsertSignedReadIfNewer(c,
authenticatedLogin, ownerLogin, ownerBch, channelName,
effectiveReadCount, timeMs, clientKeyB64, signatureB64, nowMs);
}
Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK);
resp.setLogin(authenticatedLogin);
resp.setOwner_bch_name(ownerBch);
resp.setChannel_name(channelName);
resp.setRead_count(result.readCount());
resp.setUnread_count(result.unreadCount());
resp.setTime_ms(timeMs);
resp.setApplied(result.applied());
if (result.applied()) UserCountersSupport.pushChanged(authenticatedLogin);
return resp;
} catch (Exception e) {
log.error("SetChannelReadState failed", e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR,
"INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage(
"Внутренняя ошибка SetChannelReadState", e));
}
}
private static String escapePart(String value) {
String s = String.valueOf(value == null ? "" : value);
return s.replace("\\", "\\\\").replace("|", "\\|");
}
}
@@ -0,0 +1,94 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import shine.db.DbController;
import shine.db.dao.UserNotificationSeenStateDAO;
import shine.db.dao.ChannelReadStateDAO;
import shine.db.dao.UserNotificationsStateDAO;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
/**
* Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract.
* It intentionally uses current DB state today; later it can read one materialized row/cache
* without changing API or UI.
*/
public final class UserCountersSupport {
private static final ObjectMapper MAPPER = new ObjectMapper();
private UserCountersSupport() {}
public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount,
long connectionsUnreadCount, long eventsUnreadCount) {
public long notificationsUnreadCount() {
return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount;
}
}
public static Snapshot calculate(Connection c, String login) throws Exception {
long dm = countDmUnread(c, login);
long channels = countChannelsUnread(c, login);
UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance();
UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance();
long repliesSeen = seen.getSeenAt(c, login, "replies");
long connectionsSeen = seen.getSeenAt(c, login, "connections");
long eventsSeen = seen.getSeenAt(c, login, "events");
long replies = notifications.countUnseen(c, login, "reply", repliesSeen);
long connections = notifications.countUnseen(c, login, "connection", connectionsSeen);
long events = notifications.countUnseen(c, login, "event", eventsSeen);
return new Snapshot(dm, channels, replies, connections, events);
}
public static Snapshot calculate(String login) throws Exception {
try (Connection c = DbController.getInstance().getConnection()) {
return calculate(c, login);
}
}
public static void pushChanged(String login) {
if (login == null || login.isBlank()) return;
try {
Snapshot s = calculate(login);
ObjectNode payload = toPayload(login, s);
String eventId = "user-counters-" + System.currentTimeMillis();
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) {
WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload);
}
} catch (Exception ignored) {
// Counter push is best-effort. The client can always recover with GetUserCounters.
}
}
public static ObjectNode toPayload(String login, Snapshot s) {
ObjectNode p = MAPPER.createObjectNode();
p.put("login", login);
p.put("dmUnreadCount", s.dmUnreadCount());
p.put("channelsUnreadCount", s.channelsUnreadCount());
p.put("notificationsUnreadCount", s.notificationsUnreadCount());
ObjectNode n = p.putObject("notifications");
n.put("replies", s.repliesUnreadCount());
n.put("connections", s.connectionsUnreadCount());
n.put("events", s.eventsUnreadCount());
return p;
}
private static long countDmUnread(Connection c, String login) throws Exception {
String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
}
}
}
private static long countChannelsUnread(Connection c, String login) throws Exception {
return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login);
}
}
@@ -0,0 +1,29 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetMessageLikes_Request extends Net_Request {
private MessageSelector message;
private Integer limit;
public MessageSelector getMessage() { return message; }
public void setMessage(MessageSelector message) { this.message = message; }
public Integer getLimit() { return limit; }
public void setLimit(Integer limit) { this.limit = limit; }
public static class MessageSelector {
private String blockchainName;
private Integer blockNumber;
private String blockHash;
public String getBlockchainName() { return blockchainName; }
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
public Integer getBlockNumber() { return blockNumber; }
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
public String getBlockHash() { return blockHash; }
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
}
}
@@ -0,0 +1,40 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import java.util.ArrayList;
import java.util.List;
public class Net_GetMessageLikes_Response extends Net_Response {
private List<UserItem> shining = new ArrayList<>();
private List<UserItem> official = new ArrayList<>();
private List<UserItem> others = new ArrayList<>();
private int total;
private boolean truncated;
public List<UserItem> getShining() { return shining; }
public void setShining(List<UserItem> shining) { this.shining = shining; }
public List<UserItem> getOfficial() { return official; }
public void setOfficial(List<UserItem> official) { this.official = official; }
public List<UserItem> getOthers() { return others; }
public void setOthers(List<UserItem> others) { this.others = others; }
public int getTotal() { return total; }
public void setTotal(int total) { this.total = total; }
public boolean isTruncated() { return truncated; }
public void setTruncated(boolean truncated) { this.truncated = truncated; }
public static class UserItem {
private String login;
private String firstName;
private String lastName;
private String avatarAr;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getFirstName() { return firstName; }
public void setFirstName(String firstName) { this.firstName = firstName; }
public String getLastName() { return lastName; }
public void setLastName(String lastName) { this.lastName = lastName; }
public String getAvatarAr() { return avatarAr; }
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
}
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Lightweight authenticated request for bottom-toolbar counters. */
public class Net_GetUserCounters_Request extends Net_Request {
}
@@ -0,0 +1,29 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */
public class Net_GetUserCounters_Response extends Net_Response {
private String login;
private long dmUnreadCount;
private long channelsUnreadCount;
private long notificationsUnreadCount;
private long notificationRepliesUnreadCount;
private long notificationConnectionsUnreadCount;
private long notificationEventsUnreadCount;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public long getDmUnreadCount() { return dmUnreadCount; }
public void setDmUnreadCount(long value) { this.dmUnreadCount = value; }
public long getChannelsUnreadCount() { return channelsUnreadCount; }
public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; }
public long getNotificationsUnreadCount() { return notificationsUnreadCount; }
public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; }
public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; }
public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; }
public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; }
public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; }
public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; }
public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; }
}
@@ -26,7 +26,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public static class ChannelSummary {
private ChannelRef channel;
private int messagesCount;
private long readCount;
private int unreadCount;
private boolean readStateInitialized;
private LastMessage lastMessage;
public ChannelRef getChannel() { return channel; }
@@ -35,9 +37,15 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
public int getMessagesCount() { return messagesCount; }
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
public long getReadCount() { return readCount; }
public void setReadCount(long readCount) { this.readCount = readCount; }
public int getUnreadCount() { return unreadCount; }
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
public boolean isReadStateInitialized() { return readStateInitialized; }
public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; }
public LastMessage getLastMessage() { return lastMessage; }
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
}
@@ -0,0 +1,34 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_SetChannelReadState_Request extends Net_Request {
private String login;
private String owner_bch_name;
private String channel_name;
private Long read_count;
private Long time_ms;
private String client_key;
private String signature;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getOwner_bch_name() { return owner_bch_name; }
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
public String getChannel_name() { return channel_name; }
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
public Long getRead_count() { return read_count; }
public void setRead_count(Long read_count) { this.read_count = read_count; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public String getClient_key() { return client_key; }
public void setClient_key(String client_key) { this.client_key = client_key; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}
@@ -0,0 +1,34 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_SetChannelReadState_Response extends Net_Response {
private String login;
private String owner_bch_name;
private String channel_name;
private Long read_count;
private Long unread_count;
private Long time_ms;
private Boolean applied;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public String getOwner_bch_name() { return owner_bch_name; }
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
public String getChannel_name() { return channel_name; }
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
public Long getRead_count() { return read_count; }
public void setRead_count(Long read_count) { this.read_count = read_count; }
public Long getUnread_count() { return unread_count; }
public void setUnread_count(Long unread_count) { this.unread_count = unread_count; }
public Long getTime_ms() { return time_ms; }
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
public Boolean getApplied() { return applied; }
public void setApplied(Boolean applied) { this.applied = applied; }
}
@@ -0,0 +1,120 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationState_Response;
import shine.db.KeyEncodingUtil;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.util.Arrays;
import java.util.Base64;
final class KeyRotationApiSupport {
private KeyRotationApiSupport() {}
static String normalizePublicKey32(String raw) {
String normalized = KeyEncodingUtil.normalizeKeyToBase64_32(raw);
if (normalized == null || normalized.isBlank()) {
throw new IllegalArgumentException("public key is empty");
}
try {
byte[] decoded = Base64.getDecoder().decode(normalized);
if (decoded.length != 32) throw new IllegalArgumentException("public key must be 32 bytes");
return Base64.getEncoder().encodeToString(decoded);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("public key must be Base58/Base64 of 32 bytes", e);
}
}
static byte[] parseHash32(String hex) {
if (hex == null) throw new IllegalArgumentException("hash is null");
String s = hex.trim();
if (s.length() != 64) throw new IllegalArgumentException("hash must contain 64 hex chars");
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
int hi = Character.digit(s.charAt(i * 2), 16);
int lo = Character.digit(s.charAt(i * 2 + 1), 16);
if (hi < 0 || lo < 0) throw new IllegalArgumentException("hash contains non-hex chars");
out[i] = (byte) ((hi << 4) | lo);
}
return out;
}
static String toHex(byte[] bytes) {
if (bytes == null) return null;
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
return sb.toString();
}
static String nextBlockchainName(String login, String source) {
if (login == null || login.isBlank() || source == null || source.length() < 5) {
throw new IllegalArgumentException("bad source blockchain name");
}
String prefix = login + "-";
if (!source.startsWith(prefix) || source.length() != prefix.length() + 3) {
throw new IllegalArgumentException("source blockchain name does not match login");
}
String suffix = source.substring(prefix.length());
if (!suffix.chars().allMatch(Character::isDigit)) {
throw new IllegalArgumentException("bad blockchain suffix");
}
int n = Integer.parseInt(suffix);
if (n >= 999) throw new IllegalArgumentException("blockchain fork limit reached");
return login + "-" + String.format("%03d", n + 1);
}
static void requireKeysChangedAndDistinct(String oldRoot, String oldBlockchain, String oldClient,
String newRoot, String newBlockchain, String newClient) {
java.util.Set<String> oldKeys = java.util.Set.of(oldRoot, oldBlockchain, oldClient);
java.util.Set<String> newKeys = java.util.Set.of(newRoot, newBlockchain, newClient);
if (newKeys.size() != 3) {
throw new IllegalArgumentException("new root/blockchain/client keys must be distinct");
}
for (String newKey : newKeys) {
if (oldKeys.contains(newKey)) {
throw new IllegalArgumentException("new keys must not reuse any key from the old key set");
}
}
}
static boolean hashesEqual(byte[] a, byte[] b) {
return Arrays.equals(a, b);
}
static Net_KeyRotationState_Response response(String op, String requestId, KeyRotationSessionEntry e) {
Net_KeyRotationState_Response r = new Net_KeyRotationState_Response();
r.setOp(op);
r.setRequestId(requestId);
r.setStatus(200);
if (e == null) {
r.setRotationStatus(KeyRotationStatus.NONE.name());
return r;
}
r.setRotationSessionId(e.getId());
r.setRotationStatus(e.getStatus().name());
r.setSourceBlockchainName(e.getSourceBlockchainName());
r.setCandidateBlockchainName(e.getCandidateBlockchainName());
r.setOldRootKey(e.getOldRootKey());
r.setOldBlockchainKey(e.getOldBlockchainKey());
r.setOldClientKey(e.getOldClientKey());
r.setNewRootKey(e.getNewRootKey());
r.setNewBlockchainKey(e.getNewBlockchainKey());
r.setNewClientKey(e.getNewClientKey());
r.setForkFromBlock(e.getForkFromBlock());
r.setForkFromHash(toHex(e.getForkFromHash()));
r.setSourceTipBlock(e.getSourceTipBlock());
r.setSourceTipHash(toHex(e.getSourceTipHash()));
r.setReasonCode(e.getReasonCode());
r.setComment(e.getComment());
r.setProgressCurrent(e.getProgressCurrent());
r.setProgressTotal(e.getProgressTotal());
r.setPdaRotationSignature(e.getPdaRotationSignature());
r.setWalletMigrationStatus(e.getWalletMigrationStatus());
r.setMessageMigrationStatus(e.getMessageMigrationStatus());
r.setLastError(e.getLastError());
r.setRetryCount(e.getRetryCount());
r.setCreatedAtMs(e.getCreatedAtMs());
r.setUpdatedAtMs(e.getUpdatedAtMs());
return r;
}
}
@@ -0,0 +1,36 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
/** Прерывание разрешено только пока Solana-транзакция ротации ещё не могла быть отправлена. */
public final class Net_KeyRotationAbort_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationAbort_Request req = (Net_KeyRotationAbort_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
if (current.getStatus() != KeyRotationStatus.COPYING_CHAIN && current.getStatus() != KeyRotationStatus.CHAIN_READY) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ABORT_TOO_LATE",
"После начала ротации PDA процесс можно только завершить");
}
KeyRotationSessionEntry aborted = rotations.transition(current.getId(), current.getStatus(), KeyRotationStatus.ABORTED);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), aborted);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ABORT_FAILED", "Не удалось прервать смену ключей");
}
}
}
@@ -0,0 +1,224 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType;
import blockchain.body.ForkBody;
import server.logic.ws_protocol.Base64Ws;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.DbController;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.sql.Connection;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.locks.ReentrantLock;
/**
* Принимает DataItem будущего fork во время COPYING_CHAIN.
* Candidate-блок сохраняется отдельно от blocks и не влияет на materialized state.
*/
public final class Net_KeyRotationAddBlock_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
private final BlocksDAO blocks = BlocksDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationAddBlock_Request req = (Net_KeyRotationAddBlock_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
if (req.getBlockNumber() < 0 || req.getBlockBytesB64() == null || req.getBlockBytesB64().isBlank()) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "blockNumber и blockBytesB64 обязательны");
}
String login = ctx.getLogin().trim();
KeyRotationSessionEntry initial;
try {
initial = rotations.getActiveByLogin(login);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
}
if (initial == null || initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
}
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
lock.lock();
try (Connection c = DbController.getInstance().getConnection()) {
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
if (session == null || session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
}
int stored = candidates.countStored(c, session.getId());
if (req.getBlockNumber() < stored) {
KeyRotationCandidateBlockEntry existing = candidates.getByNumber(c, session.getId(), req.getBlockNumber());
if (existing == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CANDIDATE_GAP", "Нарушена последовательность candidate-блоков");
}
BchBlockEntry repeated;
try { repeated = new BchBlockEntry(Base64Ws.decode(req.getBlockBytesB64())); }
catch (Exception e) { return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Не удалось распарсить DataItem"); }
if (!Arrays.equals(existing.getBlockHash(), repeated.getHash32())
|| !Arrays.equals(existing.getDataItemId(), repeated.getDataItemId32())) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "На этом номере уже сохранён другой candidate-блок");
}
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
}
if (req.getBlockNumber() != stored) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_OUT_OF_ORDER", "Candidate-блоки нужно добавлять последовательно с block 0");
}
if (stored >= session.getProgressTotal()) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_ALREADY_COMPLETE", "Все candidate-блоки уже приняты сервером");
}
final byte[] raw;
final BchBlockEntry block;
try {
raw = Base64Ws.decode(req.getBlockBytesB64());
block = new BchBlockEntry(raw);
block.body.check();
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCK", "Некорректный SHiNE/ANS-104 блок");
}
if (block.blockNumber != req.getBlockNumber()) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BLOCK_NUMBER_MISMATCH", "blockNumber запроса не совпадает с блоком");
}
if (!block.getDataItem().hasTag("App", "test5590")) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_APP_TAG", "Отсутствует App=test5590");
}
if (!requestPrevHashMatches(req.getPrevBlockHash(), block.prevHash32)) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_PREV_HASH_MISMATCH", "prevBlockHash запроса не совпадает с блоком");
}
byte[] newBlockchainKey;
try {
newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_BAD_STORED_KEY", "Некорректный new blockchain public key в rotation session");
}
if (newBlockchainKey.length != 32 || !BchCryptoVerifier.verifyBlock(block, newBlockchainKey)) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SIGNATURE", "Candidate DataItem должен быть подписан новым blockchain key");
}
String validationError;
try {
validationError = validateCandidate(c, session, block);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_VALIDATE_FAILED", "Не удалось проверить candidate-блок");
}
if (validationError != null) {
return NetExceptionResponseFactory.error(req, 400, validationError, "Candidate-блок не соответствует выбранному fork");
}
KeyRotationCandidateBlockEntry entry = new KeyRotationCandidateBlockEntry();
entry.setRotationSessionId(session.getId());
entry.setLogin(login);
entry.setCandidateBlockchainName(session.getCandidateBlockchainName());
entry.setBlockNumber(block.blockNumber);
entry.setBlockHash(block.getHash32());
entry.setDataItemId(block.getDataItemId32());
entry.setBlockBytes(raw);
entry.setCreatedAtMs(System.currentTimeMillis());
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
candidates.insertOrGet(c, entry);
c.commit();
} catch (Exception e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_BLOCK_CONFLICT", "Не удалось сохранить candidate-блок");
} finally {
c.setAutoCommit(oldAutoCommit);
}
// progressCurrent обновляет Arweave publisher только после реальной публикации.
KeyRotationSessionEntry refreshed = rotations.getActiveByLogin(login);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), refreshed == null ? session : refreshed);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ADD_BLOCK_FAILED", "Не удалось принять candidate-блок");
} finally {
lock.unlock();
}
}
private String validateCandidate(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
if (candidate.blockNumber <= session.getForkFromBlock()) {
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
// Перепубликуется тот же Frame и те же ANS-104 tags/target/anchor; меняются только owner/signature/DataItem id.
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
return null;
}
int techForkNumber = session.getForkFromBlock() + 1;
if (candidate.blockNumber != techForkNumber) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
if ((candidate.type & 0xFFFF) != 0
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|| !(candidate.body instanceof ForkBody fork)) {
return "KEY_ROTATION_TECH_FORK_REQUIRED";
}
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
return null;
}
private static boolean requestPrevHashMatches(String raw, byte[] actual) {
if (raw == null || raw.isBlank()) {
return isZero32(actual);
}
try {
return Arrays.equals(KeyRotationApiSupport.parseHash32(raw), actual);
} catch (Exception e) {
return false;
}
}
private static boolean isZero32(byte[] value) {
if (value == null || value.length != 32) return false;
for (byte b : value) if (b != 0) return false;
return true;
}
private static String normalizeComment(String value) {
if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
}
@@ -0,0 +1,53 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
/**
* Сейчас wallet/DM migration — явные заглушки. Continue фиксирует NOT_IMPLEMENTED
* и переводит state machine дальше, не делая вид, что данные были реально мигрированы.
*/
public final class Net_KeyRotationContinue_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationContinue_Request req = (Net_KeyRotationContinue_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
if (current == null) return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), null);
if (current.getStatus() == KeyRotationStatus.WALLET_MIGRATION) {
rotations.setMigrationSubStatus(current.getId(), "wallet_migration_status", "NOT_IMPLEMENTED");
current = rotations.transition(current.getId(), KeyRotationStatus.WALLET_MIGRATION, KeyRotationStatus.MESSAGE_MIGRATION);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
if (current.getStatus() == KeyRotationStatus.MESSAGE_MIGRATION) {
rotations.setMigrationSubStatus(current.getId(), "message_migration_status", "NOT_IMPLEMENTED");
current = rotations.transition(current.getId(), KeyRotationStatus.MESSAGE_MIGRATION, KeyRotationStatus.FINALIZING);
// FINALIZING пока не содержит отдельной пользовательской работы: сразу завершаем.
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
if (current.getStatus() == KeyRotationStatus.FINALIZING) {
current = rotations.transition(current.getId(), KeyRotationStatus.FINALIZING, KeyRotationStatus.COMPLETE);
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CONTINUE_NOT_ALLOWED",
"На текущем этапе продолжение этой операцией не требуется");
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_CONTINUE_FAILED",
"Не удалось продолжить смену ключей");
}
}
}
@@ -0,0 +1,218 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import blockchain.BchBlockEntry;
import blockchain.BchCryptoVerifier;
import blockchain.MsgSubType;
import blockchain.body.ForkBody;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.DbController;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationCandidateBlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.KeyRotationCandidateBlockEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;
/**
* Проверяет, что candidate-chain полностью сохранена и опубликована в Arweave/Turbo,
* затем переводит ротацию COPYING_CHAIN -> CHAIN_READY.
*/
public final class Net_KeyRotationFinishChain_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_KeyRotationFinishChain_Handler.class);
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
private final BlocksDAO blocks = BlocksDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationFinishChain_Request req = (Net_KeyRotationFinishChain_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
String login = ctx.getLogin().trim();
KeyRotationSessionEntry initial;
try {
initial = rotations.getActiveByLogin(login);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_DB_ERROR", "Не удалось прочитать состояние смены ключей");
}
if (initial == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
}
if (initial.getStatus() == KeyRotationStatus.CHAIN_READY) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), initial);
}
if (initial.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация не находится на этапе COPYING_CHAIN");
}
ReentrantLock lock = BlockchainLocks.lockFor(initial.getCandidateBlockchainName());
lock.lock();
try (Connection c = DbController.getInstance().getConnection()) {
KeyRotationSessionEntry session = rotations.getActiveByLogin(c, login);
if (session == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
}
if (session.getStatus() == KeyRotationStatus.CHAIN_READY) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), session);
}
if (session.getStatus() != KeyRotationStatus.COPYING_CHAIN) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_COPYING", "Ротация больше не находится на этапе COPYING_CHAIN");
}
int expected = session.getProgressTotal();
int stored = candidates.countStored(c, session.getId());
int published = candidates.countPublished(c, session.getId());
if (stored != expected) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_INCOMPLETE",
"Candidate-chain ещё не полностью загружена: " + stored + "/" + expected);
}
if (published != expected) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_NOT_PUBLISHED",
"Candidate-chain ещё не полностью опубликована в Arweave/Turbo: " + published + "/" + expected);
}
List<KeyRotationCandidateBlockEntry> entries = candidates.listBySession(c, session.getId());
String validationError = validateCompleteChain(c, session, entries);
if (validationError != null) {
try { rotations.recordError(session.getId(), validationError); } catch (Exception ignored) { }
return NetExceptionResponseFactory.error(req, 409, validationError, "Финальная проверка candidate-chain не пройдена");
}
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_FINISH_CHAIN_FAILED", "Не удалось завершить проверку candidate-chain");
} finally {
lock.unlock();
}
try {
KeyRotationSessionEntry ready = rotations.transition(
initial.getId(), KeyRotationStatus.COPYING_CHAIN, KeyRotationStatus.CHAIN_READY);
rotations.clearError(initial.getId());
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), ready);
} catch (Exception transitionFailure) {
log.warn("KeyRotationFinishChain transition failed: login={}, sessionId={}",
login, initial.getId(), transitionFailure);
// Идемпотентность для параллельного FinishChain из другой сессии.
try {
KeyRotationSessionEntry now = rotations.getActiveByLogin(login);
if (now != null && now.getStatus() == KeyRotationStatus.CHAIN_READY) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), now);
}
} catch (Exception ignored) { }
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FINISH_CHAIN_RACE", "Состояние ротации изменилось во время FinishChain");
}
}
private String validateCompleteChain(Connection c,
KeyRotationSessionEntry session,
List<KeyRotationCandidateBlockEntry> entries) throws Exception {
if (entries == null || entries.size() != session.getProgressTotal()) return "KEY_ROTATION_CHAIN_INCOMPLETE";
if (session.getProgressTotal() != session.getForkFromBlock() + 2) return "KEY_ROTATION_BAD_PROGRESS_TOTAL";
byte[] newBlockchainKey = Base64.getDecoder().decode(session.getNewBlockchainKey());
if (newBlockchainKey.length != 32) return "KEY_ROTATION_BAD_STORED_KEY";
BchBlockEntry previous = null;
for (int i = 0; i < entries.size(); i++) {
KeyRotationCandidateBlockEntry stored = entries.get(i);
if (stored.getBlockNumber() != i) return "KEY_ROTATION_CANDIDATE_GAP";
if (stored.isArweavePublishPending() || stored.getArweavePublishedAtMs() == null) {
return "KEY_ROTATION_CHAIN_NOT_PUBLISHED";
}
BchBlockEntry candidate;
try {
candidate = new BchBlockEntry(stored.getBlockBytes());
candidate.body.check();
} catch (Exception e) {
return "KEY_ROTATION_BAD_STORED_BLOCK";
}
if (candidate.blockNumber != i) return "KEY_ROTATION_BLOCK_NUMBER_MISMATCH";
if (!Arrays.equals(candidate.getHash32(), stored.getBlockHash())) return "KEY_ROTATION_STORED_HASH_MISMATCH";
if (!Arrays.equals(candidate.getDataItemId32(), stored.getDataItemId())) return "KEY_ROTATION_STORED_DATAITEM_MISMATCH";
if (!candidate.getDataItem().hasTag("App", "test5590")) return "KEY_ROTATION_BAD_APP_TAG";
if (!BchCryptoVerifier.verifyBlock(candidate, newBlockchainKey)) return "KEY_ROTATION_BAD_SIGNATURE";
if (i == 0) {
if (!isZero32(candidate.prevHash32)) return "KEY_ROTATION_GENESIS_PREV_HASH";
} else {
if (previous == null || !Arrays.equals(candidate.prevHash32, previous.getHash32())) {
return "KEY_ROTATION_CHAIN_HASH_MISMATCH";
}
}
String semanticError = validateAgainstRotation(c, session, candidate);
if (semanticError != null) return semanticError;
previous = candidate;
}
return null;
}
private String validateAgainstRotation(Connection c, KeyRotationSessionEntry session, BchBlockEntry candidate) throws Exception {
if (candidate.blockNumber <= session.getForkFromBlock()) {
BlockEntry sourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), candidate.blockNumber);
if (sourceEntry == null || sourceEntry.getBlockBytes() == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry source = new BchBlockEntry(sourceEntry.getBlockBytes());
if (!Arrays.equals(source.getFrameBytes(), candidate.getFrameBytes())) return "KEY_ROTATION_FRAME_MISMATCH";
if (!Arrays.equals(source.getDataItem().rawTags(), candidate.getDataItem().rawTags())) return "KEY_ROTATION_TAGS_MISMATCH";
if (!Arrays.equals(source.getDataItem().target(), candidate.getDataItem().target())) return "KEY_ROTATION_TARGET_MISMATCH";
if (!Arrays.equals(source.getDataItem().anchor(), candidate.getDataItem().anchor())) return "KEY_ROTATION_ANCHOR_MISMATCH";
return null;
}
if (candidate.blockNumber != session.getForkFromBlock() + 1) return "KEY_ROTATION_UNEXPECTED_BLOCK_NUMBER";
if ((candidate.type & 0xFFFF) != 0
|| (candidate.subType & 0xFFFF) != (MsgSubType.TECH_FORK & 0xFFFF)
|| !(candidate.body instanceof ForkBody fork)) {
return "KEY_ROTATION_TECH_FORK_REQUIRED";
}
if (!Arrays.equals(candidate.prevHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_PREV_HASH";
BlockEntry forkSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getForkFromBlock());
BlockEntry tipSourceEntry = blocks.getByNumber(c, session.getSourceBlockchainName(), session.getSourceTipBlock());
if (forkSourceEntry == null || tipSourceEntry == null) return "KEY_ROTATION_SOURCE_BLOCK_MISSING";
BchBlockEntry forkSource = new BchBlockEntry(forkSourceEntry.getBlockBytes());
BchBlockEntry tipSource = new BchBlockEntry(tipSourceEntry.getBlockBytes());
byte[] oldBlockchain = Base64.getDecoder().decode(session.getOldBlockchainKey());
if (!Arrays.equals(fork.parentBlockchainKey32, oldBlockchain)) return "KEY_ROTATION_TECH_FORK_PARENT_KEY";
if (fork.forkPointBlockNumber != session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_POINT";
if (!Arrays.equals(fork.forkPointBlockHash32, session.getForkFromHash())) return "KEY_ROTATION_TECH_FORK_POINT_HASH";
if (fork.forkPointTimestampMs != Math.multiplyExact(forkSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_POINT_TIME";
if (fork.parentTipBlockNumber != session.getSourceTipBlock()) return "KEY_ROTATION_TECH_FORK_TIP";
if (!Arrays.equals(fork.parentTipBlockHash32, session.getSourceTipHash())) return "KEY_ROTATION_TECH_FORK_TIP_HASH";
if (fork.parentTipTimestampMs != Math.multiplyExact(tipSource.timestamp, 1000L)) return "KEY_ROTATION_TECH_FORK_TIP_TIME";
if (fork.discardedBlocksCount != session.getSourceTipBlock() - session.getForkFromBlock()) return "KEY_ROTATION_TECH_FORK_DISCARDED";
if (fork.reasonCode != session.getReasonCode()) return "KEY_ROTATION_TECH_FORK_REASON";
if (!normalizeComment(fork.comment).equals(normalizeComment(session.getComment()))) return "KEY_ROTATION_TECH_FORK_COMMENT";
return null;
}
private static boolean isZero32(byte[] value) {
if (value == null || value.length != 32) return false;
for (byte b : value) if (b != 0) return false;
return true;
}
private static String normalizeComment(String value) {
if (value == null) return "";
return value.trim().replace("\r\n", "\n").replace('\r', '\n');
}
}
@@ -0,0 +1,83 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
/**
* Фиксирует уже отправленную клиентом Solana-транзакцию ротации PDA.
* Приватные ключи и подписанная транзакция через сервер не проходят: клиент передаёт только tx signature.
*/
public final class Net_KeyRotationRotatePda_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationRotatePda_Request req = (Net_KeyRotationRotatePda_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
String login = ctx.getLogin().trim();
final String signature;
try {
signature = normalizeSolanaSignature(req.getPdaRotationSignature());
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_SOLANA_SIGNATURE", e.getMessage());
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(login);
if (current == null) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_ACTIVE", "Активная ротация отсутствует");
}
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
}
if (current.getStatus() == KeyRotationStatus.ROTATING_PDA) {
String stored = current.getPdaRotationSignature();
if (stored != null && !stored.isBlank() && !stored.equals(signature)) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_PDA_SIGNATURE_CONFLICT",
"Для этой ротации уже сохранена другая Solana transaction signature");
}
if (stored == null || stored.isBlank()) {
rotations.setPdaRotationSignature(current.getId(), signature);
}
} else if (current.getStatus() == KeyRotationStatus.CHAIN_READY) {
current = rotations.beginPdaRotation(current.getId(), signature);
} else {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_NOT_CHAIN_READY",
"PDA можно ротировать только после CHAIN_READY");
}
// Solana sync мог увидеть новое PDA ещё до этого API-вызова.
KeyRotationSessionEntry confirmed = rotations.tryMarkPdaRotatedFromCurrentState(current.getId());
rotations.clearError(current.getId());
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), confirmed != null ? confirmed : rotations.getById(current.getId()));
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_ROTATE_PDA_FAILED",
"Не удалось зафиксировать Solana-ротацию PDA");
}
}
private static String normalizeSolanaSignature(String raw) {
if (raw == null || raw.isBlank()) throw new IllegalArgumentException("pdaRotationSignature обязательна");
String s = raw.trim();
if (s.length() > 128) throw new IllegalArgumentException("Слишком длинная Solana transaction signature");
final String alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
for (int i = 0; i < s.length(); i++) {
if (alphabet.indexOf(s.charAt(i)) < 0) {
throw new IllegalArgumentException("Solana transaction signature должна быть Base58");
}
}
return s;
}
}
@@ -0,0 +1,209 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.DbController;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import shine.db.entities.KeyRotationSessionEntry;
import shine.db.entities.KeyRotationStatus;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.concurrent.locks.ReentrantLock;
/**
* Создаёт серверную ротацию сразу в COPYING_CHAIN.
* До этого момента всё заполнение формы остаётся только локальным UI-состоянием.
*/
public final class Net_KeyRotationStart_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
private final BlocksDAO blocks = BlocksDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationStart_Request req = (Net_KeyRotationStart_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
String login = ctx.getLogin().trim();
String ctxBlockchainName = ctx.getCurrentUser() != null ? ctx.getCurrentUser().getBlockchainName() : null;
if (ctxBlockchainName == null || ctxBlockchainName.isBlank()) {
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE",
"В сессии отсутствует актуальный blockchainName; войдите заново");
}
final String newRoot;
final String newBlockchain;
final String newClient;
final byte[] requestedForkHash;
try {
newRoot = KeyRotationApiSupport.normalizePublicKey32(req.getNewRootKey());
newBlockchain = KeyRotationApiSupport.normalizePublicKey32(req.getNewBlockchainKey());
newClient = KeyRotationApiSupport.normalizePublicKey32(req.getNewClientKey());
requestedForkHash = KeyRotationApiSupport.parseHash32(req.getForkFromHash());
} catch (IllegalArgumentException e) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FIELDS", e.getMessage());
}
if (req.getReasonCode() < 1 || req.getReasonCode() > 4) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_REASON", "reasonCode должен быть 1..4");
}
String comment = req.getComment() == null ? "" : req.getComment();
if (comment.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > 1024) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_COMMENT_TOO_LONG", "Комментарий должен быть не длиннее 1024 UTF-8 байт");
}
if (req.getForkFromBlock() < 0) {
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "forkFromBlock должен быть >= 0");
}
ReentrantLock lock = BlockchainLocks.lockFor(ctxBlockchainName);
lock.lock();
try (Connection c = DbController.getInstance().getConnection()) {
boolean oldAutoCommit = c.getAutoCommit();
c.setAutoCommit(false);
try {
PdaSnapshot pda = loadPdaForUpdate(c, login);
if (pda == null) {
c.rollback();
return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_USER_NOT_FOUND", "Пользователь не найден в текущем Solana PDA state");
}
if (!KeyRotationStatus.NONE.name().equals(pda.rotationStatus)) {
KeyRotationSessionEntry active = rotations.getActiveByLogin(c, login);
c.rollback();
if (active != null
&& sameStartRequest(active, newRoot, newBlockchain, newClient,
req.getForkFromBlock(), requestedForkHash, req.getReasonCode(), comment)) {
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), active);
}
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_ALREADY_ACTIVE", "Смена ключей уже выполняется с другими параметрами");
}
if (!ctxBlockchainName.equals(pda.blockchainName)) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_SESSION_STALE",
"Активный blockchain изменился; обновите сессию и повторите");
}
String oldRoot = KeyRotationApiSupport.normalizePublicKey32(pda.rootKey);
String oldBlockchain = KeyRotationApiSupport.normalizePublicKey32(pda.blockchainKey);
String oldClient = KeyRotationApiSupport.normalizePublicKey32(pda.clientKey);
try {
KeyRotationApiSupport.requireKeysChangedAndDistinct(
oldRoot, oldBlockchain, oldClient, newRoot, newBlockchain, newClient);
} catch (IllegalArgumentException e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_NEW_KEYS", e.getMessage());
}
BlockchainStateEntry state = states.getByBlockchainName(c, pda.blockchainName);
if (state == null || state.getLastBlockHash() == null || state.getLastBlockHash().length != 32) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_CHAIN_STATE_MISSING", "Не найдено корректное текущее состояние блокчейна");
}
if (req.getForkFromBlock() > state.getLastBlockNumber()) {
c.rollback();
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Точка fork находится после текущего tip");
}
BlockEntry forkBlock = blocks.getByNumber(c, pda.blockchainName, req.getForkFromBlock());
if (forkBlock == null || forkBlock.getBlockHash() == null || forkBlock.getBlockHash().length != 32) {
c.rollback();
return NetExceptionResponseFactory.error(req, 404, "KEY_ROTATION_FORK_BLOCK_NOT_FOUND", "Выбранный блок не найден на сервере");
}
if (!KeyRotationApiSupport.hashesEqual(forkBlock.getBlockHash(), requestedForkHash)) {
c.rollback();
return NetExceptionResponseFactory.error(req, 409, "KEY_ROTATION_FORK_HASH_MISMATCH", "Хэш выбранного блока не совпадает с сервером");
}
final String candidateName;
try {
candidateName = KeyRotationApiSupport.nextBlockchainName(login, pda.blockchainName);
} catch (IllegalArgumentException e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_BLOCKCHAIN_NAME", e.getMessage());
}
KeyRotationSessionEntry entry = new KeyRotationSessionEntry();
entry.setLogin(login);
entry.setSourceBlockchainName(pda.blockchainName);
entry.setCandidateBlockchainName(candidateName);
entry.setOldRootKey(oldRoot);
entry.setOldBlockchainKey(oldBlockchain);
entry.setOldClientKey(oldClient);
entry.setNewRootKey(newRoot);
entry.setNewBlockchainKey(newBlockchain);
entry.setNewClientKey(newClient);
entry.setForkFromBlock(req.getForkFromBlock());
entry.setForkFromHash(requestedForkHash);
entry.setSourceTipBlock(state.getLastBlockNumber());
entry.setSourceTipHash(state.getLastBlockHash());
entry.setReasonCode(req.getReasonCode());
entry.setComment(comment);
entry.setProgressCurrent(0);
entry.setProgressTotal(Math.addExact(req.getForkFromBlock(), 2)); // 0..N + TECH_FORK
KeyRotationSessionEntry created = rotations.createCopyingSession(c, entry);
c.commit();
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), created);
} catch (ArithmeticException e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 400, "KEY_ROTATION_BAD_FORK_POINT", "Слишком большой номер блока");
} catch (Exception e) {
c.rollback();
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось запустить смену ключей");
} finally {
c.setAutoCommit(oldAutoCommit);
}
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_START_FAILED", "Не удалось открыть транзакцию смены ключей");
} finally {
lock.unlock();
}
}
private static boolean sameStartRequest(KeyRotationSessionEntry active,
String newRoot, String newBlockchain, String newClient,
int forkFromBlock, byte[] forkFromHash, short reasonCode, String comment) {
return active != null
&& java.util.Objects.equals(active.getNewRootKey(), newRoot)
&& java.util.Objects.equals(active.getNewBlockchainKey(), newBlockchain)
&& java.util.Objects.equals(active.getNewClientKey(), newClient)
&& active.getForkFromBlock() == forkFromBlock
&& java.util.Arrays.equals(active.getForkFromHash(), forkFromHash)
&& active.getReasonCode() == reasonCode
&& java.util.Objects.equals(active.getComment() == null ? "" : active.getComment(), comment == null ? "" : comment);
}
private static PdaSnapshot loadPdaForUpdate(Connection c, String login) throws Exception {
try (PreparedStatement ps = c.prepareStatement("""
SELECT root_key, blockchain_key, client_key, blockchain_name, rotation_status
FROM solana_user_pda_current
WHERE normalized_login = LOWER(BTRIM(?))
FOR UPDATE
""")) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return new PdaSnapshot(
rs.getString("root_key"),
rs.getString("blockchain_key"),
rs.getString("client_key"),
rs.getString("blockchain_name"),
rs.getString("rotation_status")
);
}
}
}
private record PdaSnapshot(String rootKey, String blockchainKey, String clientKey,
String blockchainName, String rotationStatus) { }
}
@@ -0,0 +1,29 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.KeyRotationSessionsDAO;
import shine.db.entities.KeyRotationSessionEntry;
public final class Net_KeyRotationStatus_Handler implements JsonMessageHandler {
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
Net_KeyRotationStatus_Request req = (Net_KeyRotationStatus_Request) baseReq;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
}
try {
KeyRotationSessionEntry current = rotations.getActiveByLogin(ctx.getLogin().trim());
return KeyRotationApiSupport.response(req.getOp(), req.getRequestId(), current);
} catch (Exception e) {
return NetExceptionResponseFactory.error(req, 500, "KEY_ROTATION_STATUS_FAILED",
"Не удалось прочитать состояние смены ключей");
}
}
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Прервать ротацию до отправки Solana-транзакции. */
public final class Net_KeyRotationAbort_Request extends Net_Request {
}
@@ -0,0 +1,17 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Один подписанный новым blockchain key DataItem будущего fork. */
public final class Net_KeyRotationAddBlock_Request extends Net_Request {
private int blockNumber;
private String prevBlockHash;
private String blockBytesB64;
public int getBlockNumber() { return blockNumber; }
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
public String getPrevBlockHash() { return prevBlockHash; }
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
public String getBlockBytesB64() { return blockBytesB64; }
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Продолжить один из интерактивных/заглушечных этапов после rebuild. */
public final class Net_KeyRotationContinue_Request extends Net_Request {
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Финальная проверка полностью опубликованной candidate-chain. */
public final class Net_KeyRotationFinishChain_Request extends Net_Request {
}
@@ -0,0 +1,14 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Клиент уже локально подписал и отправил Solana-транзакцию ротации PDA.
* Серверу передаётся только публичная Solana transaction signature для аудита/возобновления.
*/
public final class Net_KeyRotationRotatePda_Request extends Net_Request {
private String pdaRotationSignature;
public String getPdaRotationSignature() { return pdaRotationSignature; }
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
}
@@ -0,0 +1,32 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/**
* Запуск смены ключей. Старые ключи сервер берёт из текущего Solana PDA;
* клиент передаёт только новые публичные ключи и выбранную точку fork.
*/
public final class Net_KeyRotationStart_Request extends Net_Request {
private String newRootKey;
private String newBlockchainKey;
private String newClientKey;
private int forkFromBlock;
private String forkFromHash;
private short reasonCode;
private String comment;
public String getNewRootKey() { return newRootKey; }
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
public String getNewBlockchainKey() { return newBlockchainKey; }
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
public String getNewClientKey() { return newClientKey; }
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
public int getForkFromBlock() { return forkFromBlock; }
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
public String getForkFromHash() { return forkFromHash; }
public void setForkFromHash(String forkFromHash) { this.forkFromHash = forkFromHash; }
public short getReasonCode() { return reasonCode; }
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
}
@@ -0,0 +1,83 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/** Публичное состояние текущей/только что созданной ротации. */
public final class Net_KeyRotationState_Response extends Net_Response {
private Long rotationSessionId;
private String rotationStatus;
private String sourceBlockchainName;
private String candidateBlockchainName;
private String oldRootKey;
private String oldBlockchainKey;
private String oldClientKey;
private String newRootKey;
private String newBlockchainKey;
private String newClientKey;
private Integer forkFromBlock;
private String forkFromHash;
private Integer sourceTipBlock;
private String sourceTipHash;
private Short reasonCode;
private String comment;
private Integer progressCurrent;
private Integer progressTotal;
private String pdaRotationSignature;
private String walletMigrationStatus;
private String messageMigrationStatus;
private String lastError;
private Integer retryCount;
private Long createdAtMs;
private Long updatedAtMs;
public Long getRotationSessionId() { return rotationSessionId; }
public void setRotationSessionId(Long rotationSessionId) { this.rotationSessionId = rotationSessionId; }
public String getRotationStatus() { return rotationStatus; }
public void setRotationStatus(String rotationStatus) { this.rotationStatus = rotationStatus; }
public String getSourceBlockchainName() { return sourceBlockchainName; }
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
public String getCandidateBlockchainName() { return candidateBlockchainName; }
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
public String getOldRootKey() { return oldRootKey; }
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
public String getOldBlockchainKey() { return oldBlockchainKey; }
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
public String getOldClientKey() { return oldClientKey; }
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
public String getNewRootKey() { return newRootKey; }
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
public String getNewBlockchainKey() { return newBlockchainKey; }
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
public String getNewClientKey() { return newClientKey; }
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
public Integer getForkFromBlock() { return forkFromBlock; }
public void setForkFromBlock(Integer forkFromBlock) { this.forkFromBlock = forkFromBlock; }
public String getForkFromHash() { return forkFromHash; }
public void setForkFromHash(String forkFromHash) { this.forkFromHash = forkFromHash; }
public Integer getSourceTipBlock() { return sourceTipBlock; }
public void setSourceTipBlock(Integer sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
public String getSourceTipHash() { return sourceTipHash; }
public void setSourceTipHash(String sourceTipHash) { this.sourceTipHash = sourceTipHash; }
public Short getReasonCode() { return reasonCode; }
public void setReasonCode(Short reasonCode) { this.reasonCode = reasonCode; }
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public Integer getProgressCurrent() { return progressCurrent; }
public void setProgressCurrent(Integer progressCurrent) { this.progressCurrent = progressCurrent; }
public Integer getProgressTotal() { return progressTotal; }
public void setProgressTotal(Integer progressTotal) { this.progressTotal = progressTotal; }
public String getPdaRotationSignature() { return pdaRotationSignature; }
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
public String getWalletMigrationStatus() { return walletMigrationStatus; }
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
public String getMessageMigrationStatus() { return messageMigrationStatus; }
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public Integer getRetryCount() { return retryCount; }
public void setRetryCount(Integer retryCount) { this.retryCount = retryCount; }
public Long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(Long createdAtMs) { this.createdAtMs = createdAtMs; }
public Long getUpdatedAtMs() { return updatedAtMs; }
public void setUpdatedAtMs(Long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.keyRotation.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Запрос текущего состояния ротации авторизованного пользователя. */
public final class Net_KeyRotationStatus_Request extends Net_Request {
}
@@ -5,6 +5,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
@@ -23,7 +24,7 @@ public final class Net_SetNotificationState_Handler implements JsonMessageHandle
byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey");
long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи");
long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); }
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r;
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); UserCountersSupport.pushChanged(login); return r;
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
}
}
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
@@ -59,6 +60,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
UserCountersSupport.pushChanged(incoming.toLogin);
UserCountersSupport.pushChanged(incoming.fromLogin);
}
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
@@ -78,6 +79,14 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
if (pairStatus.applied()) {
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
// SendMessagePair is also the local-server delivery path. Without this push,
// a recipient on the same access server receives the DM but the toolbar
// counter stays stale until the next explicit GetUserCounters refresh.
UserCountersSupport.pushChanged(incoming.toLogin);
if (!incoming.fromLogin.equalsIgnoreCase(incoming.toLogin)) {
UserCountersSupport.pushChanged(incoming.fromLogin);
}
}
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {

Some files were not shown because too many files have changed in this diff Show More