SHA256
Compare commits
41
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
9be4c4d3a5 | ||
|
|
f5698771e4 | ||
|
|
151a2c1754 | ||
|
|
13693a0a53 | ||
|
|
451280edd2 | ||
|
|
c997057a37 | ||
|
|
f8900e531a | ||
|
|
6e5b57fd7c | ||
|
|
408e474130 | ||
|
|
ef707ec217 | ||
|
|
8b12760dde | ||
|
|
cae64639bd | ||
|
|
be696d116b | ||
|
|
23969121e5 | ||
|
|
4da2d7129a | ||
|
|
2be5ad0b7d | ||
|
|
f44ab75c64 | ||
|
|
7aa05dc609 | ||
|
|
7b81d140ec | ||
|
|
1be1d56599 | ||
|
|
2df0a78eb2 | ||
|
|
c3253dceac | ||
|
|
ef068eca81 | ||
|
|
f9ae811702 | ||
|
|
ea0098d705 | ||
|
|
4991a146d5 | ||
|
|
849250bfa8 | ||
|
|
d83f1d4cce | ||
|
|
4c7e71f21f | ||
|
|
107f85b818 | ||
|
|
3a466b4c38 | ||
|
|
08663fa339 | ||
|
|
8e86872aa7 | ||
|
|
2d059e9ff5 | ||
|
|
9f77c54955 | ||
|
|
f827c3e493 | ||
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 | ||
|
|
7d27bfdcaf | ||
|
|
023d61a1e9 | ||
|
|
85d90a7f95 |
@@ -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
|
||||
@@ -105,6 +105,7 @@ ESP32/**/*.a
|
||||
# Полные серверные бэкапы (тяжёлые архивы, не коммитим)
|
||||
deploy/backup/archive/**
|
||||
!deploy/backup/archive/.gitkeep
|
||||
SHiNE-bundle-*.zip
|
||||
|
||||
# Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим
|
||||
.agents/
|
||||
|
||||
@@ -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-настроек обязательно обновлять:
|
||||
|
||||
@@ -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
@@ -18,15 +18,11 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
|
||||
|
||||
## Настройка сервера в Solana (Solana PDA)
|
||||
|
||||
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
|
||||
В PDA хранятся:
|
||||
Серверный аккаунт SHiNE регистрируется как обычный `user_pda` формата 1.2 с присутствующим `ServerProfileBlock`. Отдельного `is_server` в PDA нет: наличие server profile означает, что аккаунт объявляет серверный endpoint.
|
||||
|
||||
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
|
||||
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
||||
с которыми синхронизируются пользовательские блокчейны;
|
||||
- **корневой ключ** сервера (`root_key`).
|
||||
PDA 1.2 сейчас допускает **один** адрес сервера (`address_format_type + address_format_version + address`). Бинарный блок использует count/array, чтобы будущая версия протокола могла увеличить лимит без смены структуры блока. Прямой список `sync_servers` удалён: пользовательские blockchain синхронизируются через Arweave.
|
||||
|
||||
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
||||
Клиенты читают server PDA из Solana, получают опубликованный endpoint и подключаются к нему. Межсерверная доставка DM через access-server routing остаётся отдельным механизмом.
|
||||
|
||||
**Управление серверной PDA выполняется через Web-панель администратора:**
|
||||
|
||||
@@ -36,10 +32,9 @@ shine-UI/server-ui.html
|
||||
|
||||
Страницы:
|
||||
- `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта;
|
||||
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers.
|
||||
- `shine-UI/server-ui/update-server-pda.html` — обновление адреса сервера.
|
||||
|
||||
Для регистрации нужен полный keyBundle (root + device + blockchain).
|
||||
Для обновления — только root + device (blockchain-ключ не нужен).
|
||||
PDA 1.2 использует три постоянные роли ключей: `root` (cold recovery), `blockchain` (обычный authority и подпись блоков) и `client` (клиент/кошелёк). Обычный update авторизуется активным последним blockchain key; root используется для recovery и может менять root.
|
||||
|
||||
Актуальные адреса программ Solana (devnet):
|
||||
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
|
||||
@@ -47,12 +42,9 @@ shine-UI/server-ui.html
|
||||
|
||||
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
|
||||
|
||||
## Синхронизация с партнёрскими серверами
|
||||
## Синхронизация пользовательских blockchain
|
||||
|
||||
Сервер должен синхронизировать блоки пользовательских блокчейнов с
|
||||
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
|
||||
они доставляются только на первый access-сервер получателя.
|
||||
Детали: `docs/Blockchain/sync-between-servers.md`
|
||||
Прямой peer-to-peer blockchain sync через `sync_servers` отключён. Сервер восстанавливает и синхронизирует пользовательские записи через Arweave. Код старого peer-sync пока может оставаться как inert legacy, но не должен включаться в runtime.
|
||||
|
||||
## Деплой
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'shine'
|
||||
version = '1.0.0'
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
}
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
dependencies {
|
||||
implementation project(':shine-server-config')
|
||||
implementation project(':shine-server-db')
|
||||
implementation project(':shine-server-crypto')
|
||||
implementation project(':shine-server-blockchain')
|
||||
implementation project(':shine-server-net-protocol')
|
||||
implementation project(':shine-server-solana-users-sync')
|
||||
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
|
||||
implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1'
|
||||
implementation 'org.slf4j:slf4j-api:2.0.16'
|
||||
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'
|
||||
}
|
||||
|
||||
test { useJUnitPlatform() }
|
||||
@@ -0,0 +1,103 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/** Minimal ANS-104 binary bundle writer/reader. */
|
||||
public final class Ans104Bundle {
|
||||
public record Entry(byte[] id32, byte[] rawDataItem) {}
|
||||
|
||||
private Ans104Bundle() {}
|
||||
|
||||
public static byte[] write(List<byte[]> rawItems) {
|
||||
if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty");
|
||||
if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items");
|
||||
|
||||
List<Ans104DataItem> parsed = new ArrayList<>(rawItems.size());
|
||||
long total = 32L + 64L * rawItems.size();
|
||||
for (byte[] raw : rawItems) {
|
||||
Ans104DataItem item = new Ans104DataItem(raw);
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature");
|
||||
parsed.add(item);
|
||||
total = Math.addExact(total, raw.length);
|
||||
}
|
||||
if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
|
||||
out.writeBytes(uint256LE(rawItems.size()));
|
||||
for (int i = 0; i < rawItems.size(); i++) {
|
||||
out.writeBytes(uint256LE(rawItems.get(i).length));
|
||||
out.writeBytes(parsed.get(i).id32());
|
||||
}
|
||||
for (byte[] raw : rawItems) out.writeBytes(raw);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
public static List<Entry> read(byte[] bundle, int maxItems) {
|
||||
if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short");
|
||||
long countLong = readUint256AsLong(bundle, 0);
|
||||
if (countLong <= 0 || countLong > Math.max(1, maxItems)) {
|
||||
throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong);
|
||||
}
|
||||
int count = (int) countLong;
|
||||
long headerLong = 32L + 64L * count;
|
||||
if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated");
|
||||
int header = (int) headerLong;
|
||||
long[] sizes = new long[count];
|
||||
byte[][] ids = new byte[count][];
|
||||
int hp = 32;
|
||||
long payloadBytes = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
sizes[i] = readUint256AsLong(bundle, hp); hp += 32;
|
||||
ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32;
|
||||
if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]);
|
||||
payloadBytes = Math.addExact(payloadBytes, sizes[i]);
|
||||
}
|
||||
if (headerLong + payloadBytes != bundle.length) {
|
||||
throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length);
|
||||
}
|
||||
List<Entry> out = new ArrayList<>(count);
|
||||
int p = header;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int len = (int) sizes[i];
|
||||
byte[] raw = Arrays.copyOfRange(bundle, p, p + len);
|
||||
p += len;
|
||||
Ans104DataItem item = new Ans104DataItem(raw);
|
||||
if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i);
|
||||
out.add(new Entry(ids[i], raw));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) {
|
||||
if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes");
|
||||
for (Entry e : read(bundle, maxItems)) {
|
||||
if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ANS-104 bundle integers are 32-byte unsigned little-endian values. */
|
||||
static byte[] uint256LE(long value) {
|
||||
if (value < 0) throw new IllegalArgumentException("negative uint256");
|
||||
byte[] out = new byte[32];
|
||||
long v = value;
|
||||
for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; }
|
||||
return out;
|
||||
}
|
||||
|
||||
static long readUint256AsLong(byte[] data, int offset) {
|
||||
if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated");
|
||||
for (int i = offset + 8; i < offset + 32; i++) {
|
||||
if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||
}
|
||||
long v = 0;
|
||||
for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL);
|
||||
if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||
return v;
|
||||
}
|
||||
}
|
||||
+37
@@ -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(); }
|
||||
}
|
||||
+174
@@ -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();
|
||||
}
|
||||
}
|
||||
+32
@@ -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();}
|
||||
}
|
||||
+252
@@ -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("\"","\\\"");}
|
||||
}
|
||||
+99
@@ -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));}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.*;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.security.spec.RSAPrivateCrtKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/** Existing native Arweave L1 transport adapted to upload in-memory ANS-104 bundles. */
|
||||
public final class ArweaveL1Uploader {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveL1Uploader.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
|
||||
|
||||
public record Tag(String name, String value) {}
|
||||
public record UploadResult(String txId, byte[] txIdBytes) {}
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private volatile Wallet wallet;
|
||||
|
||||
public ArweaveL1Uploader(ArweaveBlocksConfig cfg) { this.cfg = Objects.requireNonNull(cfg); }
|
||||
|
||||
public UploadResult upload(byte[] data, List<Tag> tags) throws Exception {
|
||||
if (data == null || data.length == 0) throw new IllegalArgumentException("Arweave upload data is empty");
|
||||
Wallet w = wallet();
|
||||
String gateway = trimGateway(cfg.publishGateway());
|
||||
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(data);
|
||||
String anchor = getRequiredText(gateway, "/tx_anchor");
|
||||
String reward = getRequiredText(gateway, "/price/" + data.length);
|
||||
if (!reward.matches("^\\d+$")) throw new IOException("Bad Arweave price: " + reward);
|
||||
verifyBalance(gateway, w.address(), reward);
|
||||
|
||||
List<List<byte[]>> tagList = new ArrayList<>();
|
||||
List<Map<String,String>> jsonTags = new ArrayList<>();
|
||||
for (Tag tag : tags == null ? List.<Tag>of() : tags) addTag(jsonTags, tagList, tag.name(), tag.value());
|
||||
|
||||
byte[] signaturePayload = deepHash(List.of(
|
||||
utf8("2"), b64Decode(w.owner()), new byte[0], utf8("0"), utf8(reward),
|
||||
b64Decode(anchor), tagList, utf8(Integer.toString(data.length)), merkle.dataRoot()
|
||||
));
|
||||
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
|
||||
byte[] txIdBytes = sha256(rawSignature);
|
||||
String txId = b64(txIdBytes);
|
||||
|
||||
Map<String,Object> tx = new LinkedHashMap<>();
|
||||
tx.put("format",2); tx.put("id",txId); tx.put("last_tx",anchor); tx.put("owner",w.owner());
|
||||
tx.put("tags",jsonTags); tx.put("target",""); tx.put("quantity","0");
|
||||
tx.put("data_root",b64(merkle.dataRoot())); tx.put("data_size",Integer.toString(data.length)); tx.put("data","");
|
||||
tx.put("reward",reward); tx.put("signature",b64(rawSignature));
|
||||
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
|
||||
|
||||
for (int i=0;i<merkle.chunks().size();i++) {
|
||||
ArweaveMerkle.Chunk meta=merkle.chunks().get(i); ArweaveMerkle.Proof proof=merkle.proofs().get(i);
|
||||
Map<String,Object> body=new LinkedHashMap<>();
|
||||
body.put("data_root",b64(merkle.dataRoot())); body.put("data_size",Integer.toString(data.length));
|
||||
body.put("data_path",b64(proof.proof())); body.put("offset",Long.toString(proof.offset()));
|
||||
body.put("chunk",b64(ArweaveMerkle.readChunk(data,meta)));
|
||||
postChunkWithRetry(gateway,body,i,merkle.chunks().size());
|
||||
}
|
||||
if (cfg.minConfirmations() > 0) waitForConfirmations(txId);
|
||||
log.info("ANS-104 bundle uploaded to Arweave: tx={} bytes={} items-transported",txId,data.length);
|
||||
return new UploadResult(txId,txIdBytes);
|
||||
}
|
||||
|
||||
private int waitForConfirmations(String txId) throws Exception {
|
||||
String gateway=trimGateway(cfg.publishGateway());
|
||||
long deadline=System.currentTimeMillis()+cfg.confirmTimeoutMinutes()*60_000L; int last=0;
|
||||
while(System.currentTimeMillis()<deadline){
|
||||
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+"/tx/"+txId+"/status")).timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if(resp.statusCode()==200){JsonNode j=MAPPER.readTree(resp.body());last=j.path("number_of_confirmations").asInt(0);if(last>=cfg.minConfirmations())return last;}
|
||||
else if(resp.statusCode()!=202&&resp.statusCode()!=404)log.warn("Arweave status tx={} HTTP {}",txId,resp.statusCode());
|
||||
Thread.sleep(cfg.confirmPollSeconds()*1000L);
|
||||
}
|
||||
throw new IOException("Arweave confirmation timeout for "+txId+", last="+last);
|
||||
}
|
||||
|
||||
private void postChunkWithRetry(String gateway,Map<String,Object> body,int index,int total)throws Exception{
|
||||
Exception last=null;
|
||||
for(int attempt=1;attempt<=12;attempt++)try{postJsonExpect2xx(gateway+"/chunk",body,Duration.ofSeconds(60));return;}catch(Exception e){last=e;if(attempt==12)break;Thread.sleep(5000L);}
|
||||
throw new IOException("Failed Arweave chunk "+(index+1)+"/"+total,last);
|
||||
}
|
||||
private void postJsonExpect2xx(String url,Object body,Duration timeout)throws Exception{
|
||||
HttpRequest req=HttpRequest.newBuilder(URI.create(url)).timeout(timeout).header("Content-Type","application/json").header("Accept","application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body),StandardCharsets.UTF_8)).build();
|
||||
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("HTTP "+resp.statusCode()+" "+safe(resp.body()));
|
||||
}
|
||||
private void verifyBalance(String gateway,String address,String reward)throws Exception{
|
||||
String balance=getRequiredText(gateway,"/wallet/"+address+"/balance");
|
||||
if(balance.matches("^\\d+$")&&new BigInteger(balance).compareTo(new BigInteger(reward))<0)throw new IllegalStateException("Not enough AR: balance="+balance+" reward="+reward);
|
||||
}
|
||||
private String getRequiredText(String gateway,String path)throws Exception{
|
||||
HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+path)).timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("Arweave HTTP "+resp.statusCode()+" for "+path+": "+safe(resp.body()));
|
||||
String s=resp.body()==null?"":resp.body().trim();if(s.isBlank())throw new IOException("Empty Arweave response for "+path);return s;
|
||||
}
|
||||
private Wallet wallet()throws Exception{
|
||||
Wallet local=wallet;if(local!=null)return local;synchronized(this){if(wallet!=null)return wallet;
|
||||
JsonNode jwk=MAPPER.readTree(Files.readString(cfg.walletJwkPath(),StandardCharsets.UTF_8));String owner=required(jwk,"n");
|
||||
PrivateKey pk=buildPrivateKey(jwk);String address=b64(sha256(b64Decode(owner)));return wallet=new Wallet(owner,address,pk);}
|
||||
}
|
||||
private static PrivateKey buildPrivateKey(JsonNode jwk)throws GeneralSecurityException{
|
||||
RSAPrivateCrtKeySpec spec=new RSAPrivateCrtKeySpec(big(required(jwk,"n")),big(required(jwk,"e")),big(required(jwk,"d")),big(required(jwk,"p")),big(required(jwk,"q")),big(required(jwk,"dp")),big(required(jwk,"dq")),big(required(jwk,"qi")));
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
}
|
||||
private static byte[] signPayload(PrivateKey key,byte[] payload)throws GeneralSecurityException{
|
||||
Signature s=Signature.getInstance("RSASSA-PSS");s.setParameter(new PSSParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,32,1));s.initSign(key);s.update(payload);return s.sign();
|
||||
}
|
||||
private static byte[] deepHash(Object data)throws GeneralSecurityException{
|
||||
if(data instanceof List<?> list){byte[] acc=sha384(utf8("list"+list.size()));for(Object item:list)acc=sha384(concat(acc,deepHash(item)));return acc;}
|
||||
if(!(data instanceof byte[] bytes))throw new IllegalArgumentException("deepHash byte[]/List expected");return sha384(concat(sha384(utf8("blob"+bytes.length)),sha384(bytes)));
|
||||
}
|
||||
private static void addTag(List<Map<String,String>> json,List<List<byte[]>> raw,String name,String value){byte[]n=utf8(name),v=utf8(value);Map<String,String>m=new LinkedHashMap<>();m.put("name",b64(n));m.put("value",b64(v));json.add(m);raw.add(List.of(n,v));}
|
||||
private static String required(JsonNode n,String f){String s=n==null?"":n.path(f).asText("").trim();if(s.isBlank())throw new IllegalStateException("Arweave JWK missing "+f);return s;}
|
||||
private static BigInteger big(String s){return new BigInteger(1,b64Decode(s));}
|
||||
private static String trimGateway(String s){return(s==null?"https://arweave.net":s.trim()).replaceAll("/+$","");}
|
||||
private static byte[] utf8(String s){return String.valueOf(s==null?"":s).getBytes(StandardCharsets.UTF_8);}
|
||||
private static byte[] b64Decode(String s){return B64D.decode(s);}private static String b64(byte[]b){return B64.encodeToString(b);}
|
||||
private static byte[] sha256(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-256").digest(b);}private static byte[] sha384(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-384").digest(b);}
|
||||
private static byte[] concat(byte[]...a){int n=0;for(byte[]x:a)n=Math.addExact(n,x.length);byte[]o=new byte[n];int p=0;for(byte[]x:a){System.arraycopy(x,0,o,p,x.length);p+=x.length;}return o;}
|
||||
private static String safe(String s){s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return s.length()<=250?s:s.substring(0,250)+"...";}
|
||||
private record Wallet(String owner,String address,PrivateKey privateKey){}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package server.archive;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts.
|
||||
*/
|
||||
final class ArweaveMerkle {
|
||||
static final int MAX_CHUNK_SIZE = 256 * 1024;
|
||||
static final int MIN_CHUNK_SIZE = 32 * 1024;
|
||||
|
||||
record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) {
|
||||
long size() { return maxByteRange - minByteRange; }
|
||||
}
|
||||
record Proof(long offset, byte[] proof) {}
|
||||
record Prepared(byte[] dataRoot, List<Chunk> chunks, List<Proof> proofs) {}
|
||||
|
||||
private sealed interface Node permits Leaf, Branch {
|
||||
byte[] id();
|
||||
long maxByteRange();
|
||||
}
|
||||
private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {}
|
||||
private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {}
|
||||
|
||||
private ArweaveMerkle() {}
|
||||
|
||||
|
||||
static Prepared prepare(byte[] data) throws IOException {
|
||||
if (data == null || data.length == 0) throw new IOException("Arweave data is empty");
|
||||
List<Chunk> chunks = chunkBytes(data);
|
||||
List<Node> leaves = new ArrayList<>(chunks.size());
|
||||
for (Chunk c : chunks) {
|
||||
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
|
||||
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
|
||||
}
|
||||
Node root = buildLayers(leaves);
|
||||
List<Proof> proofs = new ArrayList<>();
|
||||
resolveProofs(root, new byte[0], proofs);
|
||||
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
|
||||
chunks = new ArrayList<>(chunks);
|
||||
chunks.remove(chunks.size() - 1);
|
||||
proofs.remove(proofs.size() - 1);
|
||||
}
|
||||
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
|
||||
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
|
||||
}
|
||||
|
||||
private static List<Chunk> chunkBytes(byte[] data) {
|
||||
List<Chunk> out = new ArrayList<>();
|
||||
long remaining = data.length;
|
||||
long cursor = 0;
|
||||
int p = 0;
|
||||
while (remaining >= MAX_CHUNK_SIZE) {
|
||||
long chunkSize = MAX_CHUNK_SIZE;
|
||||
long next = remaining - MAX_CHUNK_SIZE;
|
||||
if (next > 0 && next < MIN_CHUNK_SIZE) chunkSize = (remaining + 1) / 2;
|
||||
byte[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize);
|
||||
out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize));
|
||||
p += (int) chunkSize;
|
||||
cursor += chunkSize;
|
||||
remaining -= chunkSize;
|
||||
}
|
||||
byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining);
|
||||
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||
return out;
|
||||
}
|
||||
|
||||
static byte[] readChunk(byte[] data, Chunk c) throws IOException {
|
||||
long len = c.size();
|
||||
if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) {
|
||||
throw new IOException("Invalid Arweave chunk range");
|
||||
}
|
||||
return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange());
|
||||
}
|
||||
|
||||
static Prepared prepare(Path file) throws IOException {
|
||||
long fileSize = Files.size(file);
|
||||
List<Chunk> chunks = chunkFile(file, fileSize);
|
||||
List<Node> leaves = new ArrayList<>(chunks.size());
|
||||
for (Chunk c : chunks) {
|
||||
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
|
||||
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
|
||||
}
|
||||
if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves");
|
||||
Node root = buildLayers(leaves);
|
||||
List<Proof> proofs = new ArrayList<>();
|
||||
resolveProofs(root, new byte[0], proofs);
|
||||
|
||||
// arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB,
|
||||
// но сам нулевой chunk/proof не загружает.
|
||||
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
|
||||
chunks = new ArrayList<>(chunks);
|
||||
chunks.remove(chunks.size() - 1);
|
||||
proofs.remove(proofs.size() - 1);
|
||||
}
|
||||
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
|
||||
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
|
||||
}
|
||||
|
||||
private static List<Chunk> chunkFile(Path file, long fileSize) throws IOException {
|
||||
List<Chunk> out = new ArrayList<>();
|
||||
long remaining = fileSize;
|
||||
long cursor = 0;
|
||||
try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) {
|
||||
while (remaining >= MAX_CHUNK_SIZE) {
|
||||
long chunkSize = MAX_CHUNK_SIZE;
|
||||
long next = remaining - MAX_CHUNK_SIZE;
|
||||
if (next > 0 && next < MIN_CHUNK_SIZE) {
|
||||
chunkSize = (remaining + 1) / 2;
|
||||
}
|
||||
byte[] data = readExact(in, (int)chunkSize);
|
||||
out.add(new Chunk(sha256(data), cursor, cursor + chunkSize));
|
||||
cursor += chunkSize;
|
||||
remaining -= chunkSize;
|
||||
}
|
||||
byte[] rest = readExact(in, (int)remaining);
|
||||
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static Node buildLayers(List<Node> nodes) {
|
||||
if (nodes.size() < 2) return nodes.get(0);
|
||||
List<Node> next = new ArrayList<>((nodes.size() + 1) / 2);
|
||||
for (int i = 0; i < nodes.size(); i += 2) {
|
||||
Node left = nodes.get(i);
|
||||
Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null;
|
||||
if (right == null) {
|
||||
next.add(left);
|
||||
continue;
|
||||
}
|
||||
long byteRange = left.maxByteRange();
|
||||
byte[] id = sha256(concat(
|
||||
sha256(left.id()),
|
||||
sha256(right.id()),
|
||||
sha256(note(byteRange))
|
||||
));
|
||||
next.add(new Branch(id, byteRange, right.maxByteRange(), left, right));
|
||||
}
|
||||
return buildLayers(next);
|
||||
}
|
||||
|
||||
private static void resolveProofs(Node node, byte[] prefix, List<Proof> out) {
|
||||
if (node instanceof Leaf leaf) {
|
||||
out.add(new Proof(leaf.maxByteRange() - 1,
|
||||
concat(prefix, leaf.dataHash(), note(leaf.maxByteRange()))));
|
||||
return;
|
||||
}
|
||||
Branch b = (Branch) node;
|
||||
byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange()));
|
||||
resolveProofs(b.left(), partial, out);
|
||||
resolveProofs(b.right(), partial, out);
|
||||
}
|
||||
|
||||
static byte[] readChunk(Path file, Chunk c) throws IOException {
|
||||
long len = c.size();
|
||||
if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len);
|
||||
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||
raf.seek(c.minByteRange());
|
||||
byte[] data = new byte[(int)len];
|
||||
raf.readFully(data);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readExact(InputStream in, int len) throws IOException {
|
||||
byte[] out = new byte[len];
|
||||
int pos = 0;
|
||||
while (pos < len) {
|
||||
int n = in.read(out, pos, len - pos);
|
||||
if (n < 0) throw new EOFException("Неожиданный EOF");
|
||||
pos += n;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] note(long value) {
|
||||
if (value < 0) throw new IllegalArgumentException("negative note");
|
||||
byte[] out = new byte[32];
|
||||
long v = value;
|
||||
for (int i = 31; i >= 0 && v != 0; i--) {
|
||||
out[i] = (byte)(v & 0xff);
|
||||
v >>>= 8;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static byte[] sha256(byte[] data) {
|
||||
try { return MessageDigest.getInstance("SHA-256").digest(data); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
private static byte[] concat(byte[]... arrays) {
|
||||
int len = 0;
|
||||
for (byte[] a : arrays) len = Math.addExact(len, a.length);
|
||||
byte[] out = new byte[len];
|
||||
int p=0;
|
||||
for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; }
|
||||
return out;
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Uploads an already user-signed ANS-104 DataItem to Turbo without modifying it. */
|
||||
public final class TurboDataItemUploader {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder();
|
||||
|
||||
public record UploadResult(String dataItemId, String owner) {}
|
||||
|
||||
private final ArweaveBlocksConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
private volatile String resolvedPaidByAddress;
|
||||
|
||||
public TurboDataItemUploader(ArweaveBlocksConfig cfg) {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
}
|
||||
|
||||
public UploadResult upload(byte[] rawDataItem, byte[] expectedId32) throws Exception {
|
||||
if (rawDataItem == null || rawDataItem.length == 0) throw new IllegalArgumentException("Turbo DataItem is empty");
|
||||
Ans104DataItem item = new Ans104DataItem(rawDataItem);
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("Turbo DataItem has bad ANS-104 signature");
|
||||
if (expectedId32 != null && !Arrays.equals(item.id32(), expectedId32)) {
|
||||
throw new IllegalArgumentException("Turbo DataItem id does not match blocks.data_item_id");
|
||||
}
|
||||
|
||||
String expectedId = B64URL.encodeToString(item.id32());
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(cfg.turboUploadUrl()))
|
||||
.timeout(Duration.ofMinutes(2))
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(rawDataItem));
|
||||
String paidBy = paidByAddress();
|
||||
if (paidBy != null) request.header("x-paid-by", paidBy);
|
||||
|
||||
HttpResponse<String> response = http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
String body = response.body() == null ? "" : response.body().trim();
|
||||
if (response.statusCode() == 409 && body.toLowerCase().contains("data item exists")) {
|
||||
return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
|
||||
}
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
String hint = response.statusCode() == 402
|
||||
? " (Turbo payment required: check server credits / Credit Share Approval / x-paid-by)"
|
||||
: "";
|
||||
throw new IOException("Turbo HTTP " + response.statusCode() + hint + ": " + safe(body));
|
||||
}
|
||||
|
||||
if (body.isBlank()) return new UploadResult(expectedId, B64URL.encodeToString(item.owner32()));
|
||||
JsonNode json;
|
||||
try { json = MAPPER.readTree(body); }
|
||||
catch (Exception ignored) { return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); }
|
||||
String returnedId = json.path("id").asText("").trim();
|
||||
if (!returnedId.isBlank() && !returnedId.equals(expectedId)) {
|
||||
throw new IOException("Turbo returned another DataItem id: expected=" + expectedId + " got=" + returnedId);
|
||||
}
|
||||
return new UploadResult(expectedId, json.path("owner").asText(""));
|
||||
}
|
||||
|
||||
/**
|
||||
* x-paid-by contains the payer's public native address, never the private key.
|
||||
* A configured Arweave JWK is used only to derive that public address.
|
||||
*/
|
||||
private String paidByAddress() throws Exception {
|
||||
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
|
||||
synchronized (this) {
|
||||
if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress;
|
||||
String explicit = cfg.turboPaidByAddress();
|
||||
if (explicit != null && !explicit.isBlank()) return resolvedPaidByAddress = explicit.trim();
|
||||
if (cfg.turboWalletJwkPath() == null) {
|
||||
resolvedPaidByAddress = "";
|
||||
return null;
|
||||
}
|
||||
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.turboWalletJwkPath(), StandardCharsets.UTF_8));
|
||||
String modulus = jwk.path("n").asText("").trim();
|
||||
if (modulus.isBlank()) throw new IllegalStateException("Turbo payer JWK missing n");
|
||||
byte[] owner = B64URL_DECODER.decode(modulus);
|
||||
resolvedPaidByAddress = B64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(owner));
|
||||
return resolvedPaidByAddress;
|
||||
}
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim();
|
||||
return v.length() <= 500 ? v : v.substring(0, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class Ans104BundleTest {
|
||||
@Test
|
||||
void writeReadAndFindPreserveExactDataItems() {
|
||||
byte[] a = item("a");
|
||||
byte[] b = item("b");
|
||||
byte[] bundle = Ans104Bundle.write(List.of(a, b));
|
||||
|
||||
List<Ans104Bundle.Entry> entries = Ans104Bundle.read(bundle, 10);
|
||||
assertEquals(2, entries.size());
|
||||
assertArrayEquals(a, entries.get(0).rawDataItem());
|
||||
assertArrayEquals(b, entries.get(1).rawDataItem());
|
||||
|
||||
byte[] bId = new Ans104DataItem(b).id32();
|
||||
assertArrayEquals(b, Ans104Bundle.find(bundle, bId, 10));
|
||||
}
|
||||
|
||||
private static byte[] item(String payload) {
|
||||
byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("bundle-" + payload);
|
||||
byte[] owner = Ed25519Util.derivePublicKey(privateKey);
|
||||
byte[] data = payload.getBytes(StandardCharsets.UTF_8);
|
||||
List<Ans104DataItem.Tag> tags = List.of(new Ans104DataItem.Tag("App", "test5590"));
|
||||
byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey);
|
||||
return Ans104DataItem.serializeEd25519(signature, owner, tags, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package blockchain;
|
||||
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Minimal ANS-104 DataItem parser/verifier used by SHiNE.
|
||||
*
|
||||
* SHiNE accepts only generic Ed25519 signature type 2:
|
||||
* uint16le signatureType
|
||||
* signature[64]
|
||||
* owner[32]
|
||||
* target_present[1] + target[32] when present
|
||||
* anchor_present[1] + anchor[32] when present
|
||||
* tags_count uint64le
|
||||
* tags_size uint64le
|
||||
* avro_tags[tags_size]
|
||||
* data[*]
|
||||
*
|
||||
* The signing message matches the deployed arbundles/Turbo DataItem implementation:
|
||||
* deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]).
|
||||
*/
|
||||
public final class Ans104DataItem {
|
||||
public static final int SIGNATURE_TYPE_ED25519 = 2;
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int OWNER_LEN = 32;
|
||||
public static final int OPTIONAL_FIELD_LEN = 32;
|
||||
public static final int ID_LEN = 32;
|
||||
public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024;
|
||||
public static final int MAX_TAGS_BYTES = 64 * 1024;
|
||||
public static final long MAX_TAGS_COUNT = 128;
|
||||
|
||||
public record Tag(String name, String value) {}
|
||||
|
||||
private final int signatureType;
|
||||
private final byte[] signature;
|
||||
private final byte[] owner;
|
||||
private final byte[] target;
|
||||
private final byte[] anchor;
|
||||
private final long tagsCount;
|
||||
private final byte[] rawTags;
|
||||
private final List<Tag> tags;
|
||||
private final byte[] data;
|
||||
private final byte[] rawBytes;
|
||||
private final byte[] id;
|
||||
|
||||
public Ans104DataItem(byte[] bytes) {
|
||||
Objects.requireNonNull(bytes, "bytes == null");
|
||||
if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) {
|
||||
throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length);
|
||||
}
|
||||
if (bytes.length > MAX_DATA_ITEM_BYTES) {
|
||||
throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
this.signatureType = Short.toUnsignedInt(bb.getShort());
|
||||
if (signatureType != SIGNATURE_TYPE_ED25519) {
|
||||
throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)");
|
||||
}
|
||||
|
||||
this.signature = readExact(bb, SIGNATURE_LEN, "signature");
|
||||
this.owner = readExact(bb, OWNER_LEN, "owner");
|
||||
this.target = readOptional32(bb, "target");
|
||||
this.anchor = readOptional32(bb, "anchor");
|
||||
|
||||
this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count");
|
||||
long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size");
|
||||
if (tagsCount > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount);
|
||||
}
|
||||
if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) {
|
||||
throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining());
|
||||
}
|
||||
this.rawTags = readExact(bb, (int) tagsSizeLong, "tags");
|
||||
this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount));
|
||||
this.data = new byte[bb.remaining()];
|
||||
bb.get(this.data);
|
||||
this.rawBytes = Arrays.copyOf(bytes, bytes.length);
|
||||
this.id = sha256(signature);
|
||||
}
|
||||
|
||||
public int signatureType() { return signatureType; }
|
||||
public byte[] signature64() { return Arrays.copyOf(signature, signature.length); }
|
||||
public byte[] owner32() { return Arrays.copyOf(owner, owner.length); }
|
||||
public byte[] target() { return Arrays.copyOf(target, target.length); }
|
||||
public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); }
|
||||
public long tagsCount() { return tagsCount; }
|
||||
public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); }
|
||||
public List<Tag> tags() { return tags; }
|
||||
public byte[] data() { return Arrays.copyOf(data, data.length); }
|
||||
public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); }
|
||||
public byte[] id32() { return Arrays.copyOf(id, id.length); }
|
||||
|
||||
public String tagValue(String name) {
|
||||
if (name == null) return null;
|
||||
for (Tag tag : tags) {
|
||||
if (name.equals(tag.name())) return tag.value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean hasTag(String name, String value) {
|
||||
for (Tag tag : tags) {
|
||||
if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean verifySignature() {
|
||||
return Ed25519Util.verify(signingMessage(), signature, owner);
|
||||
}
|
||||
|
||||
public boolean ownerEquals(byte[] expectedOwner32) {
|
||||
return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32);
|
||||
}
|
||||
|
||||
public byte[] signingMessage() {
|
||||
return buildSigningMessage(owner, target, anchor, tags, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the exact ANS-104 signing message for generic Ed25519 DataItems.
|
||||
* Useful for SHiNE clients/tests that must sign a DataItem before serializing it.
|
||||
*/
|
||||
public static byte[] buildSigningMessage(byte[] owner32, List<Tag> tags, byte[] data) {
|
||||
return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data);
|
||||
}
|
||||
|
||||
public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List<Tag> tags, byte[] data) {
|
||||
requireLen(owner32, OWNER_LEN, "owner");
|
||||
byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length);
|
||||
byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length);
|
||||
if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) {
|
||||
throw new IllegalArgumentException("target must be empty or 32 bytes");
|
||||
}
|
||||
if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) {
|
||||
throw new IllegalArgumentException("anchor must be empty or 32 bytes");
|
||||
}
|
||||
List<Tag> safeTags = validateTags(tags);
|
||||
byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length);
|
||||
|
||||
byte[] rawTags = serializeAvroTags(safeTags);
|
||||
return deepHash(List.of(
|
||||
utf8("dataitem"),
|
||||
utf8("1"),
|
||||
utf8(Integer.toString(SIGNATURE_TYPE_ED25519)),
|
||||
Arrays.copyOf(owner32, owner32.length),
|
||||
safeTarget,
|
||||
safeAnchor,
|
||||
rawTags,
|
||||
safeData
|
||||
));
|
||||
}
|
||||
|
||||
/** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */
|
||||
public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List<Tag> tags, byte[] data) {
|
||||
requireLen(signature64, SIGNATURE_LEN, "signature");
|
||||
requireLen(owner32, OWNER_LEN, "owner");
|
||||
List<Tag> safeTags = validateTags(tags);
|
||||
byte[] rawTags = serializeAvroTags(safeTags);
|
||||
byte[] safeData = data == null ? new byte[0] : data;
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(
|
||||
2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length);
|
||||
writeU16LE(out, SIGNATURE_TYPE_ED25519);
|
||||
out.writeBytes(signature64);
|
||||
out.writeBytes(owner32);
|
||||
out.write(0); // target absent
|
||||
out.write(0); // anchor absent
|
||||
writeU64LE(out, safeTags.size());
|
||||
writeU64LE(out, rawTags.length);
|
||||
out.writeBytes(rawTags);
|
||||
out.writeBytes(safeData);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] readOptional32(ByteBuffer bb, String name) {
|
||||
if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte");
|
||||
int present = Byte.toUnsignedInt(bb.get());
|
||||
if (present == 0) return new byte[0];
|
||||
if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present);
|
||||
return readExact(bb, OPTIONAL_FIELD_LEN, name);
|
||||
}
|
||||
|
||||
private static byte[] readExact(ByteBuffer bb, int len, String field) {
|
||||
if (len < 0 || bb.remaining() < len) {
|
||||
throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining());
|
||||
}
|
||||
byte[] out = new byte[len];
|
||||
bb.get(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) {
|
||||
if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field);
|
||||
long v = bb.getLong();
|
||||
if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range");
|
||||
return v;
|
||||
}
|
||||
|
||||
private static List<Tag> validateTags(List<Tag> tags) {
|
||||
List<Tag> safe = tags == null ? List.of() : List.copyOf(tags);
|
||||
if (safe.size() > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size());
|
||||
}
|
||||
for (Tag tag : safe) {
|
||||
if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) {
|
||||
throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||
}
|
||||
if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||
if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
private static byte[] serializeAvroTags(List<Tag> tags) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
if (!tags.isEmpty()) {
|
||||
writeAvroLong(out, tags.size());
|
||||
for (Tag tag : tags) {
|
||||
writeAvroString(out, tag.name());
|
||||
writeAvroString(out, tag.value());
|
||||
}
|
||||
}
|
||||
writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array
|
||||
byte[] raw = out.toByteArray();
|
||||
if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes");
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static void writeAvroString(ByteArrayOutputStream out, String value) {
|
||||
byte[] bytes = utf8(value);
|
||||
writeAvroLong(out, bytes.length);
|
||||
out.writeBytes(bytes);
|
||||
}
|
||||
|
||||
private static void writeAvroLong(ByteArrayOutputStream out, long value) {
|
||||
long n = (value << 1) ^ (value >> 63);
|
||||
while ((n & ~0x7fL) != 0) {
|
||||
out.write((int) ((n & 0x7f) | 0x80));
|
||||
n >>>= 7;
|
||||
}
|
||||
out.write((int) n);
|
||||
}
|
||||
|
||||
private static void writeU16LE(ByteArrayOutputStream out, int value) {
|
||||
out.write(value & 0xff);
|
||||
out.write((value >>> 8) & 0xff);
|
||||
}
|
||||
|
||||
private static void writeU64LE(ByteArrayOutputStream out, long value) {
|
||||
for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff));
|
||||
}
|
||||
|
||||
private static void requireLen(byte[] bytes, int expected, String name) {
|
||||
if (bytes == null || bytes.length != expected) {
|
||||
throw new IllegalArgumentException(name + " must be " + expected + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Tag> parseAvroTags(byte[] raw, long declaredCount) {
|
||||
AvroCursor c = new AvroCursor(raw);
|
||||
List<Tag> out = new ArrayList<>();
|
||||
while (!c.eof()) {
|
||||
long count = c.readLong();
|
||||
if (count == 0) break;
|
||||
if (count < 0) {
|
||||
count = -count;
|
||||
long blockBytes = c.readLong();
|
||||
if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size");
|
||||
}
|
||||
if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many Avro tags");
|
||||
}
|
||||
for (long i = 0; i < count; i++) {
|
||||
String name = c.readString();
|
||||
String value = c.readString();
|
||||
if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||
if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||
if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||
out.add(new Tag(name, value));
|
||||
}
|
||||
}
|
||||
if (!c.eof()) {
|
||||
throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags");
|
||||
}
|
||||
if (out.size() != declaredCount) {
|
||||
throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final class AvroCursor {
|
||||
private final byte[] data;
|
||||
private int p;
|
||||
private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; }
|
||||
boolean eof() { return p == data.length; }
|
||||
long readLong() {
|
||||
long raw = 0;
|
||||
int shift = 0;
|
||||
while (true) {
|
||||
if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long");
|
||||
int b = data[p++] & 0xff;
|
||||
raw |= (long) (b & 0x7f) << shift;
|
||||
if ((b & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
if (shift > 63) throw new IllegalArgumentException("Avro long too large");
|
||||
}
|
||||
return (raw >>> 1) ^ -(raw & 1L);
|
||||
}
|
||||
String readString() {
|
||||
long len = readLong();
|
||||
if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) {
|
||||
throw new IllegalArgumentException("Bad Avro string length: " + len);
|
||||
}
|
||||
String s = new String(data, p, (int) len, StandardCharsets.UTF_8);
|
||||
p += (int) len;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] deepHash(Object item) {
|
||||
if (item instanceof byte[] bytes) {
|
||||
return deepHashBlob(bytes);
|
||||
}
|
||||
if (item instanceof List<?> items) {
|
||||
byte[] acc = sha384(utf8("list" + items.size()));
|
||||
for (Object child : items) {
|
||||
acc = sha384(concat(acc, deepHash(child)));
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass()));
|
||||
}
|
||||
|
||||
private static byte[] deepHashBlob(byte[] bytes) {
|
||||
byte[] tag = utf8("blob" + bytes.length);
|
||||
return sha384(concat(sha384(tag), sha384(bytes)));
|
||||
}
|
||||
|
||||
private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); }
|
||||
private static byte[] sha256(byte[] b) { return digest("SHA-256", b); }
|
||||
private static byte[] sha384(byte[] b) { return digest("SHA-384", b); }
|
||||
private static byte[] digest(String alg, byte[] b) {
|
||||
try { return MessageDigest.getInstance(alg).digest(b); }
|
||||
catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); }
|
||||
}
|
||||
private static byte[] concat(byte[] a, byte[] b) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length);
|
||||
out.writeBytes(a); out.writeBytes(b); return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -9,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-25
@@ -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;
|
||||
|
||||
|
||||
+18
-17
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+61
-158
@@ -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');
|
||||
}
|
||||
}
|
||||
+26
-35
@@ -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; }
|
||||
|
||||
|
||||
+24
-35
@@ -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) {
|
||||
|
||||
+36
-62
@@ -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; }
|
||||
|
||||
|
||||
+25
-38
@@ -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");
|
||||
|
||||
+36
-108
@@ -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;
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package blockchain;
|
||||
|
||||
import blockchain.body.ConnectionBody;
|
||||
import blockchain.body.ReactionBody;
|
||||
import blockchain.body.StatusActionBody;
|
||||
import blockchain.body.TextLineBody;
|
||||
import blockchain.body.TextReplyBody;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
final class TargetLoginFormatTest {
|
||||
|
||||
private static final byte[] HASH = hash(7);
|
||||
|
||||
@Test
|
||||
void reactionRoundTripStoresLoginNotBlockchainName() {
|
||||
ReactionBody source = new ReactionBody("alice", 42, HASH);
|
||||
ReactionBody parsed = new ReactionBody(MsgSubType.REACTION_LIKE, ReactionBody.VER, source.toBytes()).check();
|
||||
|
||||
assertEquals("alice", parsed.toLogin());
|
||||
assertEquals(42, parsed.toBlockGlobalNumber());
|
||||
assertArrayEquals(HASH, parsed.toBlockHashBytes());
|
||||
assertFalse(new String(source.toBytes()).contains("alice-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyAndEditReplyUseSameLoginTargetShape() {
|
||||
TextReplyBody reply = new TextReplyBody(MsgSubType.TEXT_REPLY, 11, HASH, "alice", "reply");
|
||||
TextReplyBody parsedReply = new TextReplyBody(MsgSubType.TEXT_REPLY, TextReplyBody.VER, reply.toBytes()).check();
|
||||
assertEquals("alice", parsedReply.toLogin());
|
||||
|
||||
TextReplyBody edit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, 11, HASH, "alice", "");
|
||||
TextReplyBody parsedEdit = new TextReplyBody(MsgSubType.TEXT_EDIT_REPLY, TextReplyBody.VER, edit.toBytes()).check();
|
||||
assertEquals("alice", parsedEdit.toLogin());
|
||||
assertEquals("", parsedEdit.message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void editPostAndConnectionCarryLoginTarget() {
|
||||
TextLineBody editPost = new TextLineBody(
|
||||
0, -1, new byte[32], -1,
|
||||
MsgSubType.TEXT_EDIT_POST,
|
||||
5, HASH, "alice", "edited"
|
||||
);
|
||||
TextLineBody parsedEditPost = new TextLineBody(MsgSubType.TEXT_EDIT_POST, TextLineBody.VER, editPost.toBytes()).check();
|
||||
assertEquals("alice", parsedEditPost.toLogin());
|
||||
|
||||
ConnectionBody connection = new ConnectionBody(
|
||||
0, -1, new byte[32], -1,
|
||||
MsgSubType.CONNECTION_FOLLOW,
|
||||
"alice", 0, HASH
|
||||
);
|
||||
ConnectionBody parsedConnection = new ConnectionBody(MsgSubType.CONNECTION_FOLLOW, ConnectionBody.VER, connection.toBytes()).check();
|
||||
assertEquals("alice", parsedConnection.toLogin());
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusActionCarriesLoginTarget() {
|
||||
StatusActionBody source = new StatusActionBody(MsgSubType.STATUS_STARTED, "alice", 77, HASH, "");
|
||||
StatusActionBody parsed = new StatusActionBody(MsgSubType.STATUS_STARTED, StatusActionBody.VER, source.toBytes()).check();
|
||||
assertEquals("alice", parsed.toLogin());
|
||||
assertEquals(77, parsed.toBlockGlobalNumber());
|
||||
}
|
||||
|
||||
private static byte[] hash(int seed) {
|
||||
byte[] out = new byte[32];
|
||||
Arrays.fill(out, (byte) seed);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -66,19 +66,20 @@ public final class AppConfig {
|
||||
|
||||
/** Вернёт строку или пустую строку, если параметр не найден. */
|
||||
public String getStringOrEmpty(String name) {
|
||||
String value = properties.getProperty(name);
|
||||
String value = getParam(name);
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
/** Можно добавить методы для удобства */
|
||||
/** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */
|
||||
public int getInt(String name, int defaultValue) {
|
||||
String v = properties.getProperty(name);
|
||||
return v == null ? defaultValue : Integer.parseInt(v);
|
||||
String v = getParam(name);
|
||||
return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim());
|
||||
}
|
||||
|
||||
/** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */
|
||||
public boolean getBoolean(String name, boolean defaultValue) {
|
||||
String v = properties.getProperty(name);
|
||||
return v == null ? defaultValue : Boolean.parseBoolean(v);
|
||||
String v = getParam(name);
|
||||
return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim());
|
||||
}
|
||||
|
||||
private static String toEnvName(String name) {
|
||||
|
||||
+3
@@ -17,6 +17,9 @@ public final class ShineSignatureConstants {
|
||||
/** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */
|
||||
public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:";
|
||||
|
||||
/** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */
|
||||
public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:";
|
||||
|
||||
/** TAG в HeaderBody (genesis). ASCII "SHiNe". */
|
||||
public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# utils.crypto
|
||||
|
||||
Пакет отвечает за криптографию — подписи и хэши блоков.
|
||||
Используется при создании и проверке целостности `.bch`-блоков.
|
||||
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_18 = 18;
|
||||
public static final int SCHEMA_VERSION_19 = 19;
|
||||
public static final int SCHEMA_VERSION_20 = 20;
|
||||
public static final int SCHEMA_VERSION_21 = 21;
|
||||
public static final int SCHEMA_VERSION_22 = 22;
|
||||
public static final int SCHEMA_VERSION_23 = 23;
|
||||
public static final int SCHEMA_VERSION_24 = 24;
|
||||
public static final int SCHEMA_VERSION_25 = 25;
|
||||
public static final int SCHEMA_VERSION_26 = 26;
|
||||
public static final int SCHEMA_VERSION_27 = 27;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -57,6 +64,13 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
|
||||
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
|
||||
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
|
||||
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
|
||||
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -206,6 +220,34 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_20;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_21) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_21;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_22) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_22;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_23) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_23;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_24) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_24;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_25) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_25;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_26) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_26;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_27) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_27;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,46 @@ public final class KeyEncodingUtil {
|
||||
|
||||
private KeyEncodingUtil() {}
|
||||
|
||||
public static String encodeBase58(byte[] input) {
|
||||
if (input == null || input.length == 0) {
|
||||
return "";
|
||||
}
|
||||
byte[] copy = java.util.Arrays.copyOf(input, input.length);
|
||||
int zeros = 0;
|
||||
while (zeros < copy.length && copy[zeros] == 0) {
|
||||
zeros++;
|
||||
}
|
||||
byte[] encoded = new byte[copy.length * 2];
|
||||
int outputStart = encoded.length;
|
||||
int inputStart = zeros;
|
||||
while (inputStart < copy.length) {
|
||||
int remainder = divmod58(copy, inputStart);
|
||||
if (copy[inputStart] == 0) {
|
||||
inputStart++;
|
||||
}
|
||||
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(remainder);
|
||||
}
|
||||
while (outputStart < encoded.length && encoded[outputStart] == (byte) BASE58_ALPHABET.charAt(0)) {
|
||||
outputStart++;
|
||||
}
|
||||
while (--zeros >= 0) {
|
||||
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(0);
|
||||
}
|
||||
return new String(encoded, outputStart, encoded.length - outputStart, java.nio.charset.StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
public static String base58KeyToBase64_32(String rawKey) {
|
||||
if (rawKey == null) {
|
||||
return null;
|
||||
}
|
||||
String value = rawKey.trim();
|
||||
if (value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
byte[] decoded = tryDecodeBase58_32(value);
|
||||
return decoded == null ? value : Base64.getEncoder().encodeToString(decoded);
|
||||
}
|
||||
|
||||
public static String normalizeKeyToBase64_32(String rawKey) {
|
||||
if (rawKey == null) {
|
||||
return null;
|
||||
@@ -102,6 +142,17 @@ public final class KeyEncodingUtil {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int divmod58(byte[] number, int startAt) {
|
||||
int remainder = 0;
|
||||
for (int i = startAt; i < number.length; i++) {
|
||||
int digit256 = number[i] & 0xFF;
|
||||
int temp = remainder * 256 + digit256;
|
||||
number[i] = (byte) (temp / 58);
|
||||
remainder = temp % 58;
|
||||
}
|
||||
return remainder;
|
||||
}
|
||||
|
||||
private static int divmod256(byte[] number58, int startAt) {
|
||||
int remainder = 0;
|
||||
for (int i = startAt; i < number58.length; i++) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */
|
||||
public final class ArweaveBlockImportDAO {
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
|
||||
public record QueueItem(byte[] dataItemId, long blockHeight,
|
||||
byte[] rawDataItem, String status, String lastError,
|
||||
long firstSeenAtMs, long updatedAtMs) {}
|
||||
|
||||
private static volatile ArweaveBlockImportDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArweaveBlockImportDAO() {}
|
||||
|
||||
public static ArweaveBlockImportDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ArweaveBlockImportDAO.class) {
|
||||
if (instance == null) instance = new ArweaveBlockImportDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public long getLastBlockHeight() throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1");
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getLong(1) : 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void setLastBlockHeight(long height, long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||
VALUES(1,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, Math.max(0L, height));
|
||||
ps.setLong(2, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
|
||||
public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||
throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_import_queue(
|
||||
data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
) VALUES(?,?,?,?, '',?,?)
|
||||
ON CONFLICT(data_item_id) DO NOTHING
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
ps.setLong(2, blockHeight);
|
||||
ps.setBytes(3, rawDataItem);
|
||||
ps.setString(4, STATUS_PENDING);
|
||||
ps.setLong(5, nowMs);
|
||||
ps.setLong(6, nowMs);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean exists(byte[] dataItemId) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||
}
|
||||
}
|
||||
|
||||
public List<QueueItem> listPending(int limit) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||
String sql = """
|
||||
SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
FROM arweave_block_import_queue
|
||||
WHERE status=?
|
||||
ORDER BY block_height ASC, first_seen_at_ms ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<QueueItem> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, STATUS_PENDING);
|
||||
ps.setInt(2, safeLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new QueueItem(
|
||||
rs.getBytes("data_item_id"), rs.getLong("block_height"),
|
||||
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
|
||||
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void setPendingError(byte[] id, String error, long nowMs) throws SQLException {
|
||||
updateStatus(id, STATUS_PENDING, error, nowMs);
|
||||
}
|
||||
|
||||
public void reject(byte[] id, String error, long nowMs) throws SQLException {
|
||||
updateStatus(id, STATUS_REJECTED, error, nowMs);
|
||||
}
|
||||
|
||||
public void delete(byte[] id) throws SQLException {
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||
"DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) {
|
||||
ps.setBytes(1, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException {
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||
"UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) {
|
||||
ps.setString(1, status);
|
||||
ps.setString(2, trimError(error));
|
||||
ps.setLong(3, nowMs);
|
||||
ps.setBytes(4, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimError(String s) {
|
||||
String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim();
|
||||
return v.length() <= 500 ? v : v.substring(0,500);
|
||||
}
|
||||
}
|
||||
+96
-2
@@ -23,7 +23,7 @@ import java.sql.SQLException;
|
||||
* - нам нужен один понятный "блок операции", который можно вызвать из resync-flow;
|
||||
* - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам;
|
||||
* - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии;
|
||||
* - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию:
|
||||
* - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only.
|
||||
* SQL-транзакция и файловая система не коммитятся атомарно вместе;
|
||||
* поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом
|
||||
* отдельным recovery/resync-слоем после успешного commit.
|
||||
@@ -71,7 +71,7 @@ public final class BlockchainResyncCleanupDAO {
|
||||
* - если blockchain_state уже отсутствует, login берём из blockchainName.
|
||||
*
|
||||
* Отдельно важно:
|
||||
* - здесь НЕТ удаления .bch/.tmp_bch;
|
||||
* - filesystem blockchain storage отсутствует;
|
||||
* - здесь НЕТ повторной загрузки цепочки;
|
||||
* - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow.
|
||||
*/
|
||||
@@ -127,6 +127,100 @@ public final class BlockchainResyncCleanupDAO {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* После смены fork обновляет только runtime-кэш физического blockchainName у логических target-ссылок.
|
||||
* Подписанная идентичность target уже задаётся login + blockNumber + blockHash; поэтому смена cache value
|
||||
* не меняет смысл ссылки и не требует переподписывать чужие блоки.
|
||||
*/
|
||||
public void refreshLogicalTargetBlockchainCache(String login, String oldBlockchainName, String newBlockchainName) throws SQLException {
|
||||
if (login == null || login.isBlank() || newBlockchainName == null || newBlockchainName.isBlank()) {
|
||||
throw new IllegalArgumentException("login/newBlockchainName are required");
|
||||
}
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAutoCommit = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
updateTargetCacheByLogin(c, "blocks", login, newBlockchainName);
|
||||
updateTargetCacheByLogin(c, "connections_state", login, newBlockchainName);
|
||||
updateTargetCacheByLogin(c, "reactions_state", login, newBlockchainName);
|
||||
updateTargetCacheByLogin(c, "message_stats", login, newBlockchainName);
|
||||
if (oldBlockchainName != null && !oldBlockchainName.isBlank()) {
|
||||
try (PreparedStatement ps = c.prepareStatement("UPDATE message_views_state SET to_bch_name=? WHERE to_bch_name=?")) {
|
||||
ps.setString(1, newBlockchainName);
|
||||
ps.setString(2, oldBlockchainName);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement("UPDATE channel_read_state SET owner_bch_name=? WHERE owner_bch_name=?")) {
|
||||
ps.setString(1, newBlockchainName);
|
||||
ps.setString(2, oldBlockchainName);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
rebuildInboundMessageCounters(c, login, newBlockchainName);
|
||||
rebuildStatsState(c);
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException sql) throw sql;
|
||||
throw new SQLException("Failed to refresh logical target blockchain cache for login=" + login, e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAutoCommit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* После replay candidate-цепочки её собственные message_stats уже существуют, но входящие реакции/ответы
|
||||
* других пользователей были созданы раньше и не проходили триггеры повторно. Поэтому пересчитываем
|
||||
* агрегаты из исходных таблиц после перепривязки runtime to_bch_name cache.
|
||||
*/
|
||||
private void rebuildInboundMessageCounters(Connection c, String login, String newBlockchainName) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
UPDATE message_stats ms
|
||||
SET likes_count = (
|
||||
SELECT COUNT(*)::INTEGER
|
||||
FROM reactions_state rs
|
||||
WHERE rs.reaction_type = ?
|
||||
AND rs.last_sub_type = ?
|
||||
AND LOWER(rs.to_login) = LOWER(ms.to_login)
|
||||
AND rs.to_bch_name = ms.to_bch_name
|
||||
AND rs.to_block_number = ms.to_block_number
|
||||
AND rs.to_block_hash = ms.to_block_hash
|
||||
),
|
||||
replies_count = (
|
||||
SELECT COUNT(*)::INTEGER
|
||||
FROM blocks b
|
||||
WHERE b.msg_type = 1
|
||||
AND b.msg_sub_type = ?
|
||||
AND LOWER(b.to_login) = LOWER(ms.to_login)
|
||||
AND b.to_bch_name = ms.to_bch_name
|
||||
AND b.to_block_number = ms.to_block_number
|
||||
AND b.to_block_hash = ms.to_block_hash
|
||||
)
|
||||
WHERE LOWER(ms.to_login) = LOWER(?)
|
||||
AND ms.to_bch_name = ?
|
||||
""")) {
|
||||
ps.setInt(1, DatabaseInitializer.REACTION_LIKE);
|
||||
ps.setInt(2, DatabaseInitializer.REACTION_LIKE);
|
||||
ps.setInt(3, DatabaseInitializer.TEXT_REPLY);
|
||||
ps.setString(4, login);
|
||||
ps.setString(5, newBlockchainName);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateTargetCacheByLogin(Connection c, String table, String login, String newBlockchainName) throws SQLException {
|
||||
String sql = "UPDATE " + table + " SET to_bch_name=? WHERE LOWER(to_login)=LOWER(?) AND to_bch_name IS DISTINCT FROM ?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, newBlockchainName);
|
||||
ps.setString(2, login);
|
||||
ps.setString(3, newBlockchainName);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT login
|
||||
|
||||
@@ -9,286 +9,146 @@ import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO для таблицы blocks (новый формат).
|
||||
*
|
||||
* Правило:
|
||||
* - методы с Connection НЕ закрывают соединение
|
||||
* - методы без Connection сами открывают и закрывают соединение
|
||||
*
|
||||
* Ключ:
|
||||
* - (bch_name, block_number) — уникальная пара в рамках общей БД сервера.
|
||||
*/
|
||||
/** DAO for SHiNE Frame v1 blocks. block_bytes contains the complete ANS-104 DataItem. */
|
||||
public final class BlocksDAO {
|
||||
|
||||
private static volatile BlocksDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class);
|
||||
|
||||
private BlocksDAO() { }
|
||||
|
||||
public static BlocksDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (BlocksDAO.class) {
|
||||
if (instance == null) instance = new BlocksDAO();
|
||||
}
|
||||
}
|
||||
if (instance == null) synchronized (BlocksDAO.class) { if (instance == null) instance = new BlocksDAO(); }
|
||||
return instance;
|
||||
}
|
||||
|
||||
// -------------------- INSERT --------------------
|
||||
|
||||
/** Вставка с внешним соединением. Соединение НЕ закрывает. */
|
||||
public void insert(Connection c, BlockEntry e) throws SQLException {
|
||||
log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}",
|
||||
e.getMsgType(), e.getMsgSubType(),
|
||||
e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(),
|
||||
e.getPrevLineHash() == null ? null : e.getPrevLineHash().length
|
||||
);
|
||||
|
||||
String sql = """
|
||||
INSERT INTO blocks (
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,
|
||||
block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
|
||||
ps.setString(i++, e.getLogin());
|
||||
ps.setString(i++, e.getBchName());
|
||||
ps.setInt(i++, e.getBlockNumber());
|
||||
|
||||
ps.setInt(i++, e.getMsgType());
|
||||
ps.setInt(i++, e.getMsgSubType());
|
||||
|
||||
ps.setBytes(i++, e.getBlockBytes());
|
||||
|
||||
if (e.getToLogin() != null) ps.setString(i++, e.getToLogin());
|
||||
else ps.setNull(i++, Types.VARCHAR);
|
||||
|
||||
if (e.getToBchName() != null) ps.setString(i++, e.getToBchName());
|
||||
else ps.setNull(i++, Types.VARCHAR);
|
||||
|
||||
if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash());
|
||||
else ps.setNull(i++, Types.BINARY);
|
||||
|
||||
setNullableString(ps, i++, e.getToLogin());
|
||||
setNullableString(ps, i++, e.getToBchName());
|
||||
setNullableInt(ps, i++, e.getToBlockNumber());
|
||||
setNullableBytes(ps, i++, e.getToBlockHash());
|
||||
ps.setBytes(i++, e.getBlockHash());
|
||||
ps.setBytes(i++, e.getBlockSignature());
|
||||
|
||||
if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
// NEW: line_code
|
||||
if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash());
|
||||
else ps.setNull(i++, Types.BINARY);
|
||||
|
||||
if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber());
|
||||
else ps.setNull(i++, Types.INTEGER);
|
||||
|
||||
ps.setBytes(i++, e.getDataItemId());
|
||||
ps.setBoolean(i++, e.isArweavePublishPending());
|
||||
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
|
||||
setNullableInt(ps, i++, e.getEditedByBlockNumber());
|
||||
setNullableInt(ps, i++, e.getLineCode());
|
||||
setNullableInt(ps, i++, e.getPrevLineNumber());
|
||||
setNullableBytes(ps, i++, e.getPrevLineHash());
|
||||
setNullableInt(ps, i++, e.getThisLineNumber());
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Вставка без внешнего соединения. Сам открывает/закрывает. */
|
||||
public void insert(BlockEntry e) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
insert(c, e);
|
||||
public void insert(BlockEntry e) throws SQLException { try (Connection c = db.getConnection()) { insert(c, e); } }
|
||||
|
||||
public boolean existsByDataItemId(byte[] dataItemId) throws SQLException {
|
||||
if (dataItemId == null || dataItemId.length != 32) return false;
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM blocks WHERE data_item_id=? LIMIT 1")) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT: HASH BY NUMBER --------------------
|
||||
|
||||
/** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */
|
||||
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
public List<BlockEntry> listPendingArweave(int limit) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||
String sql = """
|
||||
SELECT block_hash
|
||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
LIMIT 1
|
||||
WHERE arweave_publish_pending = TRUE
|
||||
ORDER BY bch_name ASC, block_number ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, blockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return rs.getBytes("block_hash");
|
||||
}
|
||||
List<BlockEntry> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setInt(1, safeLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException {
|
||||
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
|
||||
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||
String sql = """
|
||||
UPDATE blocks
|
||||
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?
|
||||
WHERE data_item_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getHashByNumber(c, bchName, blockNumber);
|
||||
c.setAutoCommit(false);
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (byte[] id : dataItemIds) {
|
||||
ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch();
|
||||
}
|
||||
ps.executeBatch(); c.commit();
|
||||
} catch (Exception e) { c.rollback(); throw e; }
|
||||
finally { c.setAutoCommit(true); }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- SELECT: FULL ENTRY --------------------
|
||||
public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1")) {
|
||||
ps.setString(1,bchName); ps.setInt(2,blockNumber);
|
||||
try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; }
|
||||
}
|
||||
}
|
||||
public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} }
|
||||
|
||||
public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ? AND block_number = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
String sql = baseSelect()+" WHERE bch_name=? AND block_number=? LIMIT 1";
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} }
|
||||
}
|
||||
public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} }
|
||||
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, blockNumber);
|
||||
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapRow(rs);
|
||||
}
|
||||
}
|
||||
public List<BlockEntry> listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} }
|
||||
public List<BlockEntry> listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{
|
||||
String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC";
|
||||
List<BlockEntry> out=new ArrayList<>();
|
||||
try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}}
|
||||
return out;
|
||||
}
|
||||
|
||||
public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getByNumber(c, bchName, blockNumber);
|
||||
}
|
||||
}
|
||||
|
||||
public List<BlockEntry> listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive);
|
||||
}
|
||||
}
|
||||
|
||||
public List<BlockEntry> listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException {
|
||||
String sql = """
|
||||
SELECT
|
||||
login,
|
||||
bch_name,
|
||||
block_number,
|
||||
msg_type,
|
||||
msg_sub_type,
|
||||
block_bytes,
|
||||
to_login,
|
||||
to_bch_name,
|
||||
to_block_number,
|
||||
to_block_hash,
|
||||
block_hash,
|
||||
block_signature,
|
||||
edited_by_block_number,
|
||||
line_code,
|
||||
prev_line_number,
|
||||
prev_line_hash,
|
||||
this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name = ?
|
||||
AND block_number >= ?
|
||||
AND block_number <= ?
|
||||
ORDER BY block_number ASC
|
||||
""";
|
||||
|
||||
List<BlockEntry> result = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, bchName);
|
||||
ps.setInt(2, fromBlockNumberInclusive);
|
||||
ps.setInt(3, toBlockNumberInclusive);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
result.add(mapRow(rs));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------- INTERNAL --------------------
|
||||
|
||||
private BlockEntry mapRow(ResultSet rs) throws SQLException {
|
||||
BlockEntry e = new BlockEntry();
|
||||
|
||||
e.setLogin(rs.getString("login"));
|
||||
e.setBchName(rs.getString("bch_name"));
|
||||
e.setBlockNumber(rs.getInt("block_number"));
|
||||
|
||||
e.setMsgType(rs.getInt("msg_type"));
|
||||
e.setMsgSubType(rs.getInt("msg_sub_type"));
|
||||
|
||||
e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||
|
||||
String toLogin = rs.getString("to_login");
|
||||
if (rs.wasNull()) toLogin = null;
|
||||
e.setToLogin(toLogin);
|
||||
|
||||
String toBchName = rs.getString("to_bch_name");
|
||||
if (rs.wasNull()) toBchName = null;
|
||||
e.setToBchName(toBchName);
|
||||
|
||||
Integer toBlockNumber = (Integer) rs.getObject("to_block_number");
|
||||
e.setToBlockNumber(toBlockNumber);
|
||||
|
||||
byte[] toHash = rs.getBytes("to_block_hash");
|
||||
if (rs.wasNull()) toHash = null;
|
||||
e.setToBlockHash(toHash);
|
||||
|
||||
e.setBlockHash(rs.getBytes("block_hash"));
|
||||
e.setBlockSignature(rs.getBytes("block_signature"));
|
||||
|
||||
Integer editedBy = (Integer) rs.getObject("edited_by_block_number");
|
||||
e.setEditedByBlockNumber(editedBy);
|
||||
|
||||
// NEW: line_code
|
||||
Integer lineCode = (Integer) rs.getObject("line_code");
|
||||
e.setLineCode(lineCode);
|
||||
|
||||
Integer prevLn = (Integer) rs.getObject("prev_line_number");
|
||||
e.setPrevLineNumber(prevLn);
|
||||
|
||||
byte[] prevLh = rs.getBytes("prev_line_hash");
|
||||
if (rs.wasNull()) prevLh = null;
|
||||
e.setPrevLineHash(prevLh);
|
||||
|
||||
Integer thisLn = (Integer) rs.getObject("this_line_number");
|
||||
e.setThisLineNumber(thisLn);
|
||||
private static String baseSelect(){return """
|
||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||
to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id,
|
||||
arweave_publish_pending,arweave_published_at_ms,
|
||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||
FROM blocks
|
||||
""";}
|
||||
|
||||
private BlockEntry mapRow(ResultSet rs)throws SQLException{
|
||||
BlockEntry e=new BlockEntry();
|
||||
e.setLogin(rs.getString("login")); e.setBchName(rs.getString("bch_name")); e.setBlockNumber(rs.getInt("block_number"));
|
||||
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
|
||||
e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash"));
|
||||
e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id"));
|
||||
e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms"));
|
||||
e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number"));
|
||||
return e;
|
||||
}
|
||||
|
||||
private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);}
|
||||
private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);}
|
||||
private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);}
|
||||
}
|
||||
|
||||
@@ -120,4 +120,15 @@ public final class ChannelNameStateDAO {
|
||||
insert(c, entry);
|
||||
}
|
||||
}
|
||||
public String getSlugByOwnerAndRoot(String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||
String sql = "SELECT slug FROM channel_names_state WHERE owner_bch_name=? AND channel_root_block_number=? LIMIT 1";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setInt(2, rootBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString(1) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Server projection for per-user channel read/unread state.
|
||||
*
|
||||
* Important rules:
|
||||
* - rows are created only by a signed client SetChannelReadState;
|
||||
* - channel publications increment unread_count only for rows that already exist;
|
||||
* - read_count is monotonic and unread_count is reduced by the accepted read delta;
|
||||
* - unread_count is never part of the client signature and is server-owned.
|
||||
*/
|
||||
public final class ChannelReadStateDAO {
|
||||
private static volatile ChannelReadStateDAO instance;
|
||||
|
||||
private ChannelReadStateDAO() {}
|
||||
|
||||
public static ChannelReadStateDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ChannelReadStateDAO.class) {
|
||||
if (instance == null) instance = new ChannelReadStateDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs,
|
||||
String clientKey, String readSignature) {}
|
||||
|
||||
public record UpsertResult(boolean applied, long readCount, long unreadCount) {}
|
||||
|
||||
public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature
|
||||
FROM channel_read_state
|
||||
WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=?
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setString(3, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return new ReadState(
|
||||
Math.max(0L, rs.getLong("read_count")),
|
||||
Math.max(0L, rs.getLong("unread_count")),
|
||||
rs.getLong("read_updated_at_ms"),
|
||||
rs.getString("client_key"),
|
||||
rs.getString("read_signature"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException {
|
||||
ReadState state = get(c, viewerLogin, ownerBchName, channelName);
|
||||
return state == null ? 0L : state.unreadCount();
|
||||
}
|
||||
|
||||
public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(SUM(crs.unread_count),0)
|
||||
FROM channel_read_state crs
|
||||
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||
AND cs.rel_type=30
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_type_code=1
|
||||
)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the requested public channel exists and that viewer currently follows it.
|
||||
*/
|
||||
public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName,
|
||||
int followRelType) throws SQLException {
|
||||
String sql = """
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(?)
|
||||
AND cs.rel_type=?
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=?
|
||||
AND cns.slug=?
|
||||
AND cns.channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, followRelType);
|
||||
ps.setString(3, ownerBchName);
|
||||
ps.setString(4, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException {
|
||||
String sql = """
|
||||
SELECT slug
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setInt(2, rootBlockNumber);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("slug") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT channel_root_block_number
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setString(2, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getInt("channel_root_block_number") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT owner_login
|
||||
FROM channel_names_state
|
||||
WHERE owner_bch_name=? AND slug=? AND channel_type_code=1
|
||||
LIMIT 1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBchName);
|
||||
ps.setString(2, channelName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getString("owner_login") : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts the first client baseline with unread=0, or advances an existing read watermark.
|
||||
* Older timestamps and backwards read_count values are ignored.
|
||||
*/
|
||||
public UpsertResult upsertSignedReadIfNewer(Connection c,
|
||||
String viewerLogin,
|
||||
String ownerLogin,
|
||||
String ownerBchName,
|
||||
String channelName,
|
||||
long readCount,
|
||||
long readUpdatedAtMs,
|
||||
String clientKey,
|
||||
String readSignature,
|
||||
long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO channel_read_state (
|
||||
viewer_login, owner_login, owner_bch_name, channel_name,
|
||||
read_count, unread_count,
|
||||
read_updated_at_ms, client_key, read_signature, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
|
||||
ON CONFLICT (viewer_login, owner_bch_name, channel_name)
|
||||
DO UPDATE SET
|
||||
owner_login = EXCLUDED.owner_login,
|
||||
unread_count = GREATEST(
|
||||
0,
|
||||
channel_read_state.unread_count -
|
||||
GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count)
|
||||
),
|
||||
read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count),
|
||||
read_updated_at_ms = EXCLUDED.read_updated_at_ms,
|
||||
client_key = EXCLUDED.client_key,
|
||||
read_signature = EXCLUDED.read_signature,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms
|
||||
WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms
|
||||
AND EXCLUDED.read_count >= channel_read_state.read_count
|
||||
RETURNING read_count, unread_count
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerLogin);
|
||||
ps.setString(3, ownerBchName);
|
||||
ps.setString(4, channelName);
|
||||
ps.setLong(5, Math.max(0L, readCount));
|
||||
ps.setLong(6, readUpdatedAtMs);
|
||||
ps.setString(7, clientKey);
|
||||
ps.setString(8, readSignature);
|
||||
ps.setLong(9, nowMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return new UpsertResult(true,
|
||||
Math.max(0L, rs.getLong("read_count")),
|
||||
Math.max(0L, rs.getLong("unread_count")));
|
||||
}
|
||||
}
|
||||
}
|
||||
ReadState current = get(c, viewerLogin, ownerBchName, channelName);
|
||||
return new UpsertResult(false,
|
||||
current == null ? 0L : current.readCount(),
|
||||
current == null ? 0L : current.unreadCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments existing rows for current subscribers of one public channel.
|
||||
* Missing channel_read_state rows are deliberately NOT created.
|
||||
* Returns affected viewer logins for post-commit counter pushes.
|
||||
*/
|
||||
public List<String> incrementUnreadForSubscribers(Connection c,
|
||||
String ownerBchName,
|
||||
String channelName,
|
||||
int followRelType,
|
||||
long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE channel_read_state crs
|
||||
SET unread_count = crs.unread_count + 1,
|
||||
updated_at_ms = ?
|
||||
WHERE crs.owner_bch_name = ?
|
||||
AND crs.channel_name = ?
|
||||
AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM channel_names_state cns
|
||||
JOIN connections_state cs
|
||||
ON LOWER(cs.login)=LOWER(crs.viewer_login)
|
||||
AND cs.rel_type=?
|
||||
AND cs.to_bch_name=cns.owner_bch_name
|
||||
AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number
|
||||
AND cs.to_block_hash=cns.channel_root_block_hash
|
||||
WHERE cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_type_code=1
|
||||
)
|
||||
RETURNING viewer_login
|
||||
""";
|
||||
List<String> affected = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, nowMs);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setString(3, channelName);
|
||||
ps.setInt(4, followRelType);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) affected.add(rs.getString("viewer_login"));
|
||||
}
|
||||
}
|
||||
return affected;
|
||||
}
|
||||
|
||||
/** Delete the projection row when a channel FOLLOW is explicitly removed. */
|
||||
public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName,
|
||||
int rootBlockNumber) throws SQLException {
|
||||
String sql = """
|
||||
DELETE FROM channel_read_state crs
|
||||
USING channel_names_state cns
|
||||
WHERE LOWER(crs.viewer_login)=LOWER(?)
|
||||
AND crs.owner_bch_name=?
|
||||
AND cns.owner_bch_name=crs.owner_bch_name
|
||||
AND cns.slug=crs.channel_name
|
||||
AND cns.channel_root_block_number=?
|
||||
AND cns.channel_type_code=1
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setString(2, ownerBchName);
|
||||
ps.setInt(3, rootBlockNumber);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+236
@@ -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();
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -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; }
|
||||
|
||||
|
||||
+45
@@ -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; }
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package shine.db.entities;
|
||||
|
||||
/**
|
||||
* Публичное/runtime-состояние одной смены ключей.
|
||||
* Приватные ключи и пароли в этой сущности не хранятся никогда.
|
||||
*/
|
||||
public final class KeyRotationSessionEntry {
|
||||
private long id;
|
||||
private String login;
|
||||
private KeyRotationStatus status;
|
||||
private String sourceBlockchainName;
|
||||
private String candidateBlockchainName;
|
||||
private String oldRootKey;
|
||||
private String oldBlockchainKey;
|
||||
private String oldClientKey;
|
||||
private String newRootKey;
|
||||
private String newBlockchainKey;
|
||||
private String newClientKey;
|
||||
private int forkFromBlock;
|
||||
private byte[] forkFromHash;
|
||||
private int sourceTipBlock;
|
||||
private byte[] sourceTipHash;
|
||||
private short reasonCode;
|
||||
private String comment;
|
||||
private int progressCurrent;
|
||||
private int progressTotal;
|
||||
private String pdaRotationSignature;
|
||||
private String walletMigrationStatus;
|
||||
private String messageMigrationStatus;
|
||||
private String lastError;
|
||||
private Long lastErrorAtMs;
|
||||
private int retryCount;
|
||||
private long createdAtMs;
|
||||
private long updatedAtMs;
|
||||
private Long completedAtMs;
|
||||
private Long abortedAtMs;
|
||||
|
||||
public long getId() { return id; }
|
||||
public void setId(long id) { this.id = id; }
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public KeyRotationStatus getStatus() { return status; }
|
||||
public void setStatus(KeyRotationStatus status) { this.status = status; }
|
||||
public String getSourceBlockchainName() { return sourceBlockchainName; }
|
||||
public void setSourceBlockchainName(String sourceBlockchainName) { this.sourceBlockchainName = sourceBlockchainName; }
|
||||
public String getCandidateBlockchainName() { return candidateBlockchainName; }
|
||||
public void setCandidateBlockchainName(String candidateBlockchainName) { this.candidateBlockchainName = candidateBlockchainName; }
|
||||
public String getOldRootKey() { return oldRootKey; }
|
||||
public void setOldRootKey(String oldRootKey) { this.oldRootKey = oldRootKey; }
|
||||
public String getOldBlockchainKey() { return oldBlockchainKey; }
|
||||
public void setOldBlockchainKey(String oldBlockchainKey) { this.oldBlockchainKey = oldBlockchainKey; }
|
||||
public String getOldClientKey() { return oldClientKey; }
|
||||
public void setOldClientKey(String oldClientKey) { this.oldClientKey = oldClientKey; }
|
||||
public String getNewRootKey() { return newRootKey; }
|
||||
public void setNewRootKey(String newRootKey) { this.newRootKey = newRootKey; }
|
||||
public String getNewBlockchainKey() { return newBlockchainKey; }
|
||||
public void setNewBlockchainKey(String newBlockchainKey) { this.newBlockchainKey = newBlockchainKey; }
|
||||
public String getNewClientKey() { return newClientKey; }
|
||||
public void setNewClientKey(String newClientKey) { this.newClientKey = newClientKey; }
|
||||
public int getForkFromBlock() { return forkFromBlock; }
|
||||
public void setForkFromBlock(int forkFromBlock) { this.forkFromBlock = forkFromBlock; }
|
||||
public byte[] getForkFromHash() { return forkFromHash; }
|
||||
public void setForkFromHash(byte[] forkFromHash) { this.forkFromHash = forkFromHash; }
|
||||
public int getSourceTipBlock() { return sourceTipBlock; }
|
||||
public void setSourceTipBlock(int sourceTipBlock) { this.sourceTipBlock = sourceTipBlock; }
|
||||
public byte[] getSourceTipHash() { return sourceTipHash; }
|
||||
public void setSourceTipHash(byte[] sourceTipHash) { this.sourceTipHash = sourceTipHash; }
|
||||
public short getReasonCode() { return reasonCode; }
|
||||
public void setReasonCode(short reasonCode) { this.reasonCode = reasonCode; }
|
||||
public String getComment() { return comment; }
|
||||
public void setComment(String comment) { this.comment = comment; }
|
||||
public int getProgressCurrent() { return progressCurrent; }
|
||||
public void setProgressCurrent(int progressCurrent) { this.progressCurrent = progressCurrent; }
|
||||
public int getProgressTotal() { return progressTotal; }
|
||||
public void setProgressTotal(int progressTotal) { this.progressTotal = progressTotal; }
|
||||
public String getPdaRotationSignature() { return pdaRotationSignature; }
|
||||
public void setPdaRotationSignature(String pdaRotationSignature) { this.pdaRotationSignature = pdaRotationSignature; }
|
||||
public String getWalletMigrationStatus() { return walletMigrationStatus; }
|
||||
public void setWalletMigrationStatus(String walletMigrationStatus) { this.walletMigrationStatus = walletMigrationStatus; }
|
||||
public String getMessageMigrationStatus() { return messageMigrationStatus; }
|
||||
public void setMessageMigrationStatus(String messageMigrationStatus) { this.messageMigrationStatus = messageMigrationStatus; }
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
public Long getLastErrorAtMs() { return lastErrorAtMs; }
|
||||
public void setLastErrorAtMs(Long lastErrorAtMs) { this.lastErrorAtMs = lastErrorAtMs; }
|
||||
public int getRetryCount() { return retryCount; }
|
||||
public void setRetryCount(int retryCount) { this.retryCount = retryCount; }
|
||||
public long getCreatedAtMs() { return createdAtMs; }
|
||||
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
|
||||
public long getUpdatedAtMs() { return updatedAtMs; }
|
||||
public void setUpdatedAtMs(long updatedAtMs) { this.updatedAtMs = updatedAtMs; }
|
||||
public Long getCompletedAtMs() { return completedAtMs; }
|
||||
public void setCompletedAtMs(Long completedAtMs) { this.completedAtMs = completedAtMs; }
|
||||
public Long getAbortedAtMs() { return abortedAtMs; }
|
||||
public void setAbortedAtMs(Long abortedAtMs) { this.abortedAtMs = abortedAtMs; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package shine.db.entities;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Состояния серверной машины смены ключей.
|
||||
*
|
||||
* NONE используется только в solana_user_pda_current.rotation_status.
|
||||
* COMPLETE/ABORTED остаются в истории key_rotation_sessions, после чего
|
||||
* пользователь снова получает rotation_status=NONE.
|
||||
*/
|
||||
public enum KeyRotationStatus {
|
||||
NONE,
|
||||
COPYING_CHAIN,
|
||||
CHAIN_READY,
|
||||
ROTATING_PDA,
|
||||
PDA_ROTATED,
|
||||
REBUILDING_SERVER,
|
||||
WALLET_MIGRATION,
|
||||
MESSAGE_MIGRATION,
|
||||
FINALIZING,
|
||||
COMPLETE,
|
||||
ABORTED;
|
||||
|
||||
public boolean isTerminalSessionStatus() {
|
||||
return this == COMPLETE || this == ABORTED;
|
||||
}
|
||||
|
||||
public boolean isUserActiveStatus() {
|
||||
return this != NONE && !isTerminalSessionStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Допустимые переходы одной ротации.
|
||||
* Abort сознательно разрешён только до начала ROTATING_PDA: после отправки
|
||||
* Solana-транзакции нельзя надёжно знать, не будет ли она подтверждена позже.
|
||||
*/
|
||||
public boolean canTransitionTo(KeyRotationStatus next) {
|
||||
if (next == null) return false;
|
||||
return switch (this) {
|
||||
case COPYING_CHAIN -> EnumSet.of(CHAIN_READY, ABORTED).contains(next);
|
||||
case CHAIN_READY -> EnumSet.of(ROTATING_PDA, ABORTED).contains(next);
|
||||
case ROTATING_PDA -> next == PDA_ROTATED;
|
||||
case PDA_ROTATED -> next == REBUILDING_SERVER;
|
||||
case REBUILDING_SERVER -> next == WALLET_MIGRATION;
|
||||
case WALLET_MIGRATION -> next == MESSAGE_MIGRATION;
|
||||
case MESSAGE_MIGRATION -> next == FINALIZING;
|
||||
case FINALIZING -> next == COMPLETE;
|
||||
case NONE, COMPLETE, ABORTED -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static Set<KeyRotationStatus> activeStatuses() {
|
||||
return EnumSet.of(
|
||||
COPYING_CHAIN,
|
||||
CHAIN_READY,
|
||||
ROTATING_PDA,
|
||||
PDA_ROTATED,
|
||||
REBUILDING_SERVER,
|
||||
WALLET_MIGRATION,
|
||||
MESSAGE_MIGRATION,
|
||||
FINALIZING
|
||||
);
|
||||
}
|
||||
|
||||
/** Статус, который должен храниться у пользователя для данной session-state. */
|
||||
public KeyRotationStatus userStatus() {
|
||||
return isTerminalSessionStatus() ? NONE : this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Channel unread/read projection.
|
||||
-- The row is created only by a signed SetChannelReadState from the client.
|
||||
-- New channel publications only increment existing rows.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
UPDATE db_schema_version SET schema_version = 21 WHERE id = 1;
|
||||
@@ -0,0 +1,56 @@
|
||||
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
|
||||
blockchain_name TEXT PRIMARY KEY,
|
||||
last_archived_source_block_number BIGINT NOT NULL,
|
||||
last_archived_source_block_hash BYTEA NOT NULL,
|
||||
last_archive_big_block_number BIGINT NOT NULL,
|
||||
last_archive_big_block_hash BYTEA NOT NULL,
|
||||
last_chunk_offset BIGINT NOT NULL,
|
||||
last_chunk_size BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_publish_job (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
big_block_number BIGINT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
local_archive_path TEXT,
|
||||
archive_hash BYTEA,
|
||||
arweave_tx_id BYTEA,
|
||||
arweave_confirmations INTEGER,
|
||||
solana_signature TEXT,
|
||||
error_text TEXT,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
|
||||
ON archive_publish_job(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
|
||||
ON archive_publish_job(big_block_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
|
||||
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
|
||||
blockchain_name TEXT NOT NULL,
|
||||
from_source_block_number BIGINT NOT NULL,
|
||||
to_source_block_number BIGINT NOT NULL,
|
||||
previous_source_block_hash BYTEA NOT NULL,
|
||||
last_source_block_hash BYTEA NOT NULL,
|
||||
previous_archive_big_block_number BIGINT,
|
||||
previous_archive_big_block_hash BYTEA,
|
||||
previous_chunk_offset BIGINT,
|
||||
previous_chunk_size BIGINT,
|
||||
new_chunk_offset BIGINT,
|
||||
new_chunk_size BIGINT,
|
||||
PRIMARY KEY (job_id, blockchain_name)
|
||||
);
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_history
|
||||
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_history
|
||||
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE db_schema_version SET schema_version = 22, updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id = 1;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- v23: trusted SHINE-ARCHIVE import state + last chunk location index.
|
||||
-- Solana archive head itself is already copied by the normal Solana users sync (v22).
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||
ON solana_user_pda_current(is_server, archive_imported)
|
||||
WHERE archive_head_tx_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_blockchain_location (
|
||||
blockchain_name TEXT PRIMARY KEY,
|
||||
publisher_login TEXT NOT NULL,
|
||||
arweave_tx_id TEXT NOT NULL,
|
||||
archive_hash BYTEA NOT NULL,
|
||||
big_block_number BIGINT NOT NULL,
|
||||
chunk_offset BIGINT NOT NULL,
|
||||
chunk_size BIGINT NOT NULL,
|
||||
source_last_block_number BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher
|
||||
ON archive_blockchain_location(publisher_login);
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 23,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
@@ -0,0 +1,56 @@
|
||||
-- Frame v1 intentionally has no backward compatibility with the old signed block format.
|
||||
-- Refuse to migrate a populated blockchain database: test/dev data must be reset first.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM blocks LIMIT 1)
|
||||
OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN
|
||||
RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Старый SHINE-ARCHIVE v1 больше не используется.
|
||||
DROP TABLE IF EXISTS archive_publish_job_chain CASCADE;
|
||||
DROP TABLE IF EXISTS archive_publish_job CASCADE;
|
||||
DROP TABLE IF EXISTS archive_chain_cursor CASCADE;
|
||||
DROP TABLE IF EXISTS archive_blockchain_location CASCADE;
|
||||
|
||||
-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required.
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS data_item_id BYTEA;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||
ON blocks(data_item_id)
|
||||
WHERE data_item_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||
ON blocks(block_number)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||
VALUES(1,0,0) ON CONFLICT(id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
first_seen_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||
WHERE status='PENDING';
|
||||
|
||||
UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- PDA 1.2: keep the complete append-only blockchain-key/fork history alongside the active compatibility columns.
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS blockchain_forks_json TEXT NOT NULL DEFAULT '[]';
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 25,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
@@ -0,0 +1,123 @@
|
||||
-- v26: серверная state machine смены ключей пользователя.
|
||||
--
|
||||
-- Важно:
|
||||
-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера,
|
||||
-- а не полями Solana PDA;
|
||||
-- - Solana sync не должен перезаписывать эти колонки при upsert PDA;
|
||||
-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации.
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||
rotation_status IN (
|
||||
'NONE',
|
||||
'COPYING_CHAIN',
|
||||
'CHAIN_READY',
|
||||
'ROTATING_PDA',
|
||||
'PDA_ROTATED',
|
||||
'REBUILDING_SERVER',
|
||||
'WALLET_MIGRATION',
|
||||
'MESSAGE_MIGRATION',
|
||||
'FINALIZING'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||
ON solana_user_pda_current(rotation_status)
|
||||
WHERE rotation_status <> 'NONE';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN (
|
||||
'COPYING_CHAIN',
|
||||
'CHAIN_READY',
|
||||
'ROTATING_PDA',
|
||||
'PDA_ROTATED',
|
||||
'REBUILDING_SERVER',
|
||||
'WALLET_MIGRATION',
|
||||
'MESSAGE_MIGRATION',
|
||||
'FINALIZING',
|
||||
'COMPLETE',
|
||||
'ABORTED'
|
||||
)
|
||||
),
|
||||
|
||||
source_blockchain_name TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
|
||||
old_root_key TEXT NOT NULL,
|
||||
old_blockchain_key TEXT NOT NULL,
|
||||
old_client_key TEXT NOT NULL,
|
||||
new_root_key TEXT NOT NULL,
|
||||
new_blockchain_key TEXT NOT NULL,
|
||||
new_client_key TEXT NOT NULL,
|
||||
|
||||
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||
|
||||
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||
|
||||
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||
|
||||
pda_rotation_signature TEXT,
|
||||
|
||||
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||
),
|
||||
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||
),
|
||||
|
||||
last_error TEXT,
|
||||
last_error_at_ms BIGINT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
completed_at_ms BIGINT,
|
||||
aborted_at_ms BIGINT,
|
||||
|
||||
CHECK (progress_current <= progress_total),
|
||||
CHECK (fork_from_block <= source_tip_block)
|
||||
);
|
||||
|
||||
-- У одного login одновременно может существовать только одна незавершённая ротация.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||
ON key_rotation_sessions(login)
|
||||
WHERE status NOT IN ('COMPLETE', 'ABORTED');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||
ON key_rotation_sessions(status);
|
||||
|
||||
-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию.
|
||||
-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок.
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||
FOREIGN KEY (rotation_session_id)
|
||||
REFERENCES key_rotation_sessions(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 26,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
@@ -0,0 +1,31 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||
block_bytes BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
UNIQUE (rotation_session_id, block_number),
|
||||
UNIQUE (rotation_session_id, data_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||
ON key_rotation_candidate_blocks(id)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 27,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
|
||||
COMMIT;
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
@@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
||||
blockchain_name TEXT NOT NULL,
|
||||
blockchain_key TEXT NOT NULL,
|
||||
paid_limit_bytes BIGINT NOT NULL,
|
||||
blockchain_forks_json TEXT NOT NULL DEFAULT '[]',
|
||||
used_bytes BIGINT NOT NULL,
|
||||
last_block_number INTEGER NOT NULL,
|
||||
last_block_hash TEXT NOT NULL,
|
||||
@@ -291,12 +292,6 @@ AFTER TRUNCATE ON solana_user_pda_current
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tx_signature TEXT NOT NULL,
|
||||
@@ -425,6 +420,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_type_key
|
||||
ON user_settings(setting_type, setting_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channel_read_state (
|
||||
viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login),
|
||||
owner_login TEXT NOT NULL,
|
||||
owner_bch_name TEXT NOT NULL,
|
||||
channel_name TEXT NOT NULL,
|
||||
read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0),
|
||||
unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0),
|
||||
read_updated_at_ms BIGINT NOT NULL,
|
||||
client_key TEXT NOT NULL,
|
||||
read_signature TEXT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (viewer_login, owner_bch_name, channel_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer
|
||||
ON channel_read_state(viewer_login);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel
|
||||
ON channel_read_state(owner_bch_name, channel_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ip_geo_cache (
|
||||
ip TEXT PRIMARY KEY,
|
||||
geo TEXT,
|
||||
@@ -483,6 +498,10 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
to_block_hash BYTEA,
|
||||
block_hash BYTEA NOT NULL,
|
||||
block_signature BYTEA NOT NULL,
|
||||
data_item_id BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
arweave_root_tx_id TEXT,
|
||||
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
|
||||
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
|
||||
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
|
||||
@@ -494,12 +513,41 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
|
||||
ON blocks (bch_name, block_number);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||
ON blocks(data_item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||
ON blocks(block_number)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
|
||||
ON blocks (to_login, to_bch_name, to_block_number);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms)
|
||||
VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
first_seen_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||
WHERE status='PENDING';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
rel_type INTEGER NOT NULL,
|
||||
@@ -1640,6 +1688,12 @@ AFTER INSERT ON blocks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
|
||||
CREATE TRIGGER trg_channel_names_state_stats_ai
|
||||
AFTER INSERT ON channel_names_state
|
||||
@@ -2008,9 +2062,130 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
PRIMARY KEY (owner_login, category)
|
||||
);
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_history
|
||||
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_history
|
||||
ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT '';
|
||||
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||
ON solana_user_pda_current(is_server, archive_imported)
|
||||
WHERE archive_head_tx_id <> '';
|
||||
|
||||
-- Server-local key rotation state machine (schema v26).
|
||||
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||
rotation_status IN (
|
||||
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||
ON solana_user_pda_current(rotation_status)
|
||||
WHERE rotation_status <> 'NONE';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN (
|
||||
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
|
||||
'COMPLETE','ABORTED'
|
||||
)
|
||||
),
|
||||
source_blockchain_name TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
old_root_key TEXT NOT NULL,
|
||||
old_blockchain_key TEXT NOT NULL,
|
||||
old_client_key TEXT NOT NULL,
|
||||
new_root_key TEXT NOT NULL,
|
||||
new_blockchain_key TEXT NOT NULL,
|
||||
new_client_key TEXT NOT NULL,
|
||||
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||
pda_rotation_signature TEXT,
|
||||
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||
),
|
||||
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||
),
|
||||
last_error TEXT,
|
||||
last_error_at_ms BIGINT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
completed_at_ms BIGINT,
|
||||
aborted_at_ms BIGINT,
|
||||
CHECK (progress_current <= progress_total),
|
||||
CHECK (fork_from_block <= source_tip_block)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||
ON key_rotation_sessions(login)
|
||||
WHERE status NOT IN ('COMPLETE','ABORTED');
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||
ON key_rotation_sessions(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||
block_bytes BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
UNIQUE (rotation_session_id, block_number),
|
||||
UNIQUE (rotation_session_id, data_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||
ON key_rotation_candidate_blocks(id)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||
FOREIGN KEY (rotation_session_id)
|
||||
REFERENCES key_rotation_sessions(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
+49
-8
@@ -42,15 +42,28 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request;
|
||||
|
||||
// --- NEW: SearchUsers ---
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler;
|
||||
@@ -75,18 +88,24 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
|
||||
@@ -159,8 +178,6 @@ public final class JsonHandlerRegistry {
|
||||
private static final Map<String, JsonMessageHandler> HANDLERS = Map.ofEntries(
|
||||
Map.entry("GetUser", new Net_GetUser_Handler()),
|
||||
Map.entry("SearchUsers", new Net_SearchUsers_Handler()),
|
||||
Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()),
|
||||
Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()),
|
||||
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
|
||||
|
||||
// --- key rotation ---
|
||||
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
|
||||
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
|
||||
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
|
||||
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
|
||||
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
|
||||
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
|
||||
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||
@@ -208,9 +235,12 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
|
||||
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
|
||||
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
|
||||
Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()),
|
||||
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
|
||||
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
|
||||
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
|
||||
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
|
||||
Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()),
|
||||
Map.entry("ListContacts", new Net_ListContacts_Handler()),
|
||||
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
|
||||
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
|
||||
@@ -250,8 +280,6 @@ public final class JsonHandlerRegistry {
|
||||
private static final Map<String, Class<? extends Net_Request>> REQUEST_TYPES = Map.ofEntries(
|
||||
Map.entry("GetUser", Net_GetUser_Request.class),
|
||||
Map.entry("SearchUsers", Net_SearchUsers_Request.class),
|
||||
Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class),
|
||||
Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class),
|
||||
|
||||
// --- auth ---
|
||||
Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class),
|
||||
@@ -282,6 +310,16 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
|
||||
|
||||
// --- key rotation ---
|
||||
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
|
||||
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
|
||||
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
|
||||
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
|
||||
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
|
||||
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
|
||||
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||
@@ -299,9 +337,12 @@ public final class JsonHandlerRegistry {
|
||||
Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class),
|
||||
Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class),
|
||||
Map.entry("GetMessageThread", Net_GetMessageThread_Request.class),
|
||||
Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class),
|
||||
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
|
||||
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
|
||||
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
|
||||
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
|
||||
Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class),
|
||||
Map.entry("ListContacts", Net_ListContacts_Request.class),
|
||||
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
|
||||
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
|
||||
|
||||
+118
-164
@@ -54,8 +54,7 @@ public final class SolanaUserPdaImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Чтение server PDA по логину сервера. Используется сервером при старте,
|
||||
* чтобы получить актуальный server_address и список sync_servers.
|
||||
* Чтение server PDA 1.2 по логину сервера. В 1.2 разрешён ровно один endpoint.
|
||||
*/
|
||||
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
|
||||
String login = normalizeLogin(loginRaw);
|
||||
@@ -155,9 +154,16 @@ public final class SolanaUserPdaImportService {
|
||||
}
|
||||
|
||||
private static ParsedSolanaUser parseUserPda(byte[] raw) {
|
||||
if (raw == null || raw.length < 128) return null;
|
||||
if (raw == null || raw.length < 9) return null;
|
||||
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||
int major = u8(raw, 5);
|
||||
int minor = u8(raw, 6);
|
||||
if (major != 1 || minor != 2) return null;
|
||||
return parseUserPdaV12(raw);
|
||||
}
|
||||
|
||||
private static ParsedSolanaUser parseUserPdaV12(byte[] raw) {
|
||||
if (raw == null || raw.length < 128) return null;
|
||||
int recordLen = u16le(raw, 7);
|
||||
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||
|
||||
@@ -166,224 +172,166 @@ public final class SolanaUserPdaImportService {
|
||||
c += 8; // updated_at_ms
|
||||
c += 4; // record_number
|
||||
c += 32; // prev_record_hash
|
||||
if (c >= recordLen) return null;
|
||||
|
||||
int loginLen = u8(raw, c++);
|
||||
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||
c += loginLen;
|
||||
if (c >= recordLen) return null;
|
||||
|
||||
int blocksCount = u8(raw, c++);
|
||||
String blockchainName = null;
|
||||
byte[] blockchainKey32 = null;
|
||||
byte[] clientKey32 = null;
|
||||
long paidLimitBytes = 0L;
|
||||
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
||||
int forkCount = 0;
|
||||
List<String> accessServers = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < blocksCount; i++) {
|
||||
if (c + 2 > recordLen) return null;
|
||||
int blockType = u8(raw, c++);
|
||||
int blockVer = u8(raw, c++);
|
||||
if (blockVer != 0) return null;
|
||||
|
||||
if (blockType == 0) {
|
||||
c += 32; // recovery_key
|
||||
} else if (blockType == 1) {
|
||||
c += 32;
|
||||
} else if (blockType == 2) {
|
||||
if (blockType == 1) {
|
||||
if (c + 32 > recordLen) return null;
|
||||
c += 32; // root_key
|
||||
continue;
|
||||
}
|
||||
if (blockType == 2) {
|
||||
if (c + 32 > recordLen) return null;
|
||||
clientKey32 = slice(raw, c, 32);
|
||||
c += 32;
|
||||
} else if (blockType == 3) {
|
||||
int count = u8(raw, c++);
|
||||
for (int j = 0; j < count; j++) {
|
||||
c += 1; // blockchain_type
|
||||
int bchLen = u8(raw, c++);
|
||||
blockchainName = new String(raw, c, bchLen, StandardCharsets.UTF_8);
|
||||
c += bchLen;
|
||||
blockchainKey32 = slice(raw, c, 32);
|
||||
c += 32;
|
||||
paidLimitBytes = u64le(raw, c);
|
||||
c += 8;
|
||||
c += 8; // used_bytes
|
||||
c += 4; // last_block_number
|
||||
c += 32; // last_block_hash
|
||||
c += 64; // last_block_signature
|
||||
int arweavePresent = u8(raw, c++);
|
||||
if (arweavePresent == 1) {
|
||||
int arLen = u8(raw, c++);
|
||||
c += arLen;
|
||||
} else if (arweavePresent != 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else if (blockType == 30) {
|
||||
int isServer = u8(raw, c++);
|
||||
if (isServer == 1) {
|
||||
c += 1; // address_format_type
|
||||
c += 1; // address_format_version
|
||||
int addrLen = u8(raw, c++);
|
||||
c += addrLen;
|
||||
int syncCount = u8(raw, c++);
|
||||
for (int j = 0; j < syncCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
c += n;
|
||||
}
|
||||
} else if (isServer != 0) {
|
||||
return null;
|
||||
}
|
||||
} else if (blockType == 40) {
|
||||
int accessCount = u8(raw, c++);
|
||||
for (int j = 0; j < accessCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
|
||||
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
|
||||
continue;
|
||||
}
|
||||
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
||||
accessServers.add(normalizedAccessServerLogin);
|
||||
}
|
||||
}
|
||||
} else if (blockType == 50) {
|
||||
int sessionsMode = u8(raw, c++);
|
||||
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
||||
int sessionsCount = u8(raw, c++);
|
||||
if (sessionsCount > 64) return null;
|
||||
for (int j = 0; j < sessionsCount; j++) {
|
||||
int sessionType = u8(raw, c++);
|
||||
int sessionVersion = u8(raw, c++);
|
||||
int n = u8(raw, c++);
|
||||
String sessionName = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
byte[] sessionPubKey32 = slice(raw, c, 32);
|
||||
c += 32;
|
||||
sessions.add(new ParsedSessionRecord(
|
||||
sessionType,
|
||||
sessionVersion,
|
||||
sessionName,
|
||||
sessionPubKey32
|
||||
));
|
||||
}
|
||||
} else if (blockType == 70) {
|
||||
c += 1;
|
||||
} else {
|
||||
return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c > recordLen) return null;
|
||||
if (c + 2 > recordLen) return null;
|
||||
int payloadLen = u16le(raw, c);
|
||||
c += 2;
|
||||
int payloadEnd = c + payloadLen;
|
||||
if (payloadEnd < c || payloadEnd > recordLen) return null;
|
||||
|
||||
if (blockType == 3) {
|
||||
if (c + 2 > payloadEnd) return null;
|
||||
forkCount = u16le(raw, c);
|
||||
c += 2;
|
||||
if (forkCount <= 0) return null;
|
||||
for (int j = 0; j < forkCount; j++) {
|
||||
if (c + 44 > payloadEnd) return null;
|
||||
blockchainKey32 = slice(raw, c, 32);
|
||||
c += 32;
|
||||
c += 8; // fork created_at_ms
|
||||
paidLimitBytes = u32le(raw, c);
|
||||
c += 4;
|
||||
}
|
||||
if (c != payloadEnd) return null;
|
||||
} else if (blockType == 30) {
|
||||
// Server profile is not needed for auth parsing; validate/skip its payload.
|
||||
c = payloadEnd;
|
||||
} else if (blockType == 40) {
|
||||
if (c >= payloadEnd) return null;
|
||||
int accessCount = u8(raw, c++);
|
||||
if (accessCount > 1) return null;
|
||||
for (int j = 0; j < accessCount; j++) {
|
||||
if (c >= payloadEnd) return null;
|
||||
int n = u8(raw, c++);
|
||||
if (c + n > payloadEnd) return null;
|
||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
String normalized = normalizeLogin(accessServerLogin);
|
||||
if (normalized != null && !accessServers.contains(normalized)
|
||||
&& accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
||||
accessServers.add(normalized);
|
||||
}
|
||||
}
|
||||
if (c != payloadEnd) return null;
|
||||
} else {
|
||||
// Unknown variable PDA 1.2 block: forward-compatible skip by payload_len.
|
||||
c = payloadEnd;
|
||||
}
|
||||
c = payloadEnd;
|
||||
}
|
||||
|
||||
if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null;
|
||||
if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0) return null;
|
||||
String blockchainName = login + "-" + String.format(Locale.ROOT, "%03d", forkCount);
|
||||
return new ParsedSolanaUser(
|
||||
login,
|
||||
blockchainName,
|
||||
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||
Base64.getEncoder().encodeToString(clientKey32),
|
||||
paidLimitBytes,
|
||||
sessions,
|
||||
List.of(),
|
||||
accessServers
|
||||
);
|
||||
}
|
||||
|
||||
private static ParsedServerProfile parseServerProfile(byte[] raw) {
|
||||
if (raw == null || raw.length < 128) return null;
|
||||
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||
|
||||
private static ParsedServerProfile parseServerProfile(byte[] raw) {
|
||||
if (raw == null || raw.length < 9) return null;
|
||||
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||
int major = u8(raw, 5);
|
||||
int minor = u8(raw, 6);
|
||||
if (major != 1 || minor != 2) return null;
|
||||
return parseServerProfileV12(raw);
|
||||
}
|
||||
|
||||
private static ParsedServerProfile parseServerProfileV12(byte[] raw) {
|
||||
if (raw == null || raw.length < 128) return null;
|
||||
int recordLen = u16le(raw, 7);
|
||||
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||
|
||||
int c = 9;
|
||||
c += 8; // created_at_ms
|
||||
c += 8; // updated_at_ms
|
||||
c += 4; // record_number
|
||||
c += 32; // prev_record_hash
|
||||
|
||||
int c = 9 + 8 + 8 + 4 + 32;
|
||||
if (c >= recordLen) return null;
|
||||
int loginLen = u8(raw, c++);
|
||||
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||
c += loginLen;
|
||||
|
||||
if (c >= recordLen) return null;
|
||||
int blocksCount = u8(raw, c++);
|
||||
|
||||
boolean isServer = false;
|
||||
String serverAddress = "";
|
||||
List<String> syncServers = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < blocksCount; i++) {
|
||||
if (c + 2 > recordLen) return null;
|
||||
int blockType = u8(raw, c++);
|
||||
int blockVer = u8(raw, c++);
|
||||
if (blockVer != 0) return null;
|
||||
|
||||
if (blockType == 0 || blockType == 1 || blockType == 2) {
|
||||
if (blockType == 1 || blockType == 2) {
|
||||
if (c + 32 > recordLen) return null;
|
||||
c += 32;
|
||||
} else if (blockType == 3) {
|
||||
int count = u8(raw, c++);
|
||||
for (int j = 0; j < count; j++) {
|
||||
c += 1; // blockchain_type
|
||||
int bchLen = u8(raw, c++);
|
||||
c += bchLen;
|
||||
c += 32; // blockchain pubkey
|
||||
c += 8; // paid_limit_bytes
|
||||
c += 8; // used_bytes
|
||||
c += 4; // last_block_number
|
||||
c += 32; // last_block_hash
|
||||
c += 64; // last_block_signature
|
||||
int arweavePresent = u8(raw, c++);
|
||||
if (arweavePresent == 1) {
|
||||
int arLen = u8(raw, c++);
|
||||
c += arLen;
|
||||
} else if (arweavePresent != 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else if (blockType == 30) {
|
||||
int isServerValue = u8(raw, c++);
|
||||
if (isServerValue == 1) {
|
||||
isServer = true;
|
||||
continue;
|
||||
}
|
||||
if (c + 2 > recordLen) return null;
|
||||
int payloadLen = u16le(raw, c);
|
||||
c += 2;
|
||||
int payloadEnd = c + payloadLen;
|
||||
if (payloadEnd < c || payloadEnd > recordLen) return null;
|
||||
|
||||
if (blockType == 30) {
|
||||
if (c >= payloadEnd) return null;
|
||||
int addressCount = u8(raw, c++);
|
||||
if (addressCount != 1) return null;
|
||||
for (int j = 0; j < addressCount; j++) {
|
||||
if (c + 3 > payloadEnd) return null;
|
||||
c += 1; // address_format_type
|
||||
c += 1; // address_format_version
|
||||
int addrLen = u8(raw, c++);
|
||||
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
|
||||
c += addrLen;
|
||||
int syncCount = u8(raw, c++);
|
||||
for (int j = 0; j < syncCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
syncServers.add(normalizeLogin(syncLogin));
|
||||
int n = u8(raw, c++);
|
||||
if (c + n > payloadEnd) return null;
|
||||
String address = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
if (!isServer) {
|
||||
isServer = true;
|
||||
serverAddress = address;
|
||||
}
|
||||
} else if (isServerValue != 0) {
|
||||
return null;
|
||||
}
|
||||
} else if (blockType == 40) {
|
||||
int accessCount = u8(raw, c++);
|
||||
for (int j = 0; j < accessCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
c += n;
|
||||
}
|
||||
} else if (blockType == 50) {
|
||||
int sessionsMode = u8(raw, c++);
|
||||
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
||||
int sessionsCount = u8(raw, c++);
|
||||
if (sessionsCount > 64) return null;
|
||||
for (int j = 0; j < sessionsCount; j++) {
|
||||
c += 1; // session_type
|
||||
c += 1; // session_version
|
||||
int n = u8(raw, c++);
|
||||
c += n;
|
||||
c += 32;
|
||||
}
|
||||
} else if (blockType == 70) {
|
||||
c += 1;
|
||||
} else {
|
||||
return null;
|
||||
if (c != payloadEnd) return null;
|
||||
}
|
||||
|
||||
if (c > recordLen) return null;
|
||||
c = payloadEnd;
|
||||
}
|
||||
|
||||
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
|
||||
return new ParsedServerProfile(login, isServer, serverAddress, List.of());
|
||||
}
|
||||
|
||||
|
||||
private static String normalizeLogin(String login) {
|
||||
if (login == null) return null;
|
||||
String s = login.trim();
|
||||
@@ -395,6 +343,12 @@ public final class SolanaUserPdaImportService {
|
||||
private static int u16le(byte[] b, int o) {
|
||||
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
|
||||
}
|
||||
private static long u32le(byte[] b, int o) {
|
||||
return ((long) b[o] & 0xFFL)
|
||||
| (((long) b[o + 1] & 0xFFL) << 8)
|
||||
| (((long) b[o + 2] & 0xFFL) << 16)
|
||||
| (((long) b[o + 3] & 0xFFL) << 24);
|
||||
}
|
||||
private static long u64le(byte[] b, int o) {
|
||||
long out = 0L;
|
||||
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
|
||||
|
||||
+136
-6
@@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import server.sync.AddBlockSyncService;
|
||||
import shine.db.channels.ChannelNameRules;
|
||||
@@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry;
|
||||
import utils.blockchain.BlockchainNameUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
@@ -96,11 +98,25 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||
lock.lock();
|
||||
try {
|
||||
try {
|
||||
if (isKeyRotationActive(blockchainName)) {
|
||||
BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName);
|
||||
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
|
||||
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
|
||||
return error(req, 423, "key_rotation_in_progress", lastNum, lastHash);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e);
|
||||
return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, "");
|
||||
}
|
||||
|
||||
AddBlockResult r = addBlock(
|
||||
blockchainName,
|
||||
req.getBlockNumber(), // старое поле, пока оставляем
|
||||
req.getPrevBlockHash(), // старое поле, пока оставляем
|
||||
req.getBlockBytesB64()
|
||||
req.getBlockBytesB64(),
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
// ✅ УСПЕХ: как раньше
|
||||
@@ -148,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return resp;
|
||||
}
|
||||
|
||||
private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException {
|
||||
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
|
||||
if (login == null || login.isBlank()) return false;
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT rotation_status
|
||||
FROM solana_user_pda_current
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() && !"NONE".equals(rs.getString(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String humanMessage(String code) {
|
||||
if (code == null) return "Ошибка добавления блока";
|
||||
return switch (code) {
|
||||
@@ -160,6 +193,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "limit_exceeded" -> "Превышен лимит размера блокчейна";
|
||||
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
||||
case "bad_block_format" -> "Некорректный формат блока";
|
||||
case "bad_app_tag" -> "Некорректный тестовый тег ANS-104";
|
||||
case "bad_channel_tag" -> "Некорректный тег канала ANS-104";
|
||||
case "bad_block_body" -> "Некорректное тело блока";
|
||||
case "bad_channel_name" -> "Некорректное название канала";
|
||||
case "bad_block_number" -> "Некорректный номер блока";
|
||||
@@ -178,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
|
||||
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
|
||||
default -> "Ошибка: " + code;
|
||||
};
|
||||
}
|
||||
@@ -186,7 +223,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
String blockchainName,
|
||||
int globalNumberFromReq,
|
||||
String prevGlobalHashHexFromReq,
|
||||
String blockBytesB64
|
||||
String blockBytesB64,
|
||||
boolean replicateAfterWrite,
|
||||
boolean publishToArweave
|
||||
) {
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
|
||||
@@ -268,6 +307,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if (!block.getDataItem().hasTag("App", "test5590")) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// body.check()
|
||||
try {
|
||||
block.body.check();
|
||||
@@ -397,6 +440,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||
}
|
||||
|
||||
// Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=<slug>.
|
||||
try {
|
||||
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
|
||||
String actualChannelSlug = block.getDataItem().tagValue("c_test5590");
|
||||
if (expectedChannelSlug != null) {
|
||||
if (!expectedChannelSlug.equals(actualChannelSlug)) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} else if (actualChannelSlug != null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e);
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if ((block.type & 0xFFFF) == 1
|
||||
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
try {
|
||||
@@ -454,7 +513,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// 6) подпись по hash32(preimage)
|
||||
// 6) ANS-104 Ed25519 подпись и owner
|
||||
boolean sigOk;
|
||||
try {
|
||||
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
||||
@@ -512,7 +571,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 8) сформировать запись и записать (DB + state + файл)
|
||||
// 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state
|
||||
try {
|
||||
BlockEntry be = new BlockEntry();
|
||||
be.setLogin(login);
|
||||
@@ -525,6 +584,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
be.setBlockBytes(block.toBytes());
|
||||
be.setBlockHash(block.getHash32());
|
||||
be.setBlockSignature(block.getSignature64());
|
||||
be.setDataItemId(block.getDataItemId32());
|
||||
be.setArweavePublishPending(publishToArweave);
|
||||
|
||||
// line columns (optional)
|
||||
be.setLineCode(lineCode);
|
||||
@@ -586,9 +647,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
|
||||
UserNotificationEntry notificationEntry = buildNotificationEntry(block, be);
|
||||
dbWriter.appendBlockAndState(
|
||||
List<String> counterChangedLogins = dbWriter.appendBlockAndState(
|
||||
blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry);
|
||||
|
||||
for (String changedLogin : counterChangedLogins) {
|
||||
UserCountersSupport.pushChanged(changedLogin);
|
||||
}
|
||||
|
||||
if (chat200CreateSeed != null) {
|
||||
upsertChat200StateFromCreate(chat200CreateSeed);
|
||||
}
|
||||
@@ -607,15 +672,80 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}",
|
||||
login, blockchainName, block.blockNumber, newHashHex);
|
||||
|
||||
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
||||
if (replicateAfterWrite) {
|
||||
addBlockSyncService.replicateAsync(blockchainName, block.blockNumber);
|
||||
}
|
||||
|
||||
return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
|
||||
* но не запускает повторную межсерверную репликацию уже импортированного блока.
|
||||
*/
|
||||
public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) {
|
||||
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||
return new ArweaveImportResult(false, "chain_resync_in_progress", -1, "");
|
||||
}
|
||||
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
|
||||
return new ArweaveImportResult(false, "empty_block", -1, "");
|
||||
}
|
||||
try {
|
||||
BchBlockEntry probe = new BchBlockEntry(rawBlockBytes);
|
||||
if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) {
|
||||
return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32()));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Parsed again below to return the canonical bad_block_format result.
|
||||
}
|
||||
final BchBlockEntry parsed;
|
||||
try {
|
||||
parsed = new BchBlockEntry(rawBlockBytes);
|
||||
} catch (Exception e) {
|
||||
return new ArweaveImportResult(false, "bad_block_format", -1, "");
|
||||
}
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||
lock.lock();
|
||||
try {
|
||||
AddBlockResult r = addBlock(
|
||||
blockchainName,
|
||||
parsed.blockNumber,
|
||||
toHex(parsed.prevHash32),
|
||||
Base64Ws.encode(rawBlockBytes),
|
||||
false,
|
||||
false
|
||||
);
|
||||
return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Helpers ====================================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception {
|
||||
if (block.body instanceof CreateChannelBody) {
|
||||
return justCreated == null ? null : justCreated.getSlug();
|
||||
}
|
||||
int type = block.type & 0xffff;
|
||||
int sub = block.subType & 0xffff;
|
||||
boolean channelBlock = type == 1 && (
|
||||
sub == (MsgSubType.TEXT_POST & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_REPOST & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_EXERCISE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_SERVICE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_COURSE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_EDIT_POST & 0xffff));
|
||||
if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null;
|
||||
return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode());
|
||||
}
|
||||
|
||||
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
|
||||
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
|
||||
|
||||
+49
-116
@@ -4,6 +4,8 @@ import blockchain.BchBlockEntry;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.dao.ChannelNameStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.UserParamsDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
@@ -11,40 +13,24 @@ import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||
*
|
||||
* Текущая схема:
|
||||
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
|
||||
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
|
||||
* 3) создаём пустой marker <blockchainName>.write_pending;
|
||||
* 4) выполняем одну SQL-транзакцию;
|
||||
* 5) после commit атомарно заменяем основной .bch из tmp;
|
||||
* 6) убираем временные файлы.
|
||||
*
|
||||
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
|
||||
* - если marker отсутствует, а tmp/check остались, это мусор;
|
||||
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
|
||||
/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL.
|
||||
* Local .bch/tmp/marker files are intentionally not used.
|
||||
*/
|
||||
public final class BlockchainWriter {
|
||||
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private final BlocksDAO blocksDAO;
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
private final ChannelNameStateDAO channelNameStateDAO;
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
@@ -58,7 +44,11 @@ public final class BlockchainWriter {
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
public void appendBlockAndState(String blockchainName,
|
||||
/**
|
||||
* Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction.
|
||||
* There is intentionally no .bch/tmp/marker file layer anymore.
|
||||
*/
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
BlockEntry be,
|
||||
@@ -68,121 +58,64 @@ public final class BlockchainWriter {
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
|
||||
String blockHashHex = HEX.formatHex(block.getHash32());
|
||||
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
byte[] rawDataItem = block.toBytes();
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
// 1) insert block
|
||||
blocksDAO.insert(c, be);
|
||||
|
||||
// 2) update state
|
||||
st.setLastBlockNumber(block.blockNumber);
|
||||
st.setLastBlockHash(block.getHash32());
|
||||
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
|
||||
st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length);
|
||||
st.setUpdatedAtMs(nowMs);
|
||||
|
||||
stateDAO.upsert(c, st);
|
||||
|
||||
// 2.1) Если блок — USER_PARAM, синхронизируем снимок в users_params в той же транзакции.
|
||||
if (userParamEntry != null) {
|
||||
userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
Integer lineCode = be.getLineCode();
|
||||
if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) {
|
||||
String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode);
|
||||
if (channelName != null && !channelName.isBlank()) {
|
||||
counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers(
|
||||
c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (channelNameStateEntry != null) {
|
||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
}
|
||||
|
||||
if (channelMetaUpdateEntry != null) {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||
&& be.getToBlockNumber() != null && be.getToBlockNumber() > 0) {
|
||||
if (channelReadStateDAO.deleteForUnfollowTarget(
|
||||
c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) {
|
||||
counterChangedLogins.add(be.getLogin());
|
||||
}
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
c.rollback();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
if (!committed) {
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
}
|
||||
|
||||
if (e instanceof SQLException se) {
|
||||
throw se;
|
||||
}
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("appendBlockAndState failed", e);
|
||||
} finally {
|
||||
try {
|
||||
c.setAutoCommit(true);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) После commit — атомарно подменяем основной файл.
|
||||
try {
|
||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||
} catch (RuntimeException e) {
|
||||
// marker/check/tmp оставляем для startup-recovery
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
byte[] base;
|
||||
try {
|
||||
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
||||
base = fs.readBlockchain(blockchainName);
|
||||
} else {
|
||||
base = new byte[0];
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
byte[] out = new byte[base.length + blockBytes.length];
|
||||
System.arraycopy(base, 0, out, 0, base.length);
|
||||
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
|
||||
fs.writeBlockchainTmp(blockchainName, candidateBytes);
|
||||
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
|
||||
fs.writeBlockchainWritePendingMarker(blockchainName);
|
||||
}
|
||||
|
||||
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
|
||||
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
|
||||
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
|
||||
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
|
||||
}
|
||||
|
||||
private static void deleteQuietly(DeleteAction action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DeleteAction {
|
||||
void run();
|
||||
private static boolean isPublicChannelPublication(int msgType, int msgSubType) {
|
||||
if (msgType != 1) return false;
|
||||
return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -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();
|
||||
}
|
||||
}
|
||||
+20
@@ -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; }
|
||||
}
|
||||
+64
@@ -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; }
|
||||
}
|
||||
}
|
||||
+8
-33
@@ -144,38 +144,12 @@ final class ChannelsReadSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static String userSettingsChannelKey(String ownerBch, String channelName) {
|
||||
String bch = ownerBch == null ? "" : ownerBch.trim();
|
||||
String name = channelName == null ? "" : channelName.trim();
|
||||
return bch + "/" + name;
|
||||
}
|
||||
|
||||
static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException {
|
||||
if (viewerLogin == null || viewerLogin.isBlank()) return 0;
|
||||
String key = userSettingsChannelKey(ownerBch, channelName);
|
||||
String sql = """
|
||||
SELECT value_num
|
||||
FROM user_settings
|
||||
WHERE LOWER(login) = LOWER(?)
|
||||
AND setting_type = ?
|
||||
AND setting_key = ?
|
||||
LIMIT 1
|
||||
""";
|
||||
long lastSeen = messagesCount;
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, viewerLogin);
|
||||
ps.setInt(2, 1);
|
||||
ps.setString(3, key);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
long value = rs.getLong("value_num");
|
||||
if (!rs.wasNull()) lastSeen = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastSeen < 0) lastSeen = 0;
|
||||
if (lastSeen > messagesCount) return 0;
|
||||
return Math.max(0, messagesCount - (int) lastSeen);
|
||||
if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank()
|
||||
|| channelName == null || channelName.isBlank()) return 0;
|
||||
long unread = shine.db.dao.ChannelReadStateDAO.getInstance()
|
||||
.getUnreadCount(c, viewerLogin, ownerBch, channelName);
|
||||
return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread);
|
||||
}
|
||||
|
||||
static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException {
|
||||
@@ -262,12 +236,13 @@ final class ChannelsReadSupport {
|
||||
|
||||
static List<PostBlock> channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException {
|
||||
String order = asc ? "ASC" : "DESC";
|
||||
boolean bounded = limit > 0;
|
||||
String sql = """
|
||||
SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number
|
||||
FROM blocks
|
||||
WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=?
|
||||
ORDER BY block_number
|
||||
""" + order + " LIMIT ?";
|
||||
""" + order + (bounded ? " LIMIT ?" : "");
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, ownerBch);
|
||||
ps.setInt(2, MSG_TYPE_TEXT);
|
||||
@@ -278,7 +253,7 @@ final class ChannelsReadSupport {
|
||||
ps.setInt(7, MsgSubType.TEXT_SERVICE);
|
||||
ps.setInt(8, MsgSubType.TEXT_COURSE);
|
||||
ps.setInt(9, lineCode);
|
||||
ps.setInt(10, limit);
|
||||
if (bounded) ps.setInt(10, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
List<PostBlock> out = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
|
||||
+6
-4
@@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel");
|
||||
}
|
||||
|
||||
int limit = req.getLimit() == null ? 30 : req.getLimit();
|
||||
if (limit <= 0 || limit > 1000) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit");
|
||||
// limit is optional. null/0 means "return the whole channel".
|
||||
// Positive values are kept for backwards-compatible callers that still want a bounded response.
|
||||
int limit = req.getLimit() == null ? 0 : req.getLimit();
|
||||
if (limit < 0) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit");
|
||||
}
|
||||
|
||||
boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort());
|
||||
@@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler {
|
||||
if (!asc) {
|
||||
java.util.Collections.reverse(posts);
|
||||
}
|
||||
if (posts.size() > limit) {
|
||||
if (limit > 0 && posts.size() > limit) {
|
||||
posts = new ArrayList<>(posts.subList(0, limit));
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageLikes_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest;
|
||||
Net_GetMessageLikes_Request.MessageSelector message = req.getMessage();
|
||||
if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank()
|
||||
|| message.getBlockNumber() == null || message.getBlockNumber() < 0
|
||||
|| message.getBlockHash() == null || message.getBlockHash().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message");
|
||||
}
|
||||
|
||||
final byte[] blockHash;
|
||||
try {
|
||||
blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash());
|
||||
} catch (Exception e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
if (blockHash == null || blockHash.length == 0) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash");
|
||||
}
|
||||
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
String sql = """
|
||||
SELECT rs.from_login,
|
||||
COALESCE(ups.first_name, '') AS first_name,
|
||||
COALESCE(ups.last_name, '') AS last_name,
|
||||
COALESCE(ups.ava_ar, '') AS avatar_ar,
|
||||
COALESCE(ups.account_role, 'unknown') AS account_role,
|
||||
COALESCE(ups.shine_status, 'unknown') AS shine_status
|
||||
FROM reactions_state rs
|
||||
LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login)
|
||||
WHERE rs.reaction_type = 1
|
||||
AND rs.last_sub_type = 1
|
||||
AND rs.to_bch_name = ?
|
||||
AND rs.to_block_number = ?
|
||||
AND rs.to_block_hash = ?
|
||||
ORDER BY LOWER(rs.from_login)
|
||||
""";
|
||||
|
||||
List<Net_GetMessageLikes_Response.UserItem> shining = new ArrayList<>();
|
||||
List<Net_GetMessageLikes_Response.UserItem> official = new ArrayList<>();
|
||||
List<Net_GetMessageLikes_Response.UserItem> others = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, message.getBlockchainName().trim());
|
||||
ps.setInt(2, message.getBlockNumber());
|
||||
ps.setBytes(3, blockHash);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem();
|
||||
user.setLogin(rs.getString("from_login"));
|
||||
user.setFirstName(rs.getString("first_name"));
|
||||
user.setLastName(rs.getString("last_name"));
|
||||
user.setAvatarAr(rs.getString("avatar_ar"));
|
||||
|
||||
boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role"));
|
||||
boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status"));
|
||||
if (isOfficial && isShining) shining.add(user);
|
||||
else if (isOfficial) official.add(user);
|
||||
else others.add(user);
|
||||
}
|
||||
|
||||
Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setShining(shining);
|
||||
resp.setOfficial(official);
|
||||
resp.setOthers(others);
|
||||
resp.setTotal(shining.size() + official.size() + others.size());
|
||||
resp.setTruncated(false);
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("GetMessageLikes failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -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
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.Connection;
|
||||
|
||||
public class Net_GetUserCounters_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class);
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
String login = ctx.getLogin().trim();
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login);
|
||||
Net_GetUserCounters_Response r = new Net_GetUserCounters_Response();
|
||||
r.setOp(req.getOp());
|
||||
r.setRequestId(req.getRequestId());
|
||||
r.setStatus(WireCodes.Status.OK);
|
||||
r.setLogin(login);
|
||||
r.setDmUnreadCount(s.dmUnreadCount());
|
||||
r.setChannelsUnreadCount(s.channelsUnreadCount());
|
||||
r.setNotificationsUnreadCount(s.notificationsUnreadCount());
|
||||
r.setNotificationRepliesUnreadCount(s.repliesUnreadCount());
|
||||
r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount());
|
||||
r.setNotificationEventsUnreadCount(s.eventsUnreadCount());
|
||||
return r;
|
||||
} catch (Exception e) {
|
||||
log.error("GetUserCounters failed for {}", login, e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики");
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
@@ -81,8 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler {
|
||||
channelRef.setChannelRoot(rootRef);
|
||||
|
||||
row.setChannel(channelRef);
|
||||
row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber));
|
||||
row.setUnreadCount(ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount()));
|
||||
int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber);
|
||||
row.setMessagesCount(messagesCount);
|
||||
boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin);
|
||||
ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null
|
||||
? null
|
||||
: ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName);
|
||||
row.setReadStateInitialized(readState != null);
|
||||
row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount())));
|
||||
row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount()));
|
||||
|
||||
ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber);
|
||||
if (lastPost != null) {
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.Base64Ws;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
import shine.db.MsgSubType;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import utils.config.ShineSignatureConstants;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
|
||||
/** Accepts the signed client read watermark for one followed public channel. */
|
||||
public final class Net_SetChannelReadState_Handler implements JsonMessageHandler {
|
||||
private static final Logger log = LoggerFactory.getLogger(Net_SetChannelReadState_Handler.class);
|
||||
private static final long MAX_FUTURE_SKEW_MS = 5 * 60_000L;
|
||||
|
||||
@Override
|
||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
||||
Net_SetChannelReadState_Request req = (Net_SetChannelReadState_Request) baseRequest;
|
||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"NOT_AUTHENTICATED", "Требуется авторизация");
|
||||
}
|
||||
if (req.getLogin() == null || req.getLogin().isBlank()
|
||||
|| req.getOwner_bch_name() == null || req.getOwner_bch_name().isBlank()
|
||||
|| req.getChannel_name() == null || req.getChannel_name().isBlank()
|
||||
|| req.getRead_count() == null || req.getRead_count() < 0
|
||||
|| req.getTime_ms() == null || req.getTime_ms() <= 0
|
||||
|| req.getClient_key() == null || req.getClient_key().isBlank()
|
||||
|| req.getSignature() == null || req.getSignature().isBlank()) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_FIELDS", "Некорректные поля ChannelReadState");
|
||||
}
|
||||
|
||||
String authenticatedLogin = String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||
String login = req.getLogin().trim();
|
||||
String ownerBch = req.getOwner_bch_name().trim();
|
||||
String channelName = req.getChannel_name().trim();
|
||||
long readCount = req.getRead_count();
|
||||
long timeMs = req.getTime_ms();
|
||||
String clientKeyB64 = req.getClient_key().trim();
|
||||
String signatureB64 = req.getSignature().trim();
|
||||
|
||||
if (!authenticatedLogin.equalsIgnoreCase(login)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"LOGIN_MISMATCH", "Состояние чтения принадлежит другому пользователю");
|
||||
}
|
||||
long nowMs = System.currentTimeMillis();
|
||||
if (timeMs > nowMs + MAX_FUTURE_SKEW_MS) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"BAD_TIME", "Некорректное время подписи");
|
||||
}
|
||||
|
||||
try {
|
||||
byte[] pubKey32;
|
||||
byte[] sig64;
|
||||
try {
|
||||
pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key");
|
||||
sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature");
|
||||
} catch (IllegalArgumentException e) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST,
|
||||
"BAD_BASE64", "client_key/signature должны быть Base64");
|
||||
}
|
||||
|
||||
String userClientKey = String.valueOf(ctx.getCurrentUser().getClientKey() == null
|
||||
? "" : ctx.getCurrentUser().getClientKey()).trim();
|
||||
if (userClientKey.isBlank() || !userClientKey.equals(clientKeyB64)) {
|
||||
return NetExceptionResponseFactory.error(req, 403,
|
||||
"DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю");
|
||||
}
|
||||
|
||||
String signText = ShineSignatureConstants.CHANNEL_READ_STATE_PREFIX
|
||||
+ escapePart(login) + '|'
|
||||
+ escapePart(ownerBch) + '|'
|
||||
+ escapePart(channelName) + '|'
|
||||
+ timeMs + '|'
|
||||
+ readCount;
|
||||
if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) {
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED,
|
||||
"INVALID_SIGNATURE", "Подпись состояния чтения канала не прошла проверку");
|
||||
}
|
||||
|
||||
ChannelReadStateDAO dao = ChannelReadStateDAO.getInstance();
|
||||
ChannelReadStateDAO.UpsertResult result;
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
if (!dao.hasActiveSubscription(c, authenticatedLogin, ownerBch, channelName,
|
||||
MsgSubType.CONNECTION_FOLLOW)) {
|
||||
return NetExceptionResponseFactory.error(req, 409,
|
||||
"CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал");
|
||||
}
|
||||
String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName);
|
||||
Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName);
|
||||
if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) {
|
||||
return NetExceptionResponseFactory.error(req, 404,
|
||||
"CHANNEL_NOT_FOUND", "Канал не найден");
|
||||
}
|
||||
|
||||
// Never persist a watermark beyond the number of messages that really exists.
|
||||
// Without this guard, one buggy/malicious oversized read_count would be monotonic
|
||||
// and could make all future unread counters impossible to clear correctly.
|
||||
long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot);
|
||||
long effectiveReadCount = Math.min(readCount, actualMessagesCount);
|
||||
|
||||
result = dao.upsertSignedReadIfNewer(c,
|
||||
authenticatedLogin, ownerLogin, ownerBch, channelName,
|
||||
effectiveReadCount, timeMs, clientKeyB64, signatureB64, nowMs);
|
||||
}
|
||||
|
||||
Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response();
|
||||
resp.setOp(req.getOp());
|
||||
resp.setRequestId(req.getRequestId());
|
||||
resp.setStatus(WireCodes.Status.OK);
|
||||
resp.setLogin(authenticatedLogin);
|
||||
resp.setOwner_bch_name(ownerBch);
|
||||
resp.setChannel_name(channelName);
|
||||
resp.setRead_count(result.readCount());
|
||||
resp.setUnread_count(result.unreadCount());
|
||||
resp.setTime_ms(timeMs);
|
||||
resp.setApplied(result.applied());
|
||||
if (result.applied()) UserCountersSupport.pushChanged(authenticatedLogin);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
log.error("SetChannelReadState failed", e);
|
||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR,
|
||||
"INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage(
|
||||
"Внутренняя ошибка SetChannelReadState", e));
|
||||
}
|
||||
}
|
||||
|
||||
private static String escapePart(String value) {
|
||||
String s = String.valueOf(value == null ? "" : value);
|
||||
return s.replace("\\", "\\\\").replace("|", "\\|");
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.push.WsEventSender;
|
||||
import shine.db.DbController;
|
||||
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||
import shine.db.dao.ChannelReadStateDAO;
|
||||
import shine.db.dao.UserNotificationsStateDAO;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
|
||||
/**
|
||||
* Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract.
|
||||
* It intentionally uses current DB state today; later it can read one materialized row/cache
|
||||
* without changing API or UI.
|
||||
*/
|
||||
public final class UserCountersSupport {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private UserCountersSupport() {}
|
||||
|
||||
public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount,
|
||||
long connectionsUnreadCount, long eventsUnreadCount) {
|
||||
public long notificationsUnreadCount() {
|
||||
return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount;
|
||||
}
|
||||
}
|
||||
|
||||
public static Snapshot calculate(Connection c, String login) throws Exception {
|
||||
long dm = countDmUnread(c, login);
|
||||
long channels = countChannelsUnread(c, login);
|
||||
UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance();
|
||||
UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance();
|
||||
long repliesSeen = seen.getSeenAt(c, login, "replies");
|
||||
long connectionsSeen = seen.getSeenAt(c, login, "connections");
|
||||
long eventsSeen = seen.getSeenAt(c, login, "events");
|
||||
long replies = notifications.countUnseen(c, login, "reply", repliesSeen);
|
||||
long connections = notifications.countUnseen(c, login, "connection", connectionsSeen);
|
||||
long events = notifications.countUnseen(c, login, "event", eventsSeen);
|
||||
return new Snapshot(dm, channels, replies, connections, events);
|
||||
}
|
||||
|
||||
public static Snapshot calculate(String login) throws Exception {
|
||||
try (Connection c = DbController.getInstance().getConnection()) {
|
||||
return calculate(c, login);
|
||||
}
|
||||
}
|
||||
|
||||
public static void pushChanged(String login) {
|
||||
if (login == null || login.isBlank()) return;
|
||||
try {
|
||||
Snapshot s = calculate(login);
|
||||
ObjectNode payload = toPayload(login, s);
|
||||
String eventId = "user-counters-" + System.currentTimeMillis();
|
||||
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) {
|
||||
WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Counter push is best-effort. The client can always recover with GetUserCounters.
|
||||
}
|
||||
}
|
||||
|
||||
public static ObjectNode toPayload(String login, Snapshot s) {
|
||||
ObjectNode p = MAPPER.createObjectNode();
|
||||
p.put("login", login);
|
||||
p.put("dmUnreadCount", s.dmUnreadCount());
|
||||
p.put("channelsUnreadCount", s.channelsUnreadCount());
|
||||
p.put("notificationsUnreadCount", s.notificationsUnreadCount());
|
||||
ObjectNode n = p.putObject("notifications");
|
||||
n.put("replies", s.repliesUnreadCount());
|
||||
n.put("connections", s.connectionsUnreadCount());
|
||||
n.put("events", s.eventsUnreadCount());
|
||||
return p;
|
||||
}
|
||||
|
||||
private static long countDmUnread(Connection c, String login) throws Exception {
|
||||
String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long countChannelsUnread(Connection c, String login) throws Exception {
|
||||
return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_GetMessageLikes_Request extends Net_Request {
|
||||
private MessageSelector message;
|
||||
private Integer limit;
|
||||
|
||||
public MessageSelector getMessage() { return message; }
|
||||
public void setMessage(MessageSelector message) { this.message = message; }
|
||||
|
||||
public Integer getLimit() { return limit; }
|
||||
public void setLimit(Integer limit) { this.limit = limit; }
|
||||
|
||||
public static class MessageSelector {
|
||||
private String blockchainName;
|
||||
private Integer blockNumber;
|
||||
private String blockHash;
|
||||
|
||||
public String getBlockchainName() { return blockchainName; }
|
||||
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||
|
||||
public Integer getBlockNumber() { return blockNumber; }
|
||||
public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; }
|
||||
|
||||
public String getBlockHash() { return blockHash; }
|
||||
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class Net_GetMessageLikes_Response extends Net_Response {
|
||||
private List<UserItem> shining = new ArrayList<>();
|
||||
private List<UserItem> official = new ArrayList<>();
|
||||
private List<UserItem> others = new ArrayList<>();
|
||||
private int total;
|
||||
private boolean truncated;
|
||||
|
||||
public List<UserItem> getShining() { return shining; }
|
||||
public void setShining(List<UserItem> shining) { this.shining = shining; }
|
||||
public List<UserItem> getOfficial() { return official; }
|
||||
public void setOfficial(List<UserItem> official) { this.official = official; }
|
||||
public List<UserItem> getOthers() { return others; }
|
||||
public void setOthers(List<UserItem> others) { this.others = others; }
|
||||
public int getTotal() { return total; }
|
||||
public void setTotal(int total) { this.total = total; }
|
||||
public boolean isTruncated() { return truncated; }
|
||||
public void setTruncated(boolean truncated) { this.truncated = truncated; }
|
||||
|
||||
public static class UserItem {
|
||||
private String login;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String avatarAr;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String firstName) { this.firstName = firstName; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String lastName) { this.lastName = lastName; }
|
||||
public String getAvatarAr() { return avatarAr; }
|
||||
public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; }
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
/** Lightweight authenticated request for bottom-toolbar counters. */
|
||||
public class Net_GetUserCounters_Request extends Net_Request {
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */
|
||||
public class Net_GetUserCounters_Response extends Net_Response {
|
||||
private String login;
|
||||
private long dmUnreadCount;
|
||||
private long channelsUnreadCount;
|
||||
private long notificationsUnreadCount;
|
||||
private long notificationRepliesUnreadCount;
|
||||
private long notificationConnectionsUnreadCount;
|
||||
private long notificationEventsUnreadCount;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
public long getDmUnreadCount() { return dmUnreadCount; }
|
||||
public void setDmUnreadCount(long value) { this.dmUnreadCount = value; }
|
||||
public long getChannelsUnreadCount() { return channelsUnreadCount; }
|
||||
public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; }
|
||||
public long getNotificationsUnreadCount() { return notificationsUnreadCount; }
|
||||
public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; }
|
||||
public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; }
|
||||
public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; }
|
||||
public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; }
|
||||
public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; }
|
||||
public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; }
|
||||
public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; }
|
||||
}
|
||||
+8
@@ -26,7 +26,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public static class ChannelSummary {
|
||||
private ChannelRef channel;
|
||||
private int messagesCount;
|
||||
private long readCount;
|
||||
private int unreadCount;
|
||||
private boolean readStateInitialized;
|
||||
private LastMessage lastMessage;
|
||||
|
||||
public ChannelRef getChannel() { return channel; }
|
||||
@@ -35,9 +37,15 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response {
|
||||
public int getMessagesCount() { return messagesCount; }
|
||||
public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; }
|
||||
|
||||
public long getReadCount() { return readCount; }
|
||||
public void setReadCount(long readCount) { this.readCount = readCount; }
|
||||
|
||||
public int getUnreadCount() { return unreadCount; }
|
||||
public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; }
|
||||
|
||||
public boolean isReadStateInitialized() { return readStateInitialized; }
|
||||
public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; }
|
||||
|
||||
public LastMessage getLastMessage() { return lastMessage; }
|
||||
public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; }
|
||||
}
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public class Net_SetChannelReadState_Request extends Net_Request {
|
||||
private String login;
|
||||
private String owner_bch_name;
|
||||
private String channel_name;
|
||||
private Long read_count;
|
||||
private Long time_ms;
|
||||
private String client_key;
|
||||
private String signature;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getOwner_bch_name() { return owner_bch_name; }
|
||||
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||
|
||||
public String getChannel_name() { return channel_name; }
|
||||
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||
|
||||
public Long getRead_count() { return read_count; }
|
||||
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public String getClient_key() { return client_key; }
|
||||
public void setClient_key(String client_key) { this.client_key = client_key; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public class Net_SetChannelReadState_Response extends Net_Response {
|
||||
private String login;
|
||||
private String owner_bch_name;
|
||||
private String channel_name;
|
||||
private Long read_count;
|
||||
private Long unread_count;
|
||||
private Long time_ms;
|
||||
private Boolean applied;
|
||||
|
||||
public String getLogin() { return login; }
|
||||
public void setLogin(String login) { this.login = login; }
|
||||
|
||||
public String getOwner_bch_name() { return owner_bch_name; }
|
||||
public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; }
|
||||
|
||||
public String getChannel_name() { return channel_name; }
|
||||
public void setChannel_name(String channel_name) { this.channel_name = channel_name; }
|
||||
|
||||
public Long getRead_count() { return read_count; }
|
||||
public void setRead_count(Long read_count) { this.read_count = read_count; }
|
||||
|
||||
public Long getUnread_count() { return unread_count; }
|
||||
public void setUnread_count(Long unread_count) { this.unread_count = unread_count; }
|
||||
|
||||
public Long getTime_ms() { return time_ms; }
|
||||
public void setTime_ms(Long time_ms) { this.time_ms = time_ms; }
|
||||
|
||||
public Boolean getApplied() { return applied; }
|
||||
public void setApplied(Boolean applied) { this.applied = applied; }
|
||||
}
|
||||
+120
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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", "Не удалось прервать смену ключей");
|
||||
}
|
||||
}
|
||||
}
|
||||
+224
@@ -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');
|
||||
}
|
||||
}
|
||||
+53
@@ -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",
|
||||
"Не удалось продолжить смену ключей");
|
||||
}
|
||||
}
|
||||
}
|
||||
+218
@@ -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');
|
||||
}
|
||||
}
|
||||
+83
@@ -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;
|
||||
}
|
||||
}
|
||||
+209
@@ -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) { }
|
||||
}
|
||||
+29
@@ -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",
|
||||
"Не удалось прочитать состояние смены ключей");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+17
@@ -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; }
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+14
@@ -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; }
|
||||
}
|
||||
+32
@@ -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; }
|
||||
}
|
||||
+83
@@ -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; }
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+2
-1
@@ -5,6 +5,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.DbController;
|
||||
@@ -23,7 +24,7 @@ public final class Net_SetNotificationState_Handler implements JsonMessageHandle
|
||||
byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey");
|
||||
long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи");
|
||||
long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); }
|
||||
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r;
|
||||
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); UserCountersSupport.pushChanged(login); return r;
|
||||
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
@@ -59,6 +60,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
|
||||
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (status.applied()) {
|
||||
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
|
||||
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||
}
|
||||
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
|
||||
|
||||
+9
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
|
||||
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
@@ -78,6 +79,14 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
|
||||
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
|
||||
if (pairStatus.applied()) {
|
||||
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
|
||||
|
||||
// SendMessagePair is also the local-server delivery path. Without this push,
|
||||
// a recipient on the same access server receives the DM but the toolbar
|
||||
// counter stays stale until the next explicit GetUserCounters refresh.
|
||||
UserCountersSupport.pushChanged(incoming.toLogin);
|
||||
if (!incoming.fromLogin.equalsIgnoreCase(incoming.toLogin)) {
|
||||
UserCountersSupport.pushChanged(incoming.fromLogin);
|
||||
}
|
||||
}
|
||||
|
||||
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user