SHA256
Compare commits
13
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
9be4c4d3a5 | ||
|
|
f5698771e4 | ||
|
|
151a2c1754 | ||
|
|
13693a0a53 | ||
|
|
451280edd2 | ||
|
|
c997057a37 | ||
|
|
f8900e531a | ||
|
|
6e5b57fd7c | ||
|
|
408e474130 | ||
|
|
ef707ec217 | ||
|
|
8b12760dde | ||
|
|
cae64639bd | ||
|
|
be696d116b |
@@ -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
|
||||
@@ -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,103 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.Ans104DataItem;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/** Minimal ANS-104 binary bundle writer/reader. */
|
||||
public final class Ans104Bundle {
|
||||
public record Entry(byte[] id32, byte[] rawDataItem) {}
|
||||
|
||||
private Ans104Bundle() {}
|
||||
|
||||
public static byte[] write(List<byte[]> rawItems) {
|
||||
if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty");
|
||||
if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items");
|
||||
|
||||
List<Ans104DataItem> parsed = new ArrayList<>(rawItems.size());
|
||||
long total = 32L + 64L * rawItems.size();
|
||||
for (byte[] raw : rawItems) {
|
||||
Ans104DataItem item = new Ans104DataItem(raw);
|
||||
if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature");
|
||||
parsed.add(item);
|
||||
total = Math.addExact(total, raw.length);
|
||||
}
|
||||
if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream((int) total);
|
||||
out.writeBytes(uint256LE(rawItems.size()));
|
||||
for (int i = 0; i < rawItems.size(); i++) {
|
||||
out.writeBytes(uint256LE(rawItems.get(i).length));
|
||||
out.writeBytes(parsed.get(i).id32());
|
||||
}
|
||||
for (byte[] raw : rawItems) out.writeBytes(raw);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
public static List<Entry> read(byte[] bundle, int maxItems) {
|
||||
if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short");
|
||||
long countLong = readUint256AsLong(bundle, 0);
|
||||
if (countLong <= 0 || countLong > Math.max(1, maxItems)) {
|
||||
throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong);
|
||||
}
|
||||
int count = (int) countLong;
|
||||
long headerLong = 32L + 64L * count;
|
||||
if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated");
|
||||
int header = (int) headerLong;
|
||||
long[] sizes = new long[count];
|
||||
byte[][] ids = new byte[count][];
|
||||
int hp = 32;
|
||||
long payloadBytes = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
sizes[i] = readUint256AsLong(bundle, hp); hp += 32;
|
||||
ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32;
|
||||
if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]);
|
||||
payloadBytes = Math.addExact(payloadBytes, sizes[i]);
|
||||
}
|
||||
if (headerLong + payloadBytes != bundle.length) {
|
||||
throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length);
|
||||
}
|
||||
List<Entry> out = new ArrayList<>(count);
|
||||
int p = header;
|
||||
for (int i = 0; i < count; i++) {
|
||||
int len = (int) sizes[i];
|
||||
byte[] raw = Arrays.copyOfRange(bundle, p, p + len);
|
||||
p += len;
|
||||
Ans104DataItem item = new Ans104DataItem(raw);
|
||||
if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i);
|
||||
out.add(new Entry(ids[i], raw));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) {
|
||||
if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes");
|
||||
for (Entry e : read(bundle, maxItems)) {
|
||||
if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ANS-104 bundle integers are 32-byte unsigned little-endian values. */
|
||||
static byte[] uint256LE(long value) {
|
||||
if (value < 0) throw new IllegalArgumentException("negative uint256");
|
||||
byte[] out = new byte[32];
|
||||
long v = value;
|
||||
for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; }
|
||||
return out;
|
||||
}
|
||||
|
||||
static long readUint256AsLong(byte[] data, int offset) {
|
||||
if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated");
|
||||
for (int i = offset + 8; i < offset + 32; i++) {
|
||||
if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||
}
|
||||
long v = 0;
|
||||
for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL);
|
||||
if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range");
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Единые локальные имена больших архивных файлов. */
|
||||
public final class ArchiveFileNames {
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yy");
|
||||
|
||||
private ArchiveFileNames() {}
|
||||
|
||||
/**
|
||||
* Временное имя полностью собранного файла до появления Arweave TX ID.
|
||||
* Пример: archive01.00001.11.09.26.tmp.SHiNE-archive
|
||||
*/
|
||||
public static String pendingFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
return baseName(login, bigBlockNumber, createdAtMs, zone) + ".tmp.SHiNE-archive";
|
||||
}
|
||||
|
||||
/**
|
||||
* Постоянное имя после успешной загрузки в Arweave.
|
||||
* Пример: archive01.00001.11.09.26.Xm32...kP9.SHiNE-archive
|
||||
*/
|
||||
public static String uploadedFileName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
|
||||
if (txId == null || txId.length != 32) throw new IllegalArgumentException("Arweave TX ID должен быть 32 bytes");
|
||||
String tx = Base64.getUrlEncoder().withoutPadding().encodeToString(txId);
|
||||
return baseName(login, bigBlockNumber, createdAtMs, zone) + "." + tx + ".SHiNE-archive";
|
||||
}
|
||||
|
||||
public static Path pendingFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
return archiveDir.resolve(pendingFileName(login, bigBlockNumber, createdAtMs, zone));
|
||||
}
|
||||
|
||||
public static Path uploadedFilePath(Path archiveDir, String login, long bigBlockNumber, long createdAtMs, ZoneId zone, byte[] txId) {
|
||||
return archiveDir.resolve(uploadedFileName(login, bigBlockNumber, createdAtMs, zone, txId));
|
||||
}
|
||||
|
||||
private static String baseName(String login, long bigBlockNumber, long createdAtMs, ZoneId zone) {
|
||||
if (bigBlockNumber < 0 || bigBlockNumber > 0xffff_ffffL) {
|
||||
throw new IllegalArgumentException("Номер большого блока вне u32: " + bigBlockNumber);
|
||||
}
|
||||
String safeLogin = sanitize(login);
|
||||
String number = String.format("%05d", bigBlockNumber);
|
||||
String date = DATE.format(Instant.ofEpochMilli(createdAtMs).atZone(zone == null ? ZoneId.systemDefault() : zone));
|
||||
return safeLogin + "." + number + "." + date;
|
||||
}
|
||||
|
||||
static String sanitize(String login) {
|
||||
String s = login == null ? "server" : login.trim();
|
||||
if (s.isEmpty()) s = "server";
|
||||
return s.replaceAll("[^A-Za-z0-9._-]", "_");
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/** Настройки чтения архивов других доверенных SHiNE-серверов. Пустой whitelist = импорт выключен. */
|
||||
public record ArchiveImportConfig(
|
||||
Set<String> allowedPublishers,
|
||||
int intervalMinutes,
|
||||
String arweaveGateway,
|
||||
Path workDir
|
||||
) {
|
||||
public static ArchiveImportConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
Set<String> approved = new LinkedHashSet<>();
|
||||
String raw = c.getParam("archive.import.allowedPublishers");
|
||||
if (raw != null) {
|
||||
for (String item : raw.split(",")) {
|
||||
String login = item.trim().toLowerCase(Locale.ROOT);
|
||||
if (!login.isEmpty()) approved.add(login);
|
||||
}
|
||||
}
|
||||
String gateway = c.getParam("archive.arweave.gateway");
|
||||
if (gateway == null || gateway.isBlank()) gateway = "https://arweave.net";
|
||||
String dir = c.getParam("archive.import.workDir");
|
||||
if (dir == null || dir.isBlank()) dir = "data/archive-import";
|
||||
return new ArchiveImportConfig(
|
||||
Set.copyOf(approved),
|
||||
Math.max(1, c.getInt("archive.import.intervalMinutes", 60)),
|
||||
gateway.trim(),
|
||||
Path.of(dir));
|
||||
}
|
||||
|
||||
public boolean enabled() { return !allowedPublishers.isEmpty(); }
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.*;
|
||||
|
||||
/**
|
||||
* Фоновая проверка уже синхронизированной локальной таблицы User PDA.
|
||||
* Solana отдельно здесь не опрашивается: первый запуск через 10 секунд после старта,
|
||||
* затем по умолчанию раз в час.
|
||||
*/
|
||||
public final class ArchiveImportScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchiveImportScheduler.class);
|
||||
private static ScheduledExecutorService executor;
|
||||
|
||||
private ArchiveImportScheduler() {}
|
||||
|
||||
public static synchronized void startOrLog() {
|
||||
if (executor != null) return;
|
||||
ArchiveImportConfig cfg = ArchiveImportConfig.load();
|
||||
if (!cfg.enabled()) {
|
||||
log.info("Archive importer выключен: archive.import.allowedPublishers пуст");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ArchiveImportService service = new ArchiveImportService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread t = new Thread(r, "archive-importer");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
executor.scheduleWithFixedDelay(
|
||||
() -> {
|
||||
try { service.pollOnce(); }
|
||||
catch (Exception e) { log.error("Archive importer cycle failed", e); }
|
||||
},
|
||||
10,
|
||||
Math.multiplyExact((long) cfg.intervalMinutes(), 60L),
|
||||
TimeUnit.SECONDS);
|
||||
log.info("Archive importer включён. approvedPublishers={} interval={}min",
|
||||
cfg.allowedPublishers(), cfg.intervalMinutes());
|
||||
} catch (Exception e) {
|
||||
log.error("Archive importer не запущен", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static synchronized void close() {
|
||||
if (executor != null) {
|
||||
executor.shutdownNow();
|
||||
executor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
-229
@@ -1,229 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import blockchain.BchBlockEntry;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import shine.db.archive.*;
|
||||
import shine.db.dao.*;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.nio.file.*;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/** Импорт archive head только от серверов из server-side whitelist. */
|
||||
public final class ArchiveImportService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchiveImportService.class);
|
||||
private final ArchiveImportConfig cfg;
|
||||
private final ArchiveImportDAO dao = ArchiveImportDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
|
||||
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
|
||||
private final ShineArchiveReader reader = new ShineArchiveReader();
|
||||
private final Net_AddBlock_Handler addBlockHandler = new Net_AddBlock_Handler();
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
|
||||
public ArchiveImportService(ArchiveImportConfig cfg) throws IOException {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
Files.createDirectories(cfg.workDir());
|
||||
}
|
||||
|
||||
/**
|
||||
* Никаких отдельных Solana RPC запросов здесь нет.
|
||||
* Читаем только solana_user_pda_current, которую уже обновляет обычный Solana users sync.
|
||||
*/
|
||||
public void pollOnce() throws Exception {
|
||||
if (!cfg.enabled()) return;
|
||||
for (ArchivePublisherHead head : dao.listPendingPublisherHeads(cfg.allowedPublishers())) {
|
||||
try {
|
||||
importHead(head);
|
||||
} catch (Exception e) {
|
||||
log.error("Archive import publisher={} tx={} failed", head.login(), head.archiveHeadTxId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void importHead(ArchivePublisherHead publisher) throws Exception {
|
||||
String headTxId = publisher.archiveHeadTxId();
|
||||
byte[] headHash = hex(publisher.archiveHeadHash());
|
||||
|
||||
// Если процесс успел импортировать head, но упал до установки archive_imported=true,
|
||||
// следующий цикл просто завершает отметку без повторного скачивания всей истории.
|
||||
if (headTxId.equals(publisher.lastImportedArchiveTxId())) {
|
||||
if (!dao.markHeadImported(publisher.login(), headTxId)) {
|
||||
log.info("Archive head changed while finalizing import marker publisher={} oldHead={}",
|
||||
publisher.login(), headTxId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Path headFile = download(headTxId);
|
||||
ShineArchiveReader.Document head;
|
||||
try {
|
||||
head = reader.readAndVerify(headFile, publisher.login(), headHash, publisher.rootKey());
|
||||
} finally {
|
||||
Files.deleteIfExists(headFile);
|
||||
}
|
||||
|
||||
// FULL reference table позволяет догнать все archive blocks, пропущенные пока сервер был выключен.
|
||||
List<ShineArchiveReader.Reference> previous = new ArrayList<>(head.references());
|
||||
previous.sort(Comparator.comparingLong(ShineArchiveReader.Reference::bigBlockNumber));
|
||||
|
||||
int startIndex = findResumeIndex(previous, publisher.lastImportedArchiveTxId(), headTxId);
|
||||
for (int i = startIndex; i < previous.size(); i++) {
|
||||
ShineArchiveReader.Reference ref = previous.get(i);
|
||||
importOne(publisher, ref.arweaveTxId(), ref.archiveHash());
|
||||
if (!dao.advanceImportCursor(publisher.login(), headTxId, ref.arweaveTxId())) {
|
||||
throw new IllegalStateException("Archive head changed during import for publisher " + publisher.login());
|
||||
}
|
||||
}
|
||||
|
||||
// Head уже скачан и проверен выше — второй раз его из Arweave не качаем.
|
||||
importDocument(publisher, headTxId, head);
|
||||
if (!dao.advanceImportCursor(publisher.login(), headTxId, headTxId)) {
|
||||
throw new IllegalStateException("Archive head changed during final import for publisher " + publisher.login());
|
||||
}
|
||||
if (!dao.markHeadImported(publisher.login(), headTxId)) {
|
||||
throw new IllegalStateException("Archive head changed before imported marker for publisher " + publisher.login());
|
||||
}
|
||||
|
||||
log.info("Archive head fully imported publisher={} bigBlock={} tx={}",
|
||||
publisher.login(), head.bigBlockNumber(), headTxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает индекс первого ещё не импортированного previous big block.
|
||||
* Пустой cursor = новый сервер, импортируем всю FULL историю.
|
||||
*/
|
||||
private static int findResumeIndex(List<ShineArchiveReader.Reference> previous,
|
||||
String lastImportedTxId,
|
||||
String currentHeadTxId) {
|
||||
String last = lastImportedTxId == null ? "" : lastImportedTxId.trim();
|
||||
if (last.isEmpty()) return 0;
|
||||
if (last.equals(currentHeadTxId)) return previous.size();
|
||||
for (int i = 0; i < previous.size(); i++) {
|
||||
if (last.equals(previous.get(i).arweaveTxId())) return i + 1;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Последний импортированный archive TX не найден в FULL history текущего head: " + last);
|
||||
}
|
||||
|
||||
private void importOne(ArchivePublisherHead publisher, String txId, byte[] expectedHash) throws Exception {
|
||||
Path file = download(txId);
|
||||
try {
|
||||
ShineArchiveReader.Document doc = reader.readAndVerify(
|
||||
file, publisher.login(), expectedHash, publisher.rootKey());
|
||||
importDocument(publisher, txId, doc);
|
||||
} finally {
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
}
|
||||
|
||||
private void importDocument(ArchivePublisherHead publisher, String txId,
|
||||
ShineArchiveReader.Document doc) throws Exception {
|
||||
for (ShineArchiveReader.Chunk chunk : doc.chunks()) {
|
||||
processChunk(publisher.login(), txId, doc, chunk);
|
||||
}
|
||||
log.info("Archive imported publisher={} bigBlock={} tx={} chunks={}",
|
||||
publisher.login(), doc.bigBlockNumber(), txId, doc.chunks().size());
|
||||
}
|
||||
|
||||
private void processChunk(String publisherLogin, String txId,
|
||||
ShineArchiveReader.Document doc,
|
||||
ShineArchiveReader.Chunk chunk) throws Exception {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(chunk.blockchainName());
|
||||
lock.lock();
|
||||
try {
|
||||
ensureState(chunk.blockchainName());
|
||||
BlockchainStateEntry state = stateDao.getByBlockchainName(chunk.blockchainName());
|
||||
int localLast = state == null ? -1 : state.getLastBlockNumber();
|
||||
long archiveLast = -1;
|
||||
|
||||
for (byte[] raw : chunk.rawRecords()) {
|
||||
BchBlockEntry block = new BchBlockEntry(raw);
|
||||
archiveLast = Integer.toUnsignedLong(block.blockNumber);
|
||||
|
||||
if (block.blockNumber <= localLast) {
|
||||
byte[] localHash = blocksDao.getHashByNumber(chunk.blockchainName(), block.blockNumber);
|
||||
if (!Arrays.equals(localHash, block.getHash32())) {
|
||||
throw new IllegalStateException(
|
||||
"Archive conflict " + chunk.blockchainName() + "#" + block.blockNumber);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (block.blockNumber != localLast + 1) {
|
||||
throw new IllegalStateException(
|
||||
"Archive gap " + chunk.blockchainName() + ": local=" + localLast +
|
||||
" next=" + block.blockNumber);
|
||||
}
|
||||
|
||||
var result = addBlockHandler.addBlockFromArchive(chunk.blockchainName(), raw);
|
||||
if (!result.ok()) {
|
||||
throw new IllegalStateException(
|
||||
"Archive AddBlock rejected " + chunk.blockchainName() + "#" +
|
||||
block.blockNumber + ": " + result.reasonCode());
|
||||
}
|
||||
localLast = block.blockNumber;
|
||||
}
|
||||
|
||||
if (archiveLast >= 0) {
|
||||
dao.upsertLocation(new ArchiveBlockchainLocation(
|
||||
chunk.blockchainName(), publisherLogin, txId, doc.archiveHash(), doc.bigBlockNumber(),
|
||||
chunk.offset(), chunk.size(), archiveLast, System.currentTimeMillis()));
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureState(String blockchainName) throws Exception {
|
||||
if (stateDao.getByBlockchainName(blockchainName) != null) return;
|
||||
ArchiveImportDAO.BlockchainIdentity id = dao.getBlockchainIdentity(blockchainName);
|
||||
if (id == null) throw new IllegalStateException("Нет User PDA для blockchain " + blockchainName);
|
||||
|
||||
BlockchainStateEntry s = new BlockchainStateEntry();
|
||||
s.setBlockchainName(id.blockchainName());
|
||||
s.setLogin(id.login());
|
||||
s.setBlockchainKey(id.blockchainKey());
|
||||
s.setSizeLimit(id.sizeLimit());
|
||||
s.setFileSizeBytes(0);
|
||||
s.setLastBlockNumber(-1);
|
||||
s.setLastBlockHash(null);
|
||||
s.setUpdatedAtMs(System.currentTimeMillis());
|
||||
stateDao.insertIfMissing(s);
|
||||
}
|
||||
|
||||
private Path download(String txId) throws Exception {
|
||||
String gateway = cfg.arweaveGateway().replaceAll("/+$", "");
|
||||
Path target = Files.createTempFile(cfg.workDir(), "archive-", ".download");
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/" + txId))
|
||||
.timeout(Duration.ofMinutes(10))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<Path> resp = http.send(req, HttpResponse.BodyHandlers.ofFile(target));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
Files.deleteIfExists(target);
|
||||
throw new IOException("Arweave HTTP " + resp.statusCode() + " tx=" + txId);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private static byte[] hex(String value) {
|
||||
String s = String.valueOf(value == null ? "" : value).trim();
|
||||
if (s.length() != 64) throw new IllegalArgumentException("archive_head_hash должен быть HEX64");
|
||||
byte[] out = new byte[32];
|
||||
for (int i = 0; i < 32; i++) {
|
||||
out[i] = (byte) Integer.parseInt(s.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import sync.util.Base58Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Base64;
|
||||
|
||||
/** Читает Ed25519 seed32 из распространённых SHiNE/Solana форматов ключей. */
|
||||
final class ArchiveKeyLoader {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private ArchiveKeyLoader() {}
|
||||
|
||||
static byte[] loadSeed32(Path path) throws IOException {
|
||||
if (path == null) throw new IllegalArgumentException("Путь ключа не задан");
|
||||
byte[] raw = Files.readAllBytes(path);
|
||||
if (raw.length == 32) return raw.clone();
|
||||
if (raw.length == 64) return first32(raw);
|
||||
|
||||
String text = new String(raw, StandardCharsets.UTF_8).trim();
|
||||
if (text.isEmpty()) throw new IOException("Пустой key file: " + path);
|
||||
|
||||
// Solana keypair JSON: [64 чисел].
|
||||
if (text.startsWith("[")) {
|
||||
JsonNode arr = JSON.readTree(text);
|
||||
if (!arr.isArray() || (arr.size() != 32 && arr.size() != 64)) {
|
||||
throw new IOException("Ожидался JSON keypair на 32/64 байта: " + path);
|
||||
}
|
||||
byte[] bytes = new byte[arr.size()];
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
int v = arr.get(i).asInt(-1);
|
||||
if (v < 0 || v > 255) throw new IOException("Некорректный байт keypair: " + path);
|
||||
bytes[i] = (byte) v;
|
||||
}
|
||||
return bytes.length == 32 ? bytes : first32(bytes);
|
||||
}
|
||||
|
||||
// Base58 seed32 или Solana secret key 64 bytes.
|
||||
try {
|
||||
byte[] decoded = Base58Util.decode(text);
|
||||
if (decoded.length == 32) return decoded;
|
||||
if (decoded.length == 64) return first32(decoded);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Пробуем остальные текстовые форматы ниже.
|
||||
}
|
||||
|
||||
// Base64 raw seed / keypair / Ed25519 PKCS8. В SHiNE PKCS8 seed находится в последних 32 байтах.
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(text.replaceAll("\\s+", ""));
|
||||
if (decoded.length == 32) return decoded;
|
||||
if (decoded.length == 64) return first32(decoded);
|
||||
if (decoded.length > 32) {
|
||||
byte[] seed = new byte[32];
|
||||
System.arraycopy(decoded, decoded.length - 32, seed, 0, 32);
|
||||
return seed;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Ниже выдадим понятную общую ошибку.
|
||||
}
|
||||
|
||||
throw new IOException("Не удалось распознать Ed25519 key file: " + path
|
||||
+ ". Поддерживаются raw seed32, raw/keypair64, Solana JSON, Base58 seed/keypair и Base64 PKCS8.");
|
||||
}
|
||||
|
||||
private static byte[] first32(byte[] bytes) {
|
||||
byte[] out = new byte[32];
|
||||
System.arraycopy(bytes, 0, out, 0, 32);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import utils.config.AppConfig;
|
||||
import utils.config.SolanaProgramsConfig;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/** Настройки опционального архивного publisher-а. По умолчанию функция выключена. */
|
||||
public record ArchivePublisherConfig(
|
||||
boolean enabled,
|
||||
LocalTime publishTime,
|
||||
ZoneId publishZone,
|
||||
long maxFileBytes,
|
||||
Path workDir,
|
||||
String serverLogin,
|
||||
String arweaveGateway,
|
||||
Path arweaveWalletJwkPath,
|
||||
int arweaveMinConfirmations,
|
||||
int arweaveConfirmPollSeconds,
|
||||
int arweaveConfirmTimeoutMinutes,
|
||||
Path solanaRootKeyPath,
|
||||
Path solanaClientKeyPath,
|
||||
String solanaRpcUrl,
|
||||
String solanaUsersProgramId,
|
||||
String solanaPaymentsProgramId,
|
||||
int solanaConfirmPollSeconds,
|
||||
int solanaConfirmTimeoutMinutes,
|
||||
String solanaCommitment
|
||||
) {
|
||||
public static ArchivePublisherConfig load() {
|
||||
AppConfig c = AppConfig.getInstance();
|
||||
return new ArchivePublisherConfig(
|
||||
c.getBoolean("archive.publish.enabled", false),
|
||||
parseTime(orDefault(c.getParam("archive.publish.time"), "00:00")),
|
||||
parseZone(c.getParam("archive.publish.zoneId")),
|
||||
positiveLong(parseLong(c.getParam("archive.maxFileBytes"), 4_000_000_000L), "archive.maxFileBytes"),
|
||||
Path.of(orDefault(c.getParam("archive.workDir"), "data/archive")),
|
||||
requiredIfEnabled(c, "server.SHiNE.login"),
|
||||
orDefault(c.getParam("archive.arweave.gateway"), "https://arweave.net"),
|
||||
optionalPath(c.getParam("archive.arweave.walletJwkPath")),
|
||||
nonNegative(c.getInt("archive.arweave.minConfirmations", 1), "archive.arweave.minConfirmations"),
|
||||
positive(c.getInt("archive.arweave.confirmPollSeconds", 30), "archive.arweave.confirmPollSeconds"),
|
||||
positive(c.getInt("archive.arweave.confirmTimeoutMinutes", 180), "archive.arweave.confirmTimeoutMinutes"),
|
||||
optionalPath(c.getParam("archive.solana.rootKeyPath")),
|
||||
optionalPath(c.getParam("archive.solana.clientKeyPath")),
|
||||
firstNonBlank(c.getParam("solana.users.sync.rpcUrl"), c.getParam("solana.rpcUrl"), SolanaProgramsConfig.SOLANA_RPC_URL),
|
||||
firstNonBlank(c.getParam("solana.users.sync.programId"), SolanaProgramsConfig.SHINE_USERS_PROGRAM_ID),
|
||||
SolanaProgramsConfig.SHINE_PAYMENTS_PROGRAM_ID,
|
||||
positive(c.getInt("archive.solana.confirmPollSeconds", 5), "archive.solana.confirmPollSeconds"),
|
||||
positive(c.getInt("archive.solana.confirmTimeoutMinutes", 30), "archive.solana.confirmTimeoutMinutes"),
|
||||
orDefault(c.getParam("archive.solana.commitment"), "finalized")
|
||||
);
|
||||
}
|
||||
|
||||
public void validateForStart() {
|
||||
if (!enabled) return;
|
||||
if (serverLogin == null || serverLogin.isBlank()) throw new IllegalArgumentException("server.SHiNE.login обязателен для archive publisher");
|
||||
if (arweaveWalletJwkPath == null) throw new IllegalArgumentException("archive.arweave.walletJwkPath обязателен");
|
||||
if (solanaRootKeyPath == null) throw new IllegalArgumentException("archive.solana.rootKeyPath обязателен");
|
||||
if (solanaClientKeyPath == null) throw new IllegalArgumentException("archive.solana.clientKeyPath обязателен");
|
||||
if (solanaRpcUrl == null || solanaRpcUrl.isBlank()) throw new IllegalArgumentException("solana.users.sync.rpcUrl или solana.rpcUrl обязателен");
|
||||
if (maxFileBytes >= 0x1_0000_0000L) {
|
||||
throw new IllegalArgumentException("SHINE-ARCHIVE v1 требует archive.maxFileBytes < 4294967296");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requiredIfEnabled(AppConfig c, String name) {
|
||||
String v = c.getParam(name);
|
||||
return v == null ? "" : v.trim();
|
||||
}
|
||||
private static Path optionalPath(String s) { return s == null || s.isBlank() ? null : Path.of(s.trim()); }
|
||||
private static String firstNonBlank(String... values) {
|
||||
if (values != null) for (String v : values) if (v != null && !v.isBlank()) return v.trim();
|
||||
return "";
|
||||
}
|
||||
private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); }
|
||||
private static long parseLong(String s, long d) { return s == null || s.isBlank() ? d : Long.parseLong(s.trim()); }
|
||||
private static LocalTime parseTime(String value) {
|
||||
try {
|
||||
return LocalTime.parse(value.trim());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("archive.publish.time должен быть в формате HH:mm, например 00:00", e);
|
||||
}
|
||||
}
|
||||
private static ZoneId parseZone(String value) {
|
||||
if (value == null || value.isBlank()) return ZoneId.systemDefault();
|
||||
try {
|
||||
return ZoneId.of(value.trim());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Некорректный archive.publish.zoneId: " + value, e);
|
||||
}
|
||||
}
|
||||
private static int positive(int v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
|
||||
private static int nonNegative(int v, String n) { if (v < 0) throw new IllegalArgumentException(n + " должен быть >= 0"); return v; }
|
||||
private static long positiveLong(long v, String n) { if (v <= 0) throw new IllegalArgumentException(n + " должен быть > 0"); return v; }
|
||||
}
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Опциональный scheduler: один новый archive job в заданное локальное время каждый день. */
|
||||
public final class ArchivePublisherScheduler {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherScheduler.class);
|
||||
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||
private static ScheduledExecutorService executor;
|
||||
|
||||
private ArchivePublisherScheduler() {}
|
||||
|
||||
public static void startOrLog() {
|
||||
ArchivePublisherConfig cfg;
|
||||
try {
|
||||
cfg = ArchivePublisherConfig.load();
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось прочитать archive publisher config", e);
|
||||
return;
|
||||
}
|
||||
if (!cfg.enabled()) {
|
||||
log.info("Archive publisher выключен (archive.publish.enabled=false)");
|
||||
return;
|
||||
}
|
||||
if (!STARTED.compareAndSet(false, true)) return;
|
||||
|
||||
try {
|
||||
ArchivePublisherService service = new ArchivePublisherService(cfg);
|
||||
executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||
@Override public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r, "shine-archive-publisher");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}
|
||||
});
|
||||
|
||||
// Незавершённую публикацию после рестарта продолжаем сразу, а новый snapshot
|
||||
// создаём только в очередное назначенное суточное время.
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
service.resumeUnfinishedJob();
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось продолжить незавершённый archive job", e);
|
||||
}
|
||||
});
|
||||
scheduleNextDaily(service, cfg);
|
||||
log.info("Archive publisher включён: login={} dir={} dailyAt={} zone={}",
|
||||
cfg.serverLogin(), cfg.workDir(), cfg.publishTime(), cfg.publishZone());
|
||||
} catch (Exception e) {
|
||||
STARTED.set(false);
|
||||
log.error("Archive publisher НЕ запущен из-за ошибки preflight", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void scheduleNextDaily(ArchivePublisherService service, ArchivePublisherConfig cfg) {
|
||||
ScheduledExecutorService e = executor;
|
||||
if (e == null || e.isShutdown()) return;
|
||||
|
||||
ZonedDateTime now = ZonedDateTime.now(cfg.publishZone());
|
||||
ZonedDateTime next = now.toLocalDate().atTime(cfg.publishTime()).atZone(cfg.publishZone());
|
||||
if (!next.isAfter(now)) next = next.plusDays(1);
|
||||
long delayMs = Math.max(1L, Duration.between(now, next).toMillis());
|
||||
|
||||
e.schedule(() -> {
|
||||
try {
|
||||
service.runCycle();
|
||||
} catch (Exception ex) {
|
||||
log.error("Archive publisher daily cycle failed; retry нового snapshot будет в следующее назначенное время", ex);
|
||||
} finally {
|
||||
scheduleNextDaily(service, cfg);
|
||||
}
|
||||
}, delayMs, TimeUnit.MILLISECONDS);
|
||||
|
||||
log.info("Следующая архивная публикация запланирована на {}", next);
|
||||
}
|
||||
|
||||
public static void close() {
|
||||
ScheduledExecutorService e = executor;
|
||||
if (e != null) e.shutdownNow();
|
||||
}
|
||||
}
|
||||
-341
@@ -1,341 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler_utils.BlockchainLocks;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.archive.ArchivePublishJob;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.dao.ArchivePublicationDAO;
|
||||
import shine.db.dao.ArchiveImportDAO;
|
||||
import shine.db.dao.BlockchainStateDAO;
|
||||
import shine.db.dao.BlocksDAO;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.BlockchainStateEntry;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
/** Один crash-safe цикл публикации большого SHINE-ARCHIVE блока. */
|
||||
public final class ArchivePublisherService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArchivePublisherService.class);
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final ArchivePublicationDAO archiveDao = ArchivePublicationDAO.getInstance();
|
||||
private final ArchiveImportDAO archiveImportDao = ArchiveImportDAO.getInstance();
|
||||
private final BlockchainStateDAO stateDao = BlockchainStateDAO.getInstance();
|
||||
private final BlocksDAO blocksDao = BlocksDAO.getInstance();
|
||||
private final ShineArchiveWriter writer = new ShineArchiveWriter();
|
||||
private final ArweaveArchiveService arweave;
|
||||
private final SolanaArchiveHeadWriter solana;
|
||||
private final byte[] archiveSigningSeed32;
|
||||
private final AtomicBoolean running = new AtomicBoolean(false);
|
||||
|
||||
public ArchivePublisherService(ArchivePublisherConfig cfg) throws Exception {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
cfg.validateForStart();
|
||||
Files.createDirectories(cfg.workDir());
|
||||
this.archiveSigningSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
|
||||
this.arweave = new ArweaveArchiveService(cfg);
|
||||
this.solana = new SolanaArchiveHeadWriter(cfg);
|
||||
this.solana.validateKeysAgainstPda();
|
||||
backfillOwnLocationIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* После обновления старого publisher-сервера заполняет универсальный location index
|
||||
* из уже существующих finalized cursors, не дожидаясь следующего суточного archive block.
|
||||
*/
|
||||
private void backfillOwnLocationIndex() throws Exception {
|
||||
Map<Long, ArchiveBigBlockRef> finalized = new HashMap<>();
|
||||
for (ArchiveBigBlockRef ref : archiveDao.listFinalizedBigBlocks()) finalized.put(ref.bigBlockNumber(), ref);
|
||||
if (finalized.isEmpty()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
int updated = 0;
|
||||
for (BlockchainStateEntry state : stateDao.listAll()) {
|
||||
if (state == null || state.getBlockchainName() == null || state.getBlockchainName().isBlank()) continue;
|
||||
ArchiveChainCursor cursor = archiveDao.getCursor(state.getBlockchainName());
|
||||
if (cursor == null) continue;
|
||||
ArchiveBigBlockRef ref = finalized.get(cursor.lastArchiveBigBlockNumber());
|
||||
if (ref == null || ref.arweaveTxId() == null || ref.arweaveTxId().length != 32) continue;
|
||||
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
|
||||
cursor.blockchainName(), cfg.serverLogin(), b64url(ref.arweaveTxId()),
|
||||
cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
|
||||
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), now));
|
||||
updated++;
|
||||
}
|
||||
if (updated > 0) log.info("Archive publisher: восстановлен location index для {} blockchain", updated);
|
||||
}
|
||||
|
||||
/** После рестарта продолжает только уже существующий незавершённый job; новый snapshot не создаёт. */
|
||||
public boolean resumeUnfinishedJob() throws Exception {
|
||||
if (!running.compareAndSet(false, true)) return false;
|
||||
try {
|
||||
ArchivePublishJob job = archiveDao.findUnfinishedJob();
|
||||
if (job == null) return false;
|
||||
log.info("Archive publisher: после рестарта продолжаем job id={} block={} status={}",
|
||||
job.id(), job.bigBlockNumber(), job.status());
|
||||
process(job.id());
|
||||
return true;
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Запускает суточный цикл: продолжает незавершённый job либо создаёт новый frozen snapshot. */
|
||||
public boolean runCycle() throws Exception {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
log.info("Archive publisher: предыдущий цикл ещё работает, новый пропущен");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
ArchivePublishJob job = archiveDao.findUnfinishedJob();
|
||||
if (job == null) {
|
||||
job = createFrozenJob();
|
||||
if (job == null) {
|
||||
log.info("Archive publisher: новых SHiNE-блоков нет");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
log.info("Archive publisher: продолжаем незавершённый job id={} block={} status={}",
|
||||
job.id(), job.bigBlockNumber(), job.status());
|
||||
}
|
||||
process(job.id());
|
||||
return true;
|
||||
} finally {
|
||||
running.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private ArchivePublishJob createFrozenJob() throws Exception {
|
||||
List<ArchivePublishJobChain> chains = new ArrayList<>();
|
||||
for (BlockchainStateEntry listed : stateDao.listAll()) {
|
||||
if (listed == null || listed.getBlockchainName() == null || listed.getBlockchainName().isBlank()) continue;
|
||||
String name = listed.getBlockchainName();
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(name);
|
||||
lock.lock();
|
||||
try {
|
||||
BlockchainStateEntry state = stateDao.getByBlockchainName(name);
|
||||
if (state == null || state.getLastBlockNumber() < 0) continue;
|
||||
ArchiveChainCursor cursor = archiveDao.getCursor(name);
|
||||
long from;
|
||||
byte[] previousSourceHash;
|
||||
Long prevBig = null;
|
||||
byte[] prevBigHash = null;
|
||||
Long prevChunkOffset = null;
|
||||
Long prevChunkSize = null;
|
||||
|
||||
if (cursor == null) {
|
||||
from = 0L;
|
||||
previousSourceHash = new byte[32];
|
||||
} else {
|
||||
long cursorBlock = cursor.lastArchivedSourceBlockNumber();
|
||||
if (cursorBlock < 0 || cursorBlock > 0xffff_ffffL) {
|
||||
throw new IllegalStateException("Некорректный archive cursor для " + name + ": " + cursorBlock);
|
||||
}
|
||||
byte[] actualCursorHash = blocksDao.getHashByNumber(name, (int) cursorBlock);
|
||||
if (!Arrays.equals(actualCursorHash, cursor.lastArchivedSourceBlockHash())) {
|
||||
throw new IllegalStateException("Archive cursor hash не совпадает с локальной blockchain: " + name + "#" + cursorBlock);
|
||||
}
|
||||
from = cursorBlock + 1L;
|
||||
previousSourceHash = cursor.lastArchivedSourceBlockHash();
|
||||
prevBig = cursor.lastArchiveBigBlockNumber();
|
||||
prevBigHash = cursor.lastArchiveBigBlockHash();
|
||||
prevChunkOffset = cursor.lastChunkOffset();
|
||||
prevChunkSize = cursor.lastChunkSize();
|
||||
}
|
||||
|
||||
long to = Integer.toUnsignedLong(state.getLastBlockNumber());
|
||||
if (from > to) continue;
|
||||
if (from > 0xffff_ffffL || to > 0xffff_ffffL) throw new IllegalStateException("Source block number вне u32: " + name);
|
||||
byte[] lastHash = state.getLastBlockHash();
|
||||
if (lastHash == null || lastHash.length != 32) {
|
||||
lastHash = blocksDao.getHashByNumber(name, (int) to);
|
||||
}
|
||||
require32(lastHash, "last source block hash " + name);
|
||||
chains.add(new ArchivePublishJobChain(
|
||||
0L, name, from, to, previousSourceHash, lastHash,
|
||||
prevBig, prevBigHash, prevChunkOffset, prevChunkSize, null, null));
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
if (chains.isEmpty()) return null;
|
||||
chains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
|
||||
long bigBlock = archiveDao.nextBigBlockNumber();
|
||||
if (bigBlock > 0xffff_ffffL) throw new IllegalStateException("Закончился диапазон u32 big_block_number");
|
||||
long createdAt = System.currentTimeMillis();
|
||||
long id = archiveDao.createJobWithChains(bigBlock, createdAt, chains);
|
||||
log.info("Archive snapshot создан: job={} bigBlock={} chains={}", id, bigBlock, chains.size());
|
||||
return archiveDao.getJob(id);
|
||||
}
|
||||
|
||||
private void process(long jobId) throws Exception {
|
||||
while (true) {
|
||||
ArchivePublishJob job = archiveDao.getJob(jobId);
|
||||
if (job == null) throw new IllegalStateException("Archive job исчез: " + jobId);
|
||||
switch (job.status()) {
|
||||
case "SNAPSHOT_CREATED" -> buildFile(job);
|
||||
case "FILE_BUILT" -> uploadArweave(job);
|
||||
case "ARWEAVE_UPLOADED" -> waitArweave(job);
|
||||
case "ARWEAVE_CONFIRMED" -> submitSolana(job);
|
||||
case "SOLANA_SUBMITTED" -> waitSolana(job);
|
||||
case "SOLANA_FINALIZED" -> commitCursors(job);
|
||||
case "CURSORS_COMMITTED" -> {
|
||||
log.info("Archive job завершён: id={} bigBlock={} file={} tx={}", job.id(), job.bigBlockNumber(), job.localArchivePath(), b64url(job.arweaveTxId()));
|
||||
return;
|
||||
}
|
||||
case "FAILED" -> throw new IllegalStateException("Archive job помечен FAILED: " + job.errorText());
|
||||
default -> throw new IllegalStateException("Неизвестный archive job status: " + job.status());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void buildFile(ArchivePublishJob job) throws Exception {
|
||||
List<ArchivePublishJobChain> chains = archiveDao.listJobChains(job.id());
|
||||
if (chains.isEmpty()) throw new IllegalStateException("SNAPSHOT_CREATED job не содержит chains: " + job.id());
|
||||
Map<String,List<BlockEntry>> records = new LinkedHashMap<>();
|
||||
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(ch.blockchainName());
|
||||
lock.lock();
|
||||
try {
|
||||
if (ch.fromSourceBlockNumber() > 0) {
|
||||
byte[] prev = blocksDao.getHashByNumber(ch.blockchainName(), (int)(ch.fromSourceBlockNumber() - 1));
|
||||
if (!Arrays.equals(prev, ch.previousSourceBlockHash())) {
|
||||
throw new IllegalStateException("Frozen previous source hash изменился: " + ch.blockchainName());
|
||||
}
|
||||
}
|
||||
List<BlockEntry> range = blocksDao.listRangeByNumber(
|
||||
ch.blockchainName(), (int)ch.fromSourceBlockNumber(), (int)ch.toSourceBlockNumber());
|
||||
if (range.isEmpty()) throw new IllegalStateException("Frozen range пуст: " + ch.blockchainName());
|
||||
byte[] last = range.get(range.size()-1).getBlockHash();
|
||||
if (!Arrays.equals(last, ch.lastSourceBlockHash())) {
|
||||
throw new IllegalStateException("Frozen last source hash изменился: " + ch.blockchainName());
|
||||
}
|
||||
records.put(ch.blockchainName(), range);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
List<ArchiveBigBlockRef> previous = archiveDao.listFinalizedBigBlocks();
|
||||
Path file = ArchiveFileNames.pendingFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone());
|
||||
ShineArchiveWriter.BuildResult result = writer.build(
|
||||
file, cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), previous,
|
||||
chains, records, archiveSigningSeed32, cfg.maxFileBytes());
|
||||
for (Map.Entry<String, ShineArchiveWriter.ChunkPosition> e : result.chunks().entrySet()) {
|
||||
archiveDao.updateNewChunkPosition(job.id(), e.getKey(), e.getValue().offset(), e.getValue().size());
|
||||
}
|
||||
archiveDao.markFileBuilt(job.id(), result.file().toString(), result.archiveHash());
|
||||
log.info("SHINE-ARCHIVE локально сохранён: block={} file={} bytes={} hash={}",
|
||||
job.bigBlockNumber(), result.file(), result.fileSize(), hex(result.archiveHash()));
|
||||
}
|
||||
|
||||
private void uploadArweave(ArchivePublishJob job) throws Exception {
|
||||
Path file = requireLocalFile(job);
|
||||
byte[] verified = ShineArchiveWriter.verifyLocalFile(file);
|
||||
if (!Arrays.equals(verified, job.archiveHash())) throw new IllegalStateException("Локальный archive hash отличается от job DB");
|
||||
ArweaveArchiveService.UploadResult upload = arweave.upload(file, cfg.serverLogin(), job.bigBlockNumber(), job.archiveHash());
|
||||
// Сначала надёжно фиксируем полученный TX ID в БД. Если процесс упадёт до rename,
|
||||
// recovery на следующем шаге переименует тот же временный файл без повторной загрузки.
|
||||
archiveDao.markArweaveUploaded(job.id(), upload.txIdBytes());
|
||||
Path uploadedFile = markLocalFileUploaded(job, file, upload.txIdBytes());
|
||||
log.info("SHINE-ARCHIVE загружен в Arweave: block={} tx={} file={}",
|
||||
job.bigBlockNumber(), upload.txId(), uploadedFile);
|
||||
}
|
||||
|
||||
private void waitArweave(ArchivePublishJob job) throws Exception {
|
||||
ensureUploadedLocalName(job);
|
||||
String tx = b64url(job.arweaveTxId());
|
||||
int confirmations = arweave.waitForConfirmations(tx);
|
||||
archiveDao.markArweaveConfirmed(job.id(), confirmations);
|
||||
log.info("Arweave archive закреплён: block={} tx={} confirmations={}", job.bigBlockNumber(), tx, confirmations);
|
||||
}
|
||||
|
||||
|
||||
/** После успешного Arweave upload временный файл получает реальный TX ID в имени и остаётся локально. */
|
||||
private Path markLocalFileUploaded(ArchivePublishJob job, Path currentFile, byte[] txId) throws Exception {
|
||||
require32(txId, "arweave tx id");
|
||||
Path target = ArchiveFileNames.uploadedFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), txId);
|
||||
Files.createDirectories(target.toAbsolutePath().getParent());
|
||||
if (!currentFile.equals(target)) {
|
||||
try {
|
||||
Files.move(currentFile, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(currentFile, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
archiveDao.updateLocalArchivePath(job.id(), target.toString());
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Crash recovery: TX уже сохранён, а временный файл ещё не успели переименовать. */
|
||||
private void ensureUploadedLocalName(ArchivePublishJob job) throws Exception {
|
||||
require32(job.arweaveTxId(), "job.arweave_tx_id");
|
||||
Path target = ArchiveFileNames.uploadedFilePath(
|
||||
cfg.workDir(), cfg.serverLogin(), job.bigBlockNumber(), job.createdAtMs(), cfg.publishZone(), job.arweaveTxId());
|
||||
if (Files.isRegularFile(target)) {
|
||||
if (!target.toString().equals(job.localArchivePath())) {
|
||||
archiveDao.updateLocalArchivePath(job.id(), target.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) return;
|
||||
Path current = Path.of(job.localArchivePath());
|
||||
if (Files.isRegularFile(current)) {
|
||||
markLocalFileUploaded(job, current, job.arweaveTxId());
|
||||
}
|
||||
}
|
||||
|
||||
private void submitSolana(ArchivePublishJob job) throws Exception {
|
||||
require32(job.arweaveTxId(), "job.arweave_tx_id");
|
||||
require32(job.archiveHash(), "job.archive_hash");
|
||||
SolanaArchiveHeadWriter.SubmitResult submitted = solana.submitArchiveHead(job.arweaveTxId(), job.archiveHash());
|
||||
archiveDao.markSolanaSubmitted(job.id(), submitted.signature());
|
||||
}
|
||||
|
||||
private void waitSolana(ArchivePublishJob job) throws Exception {
|
||||
if (job.solanaSignature() == null || job.solanaSignature().isBlank()) throw new IllegalStateException("SOLANA_SUBMITTED без signature");
|
||||
solana.waitFinalized(job.solanaSignature());
|
||||
if (!solana.currentHeadMatches(job.arweaveTxId(), job.archiveHash())) {
|
||||
throw new IllegalStateException("После Solana finalized User PDA archive head не совпадает с job");
|
||||
}
|
||||
archiveDao.markSolanaFinalized(job.id());
|
||||
log.info("Solana archive head finalized: block={} signature={}", job.bigBlockNumber(), job.solanaSignature());
|
||||
}
|
||||
|
||||
private void commitCursors(ArchivePublishJob job) throws Exception {
|
||||
archiveDao.commitCursors(job.id(), job.archiveHash());
|
||||
|
||||
// Тот же универсальный индекс используется и для собственных опубликованных архивов.
|
||||
// Поэтому UI publisher-сервера получает ссылку даже если он не импортирует самого себя через whitelist.
|
||||
String txId = b64url(job.arweaveTxId());
|
||||
long now = System.currentTimeMillis();
|
||||
for (ArchivePublishJobChain ch : archiveDao.listJobChains(job.id())) {
|
||||
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) continue;
|
||||
archiveImportDao.upsertLocation(new shine.db.archive.ArchiveBlockchainLocation(
|
||||
ch.blockchainName(), cfg.serverLogin(), txId, job.archiveHash(), job.bigBlockNumber(),
|
||||
ch.newChunkOffset(), ch.newChunkSize(), ch.toSourceBlockNumber(), now));
|
||||
}
|
||||
}
|
||||
|
||||
private static Path requireLocalFile(ArchivePublishJob job) {
|
||||
if (job.localArchivePath() == null || job.localArchivePath().isBlank()) throw new IllegalStateException("FILE_BUILT без local_archive_path");
|
||||
Path p = Path.of(job.localArchivePath());
|
||||
if (!Files.isRegularFile(p)) throw new IllegalStateException("Локальный archive-файл отсутствует: " + p);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static void require32(byte[] v, String name) {
|
||||
if (v == null || v.length != 32) throw new IllegalStateException(name + " должен быть 32 bytes");
|
||||
}
|
||||
private static String b64url(byte[] v) { return v == null ? "" : Base64.getUrlEncoder().withoutPadding().encodeToString(v); }
|
||||
private static String hex(byte[] b){StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
}
|
||||
-259
@@ -1,259 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.*;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.security.spec.RSAPrivateCrtKeySpec;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Публикация готовых локальных SHINE-ARCHIVE файлов в Arweave v2.
|
||||
* Поддерживает стандартный data Merkle tree и загрузку через /chunk.
|
||||
*/
|
||||
public final class ArweaveArchiveService {
|
||||
private static final Logger log = LoggerFactory.getLogger(ArweaveArchiveService.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
|
||||
private static final Base64.Decoder B64D = Base64.getUrlDecoder();
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final HttpClient http;
|
||||
private volatile Wallet wallet;
|
||||
|
||||
public ArweaveArchiveService(ArchivePublisherConfig cfg) {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
}
|
||||
|
||||
public record UploadResult(String txId, byte[] txIdBytes, String gateway) {}
|
||||
|
||||
public UploadResult upload(Path archiveFile, String publisherLogin, long bigBlockNumber, byte[] archiveHash)
|
||||
throws Exception {
|
||||
long dataSize = Files.size(archiveFile);
|
||||
if (dataSize <= 0) throw new IllegalArgumentException("Пустой SHINE-ARCHIVE");
|
||||
Wallet w = wallet();
|
||||
String gateway = trimGateway(cfg.arweaveGateway());
|
||||
|
||||
ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(archiveFile);
|
||||
String anchor = getRequiredText(gateway, "/tx_anchor");
|
||||
String reward = getRequiredText(gateway, "/price/" + dataSize);
|
||||
if (!reward.matches("^\\d+$")) throw new IOException("Arweave вернул некорректную цену: " + reward);
|
||||
verifyBalance(gateway, w.address(), reward);
|
||||
|
||||
List<List<byte[]>> tagList = new ArrayList<>();
|
||||
List<Map<String,String>> jsonTags = new ArrayList<>();
|
||||
addTag(jsonTags, tagList, "Content-Type", "application/octet-stream");
|
||||
addTag(jsonTags, tagList, "App-Name", "SHiNE");
|
||||
addTag(jsonTags, tagList, "SHiNE-Type", "archive");
|
||||
addTag(jsonTags, tagList, "SHiNE-Archive-Version", "1.0");
|
||||
addTag(jsonTags, tagList, "SHiNE-Publisher", publisherLogin);
|
||||
addTag(jsonTags, tagList, "SHiNE-Big-Block", Long.toUnsignedString(bigBlockNumber));
|
||||
addTag(jsonTags, tagList, "SHiNE-Archive-SHA256", hex(archiveHash));
|
||||
|
||||
byte[] signaturePayload = deepHash(List.of(
|
||||
utf8("2"),
|
||||
b64Decode(w.owner()),
|
||||
new byte[0],
|
||||
utf8("0"),
|
||||
utf8(reward),
|
||||
b64Decode(anchor),
|
||||
tagList,
|
||||
utf8(Long.toString(dataSize)),
|
||||
merkle.dataRoot()
|
||||
));
|
||||
byte[] rawSignature = signPayload(w.privateKey(), signaturePayload);
|
||||
byte[] txIdBytes = sha256(rawSignature);
|
||||
String txId = b64(txIdBytes);
|
||||
|
||||
Map<String,Object> tx = new LinkedHashMap<>();
|
||||
tx.put("format", 2);
|
||||
tx.put("id", txId);
|
||||
tx.put("last_tx", anchor);
|
||||
tx.put("owner", w.owner());
|
||||
tx.put("tags", jsonTags);
|
||||
tx.put("target", "");
|
||||
tx.put("quantity", "0");
|
||||
tx.put("data_root", b64(merkle.dataRoot()));
|
||||
tx.put("data_size", Long.toString(dataSize));
|
||||
tx.put("data", "");
|
||||
tx.put("reward", reward);
|
||||
tx.put("signature", b64(rawSignature));
|
||||
|
||||
postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60));
|
||||
log.info("Arweave tx header принят: tx={}, chunks={}, bytes={}", txId, merkle.chunks().size(), dataSize);
|
||||
|
||||
for (int i=0; i<merkle.chunks().size(); i++) {
|
||||
ArweaveMerkle.Chunk chunkMeta = merkle.chunks().get(i);
|
||||
ArweaveMerkle.Proof proof = merkle.proofs().get(i);
|
||||
byte[] chunk = ArweaveMerkle.readChunk(archiveFile, chunkMeta);
|
||||
Map<String,Object> body = new LinkedHashMap<>();
|
||||
body.put("data_root", b64(merkle.dataRoot()));
|
||||
body.put("data_size", Long.toString(dataSize));
|
||||
body.put("data_path", b64(proof.proof()));
|
||||
body.put("offset", Long.toString(proof.offset()));
|
||||
body.put("chunk", b64(chunk));
|
||||
postChunkWithRetry(gateway, body, i, merkle.chunks().size());
|
||||
}
|
||||
return new UploadResult(txId, txIdBytes, gateway);
|
||||
}
|
||||
|
||||
/** Ждёт включения tx в блок и заданного количества подтверждений. */
|
||||
public int waitForConfirmations(String txId) throws Exception {
|
||||
if (cfg.arweaveMinConfirmations() <= 0) return 0;
|
||||
String gateway = trimGateway(cfg.arweaveGateway());
|
||||
long deadline = System.currentTimeMillis() + cfg.arweaveConfirmTimeoutMinutes() * 60_000L;
|
||||
int last = 0;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + "/tx/" + txId + "/status"))
|
||||
.timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() == 200) {
|
||||
JsonNode json = MAPPER.readTree(resp.body());
|
||||
last = json.path("number_of_confirmations").asInt(0);
|
||||
if (last >= cfg.arweaveMinConfirmations()) return last;
|
||||
} else if (resp.statusCode() != 202 && resp.statusCode() != 404) {
|
||||
log.warn("Arweave status tx={} HTTP {}: {}", txId, resp.statusCode(), safe(resp.body()));
|
||||
}
|
||||
Thread.sleep(cfg.arweaveConfirmPollSeconds() * 1000L);
|
||||
}
|
||||
throw new IOException("Истёк timeout ожидания Arweave confirmations для " + txId + ", last=" + last);
|
||||
}
|
||||
|
||||
private void postChunkWithRetry(String gateway, Map<String,Object> body, int index, int total) throws Exception {
|
||||
Exception last = null;
|
||||
for (int attempt=1; attempt<=12; attempt++) {
|
||||
try {
|
||||
postJsonExpect2xx(gateway + "/chunk", body, Duration.ofSeconds(60));
|
||||
if ((index + 1) % 25 == 0 || index + 1 == total) {
|
||||
log.info("Arweave chunks: {}/{}", index + 1, total);
|
||||
}
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
last = e;
|
||||
if (attempt == 12) break;
|
||||
log.warn("Arweave chunk {}/{} attempt {}/12: {}", index + 1, total, attempt, e.getMessage());
|
||||
Thread.sleep(5_000L);
|
||||
}
|
||||
}
|
||||
throw new IOException("Не удалось загрузить Arweave chunk " + (index + 1) + "/" + total, last);
|
||||
}
|
||||
|
||||
private void postJsonExpect2xx(String url, Object body, Duration timeout) throws Exception {
|
||||
String json = MAPPER.writeValueAsString(body);
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(timeout)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("HTTP " + resp.statusCode() + " " + safe(resp.body()));
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyBalance(String gateway, String address, String reward) throws Exception {
|
||||
if (address == null || address.isBlank()) return;
|
||||
String balance = getRequiredText(gateway, "/wallet/" + address + "/balance");
|
||||
if (balance.matches("^\\d+$") && new BigInteger(balance).compareTo(new BigInteger(reward)) < 0) {
|
||||
throw new IllegalStateException("Недостаточно AR: balance=" + balance + ", reward=" + reward);
|
||||
}
|
||||
}
|
||||
|
||||
private String getRequiredText(String gateway, String path) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(gateway + path)).timeout(Duration.ofSeconds(20)).GET().build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) {
|
||||
throw new IOException("Arweave HTTP " + resp.statusCode() + " для " + path + ": " + safe(resp.body()));
|
||||
}
|
||||
String s = resp.body() == null ? "" : resp.body().trim();
|
||||
if (s.isBlank()) throw new IOException("Пустой Arweave ответ для " + path);
|
||||
return s;
|
||||
}
|
||||
|
||||
private Wallet wallet() throws Exception {
|
||||
Wallet local = wallet;
|
||||
if (local != null) return local;
|
||||
synchronized (this) {
|
||||
if (wallet != null) return wallet;
|
||||
JsonNode jwk = MAPPER.readTree(Files.readString(cfg.arweaveWalletJwkPath(), StandardCharsets.UTF_8));
|
||||
String owner = required(jwk, "n");
|
||||
PrivateKey pk = buildPrivateKey(jwk);
|
||||
String address = b64(sha256(b64Decode(owner)));
|
||||
wallet = new Wallet(owner, address, pk);
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
|
||||
private static PrivateKey buildPrivateKey(JsonNode jwk) throws GeneralSecurityException {
|
||||
RSAPrivateCrtKeySpec spec = new RSAPrivateCrtKeySpec(
|
||||
big(required(jwk,"n")), big(required(jwk,"e")), big(required(jwk,"d")),
|
||||
big(required(jwk,"p")), big(required(jwk,"q")), big(required(jwk,"dp")),
|
||||
big(required(jwk,"dq")), big(required(jwk,"qi")));
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
}
|
||||
|
||||
private static byte[] signPayload(PrivateKey key, byte[] payload) throws GeneralSecurityException {
|
||||
Signature signature = Signature.getInstance("RSASSA-PSS");
|
||||
signature.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
|
||||
signature.initSign(key);
|
||||
signature.update(payload);
|
||||
return signature.sign();
|
||||
}
|
||||
|
||||
private static byte[] deepHash(Object data) throws GeneralSecurityException {
|
||||
if (data instanceof List<?> list) {
|
||||
byte[] tag = concat(utf8("list"), utf8(Integer.toString(list.size())));
|
||||
return deepHashChunks(list, sha384(tag));
|
||||
}
|
||||
if (!(data instanceof byte[] bytes)) throw new IllegalArgumentException("deepHash: ожидается byte[]/List");
|
||||
byte[] tag = concat(utf8("blob"), utf8(Integer.toString(bytes.length)));
|
||||
return sha384(concat(sha384(tag), sha384(bytes)));
|
||||
}
|
||||
|
||||
private static byte[] deepHashChunks(List<?> list, byte[] acc) throws GeneralSecurityException {
|
||||
byte[] cur = acc;
|
||||
for (Object item : list) cur = sha384(concat(cur, deepHash(item)));
|
||||
return cur;
|
||||
}
|
||||
|
||||
private static void addTag(List<Map<String,String>> json, List<List<byte[]>> raw, String name, String value) {
|
||||
byte[] n=utf8(name), v=utf8(value);
|
||||
Map<String,String> item = new LinkedHashMap<>();
|
||||
item.put("name", b64(n)); item.put("value", b64(v));
|
||||
json.add(item); raw.add(List.of(n,v));
|
||||
}
|
||||
|
||||
private static String required(JsonNode node, String field) {
|
||||
String s = node == null ? "" : node.path(field).asText("").trim();
|
||||
if (s.isBlank()) throw new IllegalStateException("В Arweave JWK отсутствует " + field);
|
||||
return s;
|
||||
}
|
||||
private static BigInteger big(String s) { return new BigInteger(1, b64Decode(s)); }
|
||||
private static String trimGateway(String s) { return (s == null ? "https://arweave.net" : s.trim()).replaceAll("/+$", ""); }
|
||||
private static byte[] utf8(String s) { return String.valueOf(s == null ? "" : s).getBytes(StandardCharsets.UTF_8); }
|
||||
private static byte[] b64Decode(String s) { return B64D.decode(s); }
|
||||
private static String b64(byte[] b) { return B64.encodeToString(b); }
|
||||
private static byte[] sha256(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-256").digest(b); }
|
||||
private static byte[] sha384(byte[] b) throws GeneralSecurityException { return MessageDigest.getInstance("SHA-384").digest(b); }
|
||||
private static String hex(byte[] b) { StringBuilder s=new StringBuilder(); for(byte x:b)s.append(String.format("%02x",x)); return s.toString(); }
|
||||
private static byte[] concat(byte[]... arrays) { int n=0; for(byte[] a:arrays)n=Math.addExact(n,a.length); byte[] o=new byte[n]; int p=0; for(byte[]a:arrays){System.arraycopy(a,0,o,p,a.length);p+=a.length;} return o; }
|
||||
private static String safe(String s) { s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim(); return s.length()<=250?s:s.substring(0,250)+"..."; }
|
||||
|
||||
private record Wallet(String owner, String address, PrivateKey privateKey) {}
|
||||
}
|
||||
+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){}
|
||||
}
|
||||
@@ -29,6 +29,55 @@ final class ArweaveMerkle {
|
||||
|
||||
private ArweaveMerkle() {}
|
||||
|
||||
|
||||
static Prepared prepare(byte[] data) throws IOException {
|
||||
if (data == null || data.length == 0) throw new IOException("Arweave data is empty");
|
||||
List<Chunk> chunks = chunkBytes(data);
|
||||
List<Node> leaves = new ArrayList<>(chunks.size());
|
||||
for (Chunk c : chunks) {
|
||||
byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange()))));
|
||||
leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange()));
|
||||
}
|
||||
Node root = buildLayers(leaves);
|
||||
List<Proof> proofs = new ArrayList<>();
|
||||
resolveProofs(root, new byte[0], proofs);
|
||||
if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) {
|
||||
chunks = new ArrayList<>(chunks);
|
||||
chunks.remove(chunks.size() - 1);
|
||||
proofs.remove(proofs.size() - 1);
|
||||
}
|
||||
if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch");
|
||||
return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs));
|
||||
}
|
||||
|
||||
private static List<Chunk> chunkBytes(byte[] data) {
|
||||
List<Chunk> out = new ArrayList<>();
|
||||
long remaining = data.length;
|
||||
long cursor = 0;
|
||||
int p = 0;
|
||||
while (remaining >= MAX_CHUNK_SIZE) {
|
||||
long chunkSize = MAX_CHUNK_SIZE;
|
||||
long next = remaining - MAX_CHUNK_SIZE;
|
||||
if (next > 0 && next < MIN_CHUNK_SIZE) chunkSize = (remaining + 1) / 2;
|
||||
byte[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize);
|
||||
out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize));
|
||||
p += (int) chunkSize;
|
||||
cursor += chunkSize;
|
||||
remaining -= chunkSize;
|
||||
}
|
||||
byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining);
|
||||
out.add(new Chunk(sha256(rest), cursor, cursor + remaining));
|
||||
return out;
|
||||
}
|
||||
|
||||
static byte[] readChunk(byte[] data, Chunk c) throws IOException {
|
||||
long len = c.size();
|
||||
if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) {
|
||||
throw new IOException("Invalid Arweave chunk range");
|
||||
}
|
||||
return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange());
|
||||
}
|
||||
|
||||
static Prepared prepare(Path file) throws IOException {
|
||||
long fileSize = Files.size(file);
|
||||
List<Chunk> chunks = chunkFile(file, fileSize);
|
||||
|
||||
-101
@@ -1,101 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import sync.util.Base58Util;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
/** Строгий reader/verifier формата SHINE-ARCHIVE v1.0. */
|
||||
public final class ShineArchiveReader {
|
||||
public record Reference(long bigBlockNumber, byte[] archiveHash, String arweaveTxId) {}
|
||||
public record Chunk(long offset, long size, String blockchainName, List<byte[]> rawRecords,
|
||||
long previousBigBlockRef, long previousChunkOffset, long previousChunkSize) {}
|
||||
public record Document(long bigBlockNumber, long createdAtMs, String creatorLogin,
|
||||
List<Reference> references, List<Chunk> chunks, byte[] archiveHash) {}
|
||||
|
||||
public Document readAndVerify(Path file, String expectedPublisherLogin, byte[] expectedHash, String rootPublicKeyBase58) throws Exception {
|
||||
long fileSize = Files.size(file);
|
||||
if (fileSize < 13 + 38 + 1 + 32 + 64) throw new IOException("SHINE-ARCHIVE слишком короткий");
|
||||
if (fileSize >= 0x1_0000_0000L) throw new IOException("SHINE-ARCHIVE v1 >= 4 GiB не поддерживается");
|
||||
|
||||
byte[] computedHash = digestPrefix(file, fileSize - 96);
|
||||
try (RandomAccessFile in = new RandomAccessFile(file.toFile(), "r")) {
|
||||
byte[] magic = new byte[ShineArchiveWriter.MAGIC.length];
|
||||
in.readFully(magic);
|
||||
if (!Arrays.equals(magic, ShineArchiveWriter.MAGIC)) throw new IOException("Bad SHINE-ARCHIVE magic");
|
||||
int major=in.readUnsignedByte(), minor=in.readUnsignedByte();
|
||||
if (major != 1 || minor != 0) throw new IOException("Unsupported SHINE-ARCHIVE version " + major + "." + minor);
|
||||
long headerSize=u32(in); long bigBlock=u32(in); long createdAt=in.readLong();
|
||||
int creatorLen=in.readUnsignedByte(); int referencesMode=in.readUnsignedByte();
|
||||
long refsCount=u32(in); int refSize=in.readUnsignedShort(); long parentRef=u32(in);
|
||||
long chunksCount=u32(in); long recordsCount=u32(in);
|
||||
if (referencesMode != ShineArchiveWriter.REFERENCES_FULL || refSize != ShineArchiveWriter.REFERENCE_ENTRY_SIZE) {
|
||||
throw new IOException("Unsupported references table");
|
||||
}
|
||||
String creator=readUtf8(in,creatorLen);
|
||||
if (!creator.equalsIgnoreCase(String.valueOf(expectedPublisherLogin))) {
|
||||
throw new SecurityException("Archive creator " + creator + " != trusted publisher " + expectedPublisherLogin);
|
||||
}
|
||||
if (headerSize != in.getFilePointer()) throw new IOException("Некорректный header_size");
|
||||
if (refsCount > 10_000_000L || chunksCount > 10_000_000L) throw new IOException("Слишком много refs/chunks");
|
||||
|
||||
List<Reference> refs=new ArrayList<>((int)Math.min(refsCount, Integer.MAX_VALUE));
|
||||
for(long i=0;i<refsCount;i++) {
|
||||
long number=u32(in); byte[] hash=readFixed(in,32); byte[] tx=readFixed(in,32);
|
||||
refs.add(new Reference(number,hash,Base64.getUrlEncoder().withoutPadding().encodeToString(tx)));
|
||||
}
|
||||
if (refs.isEmpty() && parentRef != ShineArchiveWriter.NO_REFERENCE_U32) throw new IOException("Некорректный parent reference");
|
||||
if (!refs.isEmpty() && parentRef >= refs.size()) throw new IOException("parent_reference_index вне таблицы");
|
||||
|
||||
List<Chunk> chunks=new ArrayList<>((int)Math.min(chunksCount, Integer.MAX_VALUE));
|
||||
long parsedRecords=0;
|
||||
for(long ci=0;ci<chunksCount;ci++) {
|
||||
long chunkOffset=in.getFilePointer(); long chunkSize=u32(in); long chunkEnd=chunkOffset+chunkSize;
|
||||
if (chunkSize < 21 || chunkEnd > fileSize-96) throw new IOException("Некорректный chunk_size");
|
||||
int nameLen=in.readUnsignedByte(); String bch=readUtf8(in,nameLen); long count=u32(in);
|
||||
if (count > 10_000_000L) throw new IOException("Слишком много records в chunk " + bch);
|
||||
List<byte[]> records=new ArrayList<>((int)Math.min(count,Integer.MAX_VALUE));
|
||||
for(long ri=0;ri<count;ri++) {
|
||||
long n=u32(in); if(n<=0 || n>Integer.MAX_VALUE || in.getFilePointer()+n>chunkEnd-12) throw new IOException("Некорректный record_size");
|
||||
records.add(readFixed(in,(int)n)); parsedRecords++;
|
||||
}
|
||||
long prevRef=u32(in), prevOffset=u32(in), prevSize=u32(in);
|
||||
if (prevRef != ShineArchiveWriter.NO_REFERENCE_U32 && prevRef >= refs.size()) throw new IOException("previous_big_block_ref вне таблицы");
|
||||
if (in.getFilePointer()!=chunkEnd) throw new IOException("Chunk framing mismatch: " + bch);
|
||||
chunks.add(new Chunk(chunkOffset,chunkSize,bch,List.copyOf(records),prevRef,prevOffset,prevSize));
|
||||
}
|
||||
if (parsedRecords != recordsCount) throw new IOException("total_user_records_count mismatch");
|
||||
|
||||
int closerLen=in.readUnsignedByte(); String closer=readUtf8(in,closerLen);
|
||||
if(!closer.equals(creator)) throw new SecurityException("creator_login != closer_login");
|
||||
if(in.getFilePointer()!=fileSize-96) throw new IOException("Некорректная граница footer/hash");
|
||||
byte[] storedHash=readFixed(in,32); byte[] signature=readFixed(in,64);
|
||||
if(in.getFilePointer()!=fileSize) throw new IOException("Лишние данные после signature");
|
||||
if(!Arrays.equals(storedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с footer");
|
||||
if(expectedHash!=null && !Arrays.equals(expectedHash,computedHash)) throw new SecurityException("SHA-256 archive не совпадает с ожидаемым hash");
|
||||
byte[] root=Base58Util.decode(rootPublicKeyBase58);
|
||||
if(root.length!=32) throw new SecurityException("Некорректный root public key publisher-а");
|
||||
byte[] signed=new byte[ShineArchiveWriter.SIGNATURE_DOMAIN.length+32];
|
||||
System.arraycopy(ShineArchiveWriter.SIGNATURE_DOMAIN,0,signed,0,ShineArchiveWriter.SIGNATURE_DOMAIN.length);
|
||||
System.arraycopy(computedHash,0,signed,ShineArchiveWriter.SIGNATURE_DOMAIN.length,32);
|
||||
if(!Ed25519Util.verify(signed,signature,root)) throw new SecurityException("Не прошла подпись SHINE-ARCHIVE publisher-а");
|
||||
return new Document(bigBlock,createdAt,creator,List.copyOf(refs),List.copyOf(chunks),computedHash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] digestPrefix(Path file,long bytes) throws Exception {
|
||||
MessageDigest md=MessageDigest.getInstance("SHA-256");
|
||||
try(InputStream in=new BufferedInputStream(Files.newInputStream(file))) {
|
||||
byte[] buf=new byte[1024*1024]; long left=bytes;
|
||||
while(left>0){int n=in.read(buf,0,(int)Math.min(buf.length,left)); if(n<0) throw new EOFException(); md.update(buf,0,n); left-=n;}
|
||||
}
|
||||
return md.digest();
|
||||
}
|
||||
private static long u32(RandomAccessFile in) throws IOException { return Integer.toUnsignedLong(in.readInt()); }
|
||||
private static byte[] readFixed(RandomAccessFile in,int n) throws IOException { byte[] b=new byte[n]; in.readFully(b); return b; }
|
||||
private static String readUtf8(RandomAccessFile in,int n) throws IOException { return new String(readFixed(in,n), StandardCharsets.UTF_8); }
|
||||
}
|
||||
-310
@@ -1,310 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.entities.BlockEntry;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Writer бинарного SHINE-ARCHIVE v1.0.
|
||||
*
|
||||
* Один blockchain_name встречается максимум один раз и содержит все новые raw records этой цепочки.
|
||||
*/
|
||||
public final class ShineArchiveWriter {
|
||||
public static final byte VERSION_MAJOR = 1;
|
||||
public static final byte VERSION_MINOR = 0;
|
||||
public static final byte REFERENCES_FULL = 0;
|
||||
public static final long NO_REFERENCE_U32 = 0xffff_ffffL;
|
||||
public static final int REFERENCE_ENTRY_SIZE = 68;
|
||||
public static final byte[] MAGIC = "SHINE-ARCHIVE".getBytes(StandardCharsets.US_ASCII);
|
||||
public static final byte[] SIGNATURE_DOMAIN = "SHINE-ARCHIVE-V1".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
public record ChunkPosition(long offset, long size) {}
|
||||
public record BuildResult(Path file, byte[] archiveHash, Map<String, ChunkPosition> chunks, long fileSize) {}
|
||||
|
||||
public BuildResult build(
|
||||
Path finalPath,
|
||||
String creatorLogin,
|
||||
long bigBlockNumber,
|
||||
long createdAtMs,
|
||||
List<ArchiveBigBlockRef> previousBigBlocks,
|
||||
List<ArchivePublishJobChain> chains,
|
||||
Map<String, List<BlockEntry>> recordsByBlockchain,
|
||||
byte[] signingPrivateSeed32,
|
||||
long maxFileBytes
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(finalPath, "finalPath");
|
||||
Objects.requireNonNull(previousBigBlocks, "previousBigBlocks");
|
||||
Objects.requireNonNull(chains, "chains");
|
||||
Objects.requireNonNull(recordsByBlockchain, "recordsByBlockchain");
|
||||
requireU32(bigBlockNumber, "big_block_number");
|
||||
if (signingPrivateSeed32 == null || signingPrivateSeed32.length != 32) {
|
||||
throw new IllegalArgumentException("Archive signing key должен быть 32-byte Ed25519 seed");
|
||||
}
|
||||
|
||||
byte[] loginBytes = utf8U8(creatorLogin, "creator_login");
|
||||
if (previousBigBlocks.size() > 0xffff_ffffL || chains.size() > 0xffff_ffffL) {
|
||||
throw new IllegalArgumentException("Слишком много references/chunks для v1");
|
||||
}
|
||||
|
||||
List<ArchivePublishJobChain> sortedChains = new ArrayList<>(chains);
|
||||
sortedChains.sort(Comparator.comparing(ArchivePublishJobChain::blockchainName));
|
||||
|
||||
long totalRecords = 0;
|
||||
for (ArchivePublishJobChain ch : sortedChains) {
|
||||
List<BlockEntry> rs = recordsByBlockchain.get(ch.blockchainName());
|
||||
if (rs == null || rs.isEmpty()) {
|
||||
throw new IllegalArgumentException("Нет frozen records для " + ch.blockchainName());
|
||||
}
|
||||
totalRecords += rs.size();
|
||||
requireU32(totalRecords, "total_user_records_count");
|
||||
}
|
||||
|
||||
Map<Long, Integer> referenceIndexByBlock = new HashMap<>();
|
||||
for (int i = 0; i < previousBigBlocks.size(); i++) {
|
||||
ArchiveBigBlockRef ref = previousBigBlocks.get(i);
|
||||
requireU32(ref.bigBlockNumber(), "PreviousBigBlockReference.big_block_number");
|
||||
require32(ref.archiveHash(), "PreviousBigBlockReference.big_block_hash");
|
||||
require32(ref.arweaveTxId(), "PreviousBigBlockReference.arweave_tx_id");
|
||||
referenceIndexByBlock.put(ref.bigBlockNumber(), i);
|
||||
}
|
||||
|
||||
Files.createDirectories(finalPath.toAbsolutePath().getParent());
|
||||
Path tmp = finalPath.resolveSibling(finalPath.getFileName() + ".tmp");
|
||||
Files.deleteIfExists(tmp);
|
||||
|
||||
Map<String, ChunkPosition> positions = new LinkedHashMap<>();
|
||||
MessageDigest digest = sha256();
|
||||
|
||||
try (OutputStream fileOut = new BufferedOutputStream(Files.newOutputStream(tmp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE));
|
||||
CountingOutputStream counting = new CountingOutputStream(fileOut);
|
||||
DigestOutputStream digestOut = new DigestOutputStream(counting, digest);
|
||||
DataOutputStream out = new DataOutputStream(digestOut)) {
|
||||
|
||||
// ---------- header ----------
|
||||
out.write(MAGIC);
|
||||
out.writeByte(VERSION_MAJOR);
|
||||
out.writeByte(VERSION_MINOR);
|
||||
|
||||
long headerSize = fixedHeaderSize() + loginBytes.length;
|
||||
writeU32(out, headerSize);
|
||||
writeU32(out, bigBlockNumber);
|
||||
out.writeLong(createdAtMs);
|
||||
out.writeByte(loginBytes.length);
|
||||
out.writeByte(REFERENCES_FULL);
|
||||
writeU32(out, previousBigBlocks.size());
|
||||
out.writeShort(REFERENCE_ENTRY_SIZE);
|
||||
long parentIndex = previousBigBlocks.isEmpty() ? NO_REFERENCE_U32 : previousBigBlocks.size() - 1L;
|
||||
writeU32(out, parentIndex);
|
||||
writeU32(out, sortedChains.size());
|
||||
writeU32(out, totalRecords);
|
||||
out.write(loginBytes);
|
||||
|
||||
// ---------- полный список предыдущих больших блоков ----------
|
||||
for (ArchiveBigBlockRef ref : previousBigBlocks) {
|
||||
writeU32(out, ref.bigBlockNumber());
|
||||
out.write(ref.archiveHash());
|
||||
out.write(ref.arweaveTxId());
|
||||
}
|
||||
|
||||
// ---------- по одному chunk на blockchain_name ----------
|
||||
for (ArchivePublishJobChain ch : sortedChains) {
|
||||
List<BlockEntry> records = new ArrayList<>(recordsByBlockchain.get(ch.blockchainName()));
|
||||
records.sort(Comparator.comparingInt(BlockEntry::getBlockNumber));
|
||||
validateFrozenRange(ch, records);
|
||||
|
||||
byte[] name = utf8U8(ch.blockchainName(), "blockchain_name");
|
||||
long chunkSize = calculateChunkSize(name.length, records);
|
||||
requireU32(chunkSize, "chunk_size");
|
||||
long chunkOffset = counting.getCount();
|
||||
requireU32(chunkOffset, "chunk_offset");
|
||||
if (chunkOffset + chunkSize > maxFileBytes) {
|
||||
throw new ArchiveTooLargeException("Большой архив превысит archive.maxFileBytes: " + (chunkOffset + chunkSize));
|
||||
}
|
||||
|
||||
writeU32(out, chunkSize);
|
||||
out.writeByte(name.length);
|
||||
out.write(name);
|
||||
writeU32(out, records.size());
|
||||
for (BlockEntry record : records) {
|
||||
byte[] raw = record.getBlockBytes();
|
||||
if (raw == null) throw new IllegalArgumentException("block_bytes=NULL: " + ch.blockchainName() + "#" + record.getBlockNumber());
|
||||
writeU32(out, raw.length);
|
||||
out.write(raw);
|
||||
}
|
||||
|
||||
if (ch.previousArchiveBigBlockNumber() == null) {
|
||||
writeU32(out, NO_REFERENCE_U32);
|
||||
writeU32(out, 0);
|
||||
writeU32(out, 0);
|
||||
} else {
|
||||
Integer refIndex = referenceIndexByBlock.get(ch.previousArchiveBigBlockNumber());
|
||||
if (refIndex == null) {
|
||||
throw new IllegalStateException("Предыдущий big block blockchain отсутствует в FULL reference table: "
|
||||
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
|
||||
}
|
||||
ArchiveBigBlockRef previousRef = previousBigBlocks.get(refIndex);
|
||||
require32(ch.previousArchiveBigBlockHash(), "previous_archive_big_block_hash " + ch.blockchainName());
|
||||
if (!Arrays.equals(previousRef.archiveHash(), ch.previousArchiveBigBlockHash())) {
|
||||
throw new IllegalStateException("Hash предыдущего big block не совпадает с FULL reference table: "
|
||||
+ ch.blockchainName() + " -> " + ch.previousArchiveBigBlockNumber());
|
||||
}
|
||||
if (ch.previousChunkOffset() == null || ch.previousChunkSize() == null) {
|
||||
throw new IllegalStateException("Нет предыдущих chunk offset/size: " + ch.blockchainName());
|
||||
}
|
||||
writeU32(out, refIndex);
|
||||
writeU32(out, ch.previousChunkOffset());
|
||||
writeU32(out, ch.previousChunkSize());
|
||||
}
|
||||
positions.put(ch.blockchainName(), new ChunkPosition(chunkOffset, chunkSize));
|
||||
}
|
||||
|
||||
// ---------- footer, login входит в hash ----------
|
||||
out.writeByte(loginBytes.length);
|
||||
out.write(loginBytes);
|
||||
out.flush();
|
||||
|
||||
byte[] archiveHash = digest.digest();
|
||||
byte[] signedPayload = new byte[SIGNATURE_DOMAIN.length + archiveHash.length];
|
||||
System.arraycopy(SIGNATURE_DOMAIN, 0, signedPayload, 0, SIGNATURE_DOMAIN.length);
|
||||
System.arraycopy(archiveHash, 0, signedPayload, SIGNATURE_DOMAIN.length, archiveHash.length);
|
||||
byte[] signature = Ed25519Util.sign(signedPayload, signingPrivateSeed32);
|
||||
|
||||
// hash/signature не должны попасть в hash самого блока.
|
||||
digestOut.on(false);
|
||||
out.write(archiveHash);
|
||||
out.write(signature);
|
||||
out.flush();
|
||||
|
||||
long finalSize = counting.getCount();
|
||||
if (finalSize >= 0x1_0000_0000L || finalSize > maxFileBytes) {
|
||||
throw new ArchiveTooLargeException("Размер SHINE-ARCHIVE v1 превышает лимит: " + finalSize);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Files.deleteIfExists(tmp);
|
||||
if (e instanceof IOException ioe) throw ioe;
|
||||
throw new IOException("Не удалось собрать SHINE-ARCHIVE", e);
|
||||
}
|
||||
|
||||
// До Arweave upload существует уже готовый локальный файл с окончательным именем.
|
||||
try {
|
||||
Files.move(tmp, finalPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(tmp, finalPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
byte[] hash = readStoredArchiveHash(finalPath);
|
||||
long size = Files.size(finalPath);
|
||||
return new BuildResult(finalPath, hash, Collections.unmodifiableMap(positions), size);
|
||||
}
|
||||
|
||||
/** Проверяет локальный файл перед оплатой Arweave. */
|
||||
public static byte[] verifyLocalFile(Path file) throws IOException {
|
||||
long size = Files.size(file);
|
||||
if (size < MAGIC.length + 2 + 96) throw new IOException("Слишком короткий SHINE-ARCHIVE: " + file);
|
||||
byte[] stored = readStoredArchiveHash(file);
|
||||
MessageDigest md = sha256();
|
||||
long hashInputSize = size - 96; // [archive_hash 32][signature 64]
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
long left = hashInputSize;
|
||||
while (left > 0) {
|
||||
int n = in.read(buf, 0, (int)Math.min(buf.length, left));
|
||||
if (n < 0) throw new EOFException("Неожиданный EOF при проверке архива");
|
||||
md.update(buf, 0, n);
|
||||
left -= n;
|
||||
}
|
||||
}
|
||||
byte[] actual = md.digest();
|
||||
if (!Arrays.equals(stored, actual)) throw new IOException("SHA-256 локального SHINE-ARCHIVE не совпадает с footer");
|
||||
return actual;
|
||||
}
|
||||
|
||||
public static byte[] readStoredArchiveHash(Path file) throws IOException {
|
||||
long size = Files.size(file);
|
||||
if (size < 96) throw new EOFException("Файл слишком короткий");
|
||||
try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) {
|
||||
raf.seek(size - 96);
|
||||
byte[] hash = new byte[32];
|
||||
raf.readFully(hash);
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
private static long fixedHeaderSize() {
|
||||
// magic + major/minor + headerSize + blockNo + time + creatorLen + mode + refCount + refSize + parentIndex + chunkCount + recordCount
|
||||
return 13L + 1 + 1 + 4 + 4 + 8 + 1 + 1 + 4 + 2 + 4 + 4 + 4;
|
||||
}
|
||||
|
||||
private static long calculateChunkSize(int nameLen, List<BlockEntry> records) {
|
||||
long size = 4L + 1L + nameLen + 4L + 12L;
|
||||
for (BlockEntry e : records) {
|
||||
byte[] raw = e.getBlockBytes();
|
||||
if (raw == null) throw new IllegalArgumentException("raw record is null");
|
||||
size += 4L + raw.length;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
private static void validateFrozenRange(ArchivePublishJobChain ch, List<BlockEntry> records) {
|
||||
long expected = ch.toSourceBlockNumber() - ch.fromSourceBlockNumber() + 1;
|
||||
if (expected <= 0 || expected != records.size()) {
|
||||
throw new IllegalArgumentException("Frozen range неполон для " + ch.blockchainName()
|
||||
+ ": ожидалось=" + expected + ", получено=" + records.size());
|
||||
}
|
||||
long n = ch.fromSourceBlockNumber();
|
||||
for (BlockEntry e : records) {
|
||||
if (Integer.toUnsignedLong(e.getBlockNumber()) != n) {
|
||||
throw new IllegalArgumentException("Нарушена непрерывность " + ch.blockchainName()
|
||||
+ ": ожидался block " + n + ", получен " + Integer.toUnsignedLong(e.getBlockNumber()));
|
||||
}
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] utf8U8(String value, String name) {
|
||||
if (value == null) throw new IllegalArgumentException(name + " == null");
|
||||
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
if (bytes.length == 0 || bytes.length > 255) throw new IllegalArgumentException(name + " должен занимать 1..255 UTF-8 байт");
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void require32(byte[] v, String name) {
|
||||
if (v == null || v.length != 32) throw new IllegalArgumentException(name + " должен быть 32 bytes");
|
||||
}
|
||||
|
||||
private static void requireU32(long v, String name) {
|
||||
if (v < 0 || v > 0xffff_ffffL) throw new IllegalArgumentException(name + " вне u32: " + v);
|
||||
}
|
||||
|
||||
private static void writeU32(DataOutputStream out, long v) throws IOException {
|
||||
requireU32(v, "u32");
|
||||
out.writeInt((int)v); // DataOutputStream = big-endian; битовый шаблон корректен и для unsigned > 2^31.
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try { return MessageDigest.getInstance("SHA-256"); }
|
||||
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
|
||||
}
|
||||
|
||||
/** OutputStream со счётчиком без ограничения int. */
|
||||
private static final class CountingOutputStream extends FilterOutputStream {
|
||||
private long count;
|
||||
CountingOutputStream(OutputStream out) { super(out); }
|
||||
long getCount() { return count; }
|
||||
@Override public void write(int b) throws IOException { out.write(b); count++; }
|
||||
@Override public void write(byte[] b, int off, int len) throws IOException { out.write(b, off, len); count += len; }
|
||||
}
|
||||
|
||||
public static final class ArchiveTooLargeException extends IOException {
|
||||
public ArchiveTooLargeException(String message) { super(message); }
|
||||
}
|
||||
}
|
||||
-373
@@ -1,373 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sync.codec.ShineUsersCodec;
|
||||
import sync.codec.ShineUsersCodec.UserPdaSnapshot;
|
||||
import sync.util.Base58Util;
|
||||
import sync.util.SolanaPdaUtil;
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Обновляет ArchiveHeadBlock (type=100) обычной инструкцией update_user_pda.
|
||||
* Отдельной Solana-инструкции для архива нет.
|
||||
*/
|
||||
public final class SolanaArchiveHeadWriter {
|
||||
private static final Logger log = LoggerFactory.getLogger(SolanaArchiveHeadWriter.class);
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
private static final String SYSTEM_PROGRAM = "11111111111111111111111111111111";
|
||||
private static final String ED25519_PROGRAM = "Ed25519SigVerify111111111111111111111111111";
|
||||
private static final String COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111";
|
||||
private static final String SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111";
|
||||
private static final String USER_SEED_PREFIX = "user_login=";
|
||||
private static final String ECONOMY_SEED = "shine_users_economy_config";
|
||||
private static final String INFLOW_SEED = "shine_payments_inflow_vault";
|
||||
private static final byte[] LAST_BLOCK_STATE_PREFIX = "SHiNE_LAST_BLOCK".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final ArchivePublisherConfig cfg;
|
||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
||||
private final byte[] rootSeed32;
|
||||
private final byte[] clientSeed32;
|
||||
|
||||
public SolanaArchiveHeadWriter(ArchivePublisherConfig cfg) throws IOException {
|
||||
this.cfg = Objects.requireNonNull(cfg);
|
||||
this.rootSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaRootKeyPath());
|
||||
this.clientSeed32 = ArchiveKeyLoader.loadSeed32(cfg.solanaClientKeyPath());
|
||||
}
|
||||
|
||||
public record SubmitResult(String signature, String userPda) {}
|
||||
|
||||
/** Preflight: проверяет, что локальные root/client private keys соответствуют текущему User PDA. */
|
||||
public void validateKeysAgainstPda() throws Exception {
|
||||
Current current = loadCurrent();
|
||||
byte[] expectedRoot = Base58Util.decode(current.snapshot.rootKey());
|
||||
byte[] expectedClient = Base58Util.decode(current.snapshot.clientKey());
|
||||
if (!Arrays.equals(expectedRoot, Ed25519Util.derivePublicKey(rootSeed32))) {
|
||||
throw new IllegalStateException("archive.solana.rootKeyPath не соответствует root_key User PDA " + cfg.serverLogin());
|
||||
}
|
||||
if (!Arrays.equals(expectedClient, Ed25519Util.derivePublicKey(clientSeed32))) {
|
||||
throw new IllegalStateException("archive.solana.clientKeyPath не соответствует client_key User PDA " + cfg.serverLogin());
|
||||
}
|
||||
}
|
||||
|
||||
public SubmitResult submitArchiveHead(byte[] arweaveTxId32, byte[] archiveHash32) throws Exception {
|
||||
require32(arweaveTxId32, "Arweave TX ID");
|
||||
require32(archiveHash32, "archive hash");
|
||||
|
||||
Current current = loadCurrent();
|
||||
UserPdaSnapshot old = current.snapshot;
|
||||
byte[] rootPub = Ed25519Util.derivePublicKey(rootSeed32);
|
||||
byte[] clientPub = Ed25519Util.derivePublicKey(clientSeed32);
|
||||
if (!Arrays.equals(rootPub, Base58Util.decode(old.rootKey()))) {
|
||||
throw new IllegalStateException("Root key archive publisher-а не совпадает с User PDA");
|
||||
}
|
||||
if (!Arrays.equals(clientPub, Base58Util.decode(old.clientKey()))) {
|
||||
throw new IllegalStateException("Client key archive publisher-а не совпадает с User PDA");
|
||||
}
|
||||
|
||||
byte[] currentRaw = Base64.getDecoder().decode(old.rawDataBase64());
|
||||
int currentRecordLen = u16le(currentRaw, 7);
|
||||
byte[] currentUnsigned = Arrays.copyOf(currentRaw, currentRecordLen - 64);
|
||||
byte[] prevHash = sha256(currentUnsigned);
|
||||
|
||||
long updatedAt = System.currentTimeMillis();
|
||||
String txB64Url = Base64.getUrlEncoder().withoutPadding().encodeToString(arweaveTxId32);
|
||||
String archiveHashHex = hex(archiveHash32);
|
||||
String zeroSig = Base58Util.encode(new byte[64]);
|
||||
|
||||
UserPdaSnapshot nextUnsignedSnapshot = new UserPdaSnapshot(
|
||||
old.pdaAddress(), old.login(), old.recordNumber() + 1, old.slot(), "",
|
||||
old.recoveryKey(), old.rootKey(), old.clientKey(), old.blockchainName(), old.blockchainKey(),
|
||||
old.paidLimitBytes(), old.usedBytes(), old.lastBlockNumber(), old.lastBlockHash(),
|
||||
old.lastBlockSignature(), old.arweaveTxId(), txB64Url, archiveHashHex,
|
||||
old.isServer(), old.addressFormatType(), old.addressFormatVersion(), old.serverAddress(),
|
||||
old.syncServers(), old.accessServers(), old.sessionsMode(), old.sessions(), old.trustedCount(),
|
||||
old.createdAtMs(), updatedAt, hex(prevHash), zeroSig, ""
|
||||
);
|
||||
byte[] nextFull = Base64.getDecoder().decode(ShineUsersCodec.serializeSnapshotToBase64(nextUnsignedSnapshot));
|
||||
int nextRecordLen = u16le(nextFull, 7);
|
||||
byte[] nextUnsigned = Arrays.copyOf(nextFull, nextRecordLen - 64);
|
||||
byte[] nextUnsignedHash = sha256(nextUnsigned);
|
||||
byte[] rootSignature = Ed25519Util.sign(nextUnsignedHash, rootSeed32);
|
||||
|
||||
byte[] updateData = buildUpdateInstruction(old, updatedAt, prevHash, arweaveTxId32, archiveHash32, rootSignature);
|
||||
byte[] lastStateHash = sha256(buildLastBlockState(old));
|
||||
byte[] lastBlockSignature = Base58Util.decode(old.lastBlockSignature());
|
||||
byte[] blockchainPub = Base58Util.decode(old.blockchainKey());
|
||||
|
||||
byte[] rootEdData = buildEd25519Instruction(rootSignature, rootPub, nextUnsignedHash);
|
||||
byte[] bchEdData = buildEd25519Instruction(lastBlockSignature, blockchainPub, lastStateHash);
|
||||
|
||||
String blockhash = getLatestBlockhash();
|
||||
byte[] message = buildLegacyMessage(current.userPda, updateData, rootEdData, bchEdData, blockhash, clientPub);
|
||||
byte[] clientSignature = Ed25519Util.sign(message, clientSeed32);
|
||||
byte[] transaction = serializeTransaction(clientSignature, message);
|
||||
String signature = sendTransaction(transaction);
|
||||
log.info("Archive head отправлен в Solana: login={} pda={} signature={}", cfg.serverLogin(), current.userPda, signature);
|
||||
return new SubmitResult(signature, current.userPda);
|
||||
}
|
||||
|
||||
public void waitFinalized(String signature) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + cfg.solanaConfirmTimeoutMinutes() * 60_000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
JsonNode result = rpc("getSignatureStatuses", List.of(List.of(signature), Map.of("searchTransactionHistory", true))).path("result");
|
||||
JsonNode values = result.path("value");
|
||||
JsonNode status = values.isArray() && values.size() > 0 ? values.get(0) : null;
|
||||
if (status != null && !status.isNull()) {
|
||||
if (status.hasNonNull("err")) {
|
||||
throw new IOException("Solana archive-head tx завершилась ошибкой: " + status.path("err"));
|
||||
}
|
||||
String confirmation = status.path("confirmationStatus").asText("");
|
||||
if ("finalized".equals(confirmation)) return;
|
||||
}
|
||||
Thread.sleep(cfg.solanaConfirmPollSeconds() * 1000L);
|
||||
}
|
||||
throw new IOException("Timeout ожидания Solana finalized: " + signature);
|
||||
}
|
||||
|
||||
public boolean currentHeadMatches(byte[] txId32, byte[] hash32) throws Exception {
|
||||
Current current = loadCurrent();
|
||||
if (current.snapshot.archiveHeadTxId() == null || current.snapshot.archiveHeadTxId().isBlank()) return false;
|
||||
byte[] currentTx = Base64.getUrlDecoder().decode(current.snapshot.archiveHeadTxId());
|
||||
byte[] currentHash = unhex(current.snapshot.archiveHeadHash());
|
||||
return Arrays.equals(currentTx, txId32) && Arrays.equals(currentHash, hash32);
|
||||
}
|
||||
|
||||
private Current loadCurrent() throws Exception {
|
||||
String normalized = cfg.serverLogin().trim().toLowerCase(Locale.ROOT);
|
||||
String userPda = SolanaPdaUtil.findProgramAddress(
|
||||
List.of(USER_SEED_PREFIX.getBytes(StandardCharsets.UTF_8), normalized.getBytes(StandardCharsets.UTF_8)),
|
||||
cfg.solanaUsersProgramId());
|
||||
JsonNode accountResult = rpc("getAccountInfo", List.of(userPda, Map.of("encoding", "base64", "commitment", cfg.solanaCommitment()))).path("result");
|
||||
JsonNode value = accountResult.path("value");
|
||||
if (value.isMissingNode() || value.isNull()) throw new IOException("User PDA не найден для " + cfg.serverLogin());
|
||||
String owner = value.path("owner").asText("");
|
||||
if (!cfg.solanaUsersProgramId().equals(owner)) throw new IOException("User PDA принадлежит другой программе: " + owner);
|
||||
JsonNode data = value.path("data");
|
||||
if (!data.isArray() || data.size() < 1) throw new IOException("Некорректный getAccountInfo.data");
|
||||
String rawB64 = data.get(0).asText();
|
||||
UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(userPda, 0L, rawB64, "");
|
||||
return new Current(userPda, snapshot);
|
||||
}
|
||||
|
||||
private byte[] buildUpdateInstruction(UserPdaSnapshot s, long updatedAt, byte[] prevHash,
|
||||
byte[] archiveTx, byte[] archiveHash, byte[] rootSig) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(4); // IX_UPDATE_USER_PDA
|
||||
strU8(out, s.login());
|
||||
out.write(Base58Util.decode(s.recoveryKey()));
|
||||
out.write(Base58Util.decode(s.rootKey()));
|
||||
u64le(out, s.createdAtMs());
|
||||
u64le(out, updatedAt);
|
||||
u32le(out, Integer.toUnsignedLong(s.recordNumber() + 1));
|
||||
out.write(prevHash);
|
||||
u64le(out, 0L); // additional_limit
|
||||
out.write(Base58Util.decode(s.clientKey()));
|
||||
out.write(Base58Util.decode(s.blockchainKey()));
|
||||
strU8(out, s.blockchainName());
|
||||
u64le(out, s.usedBytes());
|
||||
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
|
||||
out.write(unhex(s.lastBlockHash()));
|
||||
out.write(Base58Util.decode(s.lastBlockSignature()));
|
||||
strU8(out, s.arweaveTxId());
|
||||
out.write(s.isServer() ? 1 : 0);
|
||||
if (s.isServer()) {
|
||||
out.write(s.addressFormatType() & 0xff);
|
||||
out.write(s.addressFormatVersion() & 0xff);
|
||||
strU8(out, s.serverAddress());
|
||||
if (s.syncServers().size() > 255) throw new IOException("Слишком много sync_servers");
|
||||
out.write(s.syncServers().size());
|
||||
for (String v : s.syncServers()) strU8(out, v);
|
||||
}
|
||||
if (s.accessServers().size() > 255) throw new IOException("Слишком много access_servers");
|
||||
out.write(s.accessServers().size());
|
||||
for (String v : s.accessServers()) strU8(out, v);
|
||||
out.write(s.sessionsMode() & 0xff);
|
||||
if (s.sessions().size() > 255) throw new IOException("Слишком много sessions");
|
||||
out.write(s.sessions().size());
|
||||
for (ShineUsersCodec.UserSessionSnapshot session : s.sessions()) {
|
||||
out.write(session.sessionType() & 0xff);
|
||||
out.write(session.sessionVersion() & 0xff);
|
||||
strU8(out, session.sessionName());
|
||||
out.write(Base58Util.decode(session.sessionPubKey()));
|
||||
}
|
||||
out.write(s.trustedCount() & 0xff);
|
||||
// Archive extension к обычному update_user_pda: marker=1 + tx32 + hash32.
|
||||
out.write(1);
|
||||
out.write(archiveTx);
|
||||
out.write(archiveHash);
|
||||
out.write(rootSig);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildLastBlockState(UserPdaSnapshot s) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(LAST_BLOCK_STATE_PREFIX);
|
||||
strU8(out, s.login());
|
||||
strU8(out, s.blockchainName());
|
||||
u32le(out, Integer.toUnsignedLong(s.lastBlockNumber()));
|
||||
out.write(unhex(s.lastBlockHash()));
|
||||
u64le(out, s.usedBytes());
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private byte[] buildLegacyMessage(String userPda, byte[] updateData, byte[] rootEd, byte[] bchEd,
|
||||
String recentBlockhash, byte[] clientPub) throws IOException {
|
||||
String economy = SolanaPdaUtil.findProgramAddress(List.of(ECONOMY_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaUsersProgramId());
|
||||
String inflow = SolanaPdaUtil.findProgramAddress(List.of(INFLOW_SEED.getBytes(StandardCharsets.UTF_8)), cfg.solanaPaymentsProgramId());
|
||||
|
||||
List<Account> accounts = List.of(
|
||||
new Account(Base58Util.encode(clientPub), true, true),
|
||||
new Account(userPda, false, true),
|
||||
new Account(inflow, false, true),
|
||||
new Account(SYSTEM_PROGRAM, false, false),
|
||||
new Account(SYSVAR_INSTRUCTIONS, false, false),
|
||||
new Account(economy, false, false),
|
||||
new Account(COMPUTE_BUDGET_PROGRAM, false, false),
|
||||
new Account(ED25519_PROGRAM, false, false),
|
||||
new Account(cfg.solanaUsersProgramId(), false, false)
|
||||
);
|
||||
Map<String,Integer> ix = new HashMap<>();
|
||||
for (int i=0;i<accounts.size();i++) ix.put(accounts.get(i).pubkey, i);
|
||||
|
||||
List<CompiledInstruction> instructions = new ArrayList<>();
|
||||
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeHeapData(262_144)));
|
||||
instructions.add(new CompiledInstruction(ix.get(COMPUTE_BUDGET_PROGRAM), new int[0], computeLimitData(800_000)));
|
||||
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], rootEd));
|
||||
instructions.add(new CompiledInstruction(ix.get(ED25519_PROGRAM), new int[0], bchEd));
|
||||
instructions.add(new CompiledInstruction(ix.get(cfg.solanaUsersProgramId()), new int[]{
|
||||
ix.get(Base58Util.encode(clientPub)), ix.get(userPda), ix.get(SYSTEM_PROGRAM), ix.get(inflow),
|
||||
ix.get(SYSVAR_INSTRUCTIONS), ix.get(economy)
|
||||
}, updateData));
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(1); // numRequiredSignatures
|
||||
out.write(0); // numReadonlySignedAccounts
|
||||
out.write(6); // readonly unsigned: system, sysvar, economy, compute, ed25519, users
|
||||
shortVec(out, accounts.size());
|
||||
for (Account a : accounts) {
|
||||
byte[] pk = Base58Util.decode(a.pubkey);
|
||||
if (pk.length != 32) throw new IOException("Solana pubkey !=32: " + a.pubkey);
|
||||
out.write(pk);
|
||||
}
|
||||
byte[] bh = Base58Util.decode(recentBlockhash);
|
||||
if (bh.length != 32) throw new IOException("recentBlockhash !=32");
|
||||
out.write(bh);
|
||||
shortVec(out, instructions.size());
|
||||
for (CompiledInstruction ci : instructions) {
|
||||
out.write(ci.programIdIndex);
|
||||
shortVec(out, ci.accounts.length);
|
||||
for (int a : ci.accounts) out.write(a);
|
||||
shortVec(out, ci.data.length);
|
||||
out.write(ci.data);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private String getLatestBlockhash() throws Exception {
|
||||
JsonNode result = rpc("getLatestBlockhash", List.of(Map.of("commitment", cfg.solanaCommitment()))).path("result");
|
||||
String blockhash = result.path("value").path("blockhash").asText("");
|
||||
if (blockhash.isBlank()) throw new IOException("Solana getLatestBlockhash не вернул blockhash");
|
||||
return blockhash;
|
||||
}
|
||||
|
||||
private String sendTransaction(byte[] tx) throws Exception {
|
||||
String txB64 = Base64.getEncoder().encodeToString(tx);
|
||||
JsonNode result = rpc("sendTransaction", List.of(txB64, Map.of(
|
||||
"encoding", "base64",
|
||||
"skipPreflight", false,
|
||||
"preflightCommitment", "confirmed",
|
||||
"maxRetries", 5
|
||||
))).path("result");
|
||||
String sig = result.asText("");
|
||||
if (sig.isBlank()) throw new IOException("Solana sendTransaction не вернул signature");
|
||||
return sig;
|
||||
}
|
||||
|
||||
private JsonNode rpc(String method, List<?> params) throws Exception {
|
||||
Map<String,Object> body = new LinkedHashMap<>();
|
||||
body.put("jsonrpc", "2.0"); body.put("id", 1); body.put("method", method); body.put("params", params);
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(cfg.solanaRpcUrl()))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Solana RPC HTTP " + resp.statusCode() + ": " + safe(resp.body()));
|
||||
JsonNode root = JSON.readTree(resp.body());
|
||||
if (root.hasNonNull("error")) throw new IOException("Solana RPC " + method + " error: " + root.path("error"));
|
||||
return root;
|
||||
}
|
||||
|
||||
private static byte[] serializeTransaction(byte[] signature64, byte[] message) throws IOException {
|
||||
if (signature64.length != 64) throw new IOException("Solana signature !=64");
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
shortVec(out, 1);
|
||||
out.write(signature64);
|
||||
out.write(message);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] buildEd25519Instruction(byte[] signature64, byte[] publicKey32, byte[] message) {
|
||||
if (signature64.length != 64 || publicKey32.length != 32) throw new IllegalArgumentException("Некорректный Ed25519 ix key/signature");
|
||||
int sigOff=16, pkOff=sigOff+64, msgOff=pkOff+32;
|
||||
if (message.length > 0xffff) throw new IllegalArgumentException("Ed25519 message слишком длинный");
|
||||
byte[] data = new byte[msgOff + message.length];
|
||||
data[0]=1; data[1]=0;
|
||||
putU16le(data,2,sigOff); putU16le(data,4,0xffff);
|
||||
putU16le(data,6,pkOff); putU16le(data,8,0xffff);
|
||||
putU16le(data,10,msgOff); putU16le(data,12,message.length); putU16le(data,14,0xffff);
|
||||
System.arraycopy(signature64,0,data,sigOff,64);
|
||||
System.arraycopy(publicKey32,0,data,pkOff,32);
|
||||
System.arraycopy(message,0,data,msgOff,message.length);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static byte[] computeHeapData(int bytes) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(1); u32le(out, Integer.toUnsignedLong(bytes)); return out.toByteArray();
|
||||
}
|
||||
private static byte[] computeLimitData(int units) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(2); u32le(out, Integer.toUnsignedLong(units)); return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void shortVec(ByteArrayOutputStream out, int value) {
|
||||
int rem=value;
|
||||
while (true) {
|
||||
int elem=rem & 0x7f; rem >>>=7;
|
||||
if (rem==0) { out.write(elem); return; }
|
||||
out.write(elem | 0x80);
|
||||
}
|
||||
}
|
||||
private static void strU8(ByteArrayOutputStream out, String value) throws IOException {
|
||||
byte[] b=String.valueOf(value==null?"":value).getBytes(StandardCharsets.UTF_8);
|
||||
if (b.length>255) throw new IOException("Строка >255 bytes"); out.write(b.length); out.write(b);
|
||||
}
|
||||
private static void u32le(ByteArrayOutputStream out, long v) { for(int i=0;i<4;i++) out.write((int)(v >>> (8*i)) & 0xff); }
|
||||
private static void u64le(ByteArrayOutputStream out, long v) { for(int i=0;i<8;i++) out.write((int)(v >>> (8*i)) & 0xff); }
|
||||
private static void putU16le(byte[] d,int o,int v){d[o]=(byte)v;d[o+1]=(byte)(v>>>8);}
|
||||
private static int u16le(byte[] b,int o){return (b[o]&255)|((b[o+1]&255)<<8);}
|
||||
private static byte[] sha256(byte[] bytes) throws Exception { return MessageDigest.getInstance("SHA-256").digest(bytes); }
|
||||
private static String hex(byte[] b){StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
private static byte[] unhex(String s){String x=String.valueOf(s==null?"":s).trim(); if(x.length()!=64) throw new IllegalArgumentException("hex32 expected");byte[]o=new byte[32];for(int i=0;i<32;i++)o[i]=(byte)Integer.parseInt(x.substring(i*2,i*2+2),16);return o;}
|
||||
private static void require32(byte[] b,String n){if(b==null||b.length!=32)throw new IllegalArgumentException(n+" должен быть 32 bytes");}
|
||||
private static String safe(String s){String x=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return x.length()<=500?x:x.substring(0,500)+"...";}
|
||||
|
||||
private record Current(String userPda, UserPdaSnapshot snapshot) {}
|
||||
private record Account(String pubkey, boolean signer, boolean writable) {}
|
||||
private record CompiledInstruction(int programIdIndex, int[] accounts, byte[] data) {}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Base64;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class ArchiveFileNamesTest {
|
||||
private static final long CREATED_AT = Instant.parse("2026-09-11T00:00:00Z").toEpochMilli();
|
||||
private static final ZoneId UTC = ZoneId.of("UTC");
|
||||
|
||||
@Test
|
||||
void usesTemporaryAndFinalArweaveNames() {
|
||||
byte[] tx = new byte[32];
|
||||
for (int i = 0; i < tx.length; i++) tx[i] = (byte) i;
|
||||
String txText = Base64.getUrlEncoder().withoutPadding().encodeToString(tx);
|
||||
|
||||
assertEquals("archive01.00001.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive01", 1, CREATED_AT, UTC));
|
||||
assertEquals("archive01.00001.11.09.26." + txText + ".SHiNE-archive",
|
||||
ArchiveFileNames.uploadedFileName("archive01", 1, CREATED_AT, UTC, tx));
|
||||
assertEquals("archive01.100000.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive01", 100000, CREATED_AT, UTC));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizesLoginForFileSystemOnly() {
|
||||
assertEquals("archive_user.00002.11.09.26.tmp.SHiNE-archive",
|
||||
ArchiveFileNames.pendingFileName("archive/user", 2, CREATED_AT, UTC));
|
||||
}
|
||||
}
|
||||
-83
@@ -1,83 +0,0 @@
|
||||
package server.archive;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
import shine.db.entities.BlockEntry;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ShineArchiveWriterTest {
|
||||
@TempDir Path dir;
|
||||
|
||||
@Test
|
||||
void writesOneChunkWithAllRecordsAndOneBacklink() throws Exception {
|
||||
Path file = ArchiveFileNames.pendingFilePath(dir, "serverA", 2, 123456789L, ZoneId.of("UTC"));
|
||||
ArchiveBigBlockRef previous = new ArchiveBigBlockRef(1, new byte[32], new byte[32]);
|
||||
ArchivePublishJobChain chain = new ArchivePublishJobChain(
|
||||
1, "alice-001", 10, 11, new byte[32], new byte[32],
|
||||
1L, new byte[32], 100L, 55L, null, null);
|
||||
|
||||
ShineArchiveWriter.BuildResult result = new ShineArchiveWriter().build(
|
||||
file, "serverA", 2, 123456789L,
|
||||
List.of(previous), List.of(chain),
|
||||
Map.of("alice-001", List.of(block(10, new byte[]{1,2,3}), block(11, new byte[]{4,5}))),
|
||||
new byte[32], 4_000_000_000L);
|
||||
|
||||
assertArrayEquals(result.archiveHash(), ShineArchiveWriter.verifyLocalFile(file));
|
||||
assertEquals("serverA.00002.02.01.70.tmp.SHiNE-archive", file.getFileName().toString());
|
||||
|
||||
try (InputStream raw = java.nio.file.Files.newInputStream(file); DataInputStream in = new DataInputStream(raw)) {
|
||||
assertEquals("SHINE-ARCHIVE", new String(in.readNBytes(13), StandardCharsets.US_ASCII));
|
||||
assertEquals(1, in.readUnsignedByte());
|
||||
assertEquals(0, in.readUnsignedByte());
|
||||
long headerSize = Integer.toUnsignedLong(in.readInt());
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(123456789L, in.readLong());
|
||||
int creatorLen = in.readUnsignedByte();
|
||||
assertEquals(ShineArchiveWriter.REFERENCES_FULL, in.readUnsignedByte());
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(68, in.readUnsignedShort());
|
||||
assertEquals(0, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals("serverA", new String(in.readNBytes(creatorLen), StandardCharsets.UTF_8));
|
||||
assertEquals(headerSize, 51L + creatorLen);
|
||||
|
||||
assertEquals(1, Integer.toUnsignedLong(in.readInt()));
|
||||
in.skipNBytes(64); // previous hash + txid
|
||||
|
||||
long chunkSize = Integer.toUnsignedLong(in.readInt());
|
||||
int nameLen = in.readUnsignedByte();
|
||||
assertEquals("alice-001", new String(in.readNBytes(nameLen), StandardCharsets.UTF_8));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(3, Integer.toUnsignedLong(in.readInt()));
|
||||
assertArrayEquals(new byte[]{1,2,3}, in.readNBytes(3));
|
||||
assertEquals(2, Integer.toUnsignedLong(in.readInt()));
|
||||
assertArrayEquals(new byte[]{4,5}, in.readNBytes(2));
|
||||
assertEquals(0, Integer.toUnsignedLong(in.readInt())); // previous ref index
|
||||
assertEquals(100, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(55, Integer.toUnsignedLong(in.readInt()));
|
||||
assertEquals(4L + 1 + nameLen + 4 + (4+3) + (4+2) + 12, chunkSize);
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockEntry block(int number, byte[] bytes) {
|
||||
BlockEntry e = new BlockEntry();
|
||||
e.setBlockNumber(number);
|
||||
e.setBlockBytes(bytes);
|
||||
e.setBlockHash(new byte[32]);
|
||||
e.setBlockSignature(new byte[64]);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package blockchain;
|
||||
|
||||
import utils.crypto.Ed25519Util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Minimal ANS-104 DataItem parser/verifier used by SHiNE.
|
||||
*
|
||||
* SHiNE accepts only generic Ed25519 signature type 2:
|
||||
* uint16le signatureType
|
||||
* signature[64]
|
||||
* owner[32]
|
||||
* target_present[1] + target[32] when present
|
||||
* anchor_present[1] + anchor[32] when present
|
||||
* tags_count uint64le
|
||||
* tags_size uint64le
|
||||
* avro_tags[tags_size]
|
||||
* data[*]
|
||||
*
|
||||
* The signing message matches the deployed arbundles/Turbo DataItem implementation:
|
||||
* deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]).
|
||||
*/
|
||||
public final class Ans104DataItem {
|
||||
public static final int SIGNATURE_TYPE_ED25519 = 2;
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int OWNER_LEN = 32;
|
||||
public static final int OPTIONAL_FIELD_LEN = 32;
|
||||
public static final int ID_LEN = 32;
|
||||
public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024;
|
||||
public static final int MAX_TAGS_BYTES = 64 * 1024;
|
||||
public static final long MAX_TAGS_COUNT = 128;
|
||||
|
||||
public record Tag(String name, String value) {}
|
||||
|
||||
private final int signatureType;
|
||||
private final byte[] signature;
|
||||
private final byte[] owner;
|
||||
private final byte[] target;
|
||||
private final byte[] anchor;
|
||||
private final long tagsCount;
|
||||
private final byte[] rawTags;
|
||||
private final List<Tag> tags;
|
||||
private final byte[] data;
|
||||
private final byte[] rawBytes;
|
||||
private final byte[] id;
|
||||
|
||||
public Ans104DataItem(byte[] bytes) {
|
||||
Objects.requireNonNull(bytes, "bytes == null");
|
||||
if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) {
|
||||
throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length);
|
||||
}
|
||||
if (bytes.length > MAX_DATA_ITEM_BYTES) {
|
||||
throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
|
||||
this.signatureType = Short.toUnsignedInt(bb.getShort());
|
||||
if (signatureType != SIGNATURE_TYPE_ED25519) {
|
||||
throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)");
|
||||
}
|
||||
|
||||
this.signature = readExact(bb, SIGNATURE_LEN, "signature");
|
||||
this.owner = readExact(bb, OWNER_LEN, "owner");
|
||||
this.target = readOptional32(bb, "target");
|
||||
this.anchor = readOptional32(bb, "anchor");
|
||||
|
||||
this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count");
|
||||
long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size");
|
||||
if (tagsCount > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount);
|
||||
}
|
||||
if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) {
|
||||
throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining());
|
||||
}
|
||||
this.rawTags = readExact(bb, (int) tagsSizeLong, "tags");
|
||||
this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount));
|
||||
this.data = new byte[bb.remaining()];
|
||||
bb.get(this.data);
|
||||
this.rawBytes = Arrays.copyOf(bytes, bytes.length);
|
||||
this.id = sha256(signature);
|
||||
}
|
||||
|
||||
public int signatureType() { return signatureType; }
|
||||
public byte[] signature64() { return Arrays.copyOf(signature, signature.length); }
|
||||
public byte[] owner32() { return Arrays.copyOf(owner, owner.length); }
|
||||
public byte[] target() { return Arrays.copyOf(target, target.length); }
|
||||
public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); }
|
||||
public long tagsCount() { return tagsCount; }
|
||||
public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); }
|
||||
public List<Tag> tags() { return tags; }
|
||||
public byte[] data() { return Arrays.copyOf(data, data.length); }
|
||||
public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); }
|
||||
public byte[] id32() { return Arrays.copyOf(id, id.length); }
|
||||
|
||||
public String tagValue(String name) {
|
||||
if (name == null) return null;
|
||||
for (Tag tag : tags) {
|
||||
if (name.equals(tag.name())) return tag.value();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean hasTag(String name, String value) {
|
||||
for (Tag tag : tags) {
|
||||
if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean verifySignature() {
|
||||
return Ed25519Util.verify(signingMessage(), signature, owner);
|
||||
}
|
||||
|
||||
public boolean ownerEquals(byte[] expectedOwner32) {
|
||||
return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32);
|
||||
}
|
||||
|
||||
public byte[] signingMessage() {
|
||||
return buildSigningMessage(owner, target, anchor, tags, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the exact ANS-104 signing message for generic Ed25519 DataItems.
|
||||
* Useful for SHiNE clients/tests that must sign a DataItem before serializing it.
|
||||
*/
|
||||
public static byte[] buildSigningMessage(byte[] owner32, List<Tag> tags, byte[] data) {
|
||||
return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data);
|
||||
}
|
||||
|
||||
public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List<Tag> tags, byte[] data) {
|
||||
requireLen(owner32, OWNER_LEN, "owner");
|
||||
byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length);
|
||||
byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length);
|
||||
if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) {
|
||||
throw new IllegalArgumentException("target must be empty or 32 bytes");
|
||||
}
|
||||
if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) {
|
||||
throw new IllegalArgumentException("anchor must be empty or 32 bytes");
|
||||
}
|
||||
List<Tag> safeTags = validateTags(tags);
|
||||
byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length);
|
||||
|
||||
byte[] rawTags = serializeAvroTags(safeTags);
|
||||
return deepHash(List.of(
|
||||
utf8("dataitem"),
|
||||
utf8("1"),
|
||||
utf8(Integer.toString(SIGNATURE_TYPE_ED25519)),
|
||||
Arrays.copyOf(owner32, owner32.length),
|
||||
safeTarget,
|
||||
safeAnchor,
|
||||
rawTags,
|
||||
safeData
|
||||
));
|
||||
}
|
||||
|
||||
/** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */
|
||||
public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List<Tag> tags, byte[] data) {
|
||||
requireLen(signature64, SIGNATURE_LEN, "signature");
|
||||
requireLen(owner32, OWNER_LEN, "owner");
|
||||
List<Tag> safeTags = validateTags(tags);
|
||||
byte[] rawTags = serializeAvroTags(safeTags);
|
||||
byte[] safeData = data == null ? new byte[0] : data;
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(
|
||||
2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length);
|
||||
writeU16LE(out, SIGNATURE_TYPE_ED25519);
|
||||
out.writeBytes(signature64);
|
||||
out.writeBytes(owner32);
|
||||
out.write(0); // target absent
|
||||
out.write(0); // anchor absent
|
||||
writeU64LE(out, safeTags.size());
|
||||
writeU64LE(out, rawTags.length);
|
||||
out.writeBytes(rawTags);
|
||||
out.writeBytes(safeData);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] readOptional32(ByteBuffer bb, String name) {
|
||||
if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte");
|
||||
int present = Byte.toUnsignedInt(bb.get());
|
||||
if (present == 0) return new byte[0];
|
||||
if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present);
|
||||
return readExact(bb, OPTIONAL_FIELD_LEN, name);
|
||||
}
|
||||
|
||||
private static byte[] readExact(ByteBuffer bb, int len, String field) {
|
||||
if (len < 0 || bb.remaining() < len) {
|
||||
throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining());
|
||||
}
|
||||
byte[] out = new byte[len];
|
||||
bb.get(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) {
|
||||
if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field);
|
||||
long v = bb.getLong();
|
||||
if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range");
|
||||
return v;
|
||||
}
|
||||
|
||||
private static List<Tag> validateTags(List<Tag> tags) {
|
||||
List<Tag> safe = tags == null ? List.of() : List.copyOf(tags);
|
||||
if (safe.size() > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size());
|
||||
}
|
||||
for (Tag tag : safe) {
|
||||
if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) {
|
||||
throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||
}
|
||||
if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||
if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
private static byte[] serializeAvroTags(List<Tag> tags) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
if (!tags.isEmpty()) {
|
||||
writeAvroLong(out, tags.size());
|
||||
for (Tag tag : tags) {
|
||||
writeAvroString(out, tag.name());
|
||||
writeAvroString(out, tag.value());
|
||||
}
|
||||
}
|
||||
writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array
|
||||
byte[] raw = out.toByteArray();
|
||||
if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes");
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static void writeAvroString(ByteArrayOutputStream out, String value) {
|
||||
byte[] bytes = utf8(value);
|
||||
writeAvroLong(out, bytes.length);
|
||||
out.writeBytes(bytes);
|
||||
}
|
||||
|
||||
private static void writeAvroLong(ByteArrayOutputStream out, long value) {
|
||||
long n = (value << 1) ^ (value >> 63);
|
||||
while ((n & ~0x7fL) != 0) {
|
||||
out.write((int) ((n & 0x7f) | 0x80));
|
||||
n >>>= 7;
|
||||
}
|
||||
out.write((int) n);
|
||||
}
|
||||
|
||||
private static void writeU16LE(ByteArrayOutputStream out, int value) {
|
||||
out.write(value & 0xff);
|
||||
out.write((value >>> 8) & 0xff);
|
||||
}
|
||||
|
||||
private static void writeU64LE(ByteArrayOutputStream out, long value) {
|
||||
for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff));
|
||||
}
|
||||
|
||||
private static void requireLen(byte[] bytes, int expected, String name) {
|
||||
if (bytes == null || bytes.length != expected) {
|
||||
throw new IllegalArgumentException(name + " must be " + expected + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Tag> parseAvroTags(byte[] raw, long declaredCount) {
|
||||
AvroCursor c = new AvroCursor(raw);
|
||||
List<Tag> out = new ArrayList<>();
|
||||
while (!c.eof()) {
|
||||
long count = c.readLong();
|
||||
if (count == 0) break;
|
||||
if (count < 0) {
|
||||
count = -count;
|
||||
long blockBytes = c.readLong();
|
||||
if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size");
|
||||
}
|
||||
if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) {
|
||||
throw new IllegalArgumentException("Too many Avro tags");
|
||||
}
|
||||
for (long i = 0; i < count; i++) {
|
||||
String name = c.readString();
|
||||
String value = c.readString();
|
||||
if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty");
|
||||
if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes");
|
||||
if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes");
|
||||
out.add(new Tag(name, value));
|
||||
}
|
||||
}
|
||||
if (!c.eof()) {
|
||||
throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags");
|
||||
}
|
||||
if (out.size() != declaredCount) {
|
||||
throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static final class AvroCursor {
|
||||
private final byte[] data;
|
||||
private int p;
|
||||
private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; }
|
||||
boolean eof() { return p == data.length; }
|
||||
long readLong() {
|
||||
long raw = 0;
|
||||
int shift = 0;
|
||||
while (true) {
|
||||
if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long");
|
||||
int b = data[p++] & 0xff;
|
||||
raw |= (long) (b & 0x7f) << shift;
|
||||
if ((b & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
if (shift > 63) throw new IllegalArgumentException("Avro long too large");
|
||||
}
|
||||
return (raw >>> 1) ^ -(raw & 1L);
|
||||
}
|
||||
String readString() {
|
||||
long len = readLong();
|
||||
if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) {
|
||||
throw new IllegalArgumentException("Bad Avro string length: " + len);
|
||||
}
|
||||
String s = new String(data, p, (int) len, StandardCharsets.UTF_8);
|
||||
p += (int) len;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] deepHash(Object item) {
|
||||
if (item instanceof byte[] bytes) {
|
||||
return deepHashBlob(bytes);
|
||||
}
|
||||
if (item instanceof List<?> items) {
|
||||
byte[] acc = sha384(utf8("list" + items.size()));
|
||||
for (Object child : items) {
|
||||
acc = sha384(concat(acc, deepHash(child)));
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass()));
|
||||
}
|
||||
|
||||
private static byte[] deepHashBlob(byte[] bytes) {
|
||||
byte[] tag = utf8("blob" + bytes.length);
|
||||
return sha384(concat(sha384(tag), sha384(bytes)));
|
||||
}
|
||||
|
||||
private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); }
|
||||
private static byte[] sha256(byte[] b) { return digest("SHA-256", b); }
|
||||
private static byte[] sha384(byte[] b) { return digest("SHA-384", b); }
|
||||
private static byte[] digest(String alg, byte[] b) {
|
||||
try { return MessageDigest.getInstance(alg).digest(b); }
|
||||
catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); }
|
||||
}
|
||||
private static byte[] concat(byte[] a, byte[] b) {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length);
|
||||
out.writeBytes(a); out.writeBytes(b); return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -9,364 +9,116 @@ import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* BchBlockEntry — универсальный блок формата SHiNE (Frame v0).
|
||||
* SHiNE user block, Frame v1, carried as the data payload of an ANS-104 DataItem.
|
||||
*
|
||||
* =========================================================================
|
||||
* FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА)
|
||||
* =========================================================================
|
||||
* The constructor accepts the complete serialized ANS-104 DataItem because this is
|
||||
* exactly what is stored in PostgreSQL and exchanged between servers.
|
||||
*
|
||||
* Все числа BigEndian.
|
||||
* SHiNE Frame v1 (all integer fields BigEndian):
|
||||
* [2] frameCode = 0x0001
|
||||
* [32] prevHash32 = SHA-256(Frame v1 bytes of previous SHiNE block)
|
||||
* [4] blockSize = exact Frame v1 byte length
|
||||
* [4] blockNumber
|
||||
* [8] timestamp unix seconds
|
||||
* [2] type
|
||||
* [2] subType
|
||||
* [2] version
|
||||
* [N] bodyBytes
|
||||
*
|
||||
* PREIMAGE (входит в blockSize, подписывается):
|
||||
* [2] frameCode (uint16) код/версия рамки:
|
||||
* - 0x0000 = Frame v0 (текущий)
|
||||
* [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка)
|
||||
* [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode,
|
||||
* НО БЕЗ sigMarker и БЕЗ signature64
|
||||
* [4] blockNumber (int32) глобальный номер блока (>=0)
|
||||
* [8] timestamp (int64) unix seconds
|
||||
* [2] type (uint16) тип сообщения
|
||||
* [2] subType (uint16) подтип сообщения
|
||||
* [2] version (uint16) версия формата сообщения
|
||||
* [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version)
|
||||
*
|
||||
* TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0):
|
||||
* [2] sigMarker (uint16) маркер подписи:
|
||||
* - 0x0100 (256) = далее подпись Ed25519 64 байта
|
||||
* [64] signature64 (bytes) Ed25519 signature над hash32
|
||||
*
|
||||
* hash32 НЕ хранится в блоке.
|
||||
* hash32 вычисляется при парсинге:
|
||||
* preimage = первые blockSize байт
|
||||
* hash32 = SHA-256(preimage)
|
||||
*
|
||||
* Правила MVP-парсера (Frame v0):
|
||||
* - frameCode должен быть строго 0x0000, иначе REJECT.
|
||||
* - sigMarker должен быть строго 0x0100, иначе REJECT.
|
||||
* - подпись обязана присутствовать всегда (sigMarker+signature64).
|
||||
* - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”.
|
||||
*
|
||||
* Важно по безопасности:
|
||||
* - sigMarker в v0 не входит в подписываемые байты → его можно подменить,
|
||||
* поэтому единственная безопасная логика: "если не 0x0100 — reject".
|
||||
* =========================================================================
|
||||
* The user signature is NOT stored in this frame. It is the single Ed25519/Solana
|
||||
* signature of the surrounding ANS-104 DataItem.
|
||||
*/
|
||||
public final class BchBlockEntry {
|
||||
|
||||
public static final int SIGNATURE_LEN = 64;
|
||||
public static final int HASH_LEN = 32;
|
||||
|
||||
public static final int FRAME_CODE_LEN = 2;
|
||||
public static final int SIG_MARKER_LEN = 2;
|
||||
|
||||
/** Frame v0 */
|
||||
public static final int FRAME_CODE_V0 = 0x0000;
|
||||
|
||||
/** sigMarker: 256 = 0x0100 */
|
||||
public static final int SIG_MARKER_ED25519 = 0x0100;
|
||||
|
||||
/**
|
||||
* Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature),
|
||||
* чтобы не уложить сервер по памяти/диску.
|
||||
*/
|
||||
public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов).
|
||||
* Если timestamp больше now + 60 сек — блок считаем неверным.
|
||||
*/
|
||||
public static final int FRAME_CODE_V1 = 0x0001;
|
||||
public static final int MAX_BLOCK_FULL_BYTES = Ans104DataItem.MAX_DATA_ITEM_BYTES;
|
||||
public static final long MAX_FUTURE_SECONDS = 60;
|
||||
public static final int FRAME_HEADER_SIZE = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2;
|
||||
|
||||
/**
|
||||
* Размер фиксированной части PREIMAGE (без bodyBytes).
|
||||
*
|
||||
* PREIMAGE header:
|
||||
* frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8)
|
||||
* + type(2) + subType(2) + version(2)
|
||||
*/
|
||||
public static final int PREIMAGE_HEADER_SIZE =
|
||||
2 // frameCode
|
||||
+ 32 // prevHash32
|
||||
+ 4 // blockSize
|
||||
+ 4 // blockNumber
|
||||
+ 8 // timestamp
|
||||
+ 2 // type
|
||||
+ 2 // subType
|
||||
+ 2; // version
|
||||
|
||||
/** Минимальный полный размер блока (без bodyBytes). */
|
||||
public static final int MIN_FULL_BYTES =
|
||||
PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
|
||||
// --- HEADER (PREIMAGE) ---
|
||||
public final int frameCode; // uint16 (v0=0)
|
||||
public final byte[] prevHash32; // 32
|
||||
public final int blockSize; // preimage size (включая frameCode)
|
||||
public final int blockNumber; // >=0
|
||||
public final int frameCode;
|
||||
public final byte[] prevHash32;
|
||||
public final int blockSize;
|
||||
public final int blockNumber;
|
||||
public final long timestamp;
|
||||
public final short type;
|
||||
public final short subType;
|
||||
public final short version;
|
||||
|
||||
// --- BODY (PREIMAGE) ---
|
||||
public final byte[] bodyBytes;
|
||||
|
||||
/** Распарсенное тело (создаётся сразу при парсинге блока). */
|
||||
public final BodyRecord body;
|
||||
|
||||
// --- TAIL ---
|
||||
public final int sigMarker; // uint16 (v0: 0x0100)
|
||||
private final byte[] signature64; // 64
|
||||
private final Ans104DataItem dataItem;
|
||||
private final byte[] frameBytes;
|
||||
private final byte[] hash32;
|
||||
|
||||
// --- derived ---
|
||||
private final byte[] hash32; // 32, computed
|
||||
private final byte[] preimage; // blockSize bytes
|
||||
private final byte[] fullBytes; // preimage + sigMarker + signature
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор из байт ========================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
public BchBlockEntry(byte[] fullBytes) {
|
||||
Objects.requireNonNull(fullBytes, "fullBytes == null");
|
||||
|
||||
if (fullBytes.length < MIN_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES);
|
||||
}
|
||||
if (fullBytes.length > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
/** Parse a complete serialized ANS-104 DataItem containing one SHiNE Frame v1. */
|
||||
public BchBlockEntry(byte[] fullDataItemBytes) {
|
||||
Objects.requireNonNull(fullDataItemBytes, "fullDataItemBytes == null");
|
||||
this.dataItem = new Ans104DataItem(fullDataItemBytes);
|
||||
this.frameBytes = dataItem.data();
|
||||
if (frameBytes.length < FRAME_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("SHiNE Frame v1 too short: " + frameBytes.length);
|
||||
}
|
||||
|
||||
ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
|
||||
// [2] frameCode
|
||||
ByteBuffer bb = ByteBuffer.wrap(frameBytes).order(ByteOrder.BIG_ENDIAN);
|
||||
this.frameCode = Short.toUnsignedInt(bb.getShort());
|
||||
if (this.frameCode != FRAME_CODE_V0) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0
|
||||
));
|
||||
if (frameCode != FRAME_CODE_V1) {
|
||||
throw new IllegalArgumentException(String.format("Bad SHiNE frameCode: 0x%04X (expected 0x%04X)", frameCode, FRAME_CODE_V1));
|
||||
}
|
||||
|
||||
// [32] prevHash32
|
||||
this.prevHash32 = new byte[32];
|
||||
bb.get(this.prevHash32);
|
||||
|
||||
// [4] blockSize
|
||||
bb.get(prevHash32);
|
||||
this.blockSize = bb.getInt();
|
||||
if (blockSize < PREIMAGE_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE);
|
||||
if (blockSize != frameBytes.length || blockSize < FRAME_HEADER_SIZE) {
|
||||
throw new IllegalArgumentException("SHiNE blockSize mismatch: declared=" + blockSize + " actual=" + frameBytes.length);
|
||||
}
|
||||
|
||||
// fullLen must match exactly: blockSize + sigMarker(2) + signature(64)
|
||||
int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
if (expectedFullLen != fullBytes.length) {
|
||||
throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize
|
||||
+ " expectedFullLen=" + expectedFullLen
|
||||
+ " fullLen=" + fullBytes.length);
|
||||
}
|
||||
if (expectedFullLen > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
}
|
||||
|
||||
// [4] blockNumber
|
||||
this.blockNumber = bb.getInt();
|
||||
if (this.blockNumber < 0) {
|
||||
throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber);
|
||||
}
|
||||
|
||||
// [8] timestamp
|
||||
if (blockNumber < 0) throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
|
||||
this.timestamp = bb.getLong();
|
||||
|
||||
// запрет “в будущее” больше чем на 1 минуту
|
||||
long now = Instant.now().getEpochSecond();
|
||||
if (this.timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp
|
||||
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
|
||||
if (timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now);
|
||||
}
|
||||
|
||||
// [2][2][2] type/subType/version
|
||||
this.type = bb.getShort();
|
||||
this.subType = bb.getShort();
|
||||
this.version = bb.getShort();
|
||||
|
||||
// [N] bodyBytes
|
||||
int bodyLen = blockSize - PREIMAGE_HEADER_SIZE;
|
||||
if (bodyLen < 0) {
|
||||
throw new IllegalArgumentException("Invalid body length: " + bodyLen);
|
||||
}
|
||||
this.bodyBytes = new byte[bodyLen];
|
||||
bb.get(this.bodyBytes);
|
||||
|
||||
// TAIL: [2] sigMarker
|
||||
this.sigMarker = Short.toUnsignedInt(bb.getShort());
|
||||
if (this.sigMarker != SIG_MARKER_ED25519) {
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519
|
||||
));
|
||||
}
|
||||
|
||||
// TAIL: [64] signature64
|
||||
this.signature64 = new byte[SIGNATURE_LEN];
|
||||
bb.get(this.signature64);
|
||||
|
||||
// preimage = первые blockSize байт (включая frameCode)
|
||||
this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize);
|
||||
|
||||
// hash32 = sha256(preimage)
|
||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
||||
|
||||
// parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check()
|
||||
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
|
||||
|
||||
this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length);
|
||||
|
||||
if (bb.remaining() != 0) {
|
||||
throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||
}
|
||||
this.bodyBytes = new byte[bb.remaining()];
|
||||
bb.get(bodyBytes);
|
||||
this.body = BodyRecordParser.parse(type, subType, version, bodyBytes);
|
||||
this.hash32 = BchCryptoVerifier.sha256(frameBytes);
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Конструктор сборки ============================ */
|
||||
/* ===================================================================== */
|
||||
|
||||
public BchBlockEntry(byte[] prevHash32,
|
||||
int blockNumber,
|
||||
long timestamp,
|
||||
short type,
|
||||
short subType,
|
||||
short version,
|
||||
byte[] bodyBytes,
|
||||
byte[] signature64) {
|
||||
|
||||
Objects.requireNonNull(prevHash32, "prevHash32 == null");
|
||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||
Objects.requireNonNull(signature64, "signature64 == null");
|
||||
|
||||
if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32");
|
||||
if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64");
|
||||
|
||||
if (blockNumber < 0) {
|
||||
throw new IllegalArgumentException("blockNumber < 0: " + blockNumber);
|
||||
}
|
||||
|
||||
// запрет “в будущее” больше чем на 1 минуту
|
||||
long now = Instant.now().getEpochSecond();
|
||||
if (timestamp > now + MAX_FUTURE_SECONDS) {
|
||||
throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp
|
||||
+ " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS);
|
||||
}
|
||||
|
||||
this.frameCode = FRAME_CODE_V0;
|
||||
this.prevHash32 = Arrays.copyOf(prevHash32, 32);
|
||||
this.blockNumber = blockNumber;
|
||||
this.timestamp = timestamp;
|
||||
this.type = type;
|
||||
this.subType = subType;
|
||||
this.version = version;
|
||||
this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length);
|
||||
|
||||
// blockSize = размер preimage (включая frameCode)
|
||||
this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length;
|
||||
|
||||
int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN;
|
||||
if (fullLen > MAX_BLOCK_FULL_BYTES) {
|
||||
throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES);
|
||||
}
|
||||
|
||||
// parse body по header + ОБЯЗАТЕЛЬНЫЙ check()
|
||||
this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes);
|
||||
|
||||
// tail marker фиксирован
|
||||
this.sigMarker = SIG_MARKER_ED25519;
|
||||
this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
|
||||
// build preimage
|
||||
ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN);
|
||||
pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF));
|
||||
pre.put(this.prevHash32);
|
||||
pre.putInt(this.blockSize);
|
||||
pre.putInt(this.blockNumber);
|
||||
pre.putLong(this.timestamp);
|
||||
pre.putShort(this.type);
|
||||
pre.putShort(this.subType);
|
||||
pre.putShort(this.version);
|
||||
pre.put(this.bodyBytes);
|
||||
|
||||
this.preimage = pre.array();
|
||||
this.hash32 = BchCryptoVerifier.sha256(preimage);
|
||||
|
||||
// build fullBytes: preimage + sigMarker + signature64
|
||||
ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN);
|
||||
full.put(this.preimage);
|
||||
full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF));
|
||||
full.put(this.signature64);
|
||||
this.fullBytes = full.array();
|
||||
}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ============================ Getters ================================= */
|
||||
/* ===================================================================== */
|
||||
|
||||
public byte[] getPreimageBytes() {
|
||||
return Arrays.copyOf(preimage, preimage.length);
|
||||
}
|
||||
|
||||
/** Возвращает подпись Ed25519 (64 байта). */
|
||||
public byte[] getSignature64() {
|
||||
return Arrays.copyOf(signature64, SIGNATURE_LEN);
|
||||
}
|
||||
|
||||
/** Возвращает hash32 = SHA-256(preimage). */
|
||||
public byte[] getHash32() {
|
||||
return Arrays.copyOf(hash32, HASH_LEN);
|
||||
}
|
||||
|
||||
/** Возвращает полный блок: preimage + sigMarker + signature. */
|
||||
public byte[] toBytes() {
|
||||
return Arrays.copyOf(fullBytes, fullBytes.length);
|
||||
}
|
||||
public Ans104DataItem getDataItem() { return dataItem; }
|
||||
public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); }
|
||||
/** Kept as an alias for code/docs that called the old field preimage. */
|
||||
public byte[] getPreimageBytes() { return getFrameBytes(); }
|
||||
public byte[] getSignature64() { return dataItem.signature64(); }
|
||||
public byte[] getOwner32() { return dataItem.owner32(); }
|
||||
public byte[] getDataItemId32() { return dataItem.id32(); }
|
||||
public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); }
|
||||
/** Full serialized ANS-104 DataItem. */
|
||||
public byte[] toBytes() { return dataItem.rawBytes(); }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
String timeIso;
|
||||
try {
|
||||
timeIso = Instant.ofEpochSecond(timestamp).toString();
|
||||
} catch (Exception e) {
|
||||
timeIso = "некорректныйTimestamp";
|
||||
}
|
||||
|
||||
return "BchBlockEntry{"
|
||||
+ "FRAME{frameCode=0x" + hex4(frameCode)
|
||||
+ "}, HDR{"
|
||||
+ "blockSize=" + blockSize
|
||||
+ ", blockNumber=" + blockNumber
|
||||
+ ", timestamp=" + timestamp + " (" + timeIso + ")"
|
||||
+ ", type=" + (type & 0xFFFF)
|
||||
+ ", subType=" + (subType & 0xFFFF)
|
||||
+ ", version=" + (version & 0xFFFF)
|
||||
+ ", prevHash32(hex)=" + toHex(prevHash32)
|
||||
+ "}"
|
||||
+ ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}"
|
||||
+ ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}"
|
||||
+ ", DERIVED{hash32(hex)=" + toHex(hash32) + "}"
|
||||
+ "}";
|
||||
}
|
||||
|
||||
private static String hex4(int v) {
|
||||
String s = Integer.toHexString(v & 0xFFFF);
|
||||
while (s.length() < 4) s = "0" + s;
|
||||
return s;
|
||||
return "BchBlockEntry{" +
|
||||
"frameCode=0x" + Integer.toHexString(frameCode) +
|
||||
", blockNumber=" + blockNumber +
|
||||
", timestamp=" + timestamp +
|
||||
", type=" + (type & 0xffff) +
|
||||
", subType=" + (subType & 0xffff) +
|
||||
", version=" + (version & 0xffff) +
|
||||
", bodyLen=" + bodyBytes.length +
|
||||
", dataItemId=" + toHex(getDataItemId32()) +
|
||||
", blockHash=" + toHex(hash32) +
|
||||
'}';
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
if (bytes == null) return "null";
|
||||
char[] HEX = "0123456789abcdef".toCharArray();
|
||||
char[] h = "0123456789abcdef".toCharArray();
|
||||
char[] out = new char[bytes.length * 2];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
int vv = bytes[i] & 0xFF;
|
||||
out[i * 2] = HEX[vv >>> 4];
|
||||
out[i * 2 + 1] = HEX[vv & 0x0F];
|
||||
int v = bytes[i] & 0xff;
|
||||
out[i * 2] = h[v >>> 4]; out[i * 2 + 1] = h[v & 15];
|
||||
}
|
||||
return new String(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# utils.crypto
|
||||
|
||||
Пакет отвечает за криптографию — подписи и хэши блоков.
|
||||
Используется при создании и проверке целостности `.bch`-блоков.
|
||||
Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ public final class DatabaseInitializer {
|
||||
public static final int SCHEMA_VERSION_21 = 21;
|
||||
public static final int SCHEMA_VERSION_22 = 22;
|
||||
public static final int SCHEMA_VERSION_23 = 23;
|
||||
public static final int SCHEMA_VERSION_24 = 24;
|
||||
public static final int SCHEMA_VERSION_25 = 25;
|
||||
public static final int SCHEMA_VERSION_26 = 26;
|
||||
public static final int SCHEMA_VERSION_27 = 27;
|
||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||
@@ -63,6 +67,10 @@ public final class DatabaseInitializer {
|
||||
public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql";
|
||||
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql";
|
||||
public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql";
|
||||
public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql";
|
||||
public static final String POSTGRES_MIGRATION_V26_RESOURCE = "postgres/migration_v26.sql";
|
||||
public static final String POSTGRES_MIGRATION_V27_RESOURCE = "postgres/migration_v27.sql";
|
||||
|
||||
private DatabaseInitializer() {}
|
||||
|
||||
@@ -224,6 +232,22 @@ public final class DatabaseInitializer {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_23;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_24) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_24;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_25) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_25;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_26) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V26_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_26;
|
||||
}
|
||||
if (currentVersion < SCHEMA_VERSION_27) {
|
||||
runSqlScript(conn, POSTGRES_MIGRATION_V27_RESOURCE);
|
||||
currentVersion = SCHEMA_VERSION_27;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,46 @@ public final class KeyEncodingUtil {
|
||||
|
||||
private KeyEncodingUtil() {}
|
||||
|
||||
public static String encodeBase58(byte[] input) {
|
||||
if (input == null || input.length == 0) {
|
||||
return "";
|
||||
}
|
||||
byte[] copy = java.util.Arrays.copyOf(input, input.length);
|
||||
int zeros = 0;
|
||||
while (zeros < copy.length && copy[zeros] == 0) {
|
||||
zeros++;
|
||||
}
|
||||
byte[] encoded = new byte[copy.length * 2];
|
||||
int outputStart = encoded.length;
|
||||
int inputStart = zeros;
|
||||
while (inputStart < copy.length) {
|
||||
int remainder = divmod58(copy, inputStart);
|
||||
if (copy[inputStart] == 0) {
|
||||
inputStart++;
|
||||
}
|
||||
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(remainder);
|
||||
}
|
||||
while (outputStart < encoded.length && encoded[outputStart] == (byte) BASE58_ALPHABET.charAt(0)) {
|
||||
outputStart++;
|
||||
}
|
||||
while (--zeros >= 0) {
|
||||
encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(0);
|
||||
}
|
||||
return new String(encoded, outputStart, encoded.length - outputStart, java.nio.charset.StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
public static String base58KeyToBase64_32(String rawKey) {
|
||||
if (rawKey == null) {
|
||||
return null;
|
||||
}
|
||||
String value = rawKey.trim();
|
||||
if (value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
byte[] decoded = tryDecodeBase58_32(value);
|
||||
return decoded == null ? value : Base64.getEncoder().encodeToString(decoded);
|
||||
}
|
||||
|
||||
public static String normalizeKeyToBase64_32(String rawKey) {
|
||||
if (rawKey == null) {
|
||||
return null;
|
||||
@@ -102,6 +142,17 @@ public final class KeyEncodingUtil {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int divmod58(byte[] number, int startAt) {
|
||||
int remainder = 0;
|
||||
for (int i = startAt; i < number.length; i++) {
|
||||
int digit256 = number[i] & 0xFF;
|
||||
int temp = remainder * 256 + digit256;
|
||||
number[i] = (byte) (temp / 58);
|
||||
remainder = temp % 58;
|
||||
}
|
||||
return remainder;
|
||||
}
|
||||
|
||||
private static int divmod256(byte[] number58, int startAt) {
|
||||
int remainder = 0;
|
||||
for (int i = startAt; i < number58.length; i++) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Ссылка на уже успешно опубликованный большой архивный блок. */
|
||||
public record ArchiveBigBlockRef(
|
||||
long bigBlockNumber,
|
||||
byte[] archiveHash,
|
||||
byte[] arweaveTxId
|
||||
) {}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Последний известный архивный chunk конкретной blockchain на этом сервере. */
|
||||
public record ArchiveBlockchainLocation(
|
||||
String blockchainName,
|
||||
String publisherLogin,
|
||||
String arweaveTxId,
|
||||
byte[] archiveHash,
|
||||
long bigBlockNumber,
|
||||
long chunkOffset,
|
||||
long chunkSize,
|
||||
long sourceLastBlockNumber,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
@@ -1,15 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/**
|
||||
* Последнее окончательно опубликованное архивное положение одной SHiNE blockchain.
|
||||
*/
|
||||
public record ArchiveChainCursor(
|
||||
String blockchainName,
|
||||
long lastArchivedSourceBlockNumber,
|
||||
byte[] lastArchivedSourceBlockHash,
|
||||
long lastArchiveBigBlockNumber,
|
||||
byte[] lastArchiveBigBlockHash,
|
||||
long lastChunkOffset,
|
||||
long lastChunkSize,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
@@ -1,16 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Crash-safe состояние публикации одного большого SHINE-ARCHIVE блока. */
|
||||
public record ArchivePublishJob(
|
||||
long id,
|
||||
long bigBlockNumber,
|
||||
String status,
|
||||
long createdAtMs,
|
||||
String localArchivePath,
|
||||
byte[] archiveHash,
|
||||
byte[] arweaveTxId,
|
||||
Integer arweaveConfirmations,
|
||||
String solanaSignature,
|
||||
String errorText,
|
||||
long updatedAtMs
|
||||
) {}
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/** Замороженный диапазон одной blockchain внутри конкретной архивной публикации. */
|
||||
public record ArchivePublishJobChain(
|
||||
long jobId,
|
||||
String blockchainName,
|
||||
long fromSourceBlockNumber,
|
||||
long toSourceBlockNumber,
|
||||
byte[] previousSourceBlockHash,
|
||||
byte[] lastSourceBlockHash,
|
||||
Long previousArchiveBigBlockNumber,
|
||||
byte[] previousArchiveBigBlockHash,
|
||||
Long previousChunkOffset,
|
||||
Long previousChunkSize,
|
||||
Long newChunkOffset,
|
||||
Long newChunkSize
|
||||
) {}
|
||||
@@ -1,14 +0,0 @@
|
||||
package shine.db.archive;
|
||||
|
||||
/**
|
||||
* Текущая archive head trusted publisher-а из локальной таблицы,
|
||||
* которую уже заполнил обычный Solana users sync.
|
||||
*/
|
||||
public record ArchivePublisherHead(
|
||||
String login,
|
||||
String rootKey,
|
||||
String archiveHeadTxId,
|
||||
String archiveHeadHash,
|
||||
boolean archiveImported,
|
||||
String lastImportedArchiveTxId
|
||||
) {}
|
||||
@@ -1,150 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.archive.ArchiveBlockchainLocation;
|
||||
import shine.db.archive.ArchivePublisherHead;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
/** БД индекс последних archive chunks и локальное состояние импорта archive head. */
|
||||
public final class ArchiveImportDAO {
|
||||
private static volatile ArchiveImportDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArchiveImportDAO() {}
|
||||
|
||||
public static ArchiveImportDAO getInstance() {
|
||||
if (instance == null) synchronized (ArchiveImportDAO.class) {
|
||||
if (instance == null) instance = new ArchiveImportDAO();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает только доверенных publisher-ов, для которых обычный Solana users sync
|
||||
* уже увидел archive head, но этот head ещё не был полностью импортирован локально.
|
||||
*/
|
||||
public List<ArchivePublisherHead> listPendingPublisherHeads(Set<String> approvedLogins) throws SQLException {
|
||||
if (approvedLogins == null || approvedLogins.isEmpty()) return List.of();
|
||||
List<ArchivePublisherHead> out = new ArrayList<>();
|
||||
String sql = """
|
||||
SELECT login, root_key, archive_head_tx_id, archive_head_hash,
|
||||
archive_imported, archive_last_imported_tx_id
|
||||
FROM solana_user_pda_current
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND is_server=TRUE
|
||||
AND archive_head_tx_id<>''
|
||||
AND archive_head_hash<>''
|
||||
AND archive_imported=FALSE
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (String login : approvedLogins) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
out.add(new ArchivePublisherHead(
|
||||
rs.getString("login"),
|
||||
rs.getString("root_key"),
|
||||
rs.getString("archive_head_tx_id"),
|
||||
rs.getString("archive_head_hash"),
|
||||
rs.getBoolean("archive_imported"),
|
||||
rs.getString("archive_last_imported_tx_id")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сдвигает локальный курсор импорта внутри FULL archive chain.
|
||||
* Update выполняется только если текущий head всё ещё тот же, с которым работает importer.
|
||||
*/
|
||||
public boolean advanceImportCursor(String publisherLogin, String expectedHeadTxId, String importedTxId) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE solana_user_pda_current
|
||||
SET archive_last_imported_tx_id=?
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND archive_head_tx_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, importedTxId == null ? "" : importedTxId);
|
||||
ps.setString(2, publisherLogin);
|
||||
ps.setString(3, expectedHeadTxId);
|
||||
return ps.executeUpdate() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Помечает именно текущий head как полностью импортированный.
|
||||
* Если Solana users sync успел заменить head на более новый, update не произойдёт.
|
||||
*/
|
||||
public boolean markHeadImported(String publisherLogin, String expectedHeadTxId) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE solana_user_pda_current
|
||||
SET archive_imported=TRUE,
|
||||
archive_last_imported_tx_id=archive_head_tx_id
|
||||
WHERE LOWER(login)=LOWER(?)
|
||||
AND archive_head_tx_id=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, publisherLogin);
|
||||
ps.setString(2, expectedHeadTxId);
|
||||
return ps.executeUpdate() == 1;
|
||||
}
|
||||
}
|
||||
|
||||
public void upsertLocation(ArchiveBlockchainLocation e) throws SQLException {
|
||||
String sql="""
|
||||
INSERT INTO archive_blockchain_location(
|
||||
blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
|
||||
chunk_offset,chunk_size,source_last_block_number,updated_at_ms)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(blockchain_name) DO UPDATE SET
|
||||
publisher_login=EXCLUDED.publisher_login,arweave_tx_id=EXCLUDED.arweave_tx_id,
|
||||
archive_hash=EXCLUDED.archive_hash,big_block_number=EXCLUDED.big_block_number,
|
||||
chunk_offset=EXCLUDED.chunk_offset,chunk_size=EXCLUDED.chunk_size,
|
||||
source_last_block_number=EXCLUDED.source_last_block_number,updated_at_ms=EXCLUDED.updated_at_ms
|
||||
WHERE EXCLUDED.source_last_block_number >= archive_blockchain_location.source_last_block_number
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,e.blockchainName()); ps.setString(2,e.publisherLogin()); ps.setString(3,e.arweaveTxId());
|
||||
ps.setBytes(4,e.archiveHash()); ps.setLong(5,e.bigBlockNumber()); ps.setLong(6,e.chunkOffset());
|
||||
ps.setLong(7,e.chunkSize()); ps.setLong(8,e.sourceLastBlockNumber()); ps.setLong(9,e.updatedAtMs()); ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public ArchiveBlockchainLocation getLocation(String blockchainName) throws SQLException {
|
||||
String sql="""
|
||||
SELECT blockchain_name,publisher_login,arweave_tx_id,archive_hash,big_block_number,
|
||||
chunk_offset,chunk_size,source_last_block_number,updated_at_ms
|
||||
FROM archive_blockchain_location WHERE LOWER(blockchain_name)=LOWER(?)
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,blockchainName);
|
||||
try(ResultSet rs=ps.executeQuery()) {
|
||||
if(!rs.next()) return null;
|
||||
return new ArchiveBlockchainLocation(rs.getString(1),rs.getString(2),rs.getString(3),rs.getBytes(4),
|
||||
rs.getLong(5),rs.getLong(6),rs.getLong(7),rs.getLong(8),rs.getLong(9));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Данные PDA, нужные для создания пустого локального blockchain_state перед первым архивным импортом. */
|
||||
public BlockchainIdentity getBlockchainIdentity(String blockchainName) throws SQLException {
|
||||
String sql="""
|
||||
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
|
||||
FROM solana_user_pda_current WHERE LOWER(blockchain_name)=LOWER(?) LIMIT 1
|
||||
""";
|
||||
try(Connection c=db.getConnection(); PreparedStatement ps=c.prepareStatement(sql)) {
|
||||
ps.setString(1,blockchainName);
|
||||
try(ResultSet rs=ps.executeQuery()) {
|
||||
if(!rs.next()) return null;
|
||||
return new BlockchainIdentity(rs.getString(1),rs.getString(2),rs.getString(3),rs.getLong(4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public record BlockchainIdentity(String login,String blockchainName,String blockchainKey,long sizeLimit) {}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.archive.ArchivePublishJob;
|
||||
import shine.db.archive.ArchivePublishJobChain;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* DAO архивного publisher-а. Все курсоры считаются подтверждёнными только после Solana finalized.
|
||||
*/
|
||||
public final class ArchivePublicationDAO {
|
||||
private static volatile ArchivePublicationDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArchivePublicationDAO() {}
|
||||
|
||||
public static ArchivePublicationDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ArchivePublicationDAO.class) {
|
||||
if (instance == null) instance = new ArchivePublicationDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ArchiveChainCursor getCursor(String blockchainName) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
return getCursor(c, blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
public ArchiveChainCursor getCursor(Connection c, String blockchainName) throws SQLException {
|
||||
String sql = """
|
||||
SELECT blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
|
||||
last_archive_big_block_number, last_archive_big_block_hash,
|
||||
last_chunk_offset, last_chunk_size, updated_at_ms
|
||||
FROM archive_chain_cursor
|
||||
WHERE blockchain_name = ?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, blockchainName);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) return null;
|
||||
return mapCursor(rs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long nextBigBlockNumber() throws SQLException {
|
||||
String sql = """
|
||||
SELECT COALESCE(MAX(big_block_number), 0) + 1
|
||||
FROM archive_publish_job
|
||||
WHERE status = 'CURSORS_COMMITTED'
|
||||
""";
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
public List<ArchiveBigBlockRef> listFinalizedBigBlocks() throws SQLException {
|
||||
String sql = """
|
||||
SELECT big_block_number, archive_hash, arweave_tx_id
|
||||
FROM archive_publish_job
|
||||
WHERE status = 'CURSORS_COMMITTED'
|
||||
AND archive_hash IS NOT NULL
|
||||
AND arweave_tx_id IS NOT NULL
|
||||
ORDER BY big_block_number ASC
|
||||
""";
|
||||
List<ArchiveBigBlockRef> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new ArchiveBigBlockRef(
|
||||
rs.getLong("big_block_number"),
|
||||
rs.getBytes("archive_hash"),
|
||||
rs.getBytes("arweave_tx_id")
|
||||
));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public ArchivePublishJob findUnfinishedJob() throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job
|
||||
WHERE status NOT IN ('CURSORS_COMMITTED', 'FAILED')
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""";
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement(sql);
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapJob(rs) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public ArchivePublishJob getJob(long id) throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job
|
||||
WHERE id = ?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? mapJob(rs) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Атомарно создаёт frozen archive job и все его blockchain ranges. */
|
||||
public long createJobWithChains(long bigBlockNumber, long createdAtMs, List<ArchivePublishJobChain> chains) throws SQLException {
|
||||
if (chains == null || chains.isEmpty()) throw new IllegalArgumentException("Archive job без chains не создаётся");
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAuto = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
long jobId;
|
||||
try (PreparedStatement ps = c.prepareStatement("""
|
||||
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
|
||||
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
ps.setLong(1, bigBlockNumber);
|
||||
ps.setLong(2, createdAtMs);
|
||||
ps.setLong(3, createdAtMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
|
||||
jobId = rs.getLong(1);
|
||||
}
|
||||
}
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job_chain(
|
||||
job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size, new_chunk_offset, new_chunk_size
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
bindJobChain(ps, new ArchivePublishJobChain(
|
||||
jobId, ch.blockchainName(), ch.fromSourceBlockNumber(), ch.toSourceBlockNumber(),
|
||||
ch.previousSourceBlockHash(), ch.lastSourceBlockHash(),
|
||||
ch.previousArchiveBigBlockNumber(), ch.previousArchiveBigBlockHash(),
|
||||
ch.previousChunkOffset(), ch.previousChunkSize(), null, null));
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
c.commit();
|
||||
return jobId;
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("Не удалось создать frozen archive job", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAuto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long createJob(long bigBlockNumber, long createdAtMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job(big_block_number, status, created_at_ms, updated_at_ms)
|
||||
VALUES(?, 'SNAPSHOT_CREATED', ?, ?)
|
||||
RETURNING id
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, bigBlockNumber);
|
||||
ps.setLong(2, createdAtMs);
|
||||
ps.setLong(3, createdAtMs);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (!rs.next()) throw new SQLException("Не удалось получить id archive_publish_job");
|
||||
return rs.getLong(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void insertJobChain(ArchivePublishJobChain e) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO archive_publish_job_chain(
|
||||
job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
bindJobChain(ps, e);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public List<ArchivePublishJobChain> listJobChains(long jobId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
FROM archive_publish_job_chain
|
||||
WHERE job_id = ?
|
||||
ORDER BY blockchain_name ASC
|
||||
""";
|
||||
List<ArchivePublishJobChain> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, jobId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) out.add(mapJobChain(rs));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void updateNewChunkPosition(long jobId, String blockchainName, long offset, long size) throws SQLException {
|
||||
String sql = """
|
||||
UPDATE archive_publish_job_chain
|
||||
SET new_chunk_offset=?, new_chunk_size=?
|
||||
WHERE job_id=? AND blockchain_name=?
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, offset);
|
||||
ps.setLong(2, size);
|
||||
ps.setLong(3, jobId);
|
||||
ps.setString(4, blockchainName);
|
||||
if (ps.executeUpdate() != 1) throw new SQLException("Не найдена archive_publish_job_chain для " + blockchainName);
|
||||
}
|
||||
}
|
||||
|
||||
public void markFileBuilt(long jobId, String localPath, byte[] hash) throws SQLException {
|
||||
updateJob(jobId, "FILE_BUILT", "local_archive_path=? , archive_hash=?", ps -> {
|
||||
ps.setString(1, localPath);
|
||||
ps.setBytes(2, hash);
|
||||
});
|
||||
}
|
||||
|
||||
public void markArweaveUploaded(long jobId, byte[] txId) throws SQLException {
|
||||
updateJob(jobId, "ARWEAVE_UPLOADED", "arweave_tx_id=?", ps -> ps.setBytes(1, txId));
|
||||
}
|
||||
|
||||
/** Обновляет только локальное имя/путь уже собранного archive-файла. */
|
||||
public void updateLocalArchivePath(long jobId, String localPath) throws SQLException {
|
||||
updateJob(jobId, null, "local_archive_path=?", ps -> ps.setString(1, localPath));
|
||||
}
|
||||
|
||||
public void markArweaveConfirmed(long jobId, int confirmations) throws SQLException {
|
||||
updateJob(jobId, "ARWEAVE_CONFIRMED", "arweave_confirmations=?", ps -> ps.setInt(1, confirmations));
|
||||
}
|
||||
|
||||
public void markSolanaSubmitted(long jobId, String signature) throws SQLException {
|
||||
updateJob(jobId, "SOLANA_SUBMITTED", "solana_signature=?", ps -> ps.setString(1, signature));
|
||||
}
|
||||
|
||||
public void markSolanaFinalized(long jobId) throws SQLException {
|
||||
updateJob(jobId, "SOLANA_FINALIZED", null, null);
|
||||
}
|
||||
|
||||
public void markFailed(long jobId, String error) throws SQLException {
|
||||
updateJob(jobId, "FAILED", "error_text=?", ps -> ps.setString(1, error == null ? "" : error));
|
||||
}
|
||||
|
||||
/**
|
||||
* После Solana finalized атомарно переводит курсоры всех blockchain на новый chunk и закрывает job.
|
||||
*/
|
||||
public void commitCursors(long jobId, byte[] bigBlockHash) throws SQLException {
|
||||
try (Connection c = db.getConnection()) {
|
||||
boolean oldAuto = c.getAutoCommit();
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
ArchivePublishJob job = getJob(c, jobId);
|
||||
if (job == null) throw new SQLException("Archive job не найден: " + jobId);
|
||||
List<ArchivePublishJobChain> chains = listJobChains(c, jobId);
|
||||
long now = System.currentTimeMillis();
|
||||
String upsert = """
|
||||
INSERT INTO archive_chain_cursor(
|
||||
blockchain_name, last_archived_source_block_number, last_archived_source_block_hash,
|
||||
last_archive_big_block_number, last_archive_big_block_hash,
|
||||
last_chunk_offset, last_chunk_size, updated_at_ms
|
||||
) VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(blockchain_name) DO UPDATE SET
|
||||
last_archived_source_block_number=EXCLUDED.last_archived_source_block_number,
|
||||
last_archived_source_block_hash=EXCLUDED.last_archived_source_block_hash,
|
||||
last_archive_big_block_number=EXCLUDED.last_archive_big_block_number,
|
||||
last_archive_big_block_hash=EXCLUDED.last_archive_big_block_hash,
|
||||
last_chunk_offset=EXCLUDED.last_chunk_offset,
|
||||
last_chunk_size=EXCLUDED.last_chunk_size,
|
||||
updated_at_ms=EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(upsert)) {
|
||||
for (ArchivePublishJobChain ch : chains) {
|
||||
if (ch.newChunkOffset() == null || ch.newChunkSize() == null) {
|
||||
throw new SQLException("Не сохранены координаты нового chunk: " + ch.blockchainName());
|
||||
}
|
||||
ps.setString(1, ch.blockchainName());
|
||||
ps.setLong(2, ch.toSourceBlockNumber());
|
||||
ps.setBytes(3, ch.lastSourceBlockHash());
|
||||
ps.setLong(4, job.bigBlockNumber());
|
||||
ps.setBytes(5, bigBlockHash);
|
||||
ps.setLong(6, ch.newChunkOffset());
|
||||
ps.setLong(7, ch.newChunkSize());
|
||||
ps.setLong(8, now);
|
||||
ps.addBatch();
|
||||
}
|
||||
ps.executeBatch();
|
||||
}
|
||||
try (PreparedStatement ps = c.prepareStatement(
|
||||
"UPDATE archive_publish_job SET status='CURSORS_COMMITTED', updated_at_ms=? WHERE id=?")) {
|
||||
ps.setLong(1, now);
|
||||
ps.setLong(2, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
c.commit();
|
||||
} catch (Exception e) {
|
||||
c.rollback();
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("Не удалось commit archive cursors", e);
|
||||
} finally {
|
||||
c.setAutoCommit(oldAuto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ArchivePublishJob getJob(Connection c, long id) throws SQLException {
|
||||
String sql = """
|
||||
SELECT id, big_block_number, status, created_at_ms, local_archive_path,
|
||||
archive_hash, arweave_tx_id, arweave_confirmations, solana_signature,
|
||||
error_text, updated_at_ms
|
||||
FROM archive_publish_job WHERE id=?
|
||||
""";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, id);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? mapJob(rs) : null; }
|
||||
}
|
||||
}
|
||||
|
||||
private List<ArchivePublishJobChain> listJobChains(Connection c, long jobId) throws SQLException {
|
||||
String sql = """
|
||||
SELECT job_id, blockchain_name, from_source_block_number, to_source_block_number,
|
||||
previous_source_block_hash, last_source_block_hash,
|
||||
previous_archive_big_block_number, previous_archive_big_block_hash,
|
||||
previous_chunk_offset, previous_chunk_size,
|
||||
new_chunk_offset, new_chunk_size
|
||||
FROM archive_publish_job_chain
|
||||
WHERE job_id=? ORDER BY blockchain_name ASC
|
||||
""";
|
||||
List<ArchivePublishJobChain> out = new ArrayList<>();
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, jobId);
|
||||
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapJobChain(rs)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface Binder { void bind(PreparedStatement ps) throws SQLException; }
|
||||
|
||||
private void updateJob(long jobId, String status, String setFragment, Binder binder) throws SQLException {
|
||||
if (setFragment != null && !setFragment.isBlank()) {
|
||||
try (Connection c = db.getConnection()) {
|
||||
updateJobWithExtra(c, jobId, status, setFragment, binder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
String sql = status == null
|
||||
? "UPDATE archive_publish_job SET updated_at_ms=? WHERE id=?"
|
||||
: "UPDATE archive_publish_job SET status=?, updated_at_ms=? WHERE id=?";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
int i = 1;
|
||||
if (status != null) ps.setString(i++, status);
|
||||
ps.setLong(i++, System.currentTimeMillis());
|
||||
ps.setLong(i, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateJobWithExtra(Connection c, long jobId, String status, String setFragment, Binder binder) throws SQLException {
|
||||
String statusSql = status == null ? "" : ", status=?";
|
||||
String sql = "UPDATE archive_publish_job SET " + setFragment + statusSql + ", updated_at_ms=? WHERE id=?";
|
||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
binder.bind(ps);
|
||||
int count = countParameters(setFragment);
|
||||
int i = count + 1;
|
||||
if (status != null) ps.setString(i++, status);
|
||||
ps.setLong(i++, System.currentTimeMillis());
|
||||
ps.setLong(i, jobId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static int countParameters(String sqlPart) {
|
||||
int n = 0;
|
||||
for (int i = 0; i < sqlPart.length(); i++) if (sqlPart.charAt(i) == '?') n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
private static void bindJobChain(PreparedStatement ps, ArchivePublishJobChain e) throws SQLException {
|
||||
int i=1;
|
||||
ps.setLong(i++, e.jobId());
|
||||
ps.setString(i++, e.blockchainName());
|
||||
ps.setLong(i++, e.fromSourceBlockNumber());
|
||||
ps.setLong(i++, e.toSourceBlockNumber());
|
||||
ps.setBytes(i++, e.previousSourceBlockHash());
|
||||
ps.setBytes(i++, e.lastSourceBlockHash());
|
||||
setLongNullable(ps, i++, e.previousArchiveBigBlockNumber());
|
||||
ps.setBytes(i++, e.previousArchiveBigBlockHash());
|
||||
setLongNullable(ps, i++, e.previousChunkOffset());
|
||||
setLongNullable(ps, i++, e.previousChunkSize());
|
||||
setLongNullable(ps, i++, e.newChunkOffset());
|
||||
setLongNullable(ps, i, e.newChunkSize());
|
||||
}
|
||||
|
||||
private static ArchiveChainCursor mapCursor(ResultSet rs) throws SQLException {
|
||||
return new ArchiveChainCursor(
|
||||
rs.getString("blockchain_name"),
|
||||
rs.getLong("last_archived_source_block_number"),
|
||||
rs.getBytes("last_archived_source_block_hash"),
|
||||
rs.getLong("last_archive_big_block_number"),
|
||||
rs.getBytes("last_archive_big_block_hash"),
|
||||
rs.getLong("last_chunk_offset"),
|
||||
rs.getLong("last_chunk_size"),
|
||||
rs.getLong("updated_at_ms")
|
||||
);
|
||||
}
|
||||
|
||||
private static ArchivePublishJob mapJob(ResultSet rs) throws SQLException {
|
||||
int conf = rs.getInt("arweave_confirmations");
|
||||
Integer confObj = rs.wasNull() ? null : conf;
|
||||
return new ArchivePublishJob(
|
||||
rs.getLong("id"), rs.getLong("big_block_number"), rs.getString("status"),
|
||||
rs.getLong("created_at_ms"), rs.getString("local_archive_path"),
|
||||
rs.getBytes("archive_hash"), rs.getBytes("arweave_tx_id"), confObj,
|
||||
rs.getString("solana_signature"), rs.getString("error_text"), rs.getLong("updated_at_ms")
|
||||
);
|
||||
}
|
||||
|
||||
private static ArchivePublishJobChain mapJobChain(ResultSet rs) throws SQLException {
|
||||
return new ArchivePublishJobChain(
|
||||
rs.getLong("job_id"), rs.getString("blockchain_name"),
|
||||
rs.getLong("from_source_block_number"), rs.getLong("to_source_block_number"),
|
||||
rs.getBytes("previous_source_block_hash"), rs.getBytes("last_source_block_hash"),
|
||||
getLongNullable(rs, "previous_archive_big_block_number"), rs.getBytes("previous_archive_big_block_hash"),
|
||||
getLongNullable(rs, "previous_chunk_offset"), getLongNullable(rs, "previous_chunk_size"),
|
||||
getLongNullable(rs, "new_chunk_offset"), getLongNullable(rs, "new_chunk_size")
|
||||
);
|
||||
}
|
||||
|
||||
private static Long getLongNullable(ResultSet rs, String name) throws SQLException {
|
||||
long v = rs.getLong(name);
|
||||
return rs.wasNull() ? null : v;
|
||||
}
|
||||
|
||||
private static void setLongNullable(PreparedStatement ps, int idx, Long v) throws SQLException {
|
||||
if (v == null) ps.setNull(idx, Types.BIGINT); else ps.setLong(idx, v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package shine.db.dao;
|
||||
|
||||
import shine.db.DbController;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */
|
||||
public final class ArweaveBlockImportDAO {
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_REJECTED = "REJECTED";
|
||||
|
||||
public record QueueItem(byte[] dataItemId, long blockHeight,
|
||||
byte[] rawDataItem, String status, String lastError,
|
||||
long firstSeenAtMs, long updatedAtMs) {}
|
||||
|
||||
private static volatile ArweaveBlockImportDAO instance;
|
||||
private final DbController db = DbController.getInstance();
|
||||
|
||||
private ArweaveBlockImportDAO() {}
|
||||
|
||||
public static ArweaveBlockImportDAO getInstance() {
|
||||
if (instance == null) {
|
||||
synchronized (ArweaveBlockImportDAO.class) {
|
||||
if (instance == null) instance = new ArweaveBlockImportDAO();
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
public long getLastBlockHeight() throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1");
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() ? rs.getLong(1) : 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void setLastBlockHeight(long height, long nowMs) throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||
VALUES(1,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setLong(1, Math.max(0L, height));
|
||||
ps.setLong(2, nowMs);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
|
||||
public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||
throws SQLException {
|
||||
String sql = """
|
||||
INSERT INTO arweave_block_import_queue(
|
||||
data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
) VALUES(?,?,?,?, '',?,?)
|
||||
ON CONFLICT(data_item_id) DO NOTHING
|
||||
""";
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
ps.setLong(2, blockHeight);
|
||||
ps.setBytes(3, rawDataItem);
|
||||
ps.setString(4, STATUS_PENDING);
|
||||
ps.setLong(5, nowMs);
|
||||
ps.setLong(6, nowMs);
|
||||
return ps.executeUpdate() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean exists(byte[] dataItemId) throws SQLException {
|
||||
try (Connection c = db.getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) {
|
||||
ps.setBytes(1, dataItemId);
|
||||
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
|
||||
}
|
||||
}
|
||||
|
||||
public List<QueueItem> listPending(int limit) throws SQLException {
|
||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||
String sql = """
|
||||
SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||
FROM arweave_block_import_queue
|
||||
WHERE status=?
|
||||
ORDER BY block_height ASC, first_seen_at_ms ASC
|
||||
LIMIT ?
|
||||
""";
|
||||
List<QueueItem> out = new ArrayList<>();
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||
ps.setString(1, STATUS_PENDING);
|
||||
ps.setInt(2, safeLimit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
out.add(new QueueItem(
|
||||
rs.getBytes("data_item_id"), rs.getLong("block_height"),
|
||||
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
|
||||
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public void setPendingError(byte[] id, String error, long nowMs) throws SQLException {
|
||||
updateStatus(id, STATUS_PENDING, error, nowMs);
|
||||
}
|
||||
|
||||
public void reject(byte[] id, String error, long nowMs) throws SQLException {
|
||||
updateStatus(id, STATUS_REJECTED, error, nowMs);
|
||||
}
|
||||
|
||||
public void delete(byte[] id) throws SQLException {
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||
"DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) {
|
||||
ps.setBytes(1, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException {
|
||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(
|
||||
"UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) {
|
||||
ps.setString(1, status);
|
||||
ps.setString(2, trimError(error));
|
||||
ps.setLong(3, nowMs);
|
||||
ps.setBytes(4, id);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimError(String s) {
|
||||
String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim();
|
||||
return v.length() <= 500 ? v : v.substring(0,500);
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+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,56 @@
|
||||
-- Frame v1 intentionally has no backward compatibility with the old signed block format.
|
||||
-- Refuse to migrate a populated blockchain database: test/dev data must be reset first.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM blocks LIMIT 1)
|
||||
OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN
|
||||
RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Старый SHINE-ARCHIVE v1 больше не используется.
|
||||
DROP TABLE IF EXISTS archive_publish_job_chain CASCADE;
|
||||
DROP TABLE IF EXISTS archive_publish_job CASCADE;
|
||||
DROP TABLE IF EXISTS archive_chain_cursor CASCADE;
|
||||
DROP TABLE IF EXISTS archive_blockchain_location CASCADE;
|
||||
|
||||
-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required.
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS data_item_id BYTEA;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT;
|
||||
ALTER TABLE blocks
|
||||
ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||
ON blocks(data_item_id)
|
||||
WHERE data_item_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||
ON blocks(block_number)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms)
|
||||
VALUES(1,0,0) ON CONFLICT(id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
first_seen_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||
WHERE status='PENDING';
|
||||
|
||||
UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- PDA 1.2: keep the complete append-only blockchain-key/fork history alongside the active compatibility columns.
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS blockchain_forks_json TEXT NOT NULL DEFAULT '[]';
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 25,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
@@ -0,0 +1,123 @@
|
||||
-- v26: серверная state machine смены ключей пользователя.
|
||||
--
|
||||
-- Важно:
|
||||
-- - rotation_status / rotation_session_id являются ЛОКАЛЬНЫМ runtime-состоянием сервера,
|
||||
-- а не полями Solana PDA;
|
||||
-- - Solana sync не должен перезаписывать эти колонки при upsert PDA;
|
||||
-- - приватные ключи здесь никогда не хранятся, только публичные ключи ротации.
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||
rotation_status IN (
|
||||
'NONE',
|
||||
'COPYING_CHAIN',
|
||||
'CHAIN_READY',
|
||||
'ROTATING_PDA',
|
||||
'PDA_ROTATED',
|
||||
'REBUILDING_SERVER',
|
||||
'WALLET_MIGRATION',
|
||||
'MESSAGE_MIGRATION',
|
||||
'FINALIZING'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||
ON solana_user_pda_current(rotation_status)
|
||||
WHERE rotation_status <> 'NONE';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN (
|
||||
'COPYING_CHAIN',
|
||||
'CHAIN_READY',
|
||||
'ROTATING_PDA',
|
||||
'PDA_ROTATED',
|
||||
'REBUILDING_SERVER',
|
||||
'WALLET_MIGRATION',
|
||||
'MESSAGE_MIGRATION',
|
||||
'FINALIZING',
|
||||
'COMPLETE',
|
||||
'ABORTED'
|
||||
)
|
||||
),
|
||||
|
||||
source_blockchain_name TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
|
||||
old_root_key TEXT NOT NULL,
|
||||
old_blockchain_key TEXT NOT NULL,
|
||||
old_client_key TEXT NOT NULL,
|
||||
new_root_key TEXT NOT NULL,
|
||||
new_blockchain_key TEXT NOT NULL,
|
||||
new_client_key TEXT NOT NULL,
|
||||
|
||||
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||
|
||||
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||
|
||||
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||
|
||||
pda_rotation_signature TEXT,
|
||||
|
||||
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
wallet_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||
),
|
||||
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
message_migration_status IN ('PENDING', 'COMPLETE', 'SKIPPED', 'NOT_IMPLEMENTED')
|
||||
),
|
||||
|
||||
last_error TEXT,
|
||||
last_error_at_ms BIGINT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
completed_at_ms BIGINT,
|
||||
aborted_at_ms BIGINT,
|
||||
|
||||
CHECK (progress_current <= progress_total),
|
||||
CHECK (fork_from_block <= source_tip_block)
|
||||
);
|
||||
|
||||
-- У одного login одновременно может существовать только одна незавершённая ротация.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||
ON key_rotation_sessions(login)
|
||||
WHERE status NOT IN ('COMPLETE', 'ABORTED');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||
ON key_rotation_sessions(status);
|
||||
|
||||
-- Быстрый указатель из текущего пользователя на активную/последнюю runtime-сессию.
|
||||
-- FK добавляется после создания key_rotation_sessions, чтобы не создавать циклический DDL порядок.
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||
FOREIGN KEY (rotation_session_id)
|
||||
REFERENCES key_rotation_sessions(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 26,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
@@ -0,0 +1,31 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||
block_bytes BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
UNIQUE (rotation_session_id, block_number),
|
||||
UNIQUE (rotation_session_id, data_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||
ON key_rotation_candidate_blocks(id)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||
|
||||
UPDATE db_schema_version
|
||||
SET schema_version = 27,
|
||||
updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)
|
||||
WHERE id = 1;
|
||||
|
||||
COMMIT;
|
||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
||||
);
|
||||
|
||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||
VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
VALUES (1, 26, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
schema_version = EXCLUDED.schema_version,
|
||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||
@@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current (
|
||||
blockchain_name TEXT NOT NULL,
|
||||
blockchain_key TEXT NOT NULL,
|
||||
paid_limit_bytes BIGINT NOT NULL,
|
||||
blockchain_forks_json TEXT NOT NULL DEFAULT '[]',
|
||||
used_bytes BIGINT NOT NULL,
|
||||
last_block_number INTEGER NOT NULL,
|
||||
last_block_hash TEXT NOT NULL,
|
||||
@@ -291,12 +292,6 @@ AFTER TRUNCATE ON solana_user_pda_current
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS solana_user_pda_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tx_signature TEXT NOT NULL,
|
||||
@@ -503,6 +498,10 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
to_block_hash BYTEA,
|
||||
block_hash BYTEA NOT NULL,
|
||||
block_signature BYTEA NOT NULL,
|
||||
data_item_id BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
arweave_root_tx_id TEXT,
|
||||
edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0),
|
||||
line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0),
|
||||
prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0),
|
||||
@@ -514,12 +513,41 @@ CREATE TABLE IF NOT EXISTS blocks (
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number
|
||||
ON blocks (bch_name, block_number);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id
|
||||
ON blocks(data_item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending
|
||||
ON blocks(block_number)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_to_target
|
||||
ON blocks (to_login, to_bch_name, to_block_number);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_by_line
|
||||
ON blocks (bch_name, line_code, this_line_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_sync_state (
|
||||
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||
last_block_height BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms)
|
||||
VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arweave_block_import_queue (
|
||||
data_item_id BYTEA PRIMARY KEY,
|
||||
root_tx_id TEXT NOT NULL,
|
||||
block_height BIGINT NOT NULL,
|
||||
raw_data_item BYTEA NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
first_seen_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending
|
||||
ON arweave_block_import_queue(block_height,first_seen_at_ms)
|
||||
WHERE status='PENDING';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS connections_state (
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||
rel_type INTEGER NOT NULL,
|
||||
@@ -1660,6 +1688,12 @@ AFTER INSERT ON blocks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_blocks_edit_apply_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current;
|
||||
CREATE TRIGGER trg_user_stats_state_ai
|
||||
AFTER INSERT ON solana_user_pda_current
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION shine_user_stats_state_ai();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state;
|
||||
CREATE TRIGGER trg_channel_names_state_stats_ai
|
||||
AFTER INSERT ON channel_names_state
|
||||
@@ -2028,52 +2062,6 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||
PRIMARY KEY (owner_login, category)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_chain_cursor (
|
||||
blockchain_name TEXT PRIMARY KEY,
|
||||
last_archived_source_block_number BIGINT NOT NULL,
|
||||
last_archived_source_block_hash BYTEA NOT NULL,
|
||||
last_archive_big_block_number BIGINT NOT NULL,
|
||||
last_archive_big_block_hash BYTEA NOT NULL,
|
||||
last_chunk_offset BIGINT NOT NULL,
|
||||
last_chunk_size BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_publish_job (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
big_block_number BIGINT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
local_archive_path TEXT,
|
||||
archive_hash BYTEA,
|
||||
arweave_tx_id BYTEA,
|
||||
arweave_confirmations INTEGER,
|
||||
solana_signature TEXT,
|
||||
error_text TEXT,
|
||||
updated_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status
|
||||
ON archive_publish_job(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number
|
||||
ON archive_publish_job(big_block_number);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_publish_job_chain (
|
||||
job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE,
|
||||
blockchain_name TEXT NOT NULL,
|
||||
from_source_block_number BIGINT NOT NULL,
|
||||
to_source_block_number BIGINT NOT NULL,
|
||||
previous_source_block_hash BYTEA NOT NULL,
|
||||
last_source_block_hash BYTEA NOT NULL,
|
||||
previous_archive_big_block_number BIGINT,
|
||||
previous_archive_big_block_hash BYTEA,
|
||||
previous_chunk_offset BIGINT,
|
||||
previous_chunk_size BIGINT,
|
||||
new_chunk_offset BIGINT,
|
||||
new_chunk_size BIGINT,
|
||||
PRIMARY KEY (job_id, blockchain_name)
|
||||
);
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE solana_user_pda_current
|
||||
@@ -2093,16 +2081,111 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
||||
ON solana_user_pda_current(is_server, archive_imported)
|
||||
WHERE archive_head_tx_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archive_blockchain_location (
|
||||
blockchain_name TEXT PRIMARY KEY, publisher_login TEXT NOT NULL, arweave_tx_id TEXT NOT NULL,
|
||||
archive_hash BYTEA NOT NULL, big_block_number BIGINT NOT NULL, chunk_offset BIGINT NOT NULL,
|
||||
chunk_size BIGINT NOT NULL, source_last_block_number BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL
|
||||
-- Server-local key rotation state machine (schema v26).
|
||||
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||
rotation_status IN (
|
||||
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||
ON solana_user_pda_current(rotation_status)
|
||||
WHERE rotation_status <> 'NONE';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (
|
||||
status IN (
|
||||
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
|
||||
'COMPLETE','ABORTED'
|
||||
)
|
||||
),
|
||||
source_blockchain_name TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
old_root_key TEXT NOT NULL,
|
||||
old_blockchain_key TEXT NOT NULL,
|
||||
old_client_key TEXT NOT NULL,
|
||||
new_root_key TEXT NOT NULL,
|
||||
new_blockchain_key TEXT NOT NULL,
|
||||
new_client_key TEXT NOT NULL,
|
||||
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||
pda_rotation_signature TEXT,
|
||||
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||
),
|
||||
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||
),
|
||||
last_error TEXT,
|
||||
last_error_at_ms BIGINT,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
completed_at_ms BIGINT,
|
||||
aborted_at_ms BIGINT,
|
||||
CHECK (progress_current <= progress_total),
|
||||
CHECK (fork_from_block <= source_tip_block)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher ON archive_blockchain_location(publisher_login);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||
ON key_rotation_sessions(login)
|
||||
WHERE status NOT IN ('COMPLETE','ABORTED');
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||
ON key_rotation_sessions(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||
login TEXT NOT NULL,
|
||||
candidate_blockchain_name TEXT NOT NULL,
|
||||
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||
block_bytes BYTEA NOT NULL,
|
||||
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
arweave_published_at_ms BIGINT,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
UNIQUE (rotation_session_id, block_number),
|
||||
UNIQUE (rotation_session_id, data_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||
ON key_rotation_candidate_blocks(id)
|
||||
WHERE arweave_publish_pending = TRUE;
|
||||
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||
|
||||
ALTER TABLE solana_user_pda_current
|
||||
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||
ALTER TABLE solana_user_pda_current
|
||||
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||
FOREIGN KEY (rotation_session_id)
|
||||
REFERENCES key_rotation_sessions(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||
VALUES(1,23,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
VALUES(1,27,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||
|
||||
COMMIT;
|
||||
|
||||
|
||||
+37
-4
@@ -42,10 +42,25 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_UpsertEspPairing
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetArchiveBlockchainLocation_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetArchiveBlockchainLocation_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
|
||||
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
|
||||
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||
@@ -193,7 +208,16 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||
Map.entry("GetArchiveBlockchainLocation", new Net_GetArchiveBlockchainLocation_Handler()),
|
||||
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
|
||||
|
||||
// --- key rotation ---
|
||||
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
|
||||
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
|
||||
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
|
||||
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
|
||||
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
|
||||
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
|
||||
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||
@@ -286,7 +310,16 @@ public final class JsonHandlerRegistry {
|
||||
// --- blockchain ---
|
||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||
Map.entry("GetArchiveBlockchainLocation", Net_GetArchiveBlockchainLocation_Request.class),
|
||||
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
|
||||
|
||||
// --- key rotation ---
|
||||
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
|
||||
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
|
||||
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
|
||||
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
|
||||
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
|
||||
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
|
||||
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
|
||||
|
||||
// --- userParams ---
|
||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
||||
|
||||
+118
-169
@@ -28,7 +28,6 @@ public final class SolanaUserPdaImportService {
|
||||
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
||||
private static final String MAGIC = "SHiNE";
|
||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
||||
private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64;
|
||||
|
||||
private SolanaUserPdaImportService() {}
|
||||
|
||||
@@ -55,8 +54,7 @@ public final class SolanaUserPdaImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Чтение server PDA по логину сервера. Используется сервером при старте,
|
||||
* чтобы получить актуальный server_address и список sync_servers.
|
||||
* Чтение server PDA 1.2 по логину сервера. В 1.2 разрешён ровно один endpoint.
|
||||
*/
|
||||
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
|
||||
String login = normalizeLogin(loginRaw);
|
||||
@@ -156,9 +154,16 @@ public final class SolanaUserPdaImportService {
|
||||
}
|
||||
|
||||
private static ParsedSolanaUser parseUserPda(byte[] raw) {
|
||||
if (raw == null || raw.length < 128) return null;
|
||||
if (raw == null || raw.length < 9) return null;
|
||||
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||
int major = u8(raw, 5);
|
||||
int minor = u8(raw, 6);
|
||||
if (major != 1 || minor != 2) return null;
|
||||
return parseUserPdaV12(raw);
|
||||
}
|
||||
|
||||
private static ParsedSolanaUser parseUserPdaV12(byte[] raw) {
|
||||
if (raw == null || raw.length < 128) return null;
|
||||
int recordLen = u16le(raw, 7);
|
||||
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||
|
||||
@@ -167,228 +172,166 @@ public final class SolanaUserPdaImportService {
|
||||
c += 8; // updated_at_ms
|
||||
c += 4; // record_number
|
||||
c += 32; // prev_record_hash
|
||||
if (c >= recordLen) return null;
|
||||
|
||||
int loginLen = u8(raw, c++);
|
||||
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||
c += loginLen;
|
||||
if (c >= recordLen) return null;
|
||||
|
||||
int blocksCount = u8(raw, c++);
|
||||
String blockchainName = null;
|
||||
byte[] blockchainKey32 = null;
|
||||
byte[] clientKey32 = null;
|
||||
long paidLimitBytes = 0L;
|
||||
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
||||
int forkCount = 0;
|
||||
List<String> accessServers = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < blocksCount; i++) {
|
||||
if (c + 2 > recordLen) return null;
|
||||
int blockType = u8(raw, c++);
|
||||
int blockVer = u8(raw, c++);
|
||||
if (blockVer != 0) return null;
|
||||
|
||||
if (blockType == 0) {
|
||||
c += 32; // recovery_key
|
||||
} else if (blockType == 1) {
|
||||
c += 32;
|
||||
} else if (blockType == 2) {
|
||||
if (blockType == 1) {
|
||||
if (c + 32 > recordLen) return null;
|
||||
c += 32; // root_key
|
||||
continue;
|
||||
}
|
||||
if (blockType == 2) {
|
||||
if (c + 32 > recordLen) return null;
|
||||
clientKey32 = slice(raw, c, 32);
|
||||
c += 32;
|
||||
} else if (blockType == 3) {
|
||||
int count = u8(raw, c++);
|
||||
for (int j = 0; j < count; j++) {
|
||||
c += 1; // blockchain_type
|
||||
int bchLen = u8(raw, c++);
|
||||
blockchainName = new String(raw, c, bchLen, StandardCharsets.UTF_8);
|
||||
c += bchLen;
|
||||
blockchainKey32 = slice(raw, c, 32);
|
||||
c += 32;
|
||||
paidLimitBytes = u64le(raw, c);
|
||||
c += 8;
|
||||
c += 8; // used_bytes
|
||||
c += 4; // last_block_number
|
||||
c += 32; // last_block_hash
|
||||
c += 64; // last_block_signature
|
||||
int arweavePresent = u8(raw, c++);
|
||||
if (arweavePresent == 1) {
|
||||
int arLen = u8(raw, c++);
|
||||
c += arLen;
|
||||
} else if (arweavePresent != 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else if (blockType == 30) {
|
||||
int isServer = u8(raw, c++);
|
||||
if (isServer == 1) {
|
||||
c += 1; // address_format_type
|
||||
c += 1; // address_format_version
|
||||
int addrLen = u8(raw, c++);
|
||||
c += addrLen;
|
||||
int syncCount = u8(raw, c++);
|
||||
for (int j = 0; j < syncCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
c += n;
|
||||
}
|
||||
} else if (isServer != 0) {
|
||||
return null;
|
||||
}
|
||||
} else if (blockType == 40) {
|
||||
int accessCount = u8(raw, c++);
|
||||
for (int j = 0; j < accessCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
String normalizedAccessServerLogin = normalizeLogin(accessServerLogin);
|
||||
if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) {
|
||||
continue;
|
||||
}
|
||||
if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
||||
accessServers.add(normalizedAccessServerLogin);
|
||||
}
|
||||
}
|
||||
} else if (blockType == 50) {
|
||||
int sessionsMode = u8(raw, c++);
|
||||
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
||||
int sessionsCount = u8(raw, c++);
|
||||
if (sessionsCount > 64) return null;
|
||||
for (int j = 0; j < sessionsCount; j++) {
|
||||
int sessionType = u8(raw, c++);
|
||||
int sessionVersion = u8(raw, c++);
|
||||
int n = u8(raw, c++);
|
||||
String sessionName = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
byte[] sessionPubKey32 = slice(raw, c, 32);
|
||||
c += 32;
|
||||
sessions.add(new ParsedSessionRecord(
|
||||
sessionType,
|
||||
sessionVersion,
|
||||
sessionName,
|
||||
sessionPubKey32
|
||||
));
|
||||
}
|
||||
} else if (blockType == 70) {
|
||||
c += 1;
|
||||
} else if (blockType == 100) {
|
||||
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
|
||||
} else {
|
||||
return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c > recordLen) return null;
|
||||
if (c + 2 > recordLen) return null;
|
||||
int payloadLen = u16le(raw, c);
|
||||
c += 2;
|
||||
int payloadEnd = c + payloadLen;
|
||||
if (payloadEnd < c || payloadEnd > recordLen) return null;
|
||||
|
||||
if (blockType == 3) {
|
||||
if (c + 2 > payloadEnd) return null;
|
||||
forkCount = u16le(raw, c);
|
||||
c += 2;
|
||||
if (forkCount <= 0) return null;
|
||||
for (int j = 0; j < forkCount; j++) {
|
||||
if (c + 44 > payloadEnd) return null;
|
||||
blockchainKey32 = slice(raw, c, 32);
|
||||
c += 32;
|
||||
c += 8; // fork created_at_ms
|
||||
paidLimitBytes = u32le(raw, c);
|
||||
c += 4;
|
||||
}
|
||||
if (c != payloadEnd) return null;
|
||||
} else if (blockType == 30) {
|
||||
// Server profile is not needed for auth parsing; validate/skip its payload.
|
||||
c = payloadEnd;
|
||||
} else if (blockType == 40) {
|
||||
if (c >= payloadEnd) return null;
|
||||
int accessCount = u8(raw, c++);
|
||||
if (accessCount > 1) return null;
|
||||
for (int j = 0; j < accessCount; j++) {
|
||||
if (c >= payloadEnd) return null;
|
||||
int n = u8(raw, c++);
|
||||
if (c + n > payloadEnd) return null;
|
||||
String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
String normalized = normalizeLogin(accessServerLogin);
|
||||
if (normalized != null && !accessServers.contains(normalized)
|
||||
&& accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) {
|
||||
accessServers.add(normalized);
|
||||
}
|
||||
}
|
||||
if (c != payloadEnd) return null;
|
||||
} else {
|
||||
// Unknown variable PDA 1.2 block: forward-compatible skip by payload_len.
|
||||
c = payloadEnd;
|
||||
}
|
||||
c = payloadEnd;
|
||||
}
|
||||
|
||||
if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null;
|
||||
if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0) return null;
|
||||
String blockchainName = login + "-" + String.format(Locale.ROOT, "%03d", forkCount);
|
||||
return new ParsedSolanaUser(
|
||||
login,
|
||||
blockchainName,
|
||||
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||
Base64.getEncoder().encodeToString(clientKey32),
|
||||
paidLimitBytes,
|
||||
sessions,
|
||||
List.of(),
|
||||
accessServers
|
||||
);
|
||||
}
|
||||
|
||||
private static ParsedServerProfile parseServerProfile(byte[] raw) {
|
||||
if (raw == null || raw.length < 128) return null;
|
||||
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||
|
||||
private static ParsedServerProfile parseServerProfile(byte[] raw) {
|
||||
if (raw == null || raw.length < 9) return null;
|
||||
if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null;
|
||||
int major = u8(raw, 5);
|
||||
int minor = u8(raw, 6);
|
||||
if (major != 1 || minor != 2) return null;
|
||||
return parseServerProfileV12(raw);
|
||||
}
|
||||
|
||||
private static ParsedServerProfile parseServerProfileV12(byte[] raw) {
|
||||
if (raw == null || raw.length < 128) return null;
|
||||
int recordLen = u16le(raw, 7);
|
||||
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||
|
||||
int c = 9;
|
||||
c += 8; // created_at_ms
|
||||
c += 8; // updated_at_ms
|
||||
c += 4; // record_number
|
||||
c += 32; // prev_record_hash
|
||||
|
||||
int c = 9 + 8 + 8 + 4 + 32;
|
||||
if (c >= recordLen) return null;
|
||||
int loginLen = u8(raw, c++);
|
||||
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||
c += loginLen;
|
||||
|
||||
if (c >= recordLen) return null;
|
||||
int blocksCount = u8(raw, c++);
|
||||
|
||||
boolean isServer = false;
|
||||
String serverAddress = "";
|
||||
List<String> syncServers = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < blocksCount; i++) {
|
||||
if (c + 2 > recordLen) return null;
|
||||
int blockType = u8(raw, c++);
|
||||
int blockVer = u8(raw, c++);
|
||||
if (blockVer != 0) return null;
|
||||
|
||||
if (blockType == 0 || blockType == 1 || blockType == 2) {
|
||||
if (blockType == 1 || blockType == 2) {
|
||||
if (c + 32 > recordLen) return null;
|
||||
c += 32;
|
||||
} else if (blockType == 3) {
|
||||
int count = u8(raw, c++);
|
||||
for (int j = 0; j < count; j++) {
|
||||
c += 1; // blockchain_type
|
||||
int bchLen = u8(raw, c++);
|
||||
c += bchLen;
|
||||
c += 32; // blockchain pubkey
|
||||
c += 8; // paid_limit_bytes
|
||||
c += 8; // used_bytes
|
||||
c += 4; // last_block_number
|
||||
c += 32; // last_block_hash
|
||||
c += 64; // last_block_signature
|
||||
int arweavePresent = u8(raw, c++);
|
||||
if (arweavePresent == 1) {
|
||||
int arLen = u8(raw, c++);
|
||||
c += arLen;
|
||||
} else if (arweavePresent != 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else if (blockType == 30) {
|
||||
int isServerValue = u8(raw, c++);
|
||||
if (isServerValue == 1) {
|
||||
isServer = true;
|
||||
continue;
|
||||
}
|
||||
if (c + 2 > recordLen) return null;
|
||||
int payloadLen = u16le(raw, c);
|
||||
c += 2;
|
||||
int payloadEnd = c + payloadLen;
|
||||
if (payloadEnd < c || payloadEnd > recordLen) return null;
|
||||
|
||||
if (blockType == 30) {
|
||||
if (c >= payloadEnd) return null;
|
||||
int addressCount = u8(raw, c++);
|
||||
if (addressCount != 1) return null;
|
||||
for (int j = 0; j < addressCount; j++) {
|
||||
if (c + 3 > payloadEnd) return null;
|
||||
c += 1; // address_format_type
|
||||
c += 1; // address_format_version
|
||||
int addrLen = u8(raw, c++);
|
||||
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
|
||||
c += addrLen;
|
||||
int syncCount = u8(raw, c++);
|
||||
for (int j = 0; j < syncCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
syncServers.add(normalizeLogin(syncLogin));
|
||||
int n = u8(raw, c++);
|
||||
if (c + n > payloadEnd) return null;
|
||||
String address = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||
c += n;
|
||||
if (!isServer) {
|
||||
isServer = true;
|
||||
serverAddress = address;
|
||||
}
|
||||
} else if (isServerValue != 0) {
|
||||
return null;
|
||||
}
|
||||
} else if (blockType == 40) {
|
||||
int accessCount = u8(raw, c++);
|
||||
for (int j = 0; j < accessCount; j++) {
|
||||
int n = u8(raw, c++);
|
||||
c += n;
|
||||
}
|
||||
} else if (blockType == 50) {
|
||||
int sessionsMode = u8(raw, c++);
|
||||
if (sessionsMode != 1 && sessionsMode != 10) return null;
|
||||
int sessionsCount = u8(raw, c++);
|
||||
if (sessionsCount > 64) return null;
|
||||
for (int j = 0; j < sessionsCount; j++) {
|
||||
c += 1; // session_type
|
||||
c += 1; // session_version
|
||||
int n = u8(raw, c++);
|
||||
c += n;
|
||||
c += 32;
|
||||
}
|
||||
} else if (blockType == 70) {
|
||||
c += 1;
|
||||
} else if (blockType == 100) {
|
||||
c += ARCHIVE_HEAD_PAYLOAD_BYTES;
|
||||
} else {
|
||||
return null;
|
||||
if (c != payloadEnd) return null;
|
||||
}
|
||||
|
||||
if (c > recordLen) return null;
|
||||
c = payloadEnd;
|
||||
}
|
||||
|
||||
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
|
||||
return new ParsedServerProfile(login, isServer, serverAddress, List.of());
|
||||
}
|
||||
|
||||
|
||||
private static String normalizeLogin(String login) {
|
||||
if (login == null) return null;
|
||||
String s = login.trim();
|
||||
@@ -400,6 +343,12 @@ public final class SolanaUserPdaImportService {
|
||||
private static int u16le(byte[] b, int o) {
|
||||
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
|
||||
}
|
||||
private static long u32le(byte[] b, int o) {
|
||||
return ((long) b[o] & 0xFFL)
|
||||
| (((long) b[o + 1] & 0xFFL) << 8)
|
||||
| (((long) b[o + 2] & 0xFFL) << 16)
|
||||
| (((long) b[o + 3] & 0xFFL) << 24);
|
||||
}
|
||||
private static long u64le(byte[] b, int o) {
|
||||
long out = 0L;
|
||||
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
|
||||
|
||||
+94
-9
@@ -98,11 +98,24 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||
lock.lock();
|
||||
try {
|
||||
try {
|
||||
if (isKeyRotationActive(blockchainName)) {
|
||||
BlockchainStateEntry currentState = stateDAO.getByBlockchainName(blockchainName);
|
||||
int lastNum = currentState != null ? currentState.getLastBlockNumber() : -1;
|
||||
String lastHash = currentState != null ? toHex(currentState.getLastBlockHash()) : "";
|
||||
return error(req, 423, "key_rotation_in_progress", lastNum, lastHash);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("AddBlock: не удалось проверить key rotation status для {}", blockchainName, e);
|
||||
return error(req, WireCodes.Status.INTERNAL_ERROR, "key_rotation_check_failed", -1, "");
|
||||
}
|
||||
|
||||
AddBlockResult r = addBlock(
|
||||
blockchainName,
|
||||
req.getBlockNumber(), // старое поле, пока оставляем
|
||||
req.getPrevBlockHash(), // старое поле, пока оставляем
|
||||
req.getBlockBytesB64(),
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
@@ -151,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return resp;
|
||||
}
|
||||
|
||||
private boolean isKeyRotationActive(String blockchainName) throws java.sql.SQLException {
|
||||
String login = BlockchainNameUtil.loginFromBlockchainName(blockchainName);
|
||||
if (login == null || login.isBlank()) return false;
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection();
|
||||
PreparedStatement ps = c.prepareStatement("""
|
||||
SELECT rotation_status
|
||||
FROM solana_user_pda_current
|
||||
WHERE normalized_login = LOWER(BTRIM(?))
|
||||
LIMIT 1
|
||||
""")) {
|
||||
ps.setString(1, login);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
return rs.next() && !"NONE".equals(rs.getString(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String humanMessage(String code) {
|
||||
if (code == null) return "Ошибка добавления блока";
|
||||
return switch (code) {
|
||||
@@ -163,6 +193,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "limit_exceeded" -> "Превышен лимит размера блокчейна";
|
||||
case "limit_check_failed" -> "Ошибка проверки лимита размера";
|
||||
case "bad_block_format" -> "Некорректный формат блока";
|
||||
case "bad_app_tag" -> "Некорректный тестовый тег ANS-104";
|
||||
case "bad_channel_tag" -> "Некорректный тег канала ANS-104";
|
||||
case "bad_block_body" -> "Некорректное тело блока";
|
||||
case "bad_channel_name" -> "Некорректное название канала";
|
||||
case "bad_block_number" -> "Некорректный номер блока";
|
||||
@@ -181,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
|
||||
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
|
||||
default -> "Ошибка: " + code;
|
||||
};
|
||||
}
|
||||
@@ -190,7 +224,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
int globalNumberFromReq,
|
||||
String prevGlobalHashHexFromReq,
|
||||
String blockBytesB64,
|
||||
boolean replicateAfterWrite
|
||||
boolean replicateAfterWrite,
|
||||
boolean publishToArweave
|
||||
) {
|
||||
if (blockchainName == null || blockchainName.isBlank()) {
|
||||
log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq);
|
||||
@@ -272,6 +307,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if (!block.getDataItem().hasTag("App", "test5590")) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// body.check()
|
||||
try {
|
||||
block.body.check();
|
||||
@@ -401,6 +440,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||
}
|
||||
|
||||
// Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=<slug>.
|
||||
try {
|
||||
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
|
||||
String actualChannelSlug = block.getDataItem().tagValue("c_test5590");
|
||||
if (expectedChannelSlug != null) {
|
||||
if (!expectedChannelSlug.equals(actualChannelSlug)) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} else if (actualChannelSlug != null) {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e);
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
if ((block.type & 0xFFFF) == 1
|
||||
&& (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||
try {
|
||||
@@ -458,7 +513,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex);
|
||||
}
|
||||
|
||||
// 6) подпись по hash32(preimage)
|
||||
// 6) ANS-104 Ed25519 подпись и owner
|
||||
boolean sigOk;
|
||||
try {
|
||||
sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32);
|
||||
@@ -516,7 +571,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// 8) сформировать запись и записать (DB + state + файл)
|
||||
// 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state
|
||||
try {
|
||||
BlockEntry be = new BlockEntry();
|
||||
be.setLogin(login);
|
||||
@@ -529,6 +584,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
be.setBlockBytes(block.toBytes());
|
||||
be.setBlockHash(block.getHash32());
|
||||
be.setBlockSignature(block.getSignature64());
|
||||
be.setDataItemId(block.getDataItemId32());
|
||||
be.setArweavePublishPending(publishToArweave);
|
||||
|
||||
// line columns (optional)
|
||||
be.setLineCode(lineCode);
|
||||
@@ -626,18 +683,26 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
* Архивный импорт использует ту же проверку и запись, что обычный AddBlock,
|
||||
* но не запускает повторную межсерверную репликацию уже импортированного блока.
|
||||
*/
|
||||
public ArchiveImportResult addBlockFromArchive(String blockchainName, byte[] rawBlockBytes) {
|
||||
public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) {
|
||||
if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) {
|
||||
return new ArchiveImportResult(false, "chain_resync_in_progress", -1, "");
|
||||
return new ArweaveImportResult(false, "chain_resync_in_progress", -1, "");
|
||||
}
|
||||
if (rawBlockBytes == null || rawBlockBytes.length == 0) {
|
||||
return new ArchiveImportResult(false, "empty_block", -1, "");
|
||||
return new ArweaveImportResult(false, "empty_block", -1, "");
|
||||
}
|
||||
try {
|
||||
BchBlockEntry probe = new BchBlockEntry(rawBlockBytes);
|
||||
if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) {
|
||||
return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32()));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Parsed again below to return the canonical bad_block_format result.
|
||||
}
|
||||
final BchBlockEntry parsed;
|
||||
try {
|
||||
parsed = new BchBlockEntry(rawBlockBytes);
|
||||
} catch (Exception e) {
|
||||
return new ArchiveImportResult(false, "bad_block_format", -1, "");
|
||||
return new ArweaveImportResult(false, "bad_block_format", -1, "");
|
||||
}
|
||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||
lock.lock();
|
||||
@@ -647,20 +712,40 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
||||
parsed.blockNumber,
|
||||
toHex(parsed.prevHash32),
|
||||
Base64Ws.encode(rawBlockBytes),
|
||||
false,
|
||||
false
|
||||
);
|
||||
return new ArchiveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
|
||||
return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public record ArchiveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
|
||||
public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {}
|
||||
|
||||
/* ===================================================================== */
|
||||
/* ====================== Helpers ====================================== */
|
||||
/* ===================================================================== */
|
||||
|
||||
private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception {
|
||||
if (block.body instanceof CreateChannelBody) {
|
||||
return justCreated == null ? null : justCreated.getSlug();
|
||||
}
|
||||
int type = block.type & 0xffff;
|
||||
int sub = block.subType & 0xffff;
|
||||
boolean channelBlock = type == 1 && (
|
||||
sub == (MsgSubType.TEXT_POST & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_REPOST & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_EXERCISE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_SERVICE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_COURSE & 0xffff)
|
||||
|| sub == (MsgSubType.TEXT_EDIT_POST & 0xffff));
|
||||
if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null;
|
||||
return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode());
|
||||
}
|
||||
|
||||
private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) {
|
||||
if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||
&& subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null;
|
||||
|
||||
+15
-122
@@ -13,35 +13,17 @@ import shine.db.entities.BlockEntry;
|
||||
import shine.db.entities.ChannelNameStateEntry;
|
||||
import shine.db.entities.UserParamEntry;
|
||||
import shine.db.entities.UserNotificationEntry;
|
||||
import utils.files.FileStoreUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* BlockchainWriter — запись блока в БД и формирование файловой версии цепочки.
|
||||
*
|
||||
* Текущая схема:
|
||||
* 1) собираем <blockchainName>.tmp_bch как готовый кандидат на замену основного файла;
|
||||
* 2) пишем маленький sidecar <blockchainName>.write_check с blockNumber/blockHash;
|
||||
* 3) создаём пустой marker <blockchainName>.write_pending;
|
||||
* 4) выполняем одну SQL-транзакцию;
|
||||
* 5) после commit атомарно заменяем основной .bch из tmp;
|
||||
* 6) убираем временные файлы.
|
||||
*
|
||||
* Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись:
|
||||
* - если marker отсутствует, а tmp/check остались, это мусор;
|
||||
* - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор.
|
||||
/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL.
|
||||
* Local .bch/tmp/marker files are intentionally not used.
|
||||
*/
|
||||
public final class BlockchainWriter {
|
||||
|
||||
private static final HexFormat HEX = HexFormat.of();
|
||||
|
||||
private final BlocksDAO blocksDAO;
|
||||
private final BlockchainStateDAO stateDAO;
|
||||
@@ -49,7 +31,6 @@ public final class BlockchainWriter {
|
||||
private final UserParamsDAO userParamsDAO;
|
||||
private final UserNotificationsStateDAO userNotificationsStateDAO;
|
||||
private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance();
|
||||
private final FileStoreUtil fs = FileStoreUtil.getInstance();
|
||||
|
||||
public BlockchainWriter(BlocksDAO blocksDAO,
|
||||
BlockchainStateDAO stateDAO,
|
||||
@@ -63,6 +44,10 @@ public final class BlockchainWriter {
|
||||
this.userNotificationsStateDAO = userNotificationsStateDAO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction.
|
||||
* There is intentionally no .bch/tmp/marker file layer anymore.
|
||||
*/
|
||||
public List<String> appendBlockAndState(String blockchainName,
|
||||
BchBlockEntry block,
|
||||
BlockchainStateEntry st,
|
||||
@@ -73,48 +58,24 @@ public final class BlockchainWriter {
|
||||
UserNotificationEntry notificationEntry) throws SQLException {
|
||||
|
||||
long nowMs = System.currentTimeMillis();
|
||||
byte[] blockBytes = block.toBytes();
|
||||
byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes);
|
||||
String blockHashHex = HEX.formatHex(block.getHash32());
|
||||
|
||||
prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes);
|
||||
|
||||
boolean committed = false;
|
||||
byte[] rawDataItem = block.toBytes();
|
||||
List<String> counterChangedLogins = new ArrayList<>();
|
||||
try (Connection c = shine.db.DbController.getInstance().getConnection()) {
|
||||
c.setAutoCommit(false);
|
||||
try {
|
||||
// 1) insert block
|
||||
blocksDAO.insert(c, be);
|
||||
|
||||
// 2) update state
|
||||
st.setLastBlockNumber(block.blockNumber);
|
||||
st.setLastBlockHash(block.getHash32());
|
||||
st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length);
|
||||
st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length);
|
||||
st.setUpdatedAtMs(nowMs);
|
||||
|
||||
stateDAO.upsert(c, st);
|
||||
|
||||
// 2.1) Если блок — USER_PARAM, синхронизируем снимок в users_params в той же транзакции.
|
||||
if (userParamEntry != null) {
|
||||
userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
}
|
||||
if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry);
|
||||
if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
|
||||
if (channelNameStateEntry != null) {
|
||||
channelNameStateDAO.insert(c, channelNameStateEntry);
|
||||
}
|
||||
|
||||
if (channelMetaUpdateEntry != null) {
|
||||
channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry);
|
||||
}
|
||||
|
||||
// Notification projection is part of the same SQL transaction as the block.
|
||||
// If notification indexing fails, the block/state write is rolled back as well.
|
||||
if (notificationEntry != null) {
|
||||
userNotificationsStateDAO.upsert(c, notificationEntry);
|
||||
}
|
||||
|
||||
// Channel read/unread projection is updated in the same SQL transaction as the block.
|
||||
int msgType = block.type & 0xFFFF;
|
||||
int msgSubType = block.subType & 0xFFFF;
|
||||
Integer lineCode = be.getLineCode();
|
||||
@@ -126,7 +87,6 @@ public final class BlockchainWriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit UNFOLLOW removes the projection row. FOLLOW never creates it.
|
||||
if (msgType == 3
|
||||
&& msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF)
|
||||
&& be.getToBchName() != null && !be.getToBchName().isBlank()
|
||||
@@ -138,39 +98,14 @@ public final class BlockchainWriter {
|
||||
}
|
||||
|
||||
c.commit();
|
||||
committed = true;
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
c.rollback();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
|
||||
if (!committed) {
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
}
|
||||
|
||||
if (e instanceof SQLException se) {
|
||||
throw se;
|
||||
}
|
||||
try { c.rollback(); } catch (Exception ignored) {}
|
||||
if (e instanceof SQLException se) throw se;
|
||||
throw new SQLException("appendBlockAndState failed", e);
|
||||
} finally {
|
||||
try {
|
||||
c.setAutoCommit(true);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
try { c.setAutoCommit(true); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) После commit — атомарно подменяем основной файл.
|
||||
try {
|
||||
fs.atomicReplaceBlockchainFile(blockchainName);
|
||||
} catch (RuntimeException e) {
|
||||
// marker/check/tmp оставляем для startup-recovery
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 4) После успешной подмены — чистим временные артефакты.
|
||||
cleanupWriteArtifactsBestEffort(blockchainName);
|
||||
return counterChangedLogins;
|
||||
}
|
||||
|
||||
@@ -183,46 +118,4 @@ public final class BlockchainWriter {
|
||||
|| msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF)
|
||||
|| msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF);
|
||||
}
|
||||
|
||||
private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) {
|
||||
byte[] base;
|
||||
try {
|
||||
if (Files.exists(fs.resolveBlockchainPath(blockchainName))) {
|
||||
base = fs.readBlockchain(blockchainName);
|
||||
} else {
|
||||
base = new byte[0];
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
byte[] out = new byte[base.length + blockBytes.length];
|
||||
System.arraycopy(base, 0, out, 0, base.length);
|
||||
System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) {
|
||||
fs.writeBlockchainTmp(blockchainName, candidateBytes);
|
||||
fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex);
|
||||
fs.writeBlockchainWritePendingMarker(blockchainName);
|
||||
}
|
||||
|
||||
private void cleanupWriteArtifactsBestEffort(String blockchainName) {
|
||||
deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName));
|
||||
deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName));
|
||||
deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName));
|
||||
}
|
||||
|
||||
private static void deleteQuietly(DeleteAction action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DeleteAction {
|
||||
void run();
|
||||
}
|
||||
}
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||||
|
||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||
import server.logic.ws_protocol.JSON.entyties.*;
|
||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.*;
|
||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||
import server.logic.ws_protocol.WireCodes;
|
||||
import shine.db.archive.ArchiveBlockchainLocation;
|
||||
import shine.db.archive.ArchiveBigBlockRef;
|
||||
import shine.db.archive.ArchiveChainCursor;
|
||||
import shine.db.dao.ArchiveImportDAO;
|
||||
import shine.db.dao.ArchivePublicationDAO;
|
||||
import utils.config.AppConfig;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Handler implements JsonMessageHandler {
|
||||
@Override public Net_Response handle(Net_Request base, ConnectionContext ctx) {
|
||||
Net_GetArchiveBlockchainLocation_Request req=(Net_GetArchiveBlockchainLocation_Request)base;
|
||||
String bch=String.valueOf(req.getBlockchainName()==null?"":req.getBlockchainName()).trim();
|
||||
if(bch.isEmpty()) return NetExceptionResponseFactory.error(req,WireCodes.Status.BAD_REQUEST,"BAD_FIELDS","blockchainName обязателен");
|
||||
try {
|
||||
ArchiveImportDAO importDao = ArchiveImportDAO.getInstance();
|
||||
ArchiveBlockchainLocation e=importDao.getLocation(bch);
|
||||
if (e == null) {
|
||||
// Backward-compatible fallback для publisher-а, который уже имел v22 cursors до появления v23 location index.
|
||||
ArchivePublicationDAO publicationDao = ArchivePublicationDAO.getInstance();
|
||||
ArchiveChainCursor cursor = publicationDao.getCursor(bch);
|
||||
if (cursor != null) {
|
||||
ArchiveBigBlockRef matching = publicationDao.listFinalizedBigBlocks().stream()
|
||||
.filter(ref -> ref.bigBlockNumber() == cursor.lastArchiveBigBlockNumber())
|
||||
.findFirst().orElse(null);
|
||||
if (matching != null && matching.arweaveTxId() != null && matching.arweaveTxId().length == 32) {
|
||||
String publisher = java.util.Objects.toString(AppConfig.getInstance().getParam("server.SHiNE.login"), "").trim();
|
||||
String txId = Base64.getUrlEncoder().withoutPadding().encodeToString(matching.arweaveTxId());
|
||||
e = new ArchiveBlockchainLocation(
|
||||
bch, publisher, txId, cursor.lastArchiveBigBlockHash(), cursor.lastArchiveBigBlockNumber(),
|
||||
cursor.lastChunkOffset(), cursor.lastChunkSize(), cursor.lastArchivedSourceBlockNumber(), System.currentTimeMillis());
|
||||
importDao.upsertLocation(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(e==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.NOT_FOUND,"ARCHIVE_LOCATION_NOT_FOUND","Архивная ссылка пока не известна этому серверу");
|
||||
Net_GetArchiveBlockchainLocation_Response r=new Net_GetArchiveBlockchainLocation_Response();
|
||||
r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK);
|
||||
r.setBlockchainName(e.blockchainName()); r.setPublisherLogin(e.publisherLogin()); r.setArweaveTxId(e.arweaveTxId()); r.setArchiveHash(hex(e.archiveHash()));
|
||||
r.setBigBlockNumber(e.bigBlockNumber()); r.setChunkOffset(e.chunkOffset()); r.setChunkSize(e.chunkSize()); r.setSourceLastBlockNumber(e.sourceLastBlockNumber()); return r;
|
||||
} catch(Exception e) { return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"INTERNAL_ERROR",NetExceptionResponseFactory.detailedMessage("Не удалось получить архивную ссылку",e)); }
|
||||
}
|
||||
private static String hex(byte[] b){if(b==null)return"";StringBuilder s=new StringBuilder(b.length*2);for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||
}
|
||||
+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();
|
||||
}
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Request extends Net_Request {
|
||||
private String blockchainName;
|
||||
public String getBlockchainName(){return blockchainName;}
|
||||
public void setBlockchainName(String value){this.blockchainName=value;}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||
|
||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||
|
||||
public final class Net_GetArchiveBlockchainLocation_Response extends Net_Response {
|
||||
private String blockchainName,publisherLogin,arweaveTxId,archiveHash;
|
||||
private long bigBlockNumber,chunkOffset,chunkSize,sourceLastBlockNumber;
|
||||
public String getBlockchainName(){return blockchainName;} public void setBlockchainName(String v){blockchainName=v;}
|
||||
public String getPublisherLogin(){return publisherLogin;} public void setPublisherLogin(String v){publisherLogin=v;}
|
||||
public String getArweaveTxId(){return arweaveTxId;} public void setArweaveTxId(String v){arweaveTxId=v;}
|
||||
public String getArchiveHash(){return archiveHash;} public void setArchiveHash(String v){archiveHash=v;}
|
||||
public long getBigBlockNumber(){return bigBlockNumber;} public void setBigBlockNumber(long v){bigBlockNumber=v;}
|
||||
public long getChunkOffset(){return chunkOffset;} public void setChunkOffset(long v){chunkOffset=v;}
|
||||
public long getChunkSize(){return chunkSize;} public void setChunkSize(long v){chunkSize=v;}
|
||||
public long getSourceLastBlockNumber(){return sourceLastBlockNumber;} public void setSourceLastBlockNumber(long v){sourceLastBlockNumber=v;}
|
||||
}
|
||||
+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; }
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
+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 {
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user