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
366 changed files with 34743 additions and 24243 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
+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,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;
}
}
@@ -1,56 +0,0 @@
package server.archive;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/** Единые локальные имена больших архивных файлов. */
public final class ArchiveFileNames {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy");
private ArchiveFileNames() {}
/**
* Временное имя полностью собранного файла до появления Arweave TX ID.
* Пример: archive01.00001.11.09.26.tmp.SHiNE-archive
*/
public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive";
}
/**
* Постоянное имя после успешной загрузки в Arweave.
* Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive
*/
public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes");
String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId);
return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive";
}
public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone));
}
public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId));
}
private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) {
throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber);
}
String safeLogin = sanitize(login);
String number = String.format("%05d", bigBlockNumber);
String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone));
return safeLogin + "." + number + "." + date;
}
static String sanitize(String login) {
String s = login == null ? "server" : login.trim();
if (s.isEmpty()) s = "server";
return s.replaceAll("[^A-Za-z0-9._-]", "_");
}
}
@@ -1,37 +0,0 @@
package server.archive;
import utils.config.AppConfig;
import java.nio.file.Path;
import java.util.*;
/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */
public record ArchiveImportConfig(
Set<String> allowedPublishers,
int intervalMinutes,
String arweaveGateway,
Path workDir
) {
public static ArchiveImportConfig load() {
AppConfig c = AppConfig.getInstance();
Set<String> approved = new LinkedHashSet<>();
String raw = c.getParam("archive.import.allowedPublishers");
if (raw != null) {
for (String item : raw.split(",")) {
String login = item.trim().toLowerCase(Locale.ROOT);
if (!login.isEmpty()) approved.add(login);
}
}
String gateway = c.getParam("archive.arweave.gateway");
if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net";
String dir = c.getParam("archive.import.workDir");
if (dir == null || dir.isBlank()) dir = "data/archive-import";
return new ArchiveImportConfig(
Set.copyOf(approved),
Math.max(1, c.getInt("archive.import.intervalMinutes", 60)),
gateway.trim(),
Path.of(dir));
}
public boolean enabled() { return !allowedPublishers.isEmpty(); }
}
@@ -1,54 +0,0 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.*;
/**
* Фоновая проверка уже синхронизированной локальной таблицы User PDA.
* Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта,
* затем по умолчанию раз в час.
*/
public final class ArchiveImportScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class);
private static ScheduledExecutorService executor;
private ArchiveImportScheduler() {}
public static synchronized void startOrLog() {
if (executor != null) return;
ArchiveImportConfig cfg = ArchiveImportConfig.load();
if (!cfg.enabled()) {
log.info("Archive importer выключен: archive.import.allowedPublishers пуст");
return;
}
try {
ArchiveImportService service = new ArchiveImportService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "archive-importer");
t.setDaemon(true);
return t;
});
executor.scheduleWithFixedDelay(
() -> {
try { service.pollOnce(); }
catch (Exception e) { log.error("Archive importer cycle failed", e); }
},
10,
Math.multiplyExact((long) cfg.intervalMinutes(), 60L),
TimeUnit.SECONDS);
log.info("Archive importer включён. approvedPublishers={} interval={}min",
cfg.allowedPublishers(), cfg.intervalMinutes());
} catch (Exception e) {
log.error("Archive importer не запущен", e);
}
}
public static synchronized void close() {
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
}
@@ -1,229 +0,0 @@
package server.archive;
import blockchain.BchBlockEntry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.*;
import shine.db.dao.*;
import shine.db.entities.BlockchainStateEntry;
import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
/** Импорт archive head только от серверов из server-side whitelist. */
public final class ArchiveImportService {
private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class);
private final ArchiveImportConfig cfg;
private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance();
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
private final ShineArchiveReader reader = new ShineArchiveReader();
private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler();
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
public ArchiveImportService(ArchiveImportConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
Files.createDirectories(cfg.workDir());
}
/**
* Никаких отдельных Solana RPC запросов здесь нет.
* Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync.
*/
public void pollOnce() throws Exception {
if (!cfg.enabled()) return;
for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) {
try {
importHead(head);
} catch (Exception e) {
log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e);
}
}
}
private void importHead(ArchivePublisherHead publisher) throws Exception {
String headTxId = publisher.archiveHeadTxId();
byte[] headHash = hex(publisher.archiveHeadHash());
// Если процесс успел импортировать head, но упал до установки archive_imported=true,
// следующий цикл просто завершает отметку без повторного скачивания всей истории.
if (headTxId.equals(publisher.lastImportedArchiveTxId())) {
if (!dao.markHeadImported(publisher.login(), headTxId)) {
log.info("Archive head changed while finalizing import marker publisher={} oldHead={}",
publisher.login(), headTxId);
}
return;
}
Path headFile = download(headTxId);
ShineArchiveReader.Document head;
try {
head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey());
} finally {
Files.deleteIfExists(headFile);
}
// FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен.
List<ShineArchiveReader.Reference> previous = new ArrayList<>(head.references());
previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber));
int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId);
for (int i = startIndex; i < previous.size(); i++) {
ShineArchiveReader.Reference ref = previous.get(i);
importOne(publisher, ref.arweaveTxId(), ref.archiveHash());
if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) {
throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login());
}
}
// Head уже скачан и проверен выше — второй раз его из Arweave не качаем.
importDocument(publisher, headTxId, head);
if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) {
throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login());
}
if (!dao.markHeadImported(publisher.login(), headTxId)) {
throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login());
}
log.info("Archive head fully imported publisher={} bigBlock={} tx={}",
publisher.login(), head.bigBlockNumber(), headTxId);
}
/**
* Возвращает индекс первого ещё не импортированного previous big block.
* Пустой cursor = новый сервер, импортируем всю FULL историю.
*/
private static int findResumeIndex(List<ShineArchiveReader.Reference> previous,
String lastImportedTxId,
String currentHeadTxId) {
String last = lastImportedTxId == null ? "" : lastImportedTxId.trim();
if (last.isEmpty()) return 0;
if (last.equals(currentHeadTxId)) return previous.size();
for (int i = 0; i < previous.size(); i++) {
if (last.equals(previous.get(i).arweaveTxId())) return i + 1;
}
throw new IllegalStateException(
"Последний импортированный archive TX не найден в FULL history текущего head: " + last);
}
private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception {
Path file = download(txId);
try {
ShineArchiveReader.Document doc = reader.readAndVerify(
file, publisher.login(), expectedHash, publisher.rootKey());
importDocument(publisher, txId, doc);
} finally {
Files.deleteIfExists(file);
}
}
private void importDocument(ArchivePublisherHead publisher, String txId,
ShineArchiveReader.Document doc) throws Exception {
for (ShineArchiveReader.Chunk chunk : doc.chunks()) {
processChunk(publisher.login(), txId, doc, chunk);
}
log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}",
publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size());
}
private void processChunk(String publisherLogin, String txId,
ShineArchiveReader.Document doc,
ShineArchiveReader.Chunk chunk) throws Exception {
ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName());
lock.lock();
try {
ensureState(chunk.blockchainName());
BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName());
int localLast = state == null ? -1 : state.getLastBlockNumber();
long archiveLast = -1;
for (byte[] raw : chunk.rawRecords()) {
BchBlockEntry block = new BchBlockEntry(raw);
archiveLast = Integer.toUnsignedLong(block.blockNumber);
if (block.blockNumber <= localLast) {
byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber);
if (!Arrays.equals(localHash, block.getHash32())) {
throw new IllegalStateException(
"Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber);
}
continue;
}
if (block.blockNumber != localLast + 1) {
throw new IllegalStateException(
"Archive gap " + chunk.blockchainName() + ": local=" + localLast +
" next=" + block.blockNumber);
}
var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw);
if (!result.ok()) {
throw new IllegalStateException(
"Archive AddBlock rejected " + chunk.blockchainName() + "#" +
block.blockNumber + ": " + result.reasonCode());
}
localLast = block.blockNumber;
}
if (archiveLast >= 0) {
dao.upsertLocation(new ArchiveBlockchainLocation(
chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(),
chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis()));
}
} finally {
lock.unlock();
}
}
private void ensureState(String blockchainName) throws Exception {
if (stateDao.getByBlockchainName(blockchainName) != null) return;
ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName);
if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName);
BlockchainStateEntry s = new BlockchainStateEntry();
s.setBlockchainName(id.blockchainName());
s.setLogin(id.login());
s.setBlockchainKey(id.blockchainKey());
s.setSizeLimit(id.sizeLimit());
s.setFileSizeBytes(0);
s.setLastBlockNumber(-1);
s.setLastBlockHash(null);
s.setUpdatedAtMs(System.currentTimeMillis());
stateDao.insertIfMissing(s);
}
private Path download(String txId) throws Exception {
String gateway = cfg.arweaveGateway().replaceAll("/+$", "");
Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download");
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId))
.timeout(Duration.ofMinutes(10))
.GET()
.build();
HttpResponse<Path> resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target));
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
Files.deleteIfExists(target);
throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId);
}
return target;
}
private static byte[] hex(String value) {
String s = String.valueOf(value == null ? "" : value).trim();
if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64");
byte[] out = new byte[32];
for (int i = 0; i < 32; i++) {
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
}
return out;
}
}
@@ -1,75 +0,0 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import sync.util.Base58Util;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */
final class ArchiveKeyLoader {
private static final ObjectMapper JSON = new ObjectMapper();
private ArchiveKeyLoader() {}
static byte[] loadSeed32(Path path) throws IOException {
if (path == null) throw new IllegalArgumentException("Путь ключа не задан");
byte[] raw = Files.readAllBytes(path);
if (raw.length == 32) return raw.clone();
if (raw.length == 64) return first32(raw);
String text = new String(raw, StandardCharsets.UTF_8).trim();
if (text.isEmpty()) throw new IOException("Пустой key file: " + path);
// Solana keypair JSON: [64 чисел].
if (text.startsWith("[")) {
JsonNode arr = JSON.readTree(text);
if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) {
throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path);
}
byte[] bytes = new byte[arr.size()];
for (int i = 0; i < arr.size(); i++) {
int v = arr.get(i).asInt(-1);
if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path);
bytes[i] = (byte) v;
}
return bytes.length == 32 ? bytes : first32(bytes);
}
// Base58 seed32 или Solana secret key 64 bytes.
try {
byte[] decoded = Base58Util.decode(text);
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
} catch (IllegalArgumentException ignored) {
// Пробуем остальные текстовые форматы ниже.
}
// Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах.
try {
byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", ""));
if (decoded.length == 32) return decoded;
if (decoded.length == 64) return first32(decoded);
if (decoded.length > 32) {
byte[] seed = new byte[32];
System.arraycopy(decoded, decoded.length - 32, seed, 0, 32);
return seed;
}
} catch (IllegalArgumentException ignored) {
// Ниже выдадим понятную общую ошибку.
}
throw new IOException("Не удалось распознать Ed25519 key file: " + path
+ ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8.");
}
private static byte[] first32(byte[] bytes) {
byte[] out = new byte[32];
System.arraycopy(bytes, 0, out, 0, 32);
return out;
}
}
@@ -1,98 +0,0 @@
package server.archive;
import utils.config.AppConfig;
import utils.config.SolanaProgramsConfig;
import java.nio.file.Path;
import java.time.LocalTime;
import java.time.ZoneId;
/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */
public record ArchivePublisherConfig(
boolean enabled,
LocalTime publishTime,
ZoneId publishZone,
long maxFileBytes,
Path workDir,
String serverLogin,
String arweaveGateway,
Path arweaveWalletJwkPath,
int arweaveMinConfirmations,
int arweaveConfirmPollSeconds,
int arweaveConfirmTimeoutMinutes,
Path solanaRootKeyPath,
Path solanaClientKeyPath,
String solanaRpcUrl,
String solanaUsersProgramId,
String solanaPaymentsProgramId,
int solanaConfirmPollSeconds,
int solanaConfirmTimeoutMinutes,
String solanaCommitment
) {
public static ArchivePublisherConfig load() {
AppConfig c = AppConfig.getInstance();
return new ArchivePublisherConfig(
c.getBoolean("archive.publish.enabled", false),
parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")),
parseZone(c.getParam("archive.publish.zoneId")),
positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"),
Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")),
requiredIfEnabled(c, "server.SHiNE.login"),
orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"),
optionalPath(c.getParam("archive.arweave.walletJwkPath")),
nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"),
positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"),
positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"),
optionalPath(c.getParam("archive.solana.rootKeyPath")),
optionalPath(c.getParam("archive.solana.clientKeyPath")),
firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL),
firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID),
SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID,
positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"),
positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"),
orDefault(c.getParam("archive.solana.commitment"), "finalized")
);
}
public void validateForStart() {
if (!enabled) return;
if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher");
if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен");
if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен");
if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен");
if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен");
if (maxFileBytes >= 0x1_0000_0000L) {
throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296");
}
}
private static String requiredIfEnabled(AppConfig c, String name) {
String v = c.getParam(name);
return v == null ? "" : v.trim();
}
private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); }
private static String firstNonBlank(String... values) {
if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim();
return "";
}
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); }
private static LocalTime parseTime(String value) {
try {
return LocalTime.parse(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e);
}
}
private static ZoneId parseZone(String value) {
if (value == null || value.isBlank()) return ZoneId.systemDefault();
try {
return ZoneId.of(value.trim());
} catch (Exception e) {
throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e);
}
}
private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; }
private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
}
@@ -1,90 +0,0 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */
public final class ArchivePublisherScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class);
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
private static ScheduledExecutorService executor;
private ArchivePublisherScheduler() {}
public static void startOrLog() {
ArchivePublisherConfig cfg;
try {
cfg = ArchivePublisherConfig.load();
} catch (Exception e) {
log.error("Не удалось прочитать archive publisher config", e);
return;
}
if (!cfg.enabled()) {
log.info("Archive publisher выключен (archive.publish.enabled=false)");
return;
}
if (!STARTED.compareAndSet(false, true)) return;
try {
ArchivePublisherService service = new ArchivePublisherService(cfg);
executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override public Thread newThread(Runnable r) {
Thread t = new Thread(r, "shine-archive-publisher");
t.setDaemon(true);
return t;
}
});
// Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot
// создаём только в очередное назначенное суточное время.
executor.execute(() -> {
try {
service.resumeUnfinishedJob();
} catch (Exception e) {
log.error("Не удалось продолжить незавершённый archive job", e);
}
});
scheduleNextDaily(service, cfg);
log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}",
cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone());
} catch (Exception e) {
STARTED.set(false);
log.error("Archive publisher НЕ запущен из-за ошибки preflight", e);
}
}
private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) {
ScheduledExecutorService e = executor;
if (e == null || e.isShutdown()) return;
ZonedDateTime now = ZonedDateTime.now(cfg.publishZone());
ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone());
if (!next.isAfter(now)) next = next.plusDays(1);
long delayMs = Math.max(1L, Duration.between(now, next).toMillis());
e.schedule(() -> {
try {
service.runCycle();
} catch (Exception ex) {
log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex);
} finally {
scheduleNextDaily(service, cfg);
}
}, delayMs, TimeUnit.MILLISECONDS);
log.info("Следующая архивная публикация запланирована на {}", next);
}
public static void close() {
ScheduledExecutorService e = executor;
if (e != null) e.shutdownNow();
}
}
@@ -1,341 +0,0 @@
package server.archive;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.archive.ArchivePublishJob;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.dao.ArchivePublicationDAO;
import shine.db.dao.ArchiveImportDAO;
import shine.db.dao.BlockchainStateDAO;
import shine.db.dao.BlocksDAO;
import shine.db.entities.BlockEntry;
import shine.db.entities.BlockchainStateEntry;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */
public final class ArchivePublisherService {
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class);
private final ArchivePublisherConfig cfg;
private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance();
private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance();
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
private final ShineArchiveWriter writer = new ShineArchiveWriter();
private final ArweaveArchiveService arweave;
private final SolanaArchiveHeadWriter solana;
private final byte[] archiveSigningSeed32;
private final AtomicBoolean running = new AtomicBoolean(false);
public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception {
this.cfg = Objects.requireNonNull(cfg);
cfg.validateForStart();
Files.createDirectories(cfg.workDir());
this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.arweave = new ArweaveArchiveService(cfg);
this.solana = new SolanaArchiveHeadWriter(cfg);
this.solana.validateKeysAgainstPda();
backfillOwnLocationIndex();
}
/**
* После обновления старого publisher-сервера заполняет универсальный location index
* из уже существующих finalized cursors, не дожидаясь следующего суточного archive block.
*/
private void backfillOwnLocationIndex() throws Exception {
Map<Long, ArchiveBigBlockRef> finalized = new HashMap<>();
for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref);
if (finalized.isEmpty()) return;
long now = System.currentTimeMillis();
int updated = 0;
for (BlockchainStateEntry state : stateDao.listAll()) {
if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName());
if (cursor == null) continue;
ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber());
if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue;
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()),
cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now));
updated++;
}
if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated);
}
/** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */
public boolean resumeUnfinishedJob() throws Exception {
if (!running.compareAndSet(false, true)) return false;
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) return false;
log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
process(job.id());
return true;
} finally {
running.set(false);
}
}
/** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */
public boolean runCycle() throws Exception {
if (!running.compareAndSet(false, true)) {
log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен");
return false;
}
try {
ArchivePublishJob job = archiveDao.findUnfinishedJob();
if (job == null) {
job = createFrozenJob();
if (job == null) {
log.info("Archive publisher: новых SHiNE-блоков нет");
return false;
}
} else {
log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}",
job.id(), job.bigBlockNumber(), job.status());
}
process(job.id());
return true;
} finally {
running.set(false);
}
}
private ArchivePublishJob createFrozenJob() throws Exception {
List<ArchivePublishJobChain> chains = new ArrayList<>();
for (BlockchainStateEntry listed : stateDao.listAll()) {
if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue;
String name = listed.getBlockchainName();
ReentrantLock lock = BlockchainLocks.lockFor(name);
lock.lock();
try {
BlockchainStateEntry state = stateDao.getByBlockchainName(name);
if (state == null || state.getLastBlockNumber() < 0) continue;
ArchiveChainCursor cursor = archiveDao.getCursor(name);
long from;
byte[] previousSourceHash;
Long prevBig = null;
byte[] prevBigHash = null;
Long prevChunkOffset = null;
Long prevChunkSize = null;
if (cursor == null) {
from = 0L;
previousSourceHash = new byte[32];
} else {
long cursorBlock = cursor.lastArchivedSourceBlockNumber();
if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) {
throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock);
}
byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock);
if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) {
throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock);
}
from = cursorBlock + 1L;
previousSourceHash = cursor.lastArchivedSourceBlockHash();
prevBig = cursor.lastArchiveBigBlockNumber();
prevBigHash = cursor.lastArchiveBigBlockHash();
prevChunkOffset = cursor.lastChunkOffset();
prevChunkSize = cursor.lastChunkSize();
}
long to = Integer.toUnsignedLong(state.getLastBlockNumber());
if (from > to) continue;
if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name);
byte[] lastHash = state.getLastBlockHash();
if (lastHash == null || lastHash.length != 32) {
lastHash = blocksDao.getHashByNumber(name, (int) to);
}
require32(lastHash, "last source block hash " + name);
chains.add(new ArchivePublishJobChain(
0L, name, from, to, previousSourceHash, lastHash,
prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null));
} finally {
lock.unlock();
}
}
if (chains.isEmpty()) return null;
chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long bigBlock = archiveDao.nextBigBlockNumber();
if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number");
long createdAt = System.currentTimeMillis();
long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains);
log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size());
return archiveDao.getJob(id);
}
private void process(long jobId) throws Exception {
while (true) {
ArchivePublishJob job = archiveDao.getJob(jobId);
if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId);
switch (job.status()) {
case "SNAPSHOT_CREATED" -> buildFile(job);
case "FILE_BUILT" -> uploadArweave(job);
case "ARWEAVE_UPLOADED" -> waitArweave(job);
case "ARWEAVE_CONFIRMED" -> submitSolana(job);
case "SOLANA_SUBMITTED" -> waitSolana(job);
case "SOLANA_FINALIZED" -> commitCursors(job);
case "CURSORS_COMMITTED" -> {
log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId()));
return;
}
case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText());
default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status());
}
}
}
private void buildFile(ArchivePublishJob job) throws Exception {
List<ArchivePublishJobChain> chains = archiveDao.listJobChains(job.id());
if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id());
Map<String,List<BlockEntry>> records = new LinkedHashMap<>();
for (ArchivePublishJobChain ch : chains) {
ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName());
lock.lock();
try {
if (ch.fromSourceBlockNumber() > 0) {
byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1));
if (!Arrays.equals(prev, ch.previousSourceBlockHash())) {
throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName());
}
}
List<BlockEntry> range = blocksDao.listRangeByNumber(
ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber());
if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName());
byte[] last = range.get(range.size()-1).getBlockHash();
if (!Arrays.equals(last, ch.lastSourceBlockHash())) {
throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName());
}
records.put(ch.blockchainName(), range);
} finally {
lock.unlock();
}
}
List<ArchiveBigBlockRef> previous = archiveDao.listFinalizedBigBlocks();
Path file = ArchiveFileNames.pendingFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone());
ShineArchiveWriter.BuildResult result = writer.build(
file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous,
chains, records, archiveSigningSeed32, cfg.maxFileBytes());
for (Map.Entry<String, ShineArchiveWriter.ChunkPosition> e : result.chunks().entrySet()) {
archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size());
}
archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash());
log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}",
job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash()));
}
private void uploadArweave(ArchivePublishJob job) throws Exception {
Path file = requireLocalFile(job);
byte[] verified = ShineArchiveWriter.verifyLocalFile(file);
if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB");
ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash());
// Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename,
// recovery на следующем шаге переименует тот же временный файл без повторной загрузки.
archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes());
Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes());
log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}",
job.bigBlockNumber(), upload.txId(), uploadedFile);
}
private void waitArweave(ArchivePublishJob job) throws Exception {
ensureUploadedLocalName(job);
String tx = b64url(job.arweaveTxId());
int confirmations = arweave.waitForConfirmations(tx);
archiveDao.markArweaveConfirmed(job.id(), confirmations);
log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations);
}
/** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */
private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception {
require32(txId, "arweave tx id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId);
Files.createDirectories(target.toAbsolutePath().getParent());
if (!currentFile.equals(target)) {
try {
Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING);
}
}
archiveDao.updateLocalArchivePath(job.id(), target.toString());
return target;
}
/** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */
private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
Path target = ArchiveFileNames.uploadedFilePath(
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId());
if (Files.isRegularFile(target)) {
if (!target.toString().equals(job.localArchivePath())) {
archiveDao.updateLocalArchivePath(job.id(), target.toString());
}
return;
}
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return;
Path current = Path.of(job.localArchivePath());
if (Files.isRegularFile(current)) {
markLocalFileUploaded(job, current, job.arweaveTxId());
}
}
private void submitSolana(ArchivePublishJob job) throws Exception {
require32(job.arweaveTxId(), "job.arweave_tx_id");
require32(job.archiveHash(), "job.archive_hash");
SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash());
archiveDao.markSolanaSubmitted(job.id(), submitted.signature());
}
private void waitSolana(ArchivePublishJob job) throws Exception {
if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature");
solana.waitFinalized(job.solanaSignature());
if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) {
throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job");
}
archiveDao.markSolanaFinalized(job.id());
log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature());
}
private void commitCursors(ArchivePublishJob job) throws Exception {
archiveDao.commitCursors(job.id(), job.archiveHash());
// Тот же универсальный индекс используется и для собственных опубликованных архивов.
// Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist.
String txId = b64url(job.arweaveTxId());
long now = System.currentTimeMillis();
for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) {
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue;
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(),
ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now));
}
}
private static Path requireLocalFile(ArchivePublishJob job) {
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path");
Path p = Path.of(job.localArchivePath());
if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p);
return p;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes");
}
private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
}
@@ -1,259 +0,0 @@
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.nio.file.Path;
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.*;
/**
* Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2.
* Поддерживает стандартный data Merkle tree и загрузку через /chunk.
*/
public final class ArweaveArchiveService {
private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.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();
private final ArchivePublisherConfig cfg;
private final HttpClient http;
private volatile Wallet wallet;
public ArweaveArchiveService(ArchivePublisherConfig cfg) {
this.cfg = Objects.requireNonNull(cfg);
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
}
public record UploadResult(String txId, byte[] txIdBytes, String gateway) {}
public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash)
throws Exception {
long dataSize = Files.size(archiveFile);
if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE");
Wallet w = wallet();
String gateway = trimGateway(cfg.arweaveGateway());
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile);
String anchor = getRequiredText(gateway, "/tx_anchor");
String reward = getRequiredText(gateway, "/price/" + dataSize);
if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward);
verifyBalance(gateway, w.address(), reward);
List<List<byte[]>> tagList = new ArrayList<>();
List<Map<String,String>> jsonTags = new ArrayList<>();
addTag(jsonTags, tagList, "Content-Type", "application/octet-stream");
addTag(jsonTags, tagList, "App-Name", "SHiNE");
addTag(jsonTags, tagList, "SHiNE-Type", "archive");
addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0");
addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin);
addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber));
addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash));
byte[] signaturePayload = deepHash(List.of(
utf8("2"),
b64Decode(w.owner()),
new byte[0],
utf8("0"),
utf8(reward),
b64Decode(anchor),
tagList,
utf8(Long.toString(dataSize)),
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", Long.toString(dataSize));
tx.put("data", "");
tx.put("reward", reward);
tx.put("signature", b64(rawSignature));
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize);
for (int i=0; i<merkle.chunks().size(); i++) {
ArweaveMerkle.Chunk chunkMeta = merkle.chunks().get(i);
ArweaveMerkle.Proof proof = merkle.proofs().get(i);
byte[] chunk = ArweaveMerkle.readChunk(archiveFile, chunkMeta);
Map<String,Object> body = new LinkedHashMap<>();
body.put("data_root", b64(merkle.dataRoot()));
body.put("data_size", Long.toString(dataSize));
body.put("data_path", b64(proof.proof()));
body.put("offset", Long.toString(proof.offset()));
body.put("chunk", b64(chunk));
postChunkWithRetry(gateway, body, i, merkle.chunks().size());
}
return new UploadResult(txId, txIdBytes, gateway);
}
/** Ждёт включения tx в блок и заданного количества подтверждений. */
public int waitForConfirmations(String txId) throws Exception {
if (cfg.arweaveMinConfirmations() <= 0) return 0;
String gateway = trimGateway(cfg.arweaveGateway());
long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 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 json = MAPPER.readTree(resp.body());
last = json.path("number_of_confirmations").asInt(0);
if (last >= cfg.arweaveMinConfirmations()) return last;
} else if (resp.statusCode() != 202 && resp.statusCode() != 404) {
log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body()));
}
Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L);
}
throw new IOException("Истёк timeout ожидания Arweave confirmations для " + 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));
if ((index + 1) % 25 == 0 || index + 1 == total) {
log.info("Arweave chunks: {}/{}", index + 1, total);
}
return;
} catch (Exception e) {
last = e;
if (attempt == 12) break;
log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage());
Thread.sleep(5_000L);
}
}
throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last);
}
private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception {
String json = MAPPER.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(timeout)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, 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 {
if (address == null || address.isBlank()) return;
String balance = getRequiredText(gateway, "/wallet/" + address + "/balance");
if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) {
throw new IllegalStateException("Недостаточно 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() + " для " + path + ": " + safe(resp.body()));
}
String s = resp.body() == null ? "" : resp.body().trim();
if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + 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.arweaveWalletJwkPath(), StandardCharsets.UTF_8));
String owner = required(jwk, "n");
PrivateKey pk = buildPrivateKey(jwk);
String address = b64(sha256(b64Decode(owner)));
wallet = new Wallet(owner, address, pk);
return wallet;
}
}
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 signature = Signature.getInstance("RSASSA-PSS");
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
signature.initSign(key);
signature.update(payload);
return signature.sign();
}
private static byte[] deepHash(Object data) throws GeneralSecurityException {
if (data instanceof List<?> list) {
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
return deepHashChunks(list, sha384(tag));
}
if (!(data instanceof byte[] bytes)) throw new IllegalArgumentException("deepHash: ожидается byte[]/List");
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
return sha384(concat(sha384(tag), sha384(bytes)));
}
private static byte[] deepHashChunks(List<?> list, byte[] acc) throws GeneralSecurityException {
byte[] cur = acc;
for (Object item : list) cur = sha384(concat(cur, deepHash(item)));
return cur;
}
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> item = new LinkedHashMap<>();
item.put("name", b64(n)); item.put("value", b64(v));
json.add(item); raw.add(List.of(n,v));
}
private static String required(JsonNode node, String field) {
String s = node == null ? "" : node.path(field).asText("").trim();
if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field);
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 String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); }
private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.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,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){}
}
@@ -29,6 +29,55 @@ final class ArweaveMerkle {
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);
@@ -1,101 +0,0 @@
package server.archive;
import sync.util.Base58Util;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.MessageDigest;
import java.util.*;
/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */
public final class ShineArchiveReader {
public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {}
public record Chunk(long offset, long size, String blockchainName, List<byte[]> rawRecords,
long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {}
public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin,
List<Reference> references, List<Chunk> chunks, byte[] archiveHash) {}
public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception {
long fileSize = Files.size(file);
if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий");
if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается");
byte[] computedHash = digestPrefix(file, fileSize - 96);
try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) {
byte[] magic = new byte[ShineArchiveWriter.MAGIC.length];
in.readFully(magic);
if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic");
int major=in.readUnsignedByte(), minor=in.readUnsignedByte();
if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor);
long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong();
int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte();
long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in);
long chunksCount=u32(in); long recordsCount=u32(in);
if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) {
throw new IOException("Unsupported references table");
}
String creator=readUtf8(in,creatorLen);
if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) {
throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin);
}
if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size");
if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks");
List<Reference> refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE));
for(long i=0;i<refsCount;i++) {
long number=u32(in); byte[] hash=readFixed(in,32); byte[] tx=readFixed(in,32);
refs.add(new Reference(number,hash,Base64.getUrlEncoder().withoutPadding().encodeToString(tx)));
}
if (refs.isEmpty() && parentRef != ShineArchiveWriter.NO_REFERENCE_U32) throw new IOException("Некорректный parent reference");
if (!refs.isEmpty() && parentRef >= refs.size()) throw new IOException("parent_reference_index вне таблицы");
List<Chunk> chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE));
long parsedRecords=0;
for(long ci=0;ci<chunksCount;ci++) {
long chunkOffset=in.getFilePointer(); long chunkSize=u32(in); long chunkEnd=chunkOffset+chunkSize;
if (chunkSize < 21 || chunkEnd > fileSize-96) throw new IOException("Некорректный chunk_size");
int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in);
if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch);
List<byte[]> records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE));
for(long ri=0;ri<count;ri++) {
long n=u32(in); if(n<=0 || n>Integer.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size");
records.add(readFixed(in,(int)n)); parsedRecords++;
}
long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in);
if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы");
if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch);
chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize));
}
if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch");
int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen);
if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login");
if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash");
byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64);
if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature");
if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer");
if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash");
byte[] root=Base58Util.decode(rootPublicKeyBase58);
if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а");
byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32];
System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length);
System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32);
if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а");
return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash);
}
}
private static byte[] digestPrefix(Path file,long bytes) throws Exception {
MessageDigest md=MessageDigest.getInstance("SHA-256");
try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) {
byte[] buf=new byte[1024*1024]; long left=bytes;
while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;}
}
return md.digest();
}
private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); }
private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; }
private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); }
}
@@ -1,310 +0,0 @@
package server.archive;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import utils.crypto.Ed25519Util;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
* Writer бинарного SHINE-ARCHIVE v1.0.
*
* Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки.
*/
public final class ShineArchiveWriter {
public static final byte VERSION_MAJOR = 1;
public static final byte VERSION_MINOR = 0;
public static final byte REFERENCES_FULL = 0;
public static final long NO_REFERENCE_U32 = 0xffff_ffffL;
public static final int REFERENCE_ENTRY_SIZE = 68;
public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII);
public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII);
public record ChunkPosition(long offset, long size) {}
public record BuildResult(Path file, byte[] archiveHash, Map<String, ChunkPosition> chunks, long fileSize) {}
public BuildResult build(
Path finalPath,
String creatorLogin,
long bigBlockNumber,
long createdAtMs,
List<ArchiveBigBlockRef> previousBigBlocks,
List<ArchivePublishJobChain> chains,
Map<String, List<BlockEntry>> recordsByBlockchain,
byte[] signingPrivateSeed32,
long maxFileBytes
) throws IOException {
Objects.requireNonNull(finalPath, "finalPath");
Objects.requireNonNull(previousBigBlocks, "previousBigBlocks");
Objects.requireNonNull(chains, "chains");
Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain");
requireU32(bigBlockNumber, "big_block_number");
if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) {
throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed");
}
byte[] loginBytes = utf8U8(creatorLogin, "creator_login");
if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) {
throw new IllegalArgumentException("Слишком много references/chunks для v1");
}
List<ArchivePublishJobChain> sortedChains = new ArrayList<>(chains);
sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
long totalRecords = 0;
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> rs = recordsByBlockchain.get(ch.blockchainName());
if (rs == null || rs.isEmpty()) {
throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName());
}
totalRecords += rs.size();
requireU32(totalRecords, "total_user_records_count");
}
Map<Long, Integer> referenceIndexByBlock = new HashMap<>();
for (int i = 0; i < previousBigBlocks.size(); i++) {
ArchiveBigBlockRef ref = previousBigBlocks.get(i);
requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number");
require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash");
require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id");
referenceIndexByBlock.put(ref.bigBlockNumber(), i);
}
Files.createDirectories(finalPath.toAbsolutePath().getParent());
Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");
Files.deleteIfExists(tmp);
Map<String, ChunkPosition> positions = new LinkedHashMap<>();
MessageDigest digest = sha256();
try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
CountingOutputStream counting = new CountingOutputStream(fileOut);
DigestOutputStream digestOut = new DigestOutputStream(counting, digest);
DataOutputStream out = new DataOutputStream(digestOut)) {
// ---------- header ----------
out.write(MAGIC);
out.writeByte(VERSION_MAJOR);
out.writeByte(VERSION_MINOR);
long headerSize = fixedHeaderSize() + loginBytes.length;
writeU32(out, headerSize);
writeU32(out, bigBlockNumber);
out.writeLong(createdAtMs);
out.writeByte(loginBytes.length);
out.writeByte(REFERENCES_FULL);
writeU32(out, previousBigBlocks.size());
out.writeShort(REFERENCE_ENTRY_SIZE);
long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L;
writeU32(out, parentIndex);
writeU32(out, sortedChains.size());
writeU32(out, totalRecords);
out.write(loginBytes);
// ---------- полный список предыдущих больших блоков ----------
for (ArchiveBigBlockRef ref : previousBigBlocks) {
writeU32(out, ref.bigBlockNumber());
out.write(ref.archiveHash());
out.write(ref.arweaveTxId());
}
// ---------- по одному chunk на blockchain_name ----------
for (ArchivePublishJobChain ch : sortedChains) {
List<BlockEntry> records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName()));
records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber));
validateFrozenRange(ch, records);
byte[] name = utf8U8(ch.blockchainName(), "blockchain_name");
long chunkSize = calculateChunkSize(name.length, records);
requireU32(chunkSize, "chunk_size");
long chunkOffset = counting.getCount();
requireU32(chunkOffset, "chunk_offset");
if (chunkOffset + chunkSize > maxFileBytes) {
throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize));
}
writeU32(out, chunkSize);
out.writeByte(name.length);
out.write(name);
writeU32(out, records.size());
for (BlockEntry record : records) {
byte[] raw = record.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber());
writeU32(out, raw.length);
out.write(raw);
}
if (ch.previousArchiveBigBlockNumber() == null) {
writeU32(out, NO_REFERENCE_U32);
writeU32(out, 0);
writeU32(out, 0);
} else {
Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber());
if (refIndex == null) {
throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex);
require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName());
if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) {
throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: "
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
}
if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) {
throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName());
}
writeU32(out, refIndex);
writeU32(out, ch.previousChunkOffset());
writeU32(out, ch.previousChunkSize());
}
positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize));
}
// ---------- footer, login входит в hash ----------
out.writeByte(loginBytes.length);
out.write(loginBytes);
out.flush();
byte[] archiveHash = digest.digest();
byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length];
System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length);
System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length);
byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32);
// hash/signature не должны попасть в hash самого блока.
digestOut.on(false);
out.write(archiveHash);
out.write(signature);
out.flush();
long finalSize = counting.getCount();
if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) {
throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize);
}
} catch (Exception e) {
Files.deleteIfExists(tmp);
if (e instanceof IOException ioe) throw ioe;
throw new IOException("Не удалось собрать SHINE-ARCHIVE", e);
}
// До Arweave upload существует уже готовый локальный файл с окончательным именем.
try {
Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
}
byte[] hash = readStoredArchiveHash(finalPath);
long size = Files.size(finalPath);
return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size);
}
/** Проверяет локальный файл перед оплатой Arweave. */
public static byte[] verifyLocalFile(Path file) throws IOException {
long size = Files.size(file);
if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file);
byte[] stored = readStoredArchiveHash(file);
MessageDigest md = sha256();
long hashInputSize = size - 96; // [archive_hash 32][signature 64]
try (InputStream in = Files.newInputStream(file)) {
byte[] buf = new byte[1024 * 1024];
long left = hashInputSize;
while (left > 0) {
int n = in.read(buf, 0, (int)Math.min(buf.length, left));
if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива");
md.update(buf, 0, n);
left -= n;
}
}
byte[] actual = md.digest();
if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer");
return actual;
}
public static byte[] readStoredArchiveHash(Path file) throws IOException {
long size = Files.size(file);
if (size < 96) throw new EOFException("Файл слишком короткий");
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
raf.seek(size - 96);
byte[] hash = new byte[32];
raf.readFully(hash);
return hash;
}
}
private static long fixedHeaderSize() {
// magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount
return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4;
}
private static long calculateChunkSize(int nameLen, List<BlockEntry> records) {
long size = 4L + 1L + nameLen + 4L + 12L;
for (BlockEntry e : records) {
byte[] raw = e.getBlockBytes();
if (raw == null) throw new IllegalArgumentException("raw record is null");
size += 4L + raw.length;
}
return size;
}
private static void validateFrozenRange(ArchivePublishJobChain ch, List<BlockEntry> records) {
long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1;
if (expected <= 0 || expected != records.size()) {
throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName()
+ ": ожидалось=" + expected + ", получено=" + records.size());
}
long n = ch.fromSourceBlockNumber();
for (BlockEntry e : records) {
if (Integer.toUnsignedLong(e.getBlockNumber()) != n) {
throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName()
+ ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber()));
}
n++;
}
}
private static byte[] utf8U8(String value, String name) {
if (value == null) throw new IllegalArgumentException(name + " == null");
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт");
return bytes;
}
private static void require32(byte[] v, String name) {
if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes");
}
private static void requireU32(long v, String name) {
if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v);
}
private static void writeU32(DataOutputStream out, long v) throws IOException {
requireU32(v, "u32");
out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31.
}
private static MessageDigest sha256() {
try { return MessageDigest.getInstance("SHA-256"); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
/** OutputStream со счётчиком без ограничения int. */
private static final class CountingOutputStream extends FilterOutputStream {
private long count;
CountingOutputStream(OutputStream out) { super(out); }
long getCount() { return count; }
@Override public void write(int b) throws IOException { out.write(b); count++; }
@Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; }
}
public static final class ArchiveTooLargeException extends IOException {
public ArchiveTooLargeException(String message) { super(message); }
}
}
@@ -1,373 +0,0 @@
package server.archive;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sync.codec.ShineUsersCodec;
import sync.codec.ShineUsersCodec.UserPdaSnapshot;
import sync.util.Base58Util;
import sync.util.SolanaPdaUtil;
import utils.crypto.Ed25519Util;
import java.io.ByteArrayOutputStream;
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.*;
/**
* Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda.
* Отдельной Solana-инструкции для архива нет.
*/
public final class SolanaArchiveHeadWriter {
private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class);
private static final ObjectMapper JSON = new ObjectMapper();
private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111";
private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111";
private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111";
private static final String USER_SEED_PREFIX = "user_login=";
private static final String ECONOMY_SEED = "shine_users_economy_config";
private static final String INFLOW_SEED = "shine_payments_inflow_vault";
private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8);
private final ArchivePublisherConfig cfg;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
private final byte[] rootSeed32;
private final byte[] clientSeed32;
public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException {
this.cfg = Objects.requireNonNull(cfg);
this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath());
}
public record SubmitResult(String signature, String userPda) {}
/** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */
public void validateKeysAgainstPda() throws Exception {
Current current = loadCurrent();
byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey());
byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey());
if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) {
throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin());
}
if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) {
throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin());
}
}
public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception {
require32(arweaveTxId32, "Arweave TX ID");
require32(archiveHash32, "archive hash");
Current current = loadCurrent();
UserPdaSnapshot old = current.snapshot;
byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32);
byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32);
if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) {
throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA");
}
if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) {
throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA");
}
byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64());
int currentRecordLen = u16le(currentRaw, 7);
byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64);
byte[] prevHash = sha256(currentUnsigned);
long updatedAt = System.currentTimeMillis();
String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32);
String archiveHashHex = hex(archiveHash32);
String zeroSig = Base58Util.encode(new byte[64]);
UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot(
old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "",
old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(),
old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(),
old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex,
old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(),
old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(),
old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, ""
);
byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot));
int nextRecordLen = u16le(nextFull, 7);
byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64);
byte[] nextUnsignedHash = sha256(nextUnsigned);
byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32);
byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature);
byte[] lastStateHash = sha256(buildLastBlockState(old));
byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature());
byte[] blockchainPub = Base58Util.decode(old.blockchainKey());
byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash);
byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash);
String blockhash = getLatestBlockhash();
byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub);
byte[] clientSignature = Ed25519Util.sign(message, clientSeed32);
byte[] transaction = serializeTransaction(clientSignature, message);
String signature = sendTransaction(transaction);
log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature);
return new SubmitResult(signature, current.userPda);
}
public void waitFinalized(String signature) throws Exception {
long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L;
while (System.currentTimeMillis() < deadline) {
JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result");
JsonNode values = result.path("value");
JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null;
if (status != null && !status.isNull()) {
if (status.hasNonNull("err")) {
throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err"));
}
String confirmation = status.path("confirmationStatus").asText("");
if ("finalized".equals(confirmation)) return;
}
Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L);
}
throw new IOException("Timeout ожидания Solana finalized: " + signature);
}
public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception {
Current current = loadCurrent();
if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false;
byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId());
byte[] currentHash = unhex(current.snapshot.archiveHeadHash());
return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32);
}
private Current loadCurrent() throws Exception {
String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT);
String userPda = SolanaPdaUtil.findProgramAddress(
List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)),
cfg.solanaUsersProgramId());
JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result");
JsonNode value = accountResult.path("value");
if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin());
String owner = value.path("owner").asText("");
if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner);
JsonNode data = value.path("data");
if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data");
String rawB64 = data.get(0).asText();
UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, "");
return new Current(userPda, snapshot);
}
private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash,
byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(4); // IX_UPDATE_USER_PDA
strU8(out, s.login());
out.write(Base58Util.decode(s.recoveryKey()));
out.write(Base58Util.decode(s.rootKey()));
u64le(out, s.createdAtMs());
u64le(out, updatedAt);
u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1));
out.write(prevHash);
u64le(out, 0L); // additional_limit
out.write(Base58Util.decode(s.clientKey()));
out.write(Base58Util.decode(s.blockchainKey()));
strU8(out, s.blockchainName());
u64le(out, s.usedBytes());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
out.write(Base58Util.decode(s.lastBlockSignature()));
strU8(out, s.arweaveTxId());
out.write(s.isServer() ? 1 : 0);
if (s.isServer()) {
out.write(s.addressFormatType() & 0xff);
out.write(s.addressFormatVersion() & 0xff);
strU8(out, s.serverAddress());
if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers");
out.write(s.syncServers().size());
for (String v : s.syncServers()) strU8(out, v);
}
if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers");
out.write(s.accessServers().size());
for (String v : s.accessServers()) strU8(out, v);
out.write(s.sessionsMode() & 0xff);
if (s.sessions().size() > 255) throw new IOException("Слишком много sessions");
out.write(s.sessions().size());
for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) {
out.write(session.sessionType() & 0xff);
out.write(session.sessionVersion() & 0xff);
strU8(out, session.sessionName());
out.write(Base58Util.decode(session.sessionPubKey()));
}
out.write(s.trustedCount() & 0xff);
// Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32.
out.write(1);
out.write(archiveTx);
out.write(archiveHash);
out.write(rootSig);
return out.toByteArray();
}
private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(LAST_BLOCK_STATE_PREFIX);
strU8(out, s.login());
strU8(out, s.blockchainName());
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
out.write(unhex(s.lastBlockHash()));
u64le(out, s.usedBytes());
return out.toByteArray();
}
private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd,
String recentBlockhash, byte[] clientPub) throws IOException {
String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId());
String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId());
List<Account> accounts = List.of(
new Account(Base58Util.encode(clientPub), true, true),
new Account(userPda, false, true),
new Account(inflow, false, true),
new Account(SYSTEM_PROGRAM, false, false),
new Account(SYSVAR_INSTRUCTIONS, false, false),
new Account(economy, false, false),
new Account(COMPUTE_BUDGET_PROGRAM, false, false),
new Account(ED25519_PROGRAM, false, false),
new Account(cfg.solanaUsersProgramId(), false, false)
);
Map<String,Integer> ix = new HashMap<>();
for (int i=0;i<accounts.size();i++) ix.put(accounts.get(i).pubkey, i);
List<CompiledInstruction> instructions = new ArrayList<>();
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144)));
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000)));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd));
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd));
instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{
ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow),
ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy)
}, updateData));
ByteArrayOutputStream out = new ByteArrayOutputStream();
out.write(1); // numRequiredSignatures
out.write(0); // numReadonlySignedAccounts
out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users
shortVec(out, accounts.size());
for (Account a : accounts) {
byte[] pk = Base58Util.decode(a.pubkey);
if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey);
out.write(pk);
}
byte[] bh = Base58Util.decode(recentBlockhash);
if (bh.length != 32) throw new IOException("recentBlockhash !=32");
out.write(bh);
shortVec(out, instructions.size());
for (CompiledInstruction ci : instructions) {
out.write(ci.programIdIndex);
shortVec(out, ci.accounts.length);
for (int a : ci.accounts) out.write(a);
shortVec(out, ci.data.length);
out.write(ci.data);
}
return out.toByteArray();
}
private String getLatestBlockhash() throws Exception {
JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result");
String blockhash = result.path("value").path("blockhash").asText("");
if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash");
return blockhash;
}
private String sendTransaction(byte[] tx) throws Exception {
String txB64 = Base64.getEncoder().encodeToString(tx);
JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of(
"encoding", "base64",
"skipPreflight", false,
"preflightCommitment", "confirmed",
"maxRetries", 5
))).path("result");
String sig = result.asText("");
if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature");
return sig;
}
private JsonNode rpc(String method, List<?> params) throws Exception {
Map<String,Object> body = new LinkedHashMap<>();
body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params);
HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl()))
.timeout(Duration.ofSeconds(60))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(JSON.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("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body()));
JsonNode root = JSON.readTree(resp.body());
if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error"));
return root;
}
private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException {
if (signature64.length != 64) throw new IOException("Solana signature !=64");
ByteArrayOutputStream out = new ByteArrayOutputStream();
shortVec(out, 1);
out.write(signature64);
out.write(message);
return out.toByteArray();
}
private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) {
if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature");
int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32;
if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный");
byte[] data = new byte[msgOff + message.length];
data[0]=1; data[1]=0;
putU16le(data,2,sigOff); putU16le(data,4,0xffff);
putU16le(data,6,pkOff); putU16le(data,8,0xffff);
putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff);
System.arraycopy(signature64,0,data,sigOff,64);
System.arraycopy(publicKey32,0,data,pkOff,32);
System.arraycopy(message,0,data,msgOff,message.length);
return data;
}
private static byte[] computeHeapData(int bytes) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray();
}
private static byte[] computeLimitData(int units) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray();
}
private static void shortVec(ByteArrayOutputStream out, int value) {
int rem=value;
while (true) {
int elem=rem & 0x7f; rem >>>=7;
if (rem==0) { out.write(elem); return; }
out.write(elem | 0x80);
}
}
private static void strU8(ByteArrayOutputStream out, String value) throws IOException {
byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8);
if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b);
}
private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); }
private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);}
private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);}
private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); }
private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;}
private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");}
private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";}
private record Current(String userPda, UserPdaSnapshot snapshot) {}
private record Account(String pubkey, boolean signer, boolean writable) {}
private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {}
}
@@ -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);
}
}
@@ -1,34 +0,0 @@
package server.archive;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ArchiveFileNamesTest {
private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli();
private static final ZoneId UTC = ZoneId.of("UTC");
@Test
void usesTemporaryAndFinalArweaveNames() {
byte[] tx = new byte[32];
for (int i = 0; i < tx.length; i++) tx[i] = (byte) i;
String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx);
assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC));
assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive",
ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx));
assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC));
}
@Test
void sanitizesLoginForFileSystemOnly() {
assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive",
ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC));
}
}
@@ -1,83 +0,0 @@
package server.archive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchivePublishJobChain;
import shine.db.entities.BlockEntry;
import java.io.DataInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.ZoneId;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class ShineArchiveWriterTest {
@TempDir Path dir;
@Test
void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception {
Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC"));
ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]);
ArchivePublishJobChain chain = new ArchivePublishJobChain(
1, "alice-001", 10, 11, new byte[32], new byte[32],
1L, new byte[32], 100L, 55L, null, null);
ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build(
file, "serverA", 2, 123456789L,
List.of(previous), List.of(chain),
Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))),
new byte[32], 4_000_000_000L);
assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file));
assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString());
try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) {
assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII));
assertEquals(1, in.readUnsignedByte());
assertEquals(0, in.readUnsignedByte());
long headerSize = Integer.toUnsignedLong(in.readInt());
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals(123456789L, in.readLong());
int creatorLen = in.readUnsignedByte();
assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte());
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
assertEquals(68, in.readUnsignedShort());
assertEquals(0, Integer.toUnsignedLong(in.readInt()));
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8));
assertEquals(headerSize, 51L + creatorLen);
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
in.skipNBytes(64); // previous hash + txid
long chunkSize = Integer.toUnsignedLong(in.readInt());
int nameLen = in.readUnsignedByte();
assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertEquals(3, Integer.toUnsignedLong(in.readInt()));
assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3));
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
assertArrayEquals(new byte[]{4,5}, in.readNBytes(2));
assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index
assertEquals(100, Integer.toUnsignedLong(in.readInt()));
assertEquals(55, Integer.toUnsignedLong(in.readInt()));
assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize);
}
}
private static BlockEntry block(int number, byte[] bytes) {
BlockEntry e = new BlockEntry();
e.setBlockNumber(number);
e.setBlockBytes(bytes);
e.setBlockHash(new byte[32]);
e.setBlockSignature(new byte[64]);
return e;
}
}
@@ -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,364 +9,116 @@ 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,31 +1,32 @@
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, если ссылки нет. */
byte[] toBlockHashBytes();
}
}
@@ -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;
}
}
@@ -1,7 +1,7 @@
# utils.crypto
Пакет отвечает за криптографию — подписи и хэши блоков.
Используется при создании и проверке целостности `.bch`-блоков.
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
---
@@ -40,6 +40,10 @@ public final class DatabaseInitializer {
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";
@@ -63,6 +67,10 @@ public final class DatabaseInitializer {
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() {}
@@ -224,6 +232,22 @@ public final class DatabaseInitializer {
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++) {
@@ -1,8 +0,0 @@
package shine.db.archive;
/** Ссылка на уже успешно опубликованный большой архивный блок. */
public record ArchiveBigBlockRef(
long bigBlockNumber,
byte[] archiveHash,
byte[] arweaveTxId
) {}
@@ -1,14 +0,0 @@
package shine.db.archive;
/** Последний известный архивный chunk конкретной blockchain на этом сервере. */
public record ArchiveBlockchainLocation(
String blockchainName,
String publisherLogin,
String arweaveTxId,
byte[] archiveHash,
long bigBlockNumber,
long chunkOffset,
long chunkSize,
long sourceLastBlockNumber,
long updatedAtMs
) {}
@@ -1,15 +0,0 @@
package shine.db.archive;
/**
* Последнее окончательно опубликованное архивное положение одной SHiNE blockchain.
*/
public record ArchiveChainCursor(
String blockchainName,
long lastArchivedSourceBlockNumber,
byte[] lastArchivedSourceBlockHash,
long lastArchiveBigBlockNumber,
byte[] lastArchiveBigBlockHash,
long lastChunkOffset,
long lastChunkSize,
long updatedAtMs
) {}
@@ -1,16 +0,0 @@
package shine.db.archive;
/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */
public record ArchivePublishJob(
long id,
long bigBlockNumber,
String status,
long createdAtMs,
String localArchivePath,
byte[] archiveHash,
byte[] arweaveTxId,
Integer arweaveConfirmations,
String solanaSignature,
String errorText,
long updatedAtMs
) {}
@@ -1,17 +0,0 @@
package shine.db.archive;
/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */
public record ArchivePublishJobChain(
long jobId,
String blockchainName,
long fromSourceBlockNumber,
long toSourceBlockNumber,
byte[] previousSourceBlockHash,
byte[] lastSourceBlockHash,
Long previousArchiveBigBlockNumber,
byte[] previousArchiveBigBlockHash,
Long previousChunkOffset,
Long previousChunkSize,
Long newChunkOffset,
Long newChunkSize
) {}
@@ -1,14 +0,0 @@
package shine.db.archive;
/**
* Текущая archive head trusted publisher-а из локальной таблицы,
* которую уже заполнил обычный Solana users sync.
*/
public record ArchivePublisherHead(
String login,
String rootKey,
String archiveHeadTxId,
String archiveHeadHash,
boolean archiveImported,
String lastImportedArchiveTxId
) {}
@@ -1,150 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.archive.ArchiveBlockchainLocation;
import shine.db.archive.ArchivePublisherHead;
import java.sql.*;
import java.util.*;
/** БД индекс последних archive chunks и локальное состояние импорта archive head. */
public final class ArchiveImportDAO {
private static volatile ArchiveImportDAO instance;
private final DbController db = DbController.getInstance();
private ArchiveImportDAO() {}
public static ArchiveImportDAO getInstance() {
if (instance == null) synchronized (ArchiveImportDAO.class) {
if (instance == null) instance = new ArchiveImportDAO();
}
return instance;
}
/**
* Возвращает только доверенных publisher-ов, для которых обычный Solana users sync
* уже увидел archive head, но этот head ещё не был полностью импортирован локально.
*/
public List<ArchivePublisherHead> listPendingPublisherHeads(Set<String> approvedLogins) throws SQLException {
if (approvedLogins == null || approvedLogins.isEmpty()) return List.of();
List<ArchivePublisherHead> out = new ArrayList<>();
String sql = """
SELECT login, root_key, archive_head_tx_id, archive_head_hash,
archive_imported, archive_last_imported_tx_id
FROM solana_user_pda_current
WHERE LOWER(login)=LOWER(?)
AND is_server=TRUE
AND archive_head_tx_id<>''
AND archive_head_hash<>''
AND archive_imported=FALSE
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
for (String login : approvedLogins) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
out.add(new ArchivePublisherHead(
rs.getString("login"),
rs.getString("root_key"),
rs.getString("archive_head_tx_id"),
rs.getString("archive_head_hash"),
rs.getBoolean("archive_imported"),
rs.getString("archive_last_imported_tx_id")));
}
}
}
}
return out;
}
/**
* Сдвигает локальный курсор импорта внутри FULL archive chain.
* Update выполняется только если текущий head всё ещё тот же, с которым работает importer.
*/
public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException {
String sql = """
UPDATE solana_user_pda_current
SET archive_last_imported_tx_id=?
WHERE LOWER(login)=LOWER(?)
AND archive_head_tx_id=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, importedTxId == null ? "" : importedTxId);
ps.setString(2, publisherLogin);
ps.setString(3, expectedHeadTxId);
return ps.executeUpdate() == 1;
}
}
/**
* Помечает именно текущий head как полностью импортированный.
* Если Solana users sync успел заменить head на более новый, update не произойдёт.
*/
public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException {
String sql = """
UPDATE solana_user_pda_current
SET archive_imported=TRUE,
archive_last_imported_tx_id=archive_head_tx_id
WHERE LOWER(login)=LOWER(?)
AND archive_head_tx_id=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, publisherLogin);
ps.setString(2, expectedHeadTxId);
return ps.executeUpdate() == 1;
}
}
public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException {
String sql="""
INSERT INTO archive_blockchain_location(
blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
chunk_offset,chunk_size,source_last_block_number,updated_at_ms)
VALUES(?,?,?,?,?,?,?,?,?)
ON CONFLICT(blockchain_name) DO UPDATE SET
publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id,
archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number,
chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size,
source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms
WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId());
ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset());
ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate();
}
}
public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException {
String sql="""
SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
chunk_offset,chunk_size,source_last_block_number,updated_at_ms
FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?)
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,blockchainName);
try(ResultSet rs=ps.executeQuery()) {
if(!rs.next()) return null;
return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4),
rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9));
}
}
}
/** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */
public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException {
String sql="""
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1
""";
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
ps.setString(1,blockchainName);
try(ResultSet rs=ps.executeQuery()) {
if(!rs.next()) return null;
return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4));
}
}
}
public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {}
}
@@ -1,472 +0,0 @@
package shine.db.dao;
import shine.db.DbController;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.archive.ArchivePublishJob;
import shine.db.archive.ArchivePublishJobChain;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
/**
* DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized.
*/
public final class ArchivePublicationDAO {
private static volatile ArchivePublicationDAO instance;
private final DbController db = DbController.getInstance();
private ArchivePublicationDAO() {}
public static ArchivePublicationDAO getInstance() {
if (instance == null) {
synchronized (ArchivePublicationDAO.class) {
if (instance == null) instance = new ArchivePublicationDAO();
}
}
return instance;
}
public ArchiveChainCursor getCursor(String blockchainName) throws SQLException {
try (Connection c = db.getConnection()) {
return getCursor(c, blockchainName);
}
}
public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException {
String sql = """
SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
last_archive_big_block_number, last_archive_big_block_hash,
last_chunk_offset, last_chunk_size, updated_at_ms
FROM archive_chain_cursor
WHERE blockchain_name = ?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, blockchainName);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return null;
return mapCursor(rs);
}
}
}
public long nextBigBlockNumber() throws SQLException {
String sql = """
SELECT COALESCE(MAX(big_block_number), 0) + 1
FROM archive_publish_job
WHERE status = 'CURSORS_COMMITTED'
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong(1);
}
}
public List<ArchiveBigBlockRef> listFinalizedBigBlocks() throws SQLException {
String sql = """
SELECT big_block_number, archive_hash, arweave_tx_id
FROM archive_publish_job
WHERE status = 'CURSORS_COMMITTED'
AND archive_hash IS NOT NULL
AND arweave_tx_id IS NOT NULL
ORDER BY big_block_number ASC
""";
List<ArchiveBigBlockRef> out = new ArrayList<>();
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
out.add(new ArchiveBigBlockRef(
rs.getLong("big_block_number"),
rs.getBytes("archive_hash"),
rs.getBytes("arweave_tx_id")
));
}
}
return out;
}
public ArchivePublishJob findUnfinishedJob() throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job
WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED')
ORDER BY id ASC
LIMIT 1
""";
try (Connection c = db.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapJob(rs) : null;
}
}
public ArchivePublishJob getJob(long id) throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job
WHERE id = ?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? mapJob(rs) : null;
}
}
}
/** Атомарно создаёт frozen archive job и все его blockchain ranges. */
public long createJobWithChains(long bigBlockNumber, long createdAtMs, List<ArchivePublishJobChain> chains) throws SQLException {
if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся");
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
long jobId;
try (PreparedStatement ps = c.prepareStatement("""
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
RETURNING id
""")) {
ps.setLong(1, bigBlockNumber);
ps.setLong(2, createdAtMs);
ps.setLong(3, createdAtMs);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
jobId = rs.getLong(1);
}
}
String sql = """
INSERT INTO archive_publish_job_chain(
job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
for (ArchivePublishJobChain ch : chains) {
bindJobChain(ps, new ArchivePublishJobChain(
jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(),
ch.previousSourceBlockHash(), ch.lastSourceBlockHash(),
ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(),
ch.previousChunkOffset(), ch.previousChunkSize(), null, null));
ps.addBatch();
}
ps.executeBatch();
}
c.commit();
return jobId;
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException se) throw se;
throw new SQLException("Не удалось создать frozen archive job", e);
} finally {
c.setAutoCommit(oldAuto);
}
}
}
public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException {
String sql = """
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
RETURNING id
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, bigBlockNumber);
ps.setLong(2, createdAtMs);
ps.setLong(3, createdAtMs);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
return rs.getLong(1);
}
}
}
public void insertJobChain(ArchivePublishJobChain e) throws SQLException {
String sql = """
INSERT INTO archive_publish_job_chain(
job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
bindJobChain(ps, e);
ps.executeUpdate();
}
}
public List<ArchivePublishJobChain> listJobChains(long jobId) throws SQLException {
String sql = """
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
FROM archive_publish_job_chain
WHERE job_id = ?
ORDER BY blockchain_name ASC
""";
List<ArchivePublishJobChain> out = new ArrayList<>();
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, jobId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(mapJobChain(rs));
}
}
return out;
}
public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException {
String sql = """
UPDATE archive_publish_job_chain
SET new_chunk_offset=?, new_chunk_size=?
WHERE job_id=? AND blockchain_name=?
""";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, offset);
ps.setLong(2, size);
ps.setLong(3, jobId);
ps.setString(4, blockchainName);
if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName);
}
}
public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException {
updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> {
ps.setString(1, localPath);
ps.setBytes(2, hash);
});
}
public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException {
updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId));
}
/** Обновляет только локальное имя/путь уже собранного archive-файла. */
public void updateLocalArchivePath(long jobId, String localPath) throws SQLException {
updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath));
}
public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException {
updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations));
}
public void markSolanaSubmitted(long jobId, String signature) throws SQLException {
updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature));
}
public void markSolanaFinalized(long jobId) throws SQLException {
updateJob(jobId, "SOLANA_FINALIZED", null, null);
}
public void markFailed(long jobId, String error) throws SQLException {
updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error));
}
/**
* После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job.
*/
public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException {
try (Connection c = db.getConnection()) {
boolean oldAuto = c.getAutoCommit();
c.setAutoCommit(false);
try {
ArchivePublishJob job = getJob(c, jobId);
if (job == null) throw new SQLException("Archive job не найден: " + jobId);
List<ArchivePublishJobChain> chains = listJobChains(c, jobId);
long now = System.currentTimeMillis();
String upsert = """
INSERT INTO archive_chain_cursor(
blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
last_archive_big_block_number, last_archive_big_block_hash,
last_chunk_offset, last_chunk_size, updated_at_ms
) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(blockchain_name) DO UPDATE SET
last_archived_source_block_number=EXCLUDED.last_archived_source_block_number,
last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash,
last_archive_big_block_number=EXCLUDED.last_archive_big_block_number,
last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash,
last_chunk_offset=EXCLUDED.last_chunk_offset,
last_chunk_size=EXCLUDED.last_chunk_size,
updated_at_ms=EXCLUDED.updated_at_ms
""";
try (PreparedStatement ps = c.prepareStatement(upsert)) {
for (ArchivePublishJobChain ch : chains) {
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) {
throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName());
}
ps.setString(1, ch.blockchainName());
ps.setLong(2, ch.toSourceBlockNumber());
ps.setBytes(3, ch.lastSourceBlockHash());
ps.setLong(4, job.bigBlockNumber());
ps.setBytes(5, bigBlockHash);
ps.setLong(6, ch.newChunkOffset());
ps.setLong(7, ch.newChunkSize());
ps.setLong(8, now);
ps.addBatch();
}
ps.executeBatch();
}
try (PreparedStatement ps = c.prepareStatement(
"UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) {
ps.setLong(1, now);
ps.setLong(2, jobId);
ps.executeUpdate();
}
c.commit();
} catch (Exception e) {
c.rollback();
if (e instanceof SQLException se) throw se;
throw new SQLException("Не удалось commit archive cursors", e);
} finally {
c.setAutoCommit(oldAuto);
}
}
}
private ArchivePublishJob getJob(Connection c, long id) throws SQLException {
String sql = """
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
error_text, updated_at_ms
FROM archive_publish_job WHERE id=?
""";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; }
}
}
private List<ArchivePublishJobChain> listJobChains(Connection c, long jobId) throws SQLException {
String sql = """
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
previous_source_block_hash, last_source_block_hash,
previous_archive_big_block_number, previous_archive_big_block_hash,
previous_chunk_offset, previous_chunk_size,
new_chunk_offset, new_chunk_size
FROM archive_publish_job_chain
WHERE job_id=? ORDER BY blockchain_name ASC
""";
List<ArchivePublishJobChain> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setLong(1, jobId);
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); }
}
return out;
}
@FunctionalInterface
private interface Binder { void bind(PreparedStatement ps) throws SQLException; }
private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException {
if (setFragment != null && !setFragment.isBlank()) {
try (Connection c = db.getConnection()) {
updateJobWithExtra(c, jobId, status, setFragment, binder);
}
return;
}
String sql = status == null
? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?"
: "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?";
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
int i = 1;
if (status != null) ps.setString(i++, status);
ps.setLong(i++, System.currentTimeMillis());
ps.setLong(i, jobId);
ps.executeUpdate();
}
}
private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException {
String statusSql = status == null ? "" : ", status=?";
String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?";
try (PreparedStatement ps = c.prepareStatement(sql)) {
binder.bind(ps);
int count = countParameters(setFragment);
int i = count + 1;
if (status != null) ps.setString(i++, status);
ps.setLong(i++, System.currentTimeMillis());
ps.setLong(i, jobId);
ps.executeUpdate();
}
}
private static int countParameters(String sqlPart) {
int n = 0;
for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++;
return n;
}
private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException {
int i=1;
ps.setLong(i++, e.jobId());
ps.setString(i++, e.blockchainName());
ps.setLong(i++, e.fromSourceBlockNumber());
ps.setLong(i++, e.toSourceBlockNumber());
ps.setBytes(i++, e.previousSourceBlockHash());
ps.setBytes(i++, e.lastSourceBlockHash());
setLongNullable(ps, i++, e.previousArchiveBigBlockNumber());
ps.setBytes(i++, e.previousArchiveBigBlockHash());
setLongNullable(ps, i++, e.previousChunkOffset());
setLongNullable(ps, i++, e.previousChunkSize());
setLongNullable(ps, i++, e.newChunkOffset());
setLongNullable(ps, i, e.newChunkSize());
}
private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException {
return new ArchiveChainCursor(
rs.getString("blockchain_name"),
rs.getLong("last_archived_source_block_number"),
rs.getBytes("last_archived_source_block_hash"),
rs.getLong("last_archive_big_block_number"),
rs.getBytes("last_archive_big_block_hash"),
rs.getLong("last_chunk_offset"),
rs.getLong("last_chunk_size"),
rs.getLong("updated_at_ms")
);
}
private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException {
int conf = rs.getInt("arweave_confirmations");
Integer confObj = rs.wasNull() ? null : conf;
return new ArchivePublishJob(
rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"),
rs.getLong("created_at_ms"), rs.getString("local_archive_path"),
rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj,
rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms")
);
}
private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException {
return new ArchivePublishJobChain(
rs.getLong("job_id"), rs.getString("blockchain_name"),
rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"),
rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"),
getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"),
getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"),
getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size")
);
}
private static Long getLongNullable(ResultSet rs, String name) throws SQLException {
long v = rs.getLong(name);
return rs.wasNull() ? null : v;
}
private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException {
if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v);
}
}
@@ -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,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,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,
@@ -503,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),
@@ -514,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,
@@ -1660,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
@@ -2028,52 +2062,6 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
PRIMARY KEY (owner_login, category)
);
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
@@ -2093,16 +2081,111 @@ 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
-- 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 INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher ON archive_blockchain_location(publisher_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);
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,23,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,10 +42,25 @@ 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_GetArchiveBlockchainLocation_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_GetArchiveBlockchainLocation_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.entyties.Net_GetUser_Request;
@@ -193,7 +208,16 @@ public final class JsonHandlerRegistry {
// --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_Handler()),
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_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()),
@@ -286,7 +310,16 @@ public final class JsonHandlerRegistry {
// --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_Request.class),
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
Map.entry("GetArchiveBlockchainLocation", Net_GetArchiveBlockchainLocation_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),
@@ -28,7 +28,6 @@ public final class SolanaUserPdaImportService {
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final String MAGIC = "SHiNE";
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64;
private SolanaUserPdaImportService() {}
@@ -55,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);
@@ -156,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;
@@ -167,228 +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 if (blockType == 100) {
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
} 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 if (blockType == 100) {
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
} 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();
@@ -400,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);
@@ -98,11 +98,24 @@ 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(),
true,
true
);
@@ -151,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) {
@@ -163,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" -> "Некорректный номер блока";
@@ -181,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;
};
}
@@ -190,7 +224,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
int globalNumberFromReq,
String prevGlobalHashHexFromReq,
String blockBytesB64,
boolean replicateAfterWrite
boolean replicateAfterWrite,
boolean publishToArweave
) {
if (blockchainName == null || blockchainName.isBlank()) {
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
@@ -272,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();
@@ -401,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 {
@@ -458,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);
@@ -516,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);
@@ -529,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);
@@ -626,18 +683,26 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
* но не запускает повторную межсерверную репликацию уже импортированного блока.
*/
public ArchiveImportResult addBlockFromArchive(String blockchainName, byte[] rawBlockBytes) {
public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) {
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
return new ArchiveImportResult(false, "chain_resync_in_progress", -1, "");
return new ArweaveImportResult(false, "chain_resync_in_progress", -1, "");
}
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
return new ArchiveImportResult(false, "empty_block", -1, "");
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 ArchiveImportResult(false, "bad_block_format", -1, "");
return new ArweaveImportResult(false, "bad_block_format", -1, "");
}
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
lock.lock();
@@ -647,20 +712,40 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
parsed.blockNumber,
toHex(parsed.prevHash32),
Base64Ws.encode(rawBlockBytes),
false,
false
);
return new ArchiveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
} finally {
lock.unlock();
}
}
public record ArchiveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
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;
@@ -13,35 +13,17 @@ 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;
@@ -49,7 +31,6 @@ public final class BlockchainWriter {
private final UserParamsDAO userParamsDAO;
private final UserNotificationsStateDAO userNotificationsStateDAO;
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
private final FileStoreUtil fs = FileStoreUtil.getInstance();
public BlockchainWriter(BlocksDAO blocksDAO,
BlockchainStateDAO stateDAO,
@@ -63,6 +44,10 @@ public final class BlockchainWriter {
this.userNotificationsStateDAO = userNotificationsStateDAO;
}
/**
* 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,
@@ -73,48 +58,24 @@ 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);
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);
}
// Channel read/unread projection is updated in the same SQL transaction as the block.
int msgType = block.type & 0xFFFF;
int msgSubType = block.subType & 0xFFFF;
Integer lineCode = be.getLineCode();
@@ -126,7 +87,6 @@ public final class BlockchainWriter {
}
}
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
if (msgType == 3
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
&& be.getToBchName() != null && !be.getToBchName().isBlank()
@@ -138,39 +98,14 @@ public final class BlockchainWriter {
}
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;
}
@@ -183,46 +118,4 @@ public final class BlockchainWriter {
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
}
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();
}
}
@@ -1,52 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.blockchain;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.*;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.*;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.archive.ArchiveBlockchainLocation;
import shine.db.archive.ArchiveBigBlockRef;
import shine.db.archive.ArchiveChainCursor;
import shine.db.dao.ArchiveImportDAO;
import shine.db.dao.ArchivePublicationDAO;
import utils.config.AppConfig;
import java.util.Base64;
public final class Net_GetArchiveBlockchainLocation_Handler implements JsonMessageHandler {
@Override public Net_Response handle(Net_Request base, ConnectionContext ctx) {
Net_GetArchiveBlockchainLocation_Request req=(Net_GetArchiveBlockchainLocation_Request)base;
String bch=String.valueOf(req.getBlockchainName()==null?"":req.getBlockchainName()).trim();
if(bch.isEmpty()) return NetExceptionResponseFactory.error(req,WireCodes.Status.BAD_REQUEST,"BAD_FIELDS","blockchainName обязателен");
try {
ArchiveImportDAO importDao = ArchiveImportDAO.getInstance();
ArchiveBlockchainLocation e=importDao.getLocation(bch);
if (e == null) {
// Backward-compatible fallback для publisher-а, который уже имел v22 cursors до появления v23 location index.
ArchivePublicationDAO publicationDao = ArchivePublicationDAO.getInstance();
ArchiveChainCursor cursor = publicationDao.getCursor(bch);
if (cursor != null) {
ArchiveBigBlockRef matching = publicationDao.listFinalizedBigBlocks().stream()
.filter(ref -> ref.bigBlockNumber() == cursor.lastArchiveBigBlockNumber())
.findFirst().orElse(null);
if (matching != null && matching.arweaveTxId() != null && matching.arweaveTxId().length == 32) {
String publisher = java.util.Objects.toString(AppConfig.getInstance().getParam("server.SHiNE.login"), "").trim();
String txId = Base64.getUrlEncoder().withoutPadding().encodeToString(matching.arweaveTxId());
e = new ArchiveBlockchainLocation(
bch, publisher, txId, cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), System.currentTimeMillis());
importDao.upsertLocation(e);
}
}
}
if(e==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.NOT_FOUND,"ARCHIVE_LOCATION_NOT_FOUND","Архивная ссылка пока не известна этому серверу");
Net_GetArchiveBlockchainLocation_Response r=new Net_GetArchiveBlockchainLocation_Response();
r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK);
r.setBlockchainName(e.blockchainName()); r.setPublisherLogin(e.publisherLogin()); r.setArweaveTxId(e.arweaveTxId()); r.setArchiveHash(hex(e.archiveHash()));
r.setBigBlockNumber(e.bigBlockNumber()); r.setChunkOffset(e.chunkOffset()); r.setChunkSize(e.chunkSize()); r.setSourceLastBlockNumber(e.sourceLastBlockNumber()); return r;
} catch(Exception e) { return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"INTERNAL_ERROR",NetExceptionResponseFactory.detailedMessage("Не удалось получить архивную ссылку",e)); }
}
private static String hex(byte[] b){if(b==null)return"";StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
}
@@ -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();
}
}
@@ -1,9 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public final class Net_GetArchiveBlockchainLocation_Request extends Net_Request {
private String blockchainName;
public String getBlockchainName(){return blockchainName;}
public void setBlockchainName(String value){this.blockchainName=value;}
}
@@ -1,16 +0,0 @@
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
public final class Net_GetArchiveBlockchainLocation_Response extends Net_Response {
private String blockchainName,publisherLogin,arweaveTxId,archiveHash;
private long bigBlockNumber,chunkOffset,chunkSize,sourceLastBlockNumber;
public String getBlockchainName(){return blockchainName;} public void setBlockchainName(String v){blockchainName=v;}
public String getPublisherLogin(){return publisherLogin;} public void setPublisherLogin(String v){publisherLogin=v;}
public String getArweaveTxId(){return arweaveTxId;} public void setArweaveTxId(String v){arweaveTxId=v;}
public String getArchiveHash(){return archiveHash;} public void setArchiveHash(String v){archiveHash=v;}
public long getBigBlockNumber(){return bigBlockNumber;} public void setBigBlockNumber(long v){bigBlockNumber=v;}
public long getChunkOffset(){return chunkOffset;} public void setChunkOffset(long v){chunkOffset=v;}
public long getChunkSize(){return chunkSize;} public void setChunkSize(long v){chunkSize=v;}
public long getSourceLastBlockNumber(){return sourceLastBlockNumber;} public void setSourceLastBlockNumber(long v){sourceLastBlockNumber=v;}
}
@@ -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; }
}
}
@@ -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,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 {
}

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