SHA256
Compare commits
11
Commits
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
9be4c4d3a5 | ||
|
|
f5698771e4 | ||
|
|
151a2c1754 | ||
|
|
13693a0a53 | ||
|
|
451280edd2 | ||
|
|
c997057a37 | ||
|
|
f8900e531a | ||
|
|
6e5b57fd7c | ||
|
|
408e474130 | ||
|
|
ef707ec217 | ||
|
|
8b12760dde |
@@ -43,8 +43,9 @@
|
|||||||
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
|
- Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте.
|
||||||
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
|
- Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в:
|
||||||
- `docs/Solana_Architecture/README.md`
|
- `docs/Solana_Architecture/README.md`
|
||||||
- Документ формата пользовательской PDA-записи `shine_users` находится в:
|
- Документы формата пользовательской PDA-записи `shine_users`:
|
||||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
- текущий: `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 находится в:
|
- Актуальная документация по серверному модулю синхронизации Solana users находится в:
|
||||||
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
- `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md`
|
||||||
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
|
- При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять:
|
||||||
|
|||||||
@@ -52,113 +52,84 @@ function readStrU8(bytes, cursorRef) {
|
|||||||
|
|
||||||
function parseServerFieldsFromUserPda(dataBytes) {
|
function parseServerFieldsFromUserPda(dataBytes) {
|
||||||
const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(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 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));
|
const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5));
|
||||||
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
|
if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA');
|
||||||
cursorRef.value += 1; // format_major
|
const formatMajor = readU8(bytes, cursorRef);
|
||||||
cursorRef.value += 1; // format_minor
|
const formatMinor = readU8(bytes, cursorRef);
|
||||||
cursorRef.value += 2; // record_len
|
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; // created_at_ms
|
||||||
cursorRef.value += 8; // updated_at_ms
|
cursorRef.value += 8; // updated_at_ms
|
||||||
cursorRef.value += 4; // record_number
|
cursorRef.value += 4; // record_number
|
||||||
cursorRef.value += 32; // prev_record_hash
|
cursorRef.value += 32; // prev_record_hash
|
||||||
readStrU8(bytes, cursorRef); // login
|
const login = readStrU8(bytes, cursorRef);
|
||||||
const blocksCount = readU8(bytes, cursorRef);
|
const blocksCount = readU8(bytes, cursorRef);
|
||||||
|
|
||||||
let isServer = false;
|
|
||||||
let serverAddress = '';
|
let serverAddress = '';
|
||||||
let accessServers = [];
|
let accessServers = [];
|
||||||
let recoveryKey32 = null;
|
|
||||||
let rootKey32 = null;
|
let rootKey32 = null;
|
||||||
let clientKey32 = null;
|
let clientKey32 = null;
|
||||||
let blockchainKey32 = null;
|
let blockchainKey32 = null;
|
||||||
let blockchainName = '';
|
let forkCount = 0;
|
||||||
let homeserverSessions = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < blocksCount; i += 1) {
|
for (let i = 0; i < blocksCount; i += 1) {
|
||||||
const blockType = readU8(bytes, cursorRef);
|
const blockType = readU8(bytes, cursorRef);
|
||||||
cursorRef.value += 1; // block_version
|
const blockVersion = readU8(bytes, cursorRef);
|
||||||
|
if (blockVersion !== 0) throw new Error('Неподдерживаемая версия блока PDA');
|
||||||
if (blockType === 0 || blockType === 1 || blockType === 2) {
|
if (blockType === 1 || blockType === 2) {
|
||||||
const key32 = readBytes(bytes, cursorRef, 32);
|
const key32 = readBytes(bytes, cursorRef, 32);
|
||||||
if (blockType === 0) recoveryKey32 = key32;
|
|
||||||
if (blockType === 1) rootKey32 = key32;
|
if (blockType === 1) rootKey32 = key32;
|
||||||
if (blockType === 2) clientKey32 = key32;
|
else clientKey32 = key32;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payloadLen = readU16();
|
||||||
|
const payloadEnd = cursorRef.value + payloadLen;
|
||||||
|
if (payloadEnd > recordLen - 64) throw new Error('Повреждённый variable block PDA');
|
||||||
|
|
||||||
if (blockType === 3) {
|
if (blockType === 3) {
|
||||||
const count = readU8(bytes, cursorRef);
|
forkCount = readU16();
|
||||||
for (let j = 0; j < count; j += 1) {
|
if (forkCount < 1) throw new Error('BlockchainRegistry пуст');
|
||||||
cursorRef.value += 1;
|
for (let j = 0; j < forkCount; j += 1) {
|
||||||
const currentBlockchainName = readStrU8(bytes, cursorRef);
|
blockchainKey32 = readBytes(bytes, cursorRef, 32);
|
||||||
const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32);
|
readU64(); // created_at_ms fork
|
||||||
if (!blockchainKey32) {
|
readU32(); // paid_limit_bytes
|
||||||
blockchainKey32 = currentBlockchainKey32;
|
|
||||||
blockchainName = currentBlockchainName;
|
|
||||||
}
|
|
||||||
cursorRef.value += 8 + 8 + 4 + 32 + 64;
|
|
||||||
const arPresent = readU8(bytes, cursorRef);
|
|
||||||
if (arPresent === 1) readStrU8(bytes, cursorRef);
|
|
||||||
}
|
}
|
||||||
continue;
|
} else if (blockType === 30) {
|
||||||
}
|
const addressCount = readU8(bytes, cursorRef);
|
||||||
if (blockType === 30) {
|
if (addressCount !== 1) throw new Error('PDA 1.2 допускает ровно один адрес сервера');
|
||||||
isServer = readU8(bytes, cursorRef) === 1;
|
readU8(bytes, cursorRef); // address_format_type
|
||||||
if (isServer) {
|
readU8(bytes, cursorRef); // address_format_version
|
||||||
cursorRef.value += 1; // address_format_type
|
serverAddress = readStrU8(bytes, cursorRef);
|
||||||
cursorRef.value += 1; // address_format_version
|
} else if (blockType === 40) {
|
||||||
serverAddress = readStrU8(bytes, cursorRef);
|
|
||||||
const syncCount = readU8(bytes, cursorRef);
|
|
||||||
for (let j = 0; j < syncCount; j += 1) readStrU8(bytes, cursorRef);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (blockType === 40) {
|
|
||||||
const accessCount = readU8(bytes, cursorRef);
|
const accessCount = readU8(bytes, cursorRef);
|
||||||
|
if (accessCount > 1) throw new Error('PDA 1.2 допускает максимум один access server');
|
||||||
accessServers = [];
|
accessServers = [];
|
||||||
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
|
for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef));
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (blockType === 50) {
|
cursorRef.value = payloadEnd;
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!rootKey32 || !clientKey32 || !blockchainKey32) throw new Error('PDA 1.2 не содержит обязательные ключи');
|
||||||
return {
|
return {
|
||||||
isServer,
|
isServer: Boolean(serverAddress),
|
||||||
serverAddress: normalizeHostLike(serverAddress),
|
serverAddress: normalizeHostLike(serverAddress),
|
||||||
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
|
accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean),
|
||||||
publicKeys: {
|
publicKeys: {
|
||||||
recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '',
|
recoveryKeyBase58: '',
|
||||||
rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '',
|
rootKeyBase58: new PublicKey(rootKey32).toBase58(),
|
||||||
clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '',
|
clientKeyBase58: new PublicKey(clientKey32).toBase58(),
|
||||||
blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '',
|
blockchainKeyBase58: new PublicKey(blockchainKey32).toBase58(),
|
||||||
blockchainName,
|
blockchainName: `${normalizeServerLogin(login)}-${String(forkCount).padStart(3, '0')}`,
|
||||||
},
|
},
|
||||||
homeserverSessions,
|
homeserverSessions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-15
@@ -18,15 +18,11 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web
|
|||||||
|
|
||||||
## Настройка сервера в Solana (Solana PDA)
|
## Настройка сервера в Solana (Solana PDA)
|
||||||
|
|
||||||
Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`.
|
Серверный аккаунт SHiNE регистрируется как обычный `user_pda` формата 1.2 с присутствующим `ServerProfileBlock`. Отдельного `is_server` в PDA нет: наличие server profile означает, что аккаунт объявляет серверный endpoint.
|
||||||
В PDA хранятся:
|
|
||||||
|
|
||||||
- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`);
|
PDA 1.2 сейчас допускает **один** адрес сервера (`address_format_type + address_format_version + address`). Бинарный блок использует count/array, чтобы будущая версия протокола могла увеличить лимит без смены структуры блока. Прямой список `sync_servers` удалён: пользовательские blockchain синхронизируются через Arweave.
|
||||||
- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров,
|
|
||||||
с которыми синхронизируются пользовательские блокчейны;
|
|
||||||
- **корневой ключ** сервера (`root_key`).
|
|
||||||
|
|
||||||
Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться.
|
Клиенты читают server PDA из Solana, получают опубликованный endpoint и подключаются к нему. Межсерверная доставка DM через access-server routing остаётся отдельным механизмом.
|
||||||
|
|
||||||
**Управление серверной PDA выполняется через Web-панель администратора:**
|
**Управление серверной PDA выполняется через Web-панель администратора:**
|
||||||
|
|
||||||
@@ -36,10 +32,9 @@ shine-UI/server-ui.html
|
|||||||
|
|
||||||
Страницы:
|
Страницы:
|
||||||
- `shine-UI/server-ui/create-server-pda.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).
|
PDA 1.2 использует три постоянные роли ключей: `root` (cold recovery), `blockchain` (обычный authority и подпись блоков) и `client` (клиент/кошелёк). Обычный update авторизуется активным последним blockchain key; root используется для recovery и может менять root.
|
||||||
Для обновления — только root + device (blockchain-ключ не нужен).
|
|
||||||
|
|
||||||
Актуальные адреса программ Solana (devnet):
|
Актуальные адреса программ Solana (devnet):
|
||||||
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
|
- `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6`
|
||||||
@@ -47,12 +42,9 @@ shine-UI/server-ui.html
|
|||||||
|
|
||||||
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
|
Подробнее: `docs/Инициализация_Solana_регистрации/README.md`
|
||||||
|
|
||||||
## Синхронизация с партнёрскими серверами
|
## Синхронизация пользовательских blockchain
|
||||||
|
|
||||||
Сервер должен синхронизировать блоки пользовательских блокчейнов с
|
Прямой peer-to-peer blockchain sync через `sync_servers` отключён. Сервер восстанавливает и синхронизирует пользовательские записи через Arweave. Код старого peer-sync пока может оставаться как inert legacy, но не должен включаться в runtime.
|
||||||
серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются:
|
|
||||||
они доставляются только на первый access-сервер получателя.
|
|
||||||
Детали: `docs/Blockchain/sync-between-servers.md`
|
|
||||||
|
|
||||||
## Деплой
|
## Деплой
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@ public final class ArweaveBlockPublisherScheduler {
|
|||||||
ArweaveBlocksConfig cfg;
|
ArweaveBlocksConfig cfg;
|
||||||
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
|
try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); }
|
||||||
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
|
catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; }
|
||||||
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled"); return; }
|
if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled (mode=none)"); return; }
|
||||||
if (!STARTED.compareAndSet(false,true)) return;
|
if (!STARTED.compareAndSet(false,true)) return;
|
||||||
try {
|
try {
|
||||||
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
|
ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg);
|
||||||
@@ -29,7 +29,7 @@ public final class ArweaveBlockPublisherScheduler {
|
|||||||
});
|
});
|
||||||
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
|
Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } };
|
||||||
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
|
executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES);
|
||||||
log.info("Arweave user-block publisher enabled: interval={}m gateway={}", cfg.publishIntervalMinutes(), cfg.publishGateway());
|
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); }
|
} catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+127
-12
@@ -3,28 +3,142 @@ package server.archive;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import shine.db.dao.BlocksDAO;
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** Publishes locally-created signed user DataItems as one standard ANS-104 bundle. */
|
/** Publishes locally-created signed user DataItems through the configured transport. */
|
||||||
public final class ArweaveBlockPublisherService {
|
public final class ArweaveBlockPublisherService {
|
||||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
|
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class);
|
||||||
|
|
||||||
private final ArweaveBlocksConfig cfg;
|
private final ArweaveBlocksConfig cfg;
|
||||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
private final ArweaveL1Uploader uploader;
|
private final KeyRotationCandidateBlocksDAO candidateBlocksDAO = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
|
private final ArweaveL1Uploader arweaveUploader;
|
||||||
|
private final TurboDataItemUploader turboUploader;
|
||||||
|
|
||||||
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
|
public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) {
|
||||||
this.cfg = cfg;
|
this.cfg = cfg;
|
||||||
this.uploader = new ArweaveL1Uploader(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 {
|
public int runCycle() throws Exception {
|
||||||
List<BlockEntry> candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems());
|
if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0;
|
||||||
if (candidates.isEmpty()) 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[]> items = new ArrayList<>();
|
||||||
List<byte[]> ids = new ArrayList<>();
|
List<byte[]> ids = new ArrayList<>();
|
||||||
long estimated = 32;
|
long estimated = 32;
|
||||||
@@ -39,7 +153,9 @@ public final class ArweaveBlockPublisherService {
|
|||||||
e.getBchName(), e.getBlockNumber(), raw.length);
|
e.getBchName(), e.getBlockNumber(), raw.length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
items.add(raw); ids.add(id); estimated = next;
|
items.add(raw);
|
||||||
|
ids.add(id);
|
||||||
|
estimated = next;
|
||||||
}
|
}
|
||||||
if (items.isEmpty()) return 0;
|
if (items.isEmpty()) return 0;
|
||||||
|
|
||||||
@@ -47,13 +163,12 @@ public final class ArweaveBlockPublisherService {
|
|||||||
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
List<ArweaveL1Uploader.Tag> rootTags = List.of(
|
||||||
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"),
|
||||||
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
new ArweaveL1Uploader.Tag("Bundle-Format", "binary"),
|
||||||
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0"),
|
new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0")
|
||||||
// Deliberately different from App=test5590 so discovery returns child DataItems only.
|
|
||||||
new ArweaveL1Uploader.Tag("App", "test5590-batch")
|
|
||||||
);
|
);
|
||||||
ArweaveL1Uploader.UploadResult result = uploader.upload(bundle, rootTags);
|
ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags);
|
||||||
blocksDAO.markArweavePublished(ids, result.txId(), System.currentTimeMillis());
|
blocksDAO.markArweavePublished(ids, System.currentTimeMillis());
|
||||||
log.info("Published {} SHiNE test DataItems in root tx {} (bundle={} bytes)", items.size(), result.txId(), bundle.length);
|
log.info("Published {} SHiNE test DataItems in direct Arweave root tx {} (bundle={} bytes)",
|
||||||
|
items.size(), result.txId(), bundle.length);
|
||||||
return items.size();
|
return items.size();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-38
@@ -14,7 +14,9 @@ import shine.db.dao.SolanaUserPdaCurrentDAO;
|
|||||||
import shine.db.entities.BlockchainStateEntry;
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
@@ -24,8 +26,9 @@ import java.time.Duration;
|
|||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discovers App=test5590 child DataItems, extracts their exact serialized bytes
|
* Discovers individual App=test5590 DataItems and imports the exact signed ANS-104 bytes.
|
||||||
* from the root ANS-104 bundle and imports them through the normal AddBlock checks.
|
* 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 {
|
public final class ArweaveBlockSyncService {
|
||||||
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
|
private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class);
|
||||||
@@ -33,7 +36,10 @@ public final class ArweaveBlockSyncService {
|
|||||||
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
|
private static final Base64.Decoder B64URL = Base64.getUrlDecoder();
|
||||||
|
|
||||||
private final ArweaveBlocksConfig cfg;
|
private final ArweaveBlocksConfig cfg;
|
||||||
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build();
|
private final HttpClient http = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(20))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build();
|
||||||
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
|
private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance();
|
||||||
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
private final BlocksDAO blocksDAO = BlocksDAO.getInstance();
|
||||||
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
|
private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance();
|
||||||
@@ -52,7 +58,7 @@ public final class ArweaveBlockSyncService {
|
|||||||
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
|
long minHeight = Math.max(cfg.syncStartBlockHeight(), stored);
|
||||||
String cursor = null;
|
String cursor = null;
|
||||||
long maxHeight = minHeight;
|
long maxHeight = minHeight;
|
||||||
Map<String, byte[]> rootCache = new HashMap<>();
|
long lowestRetryHeight = Long.MAX_VALUE;
|
||||||
int discovered = 0;
|
int discovered = 0;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -66,36 +72,40 @@ public final class ArweaveBlockSyncService {
|
|||||||
for (JsonNode edge : edges) {
|
for (JsonNode edge : edges) {
|
||||||
nextCursor = edge.path("cursor").asText(null);
|
nextCursor = edge.path("cursor").asText(null);
|
||||||
JsonNode node = edge.path("node");
|
JsonNode node = edge.path("node");
|
||||||
String itemIdText = node.path("id").asText("").trim();
|
String dataItemId = node.path("id").asText("").trim();
|
||||||
String rootTx = node.path("bundledIn").path("id").asText("").trim();
|
|
||||||
long height = node.path("block").path("height").asLong(-1L);
|
long height = node.path("block").path("height").asLong(-1L);
|
||||||
if (itemIdText.isBlank() || rootTx.isBlank() || height < 0) continue;
|
if (dataItemId.isBlank() || height < 0) continue;
|
||||||
maxHeight = Math.max(maxHeight, height);
|
maxHeight = Math.max(maxHeight, height);
|
||||||
byte[] id32;
|
|
||||||
try { id32 = B64URL.decode(itemIdText); }
|
|
||||||
catch (IllegalArgumentException bad) { continue; }
|
|
||||||
if (id32.length != 32 || blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
|
||||||
|
|
||||||
byte[] bundle = rootCache.computeIfAbsent(rootTx, key -> {
|
byte[] id32;
|
||||||
try { return downloadRootBundle(key); }
|
try {
|
||||||
catch (Exception e) { throw new RootDownloadRuntimeException(e); }
|
id32 = B64URL.decode(dataItemId);
|
||||||
});
|
if (id32.length != 32) throw new IllegalArgumentException("id length=" + id32.length);
|
||||||
byte[] raw = Ans104Bundle.find(bundle, id32, Math.max(cfg.publishMaxItems() * 4, 100_000));
|
} catch (Exception e) {
|
||||||
if (raw == null) throw new IOException("DataItem " + itemIdText + " not found in root bundle " + rootTx);
|
log.warn("Ignoring malformed Arweave DataItem id {}: {}", dataItemId, e.getMessage());
|
||||||
Ans104DataItem parsed = new Ans104DataItem(raw);
|
continue;
|
||||||
if (!parsed.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) continue;
|
}
|
||||||
if (!parsed.verifySignature()) throw new IOException("Bad ANS-104 signature for " + itemIdText);
|
if (blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue;
|
||||||
importDAO.enqueueIfMissing(id32, rootTx, height, raw, System.currentTimeMillis());
|
|
||||||
discovered++;
|
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);
|
boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false);
|
||||||
cursor = hasNext ? nextCursor : null;
|
cursor = hasNext ? nextCursor : null;
|
||||||
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
|
if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor");
|
||||||
} while (cursor != null);
|
} while (cursor != null);
|
||||||
|
|
||||||
// Keep one-height overlap: the next query includes this height and deduplicates IDs.
|
// Keep inclusive overlap. If a gateway has indexed GraphQL before offsets, do not advance past that item.
|
||||||
importDAO.setLastBlockHeight(maxHeight, System.currentTimeMillis());
|
long checkpoint = lowestRetryHeight == Long.MAX_VALUE ? maxHeight : Math.min(maxHeight, lowestRetryHeight);
|
||||||
if (discovered > 0) log.info("Arweave discovery queued {} new SHiNE test DataItems through height {}", discovered, maxHeight);
|
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 {
|
private void drainQueue() throws Exception {
|
||||||
@@ -108,13 +118,12 @@ public final class ArweaveBlockSyncService {
|
|||||||
if (pending.isEmpty()) break;
|
if (pending.isEmpty()) break;
|
||||||
for (ArweaveBlockImportDAO.QueueItem q : pending) {
|
for (ArweaveBlockImportDAO.QueueItem q : pending) {
|
||||||
Ans104DataItem item;
|
Ans104DataItem item;
|
||||||
BchBlockEntry block;
|
|
||||||
try {
|
try {
|
||||||
item = new Ans104DataItem(q.rawDataItem());
|
item = new Ans104DataItem(q.rawDataItem());
|
||||||
if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch");
|
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.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");
|
if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature");
|
||||||
block = new BchBlockEntry(q.rawDataItem());
|
new BchBlockEntry(q.rawDataItem());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
|
importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis());
|
||||||
continue;
|
continue;
|
||||||
@@ -158,7 +167,10 @@ public final class ArweaveBlockSyncService {
|
|||||||
|
|
||||||
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
|
private JsonNode graphQlPage(long minHeight, String cursor) throws Exception {
|
||||||
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
|
String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\"";
|
||||||
String query = "query { transactions(tags:[{name:\"App\",values:[\"test5590\"]}], block:{min:" + minHeight + "}, first:" + cfg.syncPageSize() + ", after:" + after + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id bundledIn { id } block { height } } } } }";
|
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));
|
String body = MAPPER.writeValueAsString(Map.of("query", query));
|
||||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
|
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql"))
|
||||||
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
|
.timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json")
|
||||||
@@ -168,18 +180,73 @@ public final class ArweaveBlockSyncService {
|
|||||||
return MAPPER.readTree(resp.body());
|
return MAPPER.readTree(resp.body());
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] downloadRootBundle(String txId) throws Exception {
|
/**
|
||||||
HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/" + txId))
|
* Gateways normally expose only the payload at /{dataItemId}. SHiNE needs the complete signed
|
||||||
.timeout(Duration.ofMinutes(5)).GET().build();
|
* DataItem, so obtain its exact offset/size inside the root L1 transaction and range-read it.
|
||||||
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
|
*/
|
||||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("Arweave root HTTP " + resp.statusCode() + " tx=" + txId);
|
private byte[] downloadSignedDataItem(String dataItemId) throws Exception {
|
||||||
byte[] body = resp.body();
|
JsonNode offsets = getOffsets(dataItemId);
|
||||||
if (body == null || body.length == 0) throw new IOException("Empty Arweave root bundle " + txId);
|
String rootTxId = offsets.path("rootTxId").asText("").trim();
|
||||||
if (body.length > cfg.syncMaxRootBundleBytes()) throw new IOException("Root bundle exceeds syncMaxRootBundleBytes: " + body.length);
|
long rootOffset = offsets.path("rootOffset").asLong(-1L);
|
||||||
return body;
|
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 trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");}
|
||||||
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
|
private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");}
|
||||||
private static final class RootDownloadRuntimeException extends RuntimeException { RootDownloadRuntimeException(Throwable cause){super(cause);} }
|
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-8
@@ -1,12 +1,14 @@
|
|||||||
package server.archive;
|
package server.archive;
|
||||||
|
|
||||||
|
import blockchain.Ans104DataItem;
|
||||||
import utils.config.AppConfig;
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
/** Configuration of the new per-user-block ANS-104 Arweave transport. */
|
/** Configuration of per-user-block ANS-104 Arweave/Turbo transport. */
|
||||||
public record ArweaveBlocksConfig(
|
public record ArweaveBlocksConfig(
|
||||||
boolean publishEnabled,
|
PublishMode publishMode,
|
||||||
int publishIntervalMinutes,
|
int publishIntervalMinutes,
|
||||||
int publishMaxItems,
|
int publishMaxItems,
|
||||||
long publishMaxBundleBytes,
|
long publishMaxBundleBytes,
|
||||||
@@ -15,22 +17,44 @@ public record ArweaveBlocksConfig(
|
|||||||
int minConfirmations,
|
int minConfirmations,
|
||||||
int confirmPollSeconds,
|
int confirmPollSeconds,
|
||||||
int confirmTimeoutMinutes,
|
int confirmTimeoutMinutes,
|
||||||
|
String turboUploadUrl,
|
||||||
|
String turboPaidByAddress,
|
||||||
|
Path turboWalletJwkPath,
|
||||||
boolean syncEnabled,
|
boolean syncEnabled,
|
||||||
int syncIntervalMinutes,
|
int syncIntervalMinutes,
|
||||||
int syncPageSize,
|
int syncPageSize,
|
||||||
int syncQueueBatchSize,
|
int syncQueueBatchSize,
|
||||||
long syncStartBlockHeight,
|
long syncStartBlockHeight,
|
||||||
long syncMaxRootBundleBytes,
|
long syncMaxDataItemBytes,
|
||||||
String syncGateway
|
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_NAME = "App";
|
||||||
public static final String TEST_TAG_VALUE = "test5590";
|
public static final String TEST_TAG_VALUE = "test5590";
|
||||||
public static final String CHANNEL_TAG_NAME = "c";
|
public static final String CHANNEL_TAG_NAME = "c_test5590";
|
||||||
|
|
||||||
public static ArweaveBlocksConfig load() {
|
public static ArweaveBlocksConfig load() {
|
||||||
AppConfig c = AppConfig.getInstance();
|
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(
|
return new ArweaveBlocksConfig(
|
||||||
c.getBoolean("arweave.blocks.publish.enabled", false),
|
PublishMode.parse(c.getParam("arweave.blocks.publish.mode")),
|
||||||
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
|
positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"),
|
||||||
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
|
positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"),
|
||||||
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
|
positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"),
|
||||||
@@ -39,23 +63,32 @@ public record ArweaveBlocksConfig(
|
|||||||
nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"),
|
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.confirmPollSeconds", 30), "publish.confirmPollSeconds"),
|
||||||
positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"),
|
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),
|
c.getBoolean("arweave.blocks.sync.enabled", false),
|
||||||
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
|
positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"),
|
||||||
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
|
clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100),
|
||||||
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
|
positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"),
|
||||||
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
|
nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"),
|
||||||
positiveLong(parseLong(c.getParam("arweave.blocks.sync.maxRootBundleBytes"), 256L * 1024 * 1024), "sync.maxRootBundleBytes"),
|
positiveLong(maxDataItemBytes, "sync.maxDataItemBytes"),
|
||||||
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
|
orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean publishEnabled() { return publishMode != PublishMode.NONE; }
|
||||||
|
|
||||||
public void validatePublisher() {
|
public void validatePublisher() {
|
||||||
if (publishEnabled && walletJwkPath == null) {
|
if (publishMode == PublishMode.ARWEAVE && walletJwkPath == null) {
|
||||||
throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required when publisher is enabled");
|
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 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 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 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 positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;}
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ public final class BodyRecordParser {
|
|||||||
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
&& (v == (CreateChannelBody.VER & 0xFFFF))) {
|
||||||
return new CreateChannelBody(subType, version, bodyBytes).check();
|
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(
|
throw new IllegalArgumentException(
|
||||||
String.format("Unknown TECH body type/version/subType: type=%d ver=%d subType=%d", t, v, st)
|
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). */
|
/** HeaderBody: subType всегда 0 (compat). */
|
||||||
public static final short HEADER_COMPAT = 0;
|
public static final short HEADER_COMPAT = 0;
|
||||||
public static final short TECH_CREATE_CHANNEL = 1;
|
public static final short TECH_CREATE_CHANNEL = 1;
|
||||||
|
/** Новый fork/ротация ключей: ссылка на родительскую цепочку и точку отката. */
|
||||||
|
public static final short TECH_FORK = 2;
|
||||||
|
|
||||||
/* ===================== TEXT (msg_type=1) ===================== */
|
/* ===================== TEXT (msg_type=1) ===================== */
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* REPLY — ответ на сообщение.
|
* REPLY — ответ на сообщение.
|
||||||
* НЕ в линии. Имеет target (toBlockchainName + blockNumber + hash32).
|
* НЕ в линии. Имеет target (toLogin + blockNumber + hash32).
|
||||||
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
* Может указывать на чужой блокчейн/чужую линию/чужой канал.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPLY = 20;
|
public static final short TEXT_REPLY = 20;
|
||||||
@@ -69,7 +71,7 @@ public final class MsgSubType {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
* REPOST — отложенная будущая заготовка репоста сообщения в линии канала.
|
||||||
* Имеет hasLine + target (toBlockchainName + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
* Имеет hasLine + target (toLogin + toBlockGlobalNumber + toBlockHash32) + текст комментария.
|
||||||
*/
|
*/
|
||||||
public static final short TEXT_REPOST = 50;
|
public static final short TEXT_REPOST = 50;
|
||||||
|
|
||||||
|
|||||||
+18
-17
@@ -1,31 +1,32 @@
|
|||||||
package blockchain.body;
|
package blockchain.body;
|
||||||
|
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BodyHasTarget — дополнительный интерфейс для body, которые "ссылаются" на цель (to-поля).
|
* BodyHasTarget — дополнительный интерфейс для body, которые ссылаются на логическую цель.
|
||||||
*
|
*
|
||||||
* Новое правило:
|
* Актуальное правило target:
|
||||||
* - toLogin НЕ храним в байтах блока.
|
* - в подписываемых байтах хранится login цели;
|
||||||
* - toLogin всегда вычисляется из toBchName по стандарту login+"-NNN".
|
* - номер fork/blockchainName в target не хранится;
|
||||||
|
* - идентичность цели задаётся как login + blockNumber + blockHash.
|
||||||
*
|
*
|
||||||
* Все методы могут возвращать null.
|
* Это позволяет одной и той же логической записи сохранять ссылки после fork,
|
||||||
|
* если её номер и SHA-256 hash остались прежними.
|
||||||
*/
|
*/
|
||||||
public interface BodyHasTarget {
|
public interface BodyHasTarget {
|
||||||
|
|
||||||
/** login цели (nullable). Вычисляется из toBchName(). */
|
/** login цели. Актуальные runtime-body обязаны переопределять этот метод. */
|
||||||
default String toLogin() {
|
default String toLogin() { return null; }
|
||||||
String bch = toBchName();
|
|
||||||
if (bch == null) return null;
|
|
||||||
return BlockchainNameUtil.loginFromBlockchainName(bch);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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();
|
Integer toBlockGlobalNumber();
|
||||||
|
|
||||||
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
/** hash целевого блока (обычно 32 байта). Может быть null, если ссылки нет. */
|
||||||
byte[] toBlockHashBytes();
|
byte[] toBlockHashBytes();
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-158
@@ -1,7 +1,6 @@
|
|||||||
package blockchain.body;
|
package blockchain.body;
|
||||||
|
|
||||||
import blockchain.MsgSubType;
|
import blockchain.MsgSubType;
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.ByteOrder;
|
import java.nio.ByteOrder;
|
||||||
@@ -12,96 +11,38 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
* ConnectionBody — type=3, ver=1 (в заголовке блока).
|
||||||
*
|
*
|
||||||
* subType (в заголовке блока) как MsgSubType:
|
* bodyBytes (BigEndian):
|
||||||
* 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 НЕ ХРАНИМ):
|
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
* [4] thisLineNumber
|
* [4] thisLineNumber
|
||||||
*
|
* [1] toLoginLen (uint8)
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [N] toLogin UTF-8
|
||||||
* [N] toBlockchainName UTF-8
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [4] toBlockGlobalNumber (int32)
|
|
||||||
* [32] toBlockHash32 (raw 32 bytes)
|
* [32] toBlockHash32 (raw 32 bytes)
|
||||||
*
|
*
|
||||||
* toLogin вычисляется автоматически из toBlockchainName:
|
* Номер fork/blockchainName в target не хранится.
|
||||||
* toLogin = BlockchainNameUtil.loginFromBlockchainName(toBlockchainName)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* =========================================================================
|
|
||||||
* ПРАВИЛО 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 final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasLine {
|
||||||
|
|
||||||
public static final short TYPE = 3;
|
public static final short TYPE = 3;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // из header
|
public final short version;
|
||||||
|
|
||||||
// line
|
|
||||||
public final int lineCode;
|
public final int lineCode;
|
||||||
public final int prevLineNumber;
|
public final int prevLineNumber;
|
||||||
public final byte[] prevLineHash32;
|
public final byte[] prevLineHash32;
|
||||||
public final int thisLineNumber;
|
public final int thisLineNumber;
|
||||||
|
|
||||||
// payload
|
public final String toLogin;
|
||||||
public final String toBlockchainName;
|
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
public ConnectionBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
|
||||||
@@ -111,34 +52,25 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
if (!isValidSubType(this.subType)) {
|
if (!isValidSubType(this.subType)) {
|
||||||
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad connection subType: " + (this.subType & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
if (bodyBytes.length < 4 + 4 + 32 + 4 + 1 + 1 + 4 + 32) {
|
||||||
// минимум:
|
|
||||||
// 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) {
|
|
||||||
throw new IllegalArgumentException("ConnectionBody too short");
|
throw new IllegalArgumentException("ConnectionBody too short");
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
this.lineCode = bb.getInt();
|
this.lineCode = bb.getInt();
|
||||||
|
|
||||||
this.prevLineNumber = bb.getInt();
|
this.prevLineNumber = bb.getInt();
|
||||||
|
|
||||||
this.prevLineHash32 = new byte[32];
|
this.prevLineHash32 = new byte[32];
|
||||||
bb.get(this.prevLineHash32);
|
bb.get(this.prevLineHash32);
|
||||||
|
|
||||||
this.thisLineNumber = bb.getInt();
|
this.thisLineNumber = bb.getInt();
|
||||||
|
|
||||||
int bchLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (bchLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
if (bb.remaining() < bchLen + 4 + 32) throw new IllegalArgumentException("Connection payload too short");
|
if (bb.remaining() < loginLen + 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);
|
|
||||||
|
|
||||||
|
byte[] loginBytes = new byte[loginLen];
|
||||||
|
bb.get(loginBytes);
|
||||||
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
|
|
||||||
@@ -150,39 +82,68 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
byte[] prevLineHash32,
|
byte[] prevLineHash32,
|
||||||
int thisLineNumber,
|
int thisLineNumber,
|
||||||
short subType,
|
short subType,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
int toBlockGlobalNumber,
|
int toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32) {
|
byte[] toBlockHash32) {
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
|
||||||
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
if (lineCode < 0) throw new IllegalArgumentException("lineCode < 0");
|
||||||
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
if (!isValidSubType(subType)) throw new IllegalArgumentException("Bad connection subType: " + (subType & 0xFFFF));
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
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 (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.lineCode = lineCode;
|
this.lineCode = lineCode;
|
||||||
|
|
||||||
this.prevLineNumber = prevLineNumber;
|
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.thisLineNumber = thisLineNumber;
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
this.toLogin = toLogin;
|
||||||
this.toBlockchainName = toBlockchainName;
|
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
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) {
|
private static boolean isValidSubType(short st) {
|
||||||
int v = st & 0xFFFF;
|
int v = st & 0xFFFF;
|
||||||
return v == (MsgSubType.CONNECTION_CLOSE_FRIEND & 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);
|
|| 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) {
|
private static boolean isAllZero32(byte[] b) {
|
||||||
if (b == null || b.length != 32) return true;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasLine ====================== */
|
|
||||||
@Override public int lineCode() { return lineCode; }
|
@Override public int lineCode() { return lineCode; }
|
||||||
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
@Override public int prevLineBlockGlobalNumber() { return prevLineNumber; }
|
||||||
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
@Override public byte[] prevLineBlockHash32() { return prevLineHash32 == null ? null : Arrays.copyOf(prevLineHash32, 32); }
|
||||||
@Override public int lineSeq() { return thisLineNumber; }
|
@Override public int lineSeq() { return thisLineNumber; }
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ===================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@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
|
* 1 = LIKE
|
||||||
* 2 = UNLIKE
|
* 2 = UNLIKE
|
||||||
*
|
*
|
||||||
* bodyBytes (BigEndian), новый формат:
|
* bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [32] toBlockHash32 (raw 32 bytes)
|
* [32] toBlockHash32 (raw 32 bytes)
|
||||||
*
|
*
|
||||||
|
* Номер fork/blockchainName в target не хранится.
|
||||||
* ЛИНИИ НЕТ.
|
* ЛИНИИ НЕТ.
|
||||||
*/
|
*/
|
||||||
public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
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 static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // из header
|
public final short version;
|
||||||
|
|
||||||
public final String toBlockchainName;
|
public final String toLogin;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
@@ -49,40 +50,37 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
if (!isSupportedSubType(this.subType)) {
|
if (!isSupportedSubType(this.subType)) {
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (this.subType & 0xFFFF));
|
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");
|
if (bodyBytes.length < 1 + 1 + 4 + 32) throw new IllegalArgumentException("ReactionBody too short");
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
if (bb.remaining() < nameLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
if (bb.remaining() < loginLen + 4 + 32) throw new IllegalArgumentException("ReactionBody payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
|
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
|
|
||||||
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
if (bb.remaining() != 0) throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining());
|
||||||
}
|
}
|
||||||
|
|
||||||
public ReactionBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
public ReactionBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
|
||||||
this.subType = MsgSubType.REACTION_LIKE;
|
this.subType = MsgSubType.REACTION_LIKE;
|
||||||
this.version = VER;
|
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 (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
}
|
}
|
||||||
@@ -91,37 +89,30 @@ public final class ReactionBody implements BodyRecord, BodyHasTarget {
|
|||||||
public ReactionBody check() {
|
public ReactionBody check() {
|
||||||
if (!isSupportedSubType(subType))
|
if (!isSupportedSubType(subType))
|
||||||
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad reaction subType: " + (subType & 0xFFFF));
|
||||||
|
if (toLogin == null || toLogin.isBlank())
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
throw new IllegalArgumentException("toLogin is blank");
|
||||||
throw new IllegalArgumentException("toBlockchainName is blank");
|
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] nameBytes = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginBytes = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameBytes.length == 0 || nameBytes.length > 255)
|
if (loginBytes.length == 0 || loginBytes.length > 255)
|
||||||
throw new IllegalArgumentException("toBlockchainName utf8 len must be 1..255");
|
throw new IllegalArgumentException("toLogin utf8 len must be 1..255");
|
||||||
|
|
||||||
int cap = 1 + nameBytes.length + 4 + 32;
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginBytes.length + 4 + 32).order(ByteOrder.BIG_ENDIAN);
|
||||||
|
bb.put((byte) loginBytes.length);
|
||||||
ByteBuffer bb = ByteBuffer.allocate(cap).order(ByteOrder.BIG_ENDIAN);
|
bb.put(loginBytes);
|
||||||
bb.put((byte) nameBytes.length);
|
|
||||||
bb.put(nameBytes);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ====================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
|||||||
+24
-35
@@ -13,11 +13,11 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* StatusActionBody — type=5, ver=1.
|
* StatusActionBody — type=5, ver=1.
|
||||||
*
|
*
|
||||||
* Все STATUS_ACTION имеют target на конкретный блок и опциональный текст-пояснение.
|
* Все STATUS_ACTION имеют target на конкретный логический блок и опциональный текст-пояснение.
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -31,7 +31,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
public final String toBlockchainName;
|
public final String toLogin;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
public final String message;
|
public final String message;
|
||||||
@@ -51,33 +51,32 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "STATUS_ACTION too short");
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("STATUS_ACTION toLoginLen is 0");
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "STATUS_ACTION payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
this.message = readStrictUtf8Len16AllowEmpty(bb, "StatusActionBody text");
|
||||||
|
|
||||||
ensureNoTail(bb, "StatusActionBody");
|
ensureNoTail(bb, "StatusActionBody");
|
||||||
}
|
}
|
||||||
|
|
||||||
public StatusActionBody(short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
public StatusActionBody(short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Unsupported STATUS_ACTION subType");
|
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 (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
this.message = message;
|
this.message = message;
|
||||||
@@ -85,16 +84,10 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public StatusActionBody check() {
|
public StatusActionBody check() {
|
||||||
if (!isSupportedSubType(subType)) {
|
if (!isSupportedSubType(subType)) throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
||||||
throw new IllegalArgumentException("Bad STATUS_ACTION subType: " + (subType & 0xFFFF));
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("STATUS_ACTION toLogin is blank");
|
||||||
}
|
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName is blank");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
}
|
|
||||||
if (message == null) throw new IllegalArgumentException("message is null");
|
if (message == null) throw new IllegalArgumentException("message is null");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -104,15 +97,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||||
throw new IllegalArgumentException("STATUS_ACTION toBlockchainName utf8 len must be 1..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);
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
bb.put((byte) nameUtf8.length);
|
bb.put((byte) loginUtf8.length);
|
||||||
bb.put(nameUtf8);
|
bb.put(loginUtf8);
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
@@ -120,7 +112,7 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
@@ -141,14 +133,11 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
|
|||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) return "";
|
if (len == 0) return "";
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
return decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
} catch (CharacterCodingException e) {
|
} catch (CharacterCodingException e) {
|
||||||
|
|||||||
+36
-62
@@ -33,23 +33,13 @@ import java.util.Objects;
|
|||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
* [N] text UTF-8
|
* [N] text UTF-8
|
||||||
*
|
*
|
||||||
* EDIT_POST:
|
* EDIT_POST / REPOST:
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
* [4] prevLineNumber
|
* [4] prevLineNumber
|
||||||
* [32] prevLineHash32
|
* [32] prevLineHash32
|
||||||
* [4] thisLineNumber
|
* [4] thisLineNumber
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [1] toLoginLen (uint8)
|
||||||
* [32] toBlockHash32
|
* [N] toLogin UTF-8
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*
|
|
||||||
* REPOST:
|
|
||||||
* [4] lineCode
|
|
||||||
* [4] prevLineNumber
|
|
||||||
* [32] prevLineHash32
|
|
||||||
* [4] thisLineNumber
|
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber (int32)
|
* [4] toBlockGlobalNumber (int32)
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -72,7 +62,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
public final int thisLineNumber;
|
public final int thisLineNumber;
|
||||||
|
|
||||||
// target (для EDIT_POST / REPOST)
|
// 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 Integer toBlockGlobalNumber; // nullable для POST
|
||||||
public final byte[] toBlockHash32; // nullable для POST
|
public final byte[] toBlockHash32; // nullable для POST
|
||||||
|
|
||||||
@@ -116,29 +106,19 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
|
|
||||||
this.thisLineNumber = bb.getInt();
|
this.thisLineNumber = bb.getInt();
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
// нужен target
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "TEXT target missing");
|
||||||
ensureMin(bb, (4 + 32) + 2, "EDIT_POST missing target");
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
int tgtNum = bb.getInt();
|
if (loginLen <= 0) throw new IllegalArgumentException("toLoginLen is 0");
|
||||||
byte[] tgtHash = new byte[32];
|
ensureMin(bb, loginLen + 4 + 32 + 2, "TEXT target payload too short");
|
||||||
bb.get(tgtHash);
|
byte[] loginBytes = new byte[loginLen];
|
||||||
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = null;
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
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);
|
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
} else {
|
} else {
|
||||||
this.toBlockchainName = null;
|
this.toLogin = null;
|
||||||
this.toBlockGlobalNumber = null;
|
this.toBlockGlobalNumber = null;
|
||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
@@ -158,7 +138,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
short subType,
|
short subType,
|
||||||
Integer toBlockGlobalNumber,
|
Integer toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
String message) {
|
String message) {
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
@@ -189,27 +169,29 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
this.thisLineNumber = thisLineNumber;
|
this.thisLineNumber = thisLineNumber;
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = null;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
if (toBlockchainName.isBlank()) throw new IllegalArgumentException("toBlockchainName is blank");
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
Objects.requireNonNull(toBlockGlobalNumber, "toBlockGlobalNumber == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
|
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
} else {
|
} else {
|
||||||
this.toBlockchainName = null;
|
this.toLogin = null;
|
||||||
this.toBlockGlobalNumber = null;
|
this.toBlockGlobalNumber = null;
|
||||||
this.toBlockHash32 = null;
|
this.toBlockHash32 = null;
|
||||||
}
|
}
|
||||||
@@ -240,13 +222,13 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockGlobalNumber invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
throw new IllegalArgumentException("EDIT_POST toBlockHash32 invalid");
|
||||||
if (toBlockchainName != null)
|
if (toLogin == null || toLogin.isBlank())
|
||||||
throw new IllegalArgumentException("EDIT_POST must not contain toBlockchainName");
|
throw new IllegalArgumentException("EDIT_POST toLogin is blank");
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
if (message == null || message.isBlank())
|
if (message == null || message.isBlank())
|
||||||
throw new IllegalArgumentException("REPOST message is blank");
|
throw new IllegalArgumentException("REPOST message is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
if (toLogin == null || toLogin.isBlank())
|
||||||
throw new IllegalArgumentException("REPOST toBlockchainName is blank");
|
throw new IllegalArgumentException("REPOST toLogin is blank");
|
||||||
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber == null || toBlockGlobalNumber < 0)
|
||||||
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
throw new IllegalArgumentException("REPOST toBlockGlobalNumber invalid");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
if (toBlockHash32 == null || toBlockHash32.length != 32)
|
||||||
@@ -259,7 +241,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
} else if (message == null) {
|
} else if (message == null) {
|
||||||
throw new IllegalArgumentException("Text message is 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");
|
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_SERVICE & 0xFFFF)
|
||||||
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
|| st == (MsgSubType.TEXT_COURSE & 0xFFFF)) {
|
||||||
cap = (4 + 4 + 32 + 4) + 2 + msgUtf8.length;
|
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 {
|
} else {
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPOST missing toBlockchainName");
|
if (toLogin == null) throw new IllegalArgumentException("target missing toLogin");
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] nameUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
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 (toBlockGlobalNumber == null) throw new IllegalArgumentException("target missing toBlockGlobalNumber");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("REPOST toBlockHash32 != 32");
|
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;
|
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.put(prevLineHash32 == null ? new byte[32] : Arrays.copyOf(prevLineHash32, 32));
|
||||||
bb.putInt(thisLineNumber);
|
bb.putInt(thisLineNumber);
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_EDIT_POST & 0xFFFF) || st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
||||||
bb.putInt(toBlockGlobalNumber);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
bb.put(toBlockHash32);
|
bb.put((byte) loginUtf8.length);
|
||||||
} else if (st == (MsgSubType.TEXT_REPOST & 0xFFFF)) {
|
bb.put(loginUtf8);
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
|
||||||
bb.put((byte) nameUtf8.length);
|
|
||||||
bb.put(nameUtf8);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
}
|
}
|
||||||
@@ -331,7 +305,7 @@ public final class TextLineBody implements BodyRecord, BodyHasLine, BodyHasTarge
|
|||||||
@Override public int lineSeq() { return thisLineNumber; }
|
@Override public int lineSeq() { return thisLineNumber; }
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ===================== */
|
/* ====================== BodyHasTarget ===================== */
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
|
|||||||
+25
-38
@@ -11,14 +11,11 @@ import java.util.Arrays;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextRatingBody — type=1, ver=1.
|
* TextRatingBody — type=1, ver=1, subType=30.
|
||||||
*
|
|
||||||
* subType:
|
|
||||||
* - RATING (30)
|
|
||||||
*
|
*
|
||||||
* Формат bodyBytes (BigEndian):
|
* Формат bodyBytes (BigEndian):
|
||||||
* [1] toBlockchainNameLen (uint8)
|
* [1] toLoginLen (uint8)
|
||||||
* [N] toBlockchainName UTF-8
|
* [N] toLogin UTF-8
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
@@ -32,7 +29,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
public final short subType;
|
public final short subType;
|
||||||
public final short version;
|
public final short version;
|
||||||
public final String toBlockchainName;
|
public final String toLogin;
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32;
|
public final byte[] toBlockHash32;
|
||||||
public final String message;
|
public final String message;
|
||||||
@@ -52,33 +49,32 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "RATING too short");
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("RATING toBlockchainNameLen is 0");
|
if (loginLen <= 0) throw new IllegalArgumentException("RATING toLoginLen is 0");
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "RATING payload too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "RATING payload too short");
|
||||||
|
|
||||||
byte[] nameBytes = new byte[nameLen];
|
byte[] loginBytes = new byte[loginLen];
|
||||||
bb.get(nameBytes);
|
bb.get(loginBytes);
|
||||||
this.toBlockchainName = new String(nameBytes, StandardCharsets.UTF_8);
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
this.toBlockGlobalNumber = bb.getInt();
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
this.toBlockHash32 = new byte[32];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(this.toBlockHash32);
|
bb.get(this.toBlockHash32);
|
||||||
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
this.message = readStrictUtf8Len16(bb, "TextRatingBody text");
|
||||||
|
|
||||||
ensureNoTail(bb, "TextRatingBody");
|
ensureNoTail(bb, "TextRatingBody");
|
||||||
}
|
}
|
||||||
|
|
||||||
public TextRatingBody(String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
public TextRatingBody(String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32, String message) {
|
||||||
Objects.requireNonNull(toBlockchainName, "toBlockchainName == null");
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
Objects.requireNonNull(message, "message == 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 (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
||||||
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
if (message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
|
|
||||||
this.subType = MsgSubType.TEXT_RATING;
|
this.subType = MsgSubType.TEXT_RATING;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
this.toBlockchainName = toBlockchainName;
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
this.message = message;
|
this.message = message;
|
||||||
@@ -86,16 +82,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TextRatingBody check() {
|
public TextRatingBody check() {
|
||||||
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF)) {
|
if ((subType & 0xFFFF) != (MsgSubType.TEXT_RATING & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextRatingBody subType: " + (subType & 0xFFFF));
|
throw new IllegalArgumentException("Bad RATING subType");
|
||||||
}
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("RATING toLogin is blank");
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank()) {
|
|
||||||
throw new IllegalArgumentException("RATING toBlockchainName is blank");
|
|
||||||
}
|
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32 == null || toBlockHash32.length != 32) {
|
if (toBlockHash32 == null || toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 invalid");
|
||||||
throw new IllegalArgumentException("toBlockHash32 invalid");
|
|
||||||
}
|
|
||||||
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("message is blank");
|
||||||
return this;
|
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 == 0) throw new IllegalArgumentException("Text payload is empty");
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
byte[] nameUtf8 = toBlockchainName.getBytes(StandardCharsets.UTF_8);
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255) {
|
if (loginUtf8.length == 0 || loginUtf8.length > 255)
|
||||||
throw new IllegalArgumentException("RATING toBlockchainName utf8 len must be 1..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);
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
bb.put((byte) nameUtf8.length);
|
bb.put((byte) loginUtf8.length);
|
||||||
bb.put(nameUtf8);
|
bb.put(loginUtf8);
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
@@ -122,7 +112,7 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
@Override public String toLogin() { return toLogin; }
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
||||||
|
|
||||||
@@ -130,14 +120,11 @@ public final class TextRatingBody implements BodyRecord, BodyHasTarget {
|
|||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
if (len == 0) throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
if (s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
|
|||||||
+36
-108
@@ -17,84 +17,56 @@ import java.util.Objects;
|
|||||||
* - REPLY (20)
|
* - REPLY (20)
|
||||||
* - EDIT_REPLY (21)
|
* - EDIT_REPLY (21)
|
||||||
*
|
*
|
||||||
* Форматы bodyBytes (BigEndian):
|
* Оба subtype используют одинаковый target-формат:
|
||||||
*
|
* [1] toLoginLen (uint8)
|
||||||
* REPLY:
|
* [N] toLogin UTF-8
|
||||||
* [1] toBlockchainNameLen (uint8)
|
|
||||||
* [N] toBlockchainName UTF-8
|
|
||||||
* [4] toBlockGlobalNumber
|
* [4] toBlockGlobalNumber
|
||||||
* [32] toBlockHash32
|
* [32] toBlockHash32
|
||||||
* [2] textLenBytes (uint16)
|
* [2] textLenBytes (uint16)
|
||||||
* [M] text UTF-8
|
* [M] text UTF-8
|
||||||
*
|
*
|
||||||
* EDIT_REPLY:
|
* Для EDIT_REPLY text может быть пустым (логическое удаление).
|
||||||
* [4] toBlockGlobalNumber
|
|
||||||
* [32] toBlockHash32
|
|
||||||
* [2] textLenBytes (uint16)
|
|
||||||
* [N] text UTF-8
|
|
||||||
*/
|
*/
|
||||||
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
||||||
|
|
||||||
public static final short TYPE = 1;
|
public static final short TYPE = 1;
|
||||||
public static final short VER = 1;
|
public static final short VER = 1;
|
||||||
|
|
||||||
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
public static final int KEY = ((TYPE & 0xFFFF) << 16) | (VER & 0xFFFF);
|
||||||
|
|
||||||
public final short subType; // из header
|
public final short subType;
|
||||||
public final short version; // (=1)
|
public final short version;
|
||||||
|
|
||||||
// target
|
public final String toLogin;
|
||||||
public final String toBlockchainName; // nullable для EDIT_REPLY
|
|
||||||
public final int toBlockGlobalNumber;
|
public final int toBlockGlobalNumber;
|
||||||
public final byte[] toBlockHash32; // 32
|
public final byte[] toBlockHash32;
|
||||||
|
|
||||||
// text
|
|
||||||
public final String message;
|
public final String message;
|
||||||
|
|
||||||
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
public TextReplyBody(short subType, short version, byte[] bodyBytes) {
|
||||||
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
Objects.requireNonNull(bodyBytes, "bodyBytes == null");
|
||||||
|
|
||||||
this.subType = subType;
|
this.subType = subType;
|
||||||
this.version = version;
|
this.version = version;
|
||||||
|
|
||||||
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
if ((this.version & 0xFFFF) != (VER & 0xFFFF)) {
|
||||||
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
throw new IllegalArgumentException("TextReplyBody version must be 1, got=" + (this.version & 0xFFFF));
|
||||||
}
|
}
|
||||||
|
|
||||||
int st = this.subType & 0xFFFF;
|
int st = this.subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 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);
|
throw new IllegalArgumentException("TextReplyBody supports only REPLY/EDIT_REPLY, got subType=" + st);
|
||||||
}
|
}
|
||||||
|
|
||||||
ByteBuffer bb = ByteBuffer.wrap(bodyBytes).order(ByteOrder.BIG_ENDIAN);
|
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)) {
|
int loginLen = Byte.toUnsignedInt(bb.get());
|
||||||
// минимум: nameLen[1]+name[1]+global[4]+hash[32]+textLen[2]
|
if (loginLen <= 0) throw new IllegalArgumentException("TextReplyBody toLoginLen is 0");
|
||||||
ensureMin(bb, 1 + 1 + 4 + 32 + 2, "REPLY too short");
|
ensureMin(bb, loginLen + 4 + 32 + 2, "TextReplyBody payload too short");
|
||||||
|
|
||||||
int nameLen = Byte.toUnsignedInt(bb.get());
|
byte[] loginBytes = new byte[loginLen];
|
||||||
if (nameLen <= 0) throw new IllegalArgumentException("REPLY toBlockchainNameLen is 0");
|
bb.get(loginBytes);
|
||||||
ensureMin(bb, nameLen + 4 + 32 + 2, "REPLY payload too short");
|
this.toLogin = new String(loginBytes, StandardCharsets.UTF_8);
|
||||||
|
this.toBlockGlobalNumber = bb.getInt();
|
||||||
byte[] nameBytes = new byte[nameLen];
|
this.toBlockHash32 = new byte[32];
|
||||||
bb.get(nameBytes);
|
bb.get(this.toBlockHash32);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
this.message = readStrictUtf8Len16(bb, "TextReplyBody text", st == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF));
|
||||||
ensureNoTail(bb, "TextReplyBody");
|
ensureNoTail(bb, "TextReplyBody");
|
||||||
@@ -103,11 +75,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
public TextReplyBody(short subType,
|
public TextReplyBody(short subType,
|
||||||
int toBlockGlobalNumber,
|
int toBlockGlobalNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
String message) {
|
String message) {
|
||||||
|
|
||||||
Objects.requireNonNull(message, "message == null");
|
Objects.requireNonNull(message, "message == null");
|
||||||
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
Objects.requireNonNull(toBlockHash32, "toBlockHash32 == null");
|
||||||
|
Objects.requireNonNull(toLogin, "toLogin == null");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 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()) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && message.isBlank()) {
|
||||||
throw new IllegalArgumentException("message is blank");
|
throw new IllegalArgumentException("message is blank");
|
||||||
}
|
}
|
||||||
|
if (toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("toBlockGlobalNumber < 0");
|
||||||
if (toBlockHash32.length != 32) throw new IllegalArgumentException("toBlockHash32 != 32");
|
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.subType = subType;
|
||||||
this.version = VER;
|
this.version = VER;
|
||||||
|
this.toLogin = toLogin;
|
||||||
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
this.toBlockGlobalNumber = toBlockGlobalNumber;
|
||||||
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
this.toBlockHash32 = Arrays.copyOf(toBlockHash32, 32);
|
||||||
|
|
||||||
this.message = message;
|
this.message = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,23 +105,14 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
if (st != (MsgSubType.TEXT_REPLY & 0xFFFF) && st != (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF))
|
||||||
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
throw new IllegalArgumentException("Bad TextReplyBody subType: " + st);
|
||||||
|
if (toLogin == null || toLogin.isBlank()) throw new IllegalArgumentException("toLogin is blank");
|
||||||
if (toBlockGlobalNumber < 0)
|
if (toBlockGlobalNumber < 0) throw new IllegalArgumentException("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 (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
||||||
if (message == null || message.isBlank())
|
if (message == null || message.isBlank()) throw new IllegalArgumentException("Text message is blank");
|
||||||
throw new IllegalArgumentException("Text message is blank");
|
} else if (message == null) {
|
||||||
if (toBlockchainName == null || toBlockchainName.isBlank())
|
throw new IllegalArgumentException("EDIT_REPLY message is null");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,47 +120,27 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
public byte[] toBytes() {
|
public byte[] toBytes() {
|
||||||
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
byte[] msgUtf8 = message.getBytes(StandardCharsets.UTF_8);
|
||||||
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
if (msgUtf8.length > 65535) throw new IllegalArgumentException("Text too long (>65535 bytes)");
|
||||||
|
|
||||||
int st = subType & 0xFFFF;
|
int st = subType & 0xFFFF;
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF) && msgUtf8.length == 0) {
|
||||||
throw new IllegalArgumentException("Text payload is empty");
|
throw new IllegalArgumentException("Text payload is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (st == (MsgSubType.TEXT_REPLY & 0xFFFF)) {
|
byte[] loginUtf8 = toLogin.getBytes(StandardCharsets.UTF_8);
|
||||||
if (toBlockchainName == null) throw new IllegalArgumentException("REPLY missing toBlockchainName");
|
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);
|
ByteBuffer bb = ByteBuffer.allocate(1 + loginUtf8.length + 4 + 32 + 2 + msgUtf8.length)
|
||||||
if (nameUtf8.length == 0 || nameUtf8.length > 255)
|
.order(ByteOrder.BIG_ENDIAN);
|
||||||
throw new IllegalArgumentException("REPLY toBlockchainName utf8 len must be 1..255");
|
bb.put((byte) loginUtf8.length);
|
||||||
|
bb.put(loginUtf8);
|
||||||
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);
|
|
||||||
bb.putInt(toBlockGlobalNumber);
|
bb.putInt(toBlockGlobalNumber);
|
||||||
bb.put(toBlockHash32);
|
bb.put(toBlockHash32);
|
||||||
bb.putShort((short) msgUtf8.length);
|
bb.putShort((short) msgUtf8.length);
|
||||||
bb.put(msgUtf8);
|
bb.put(msgUtf8);
|
||||||
|
|
||||||
return bb.array();
|
return bb.array();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== BodyHasTarget ====================== */
|
@Override public String toLogin() { return toLogin; }
|
||||||
|
|
||||||
@Override public String toBchName() { return toBlockchainName; }
|
|
||||||
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
@Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; }
|
||||||
@Override public byte[] toBlockHashBytes() { return toBlockHash32; }
|
@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);
|
return (subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_REPLY & 0xFFFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================== helpers ====================== */
|
|
||||||
|
|
||||||
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
private static String readStrictUtf8Len16(ByteBuffer bb, String fieldName, boolean allowEmpty) {
|
||||||
int len = Short.toUnsignedInt(bb.getShort());
|
int len = Short.toUnsignedInt(bb.getShort());
|
||||||
if (len == 0) {
|
if (len == 0) {
|
||||||
@@ -224,14 +155,11 @@ public final class TextReplyBody implements BodyRecord, BodyHasTarget {
|
|||||||
throw new IllegalArgumentException(fieldName + " is empty");
|
throw new IllegalArgumentException(fieldName + " is empty");
|
||||||
}
|
}
|
||||||
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
if (bb.remaining() < len) throw new IllegalArgumentException(fieldName + " payload too short (len=" + len + ")");
|
||||||
|
|
||||||
byte[] bytes = new byte[len];
|
byte[] bytes = new byte[len];
|
||||||
bb.get(bytes);
|
bb.get(bytes);
|
||||||
|
|
||||||
var decoder = StandardCharsets.UTF_8.newDecoder()
|
var decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
.onUnmappableCharacter(CodingErrorAction.REPORT);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
String s = decoder.decode(ByteBuffer.wrap(bytes)).toString();
|
||||||
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
if (!allowEmpty && s.isBlank()) throw new IllegalArgumentException(fieldName + " is blank");
|
||||||
|
|||||||
+4
-4
@@ -16,7 +16,7 @@ final class Ans104DataItemTest {
|
|||||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||||
List<Ans104DataItem.Tag> tags = List.of(
|
List<Ans104DataItem.Tag> tags = List.of(
|
||||||
new Ans104DataItem.Tag("App", "test5590"),
|
new Ans104DataItem.Tag("App", "test5590"),
|
||||||
new Ans104DataItem.Tag("c", "books")
|
new Ans104DataItem.Tag("c_test5590", "books")
|
||||||
);
|
);
|
||||||
|
|
||||||
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||||
@@ -28,7 +28,7 @@ final class Ans104DataItemTest {
|
|||||||
assertArrayEquals(owner, parsed.owner32());
|
assertArrayEquals(owner, parsed.owner32());
|
||||||
assertArrayEquals(data, parsed.data());
|
assertArrayEquals(data, parsed.data());
|
||||||
assertTrue(parsed.hasTag("App", "test5590"));
|
assertTrue(parsed.hasTag("App", "test5590"));
|
||||||
assertEquals("books", parsed.tagValue("c"));
|
assertEquals("books", parsed.tagValue("c_test5590"));
|
||||||
assertTrue(parsed.verifySignature());
|
assertTrue(parsed.verifySignature());
|
||||||
assertEquals(32, parsed.id32().length);
|
assertEquals(32, parsed.id32().length);
|
||||||
}
|
}
|
||||||
@@ -51,11 +51,11 @@ final class Ans104DataItemTest {
|
|||||||
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8);
|
||||||
List<Ans104DataItem.Tag> tags = List.of(
|
List<Ans104DataItem.Tag> tags = List.of(
|
||||||
new Ans104DataItem.Tag("App", "test5590"),
|
new Ans104DataItem.Tag("App", "test5590"),
|
||||||
new Ans104DataItem.Tag("c", "books")
|
new Ans104DataItem.Tag("c_test5590", "books")
|
||||||
);
|
);
|
||||||
|
|
||||||
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data);
|
||||||
byte[] expected = hex("7e67d0debce103606d697a1e3785130ca20cb89cd8a06f9a65b98b2d2427eaf411fcf7d482da268e44f3ac25c57c3cb9");
|
byte[] expected = hex("1abe0371d12268b34be32ca5ebf3d3d2189f9d311d9004c142538ca8a4312faa329d1a4873df72dd01b632a2ecce6b87");
|
||||||
assertArrayEquals(expected, actual);
|
assertArrayEquals(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,9 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_22 = 22;
|
public static final int SCHEMA_VERSION_22 = 22;
|
||||||
public static final int SCHEMA_VERSION_23 = 23;
|
public static final int SCHEMA_VERSION_23 = 23;
|
||||||
public static final int SCHEMA_VERSION_24 = 24;
|
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_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.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";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
@@ -65,6 +68,9 @@ public final class DatabaseInitializer {
|
|||||||
public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.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_V23_RESOURCE = "postgres/migration_v23.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.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() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -230,6 +236,18 @@ public final class DatabaseInitializer {
|
|||||||
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE);
|
||||||
currentVersion = SCHEMA_VERSION_24;
|
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() {}
|
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) {
|
public static String normalizeKeyToBase64_32(String rawKey) {
|
||||||
if (rawKey == null) {
|
if (rawKey == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -102,6 +142,17 @@ public final class KeyEncodingUtil {
|
|||||||
return result;
|
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) {
|
private static int divmod256(byte[] number58, int startAt) {
|
||||||
int remainder = 0;
|
int remainder = 0;
|
||||||
for (int i = startAt; i < number58.length; i++) {
|
for (int i = startAt; i < number58.length; i++) {
|
||||||
|
|||||||
+11
-12
@@ -6,12 +6,12 @@ import java.sql.*;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** Persistent discovery/import queue for ANS-104 SHiNE blocks found through Arweave. */
|
/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */
|
||||||
public final class ArweaveBlockImportDAO {
|
public final class ArweaveBlockImportDAO {
|
||||||
public static final String STATUS_PENDING = "PENDING";
|
public static final String STATUS_PENDING = "PENDING";
|
||||||
public static final String STATUS_REJECTED = "REJECTED";
|
public static final String STATUS_REJECTED = "REJECTED";
|
||||||
|
|
||||||
public record QueueItem(byte[] dataItemId, String rootTxId, long blockHeight,
|
public record QueueItem(byte[] dataItemId, long blockHeight,
|
||||||
byte[] rawDataItem, String status, String lastError,
|
byte[] rawDataItem, String status, String lastError,
|
||||||
long firstSeenAtMs, long updatedAtMs) {}
|
long firstSeenAtMs, long updatedAtMs) {}
|
||||||
|
|
||||||
@@ -51,22 +51,21 @@ public final class ArweaveBlockImportDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
|
/** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */
|
||||||
public boolean enqueueIfMissing(byte[] dataItemId, String rootTxId, long blockHeight, byte[] rawDataItem, long nowMs)
|
public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs)
|
||||||
throws SQLException {
|
throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
INSERT INTO arweave_block_import_queue(
|
INSERT INTO arweave_block_import_queue(
|
||||||
data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||||
) VALUES(?,?,?,?,?,'',?,?)
|
) VALUES(?,?,?,?, '',?,?)
|
||||||
ON CONFLICT(data_item_id) DO NOTHING
|
ON CONFLICT(data_item_id) DO NOTHING
|
||||||
""";
|
""";
|
||||||
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
ps.setBytes(1, dataItemId);
|
ps.setBytes(1, dataItemId);
|
||||||
ps.setString(2, rootTxId);
|
ps.setLong(2, blockHeight);
|
||||||
ps.setLong(3, blockHeight);
|
ps.setBytes(3, rawDataItem);
|
||||||
ps.setBytes(4, rawDataItem);
|
ps.setString(4, STATUS_PENDING);
|
||||||
ps.setString(5, STATUS_PENDING);
|
ps.setLong(5, nowMs);
|
||||||
ps.setLong(6, nowMs);
|
ps.setLong(6, nowMs);
|
||||||
ps.setLong(7, nowMs);
|
|
||||||
return ps.executeUpdate() > 0;
|
return ps.executeUpdate() > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,7 +81,7 @@ public final class ArweaveBlockImportDAO {
|
|||||||
public List<QueueItem> listPending(int limit) throws SQLException {
|
public List<QueueItem> listPending(int limit) throws SQLException {
|
||||||
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
int safeLimit = Math.max(1, Math.min(limit, 100_000));
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT data_item_id,root_tx_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms
|
||||||
FROM arweave_block_import_queue
|
FROM arweave_block_import_queue
|
||||||
WHERE status=?
|
WHERE status=?
|
||||||
ORDER BY block_height ASC, first_seen_at_ms ASC
|
ORDER BY block_height ASC, first_seen_at_ms ASC
|
||||||
@@ -95,7 +94,7 @@ public final class ArweaveBlockImportDAO {
|
|||||||
try (ResultSet rs = ps.executeQuery()) {
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
out.add(new QueueItem(
|
out.add(new QueueItem(
|
||||||
rs.getBytes("data_item_id"), rs.getString("root_tx_id"), rs.getLong("block_height"),
|
rs.getBytes("data_item_id"), rs.getLong("block_height"),
|
||||||
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
|
rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"),
|
||||||
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
|
rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms")));
|
||||||
}
|
}
|
||||||
|
|||||||
+94
@@ -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 {
|
private String resolveLoginForCleanup(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login
|
SELECT login
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ public final class BlocksDAO {
|
|||||||
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
||||||
to_login,to_bch_name,to_block_number,to_block_hash,
|
to_login,to_bch_name,to_block_number,to_block_hash,
|
||||||
block_hash,block_signature,data_item_id,
|
block_hash,block_signature,data_item_id,
|
||||||
arweave_publish_pending,arweave_published_at_ms,arweave_root_tx_id,
|
arweave_publish_pending,arweave_published_at_ms,
|
||||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
""";
|
""";
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
int i = 1;
|
int i = 1;
|
||||||
@@ -48,7 +48,6 @@ public final class BlocksDAO {
|
|||||||
ps.setBytes(i++, e.getDataItemId());
|
ps.setBytes(i++, e.getDataItemId());
|
||||||
ps.setBoolean(i++, e.isArweavePublishPending());
|
ps.setBoolean(i++, e.isArweavePublishPending());
|
||||||
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
|
if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs());
|
||||||
setNullableString(ps, i++, e.getArweaveRootTxId());
|
|
||||||
setNullableInt(ps, i++, e.getEditedByBlockNumber());
|
setNullableInt(ps, i++, e.getEditedByBlockNumber());
|
||||||
setNullableInt(ps, i++, e.getLineCode());
|
setNullableInt(ps, i++, e.getLineCode());
|
||||||
setNullableInt(ps, i++, e.getPrevLineNumber());
|
setNullableInt(ps, i++, e.getPrevLineNumber());
|
||||||
@@ -74,7 +73,7 @@ public final class BlocksDAO {
|
|||||||
String sql = """
|
String sql = """
|
||||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
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,
|
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,arweave_root_tx_id,
|
arweave_publish_pending,arweave_published_at_ms,
|
||||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
WHERE arweave_publish_pending = TRUE
|
WHERE arweave_publish_pending = TRUE
|
||||||
@@ -89,18 +88,18 @@ public final class BlocksDAO {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markArweavePublished(List<byte[]> dataItemIds, String rootTxId, long publishedAtMs) throws SQLException {
|
public void markArweavePublished(List<byte[]> dataItemIds, long publishedAtMs) throws SQLException {
|
||||||
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
if (dataItemIds == null || dataItemIds.isEmpty()) return;
|
||||||
String sql = """
|
String sql = """
|
||||||
UPDATE blocks
|
UPDATE blocks
|
||||||
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?, arweave_root_tx_id=?
|
SET arweave_publish_pending=FALSE, arweave_published_at_ms=?
|
||||||
WHERE data_item_id=?
|
WHERE data_item_id=?
|
||||||
""";
|
""";
|
||||||
try (Connection c = db.getConnection()) {
|
try (Connection c = db.getConnection()) {
|
||||||
c.setAutoCommit(false);
|
c.setAutoCommit(false);
|
||||||
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
for (byte[] id : dataItemIds) {
|
for (byte[] id : dataItemIds) {
|
||||||
ps.setLong(1, publishedAtMs); ps.setString(2, rootTxId); ps.setBytes(3, id); ps.addBatch();
|
ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch();
|
||||||
}
|
}
|
||||||
ps.executeBatch(); c.commit();
|
ps.executeBatch(); c.commit();
|
||||||
} catch (Exception e) { c.rollback(); throw e; }
|
} catch (Exception e) { c.rollback(); throw e; }
|
||||||
@@ -133,7 +132,7 @@ public final class BlocksDAO {
|
|||||||
private static String baseSelect(){return """
|
private static String baseSelect(){return """
|
||||||
SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes,
|
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,
|
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,arweave_root_tx_id,
|
arweave_publish_pending,arweave_published_at_ms,
|
||||||
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number
|
||||||
FROM blocks
|
FROM blocks
|
||||||
""";}
|
""";}
|
||||||
@@ -144,7 +143,7 @@ public final class BlocksDAO {
|
|||||||
e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes"));
|
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.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.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.setArweaveRootTxId(rs.getString("arweave_root_tx_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"));
|
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;
|
return e;
|
||||||
}
|
}
|
||||||
|
|||||||
+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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-11
@@ -8,7 +8,6 @@ import java.sql.Connection;
|
|||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.Base64;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
|
* Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем.
|
||||||
@@ -38,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 {
|
public SolanaUserPdaCurrentEntry getByBlockchainName(Connection c, String blockchainName) throws SQLException {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT
|
SELECT
|
||||||
@@ -61,28 +81,35 @@ public final class SolanaUserPdaCurrentDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve a current user by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
|
/** Resolve a user/fork by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */
|
||||||
public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException {
|
public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException {
|
||||||
if (owner32 == null || owner32.length != 32) return null;
|
if (owner32 == null || owner32.length != 32) return null;
|
||||||
String wanted = Base64.getEncoder().encodeToString(owner32);
|
String wanted = KeyEncodingUtil.encodeBase58(owner32);
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT login, blockchain_name, blockchain_key, paid_limit_bytes
|
SELECT
|
||||||
FROM solana_user_pda_current
|
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); ResultSet rs = ps.executeQuery()) {
|
try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
while (rs.next()) {
|
ps.setString(1, wanted);
|
||||||
SolanaUserPdaCurrentEntry e = mapRow(rs);
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
if (wanted.equals(e.getBlockchainKey())) return e;
|
if (!rs.next()) return null;
|
||||||
|
return mapRow(rs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
|
private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
|
SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry();
|
||||||
entry.setLogin(rs.getString("login"));
|
entry.setLogin(rs.getString("login"));
|
||||||
entry.setBlockchainName(rs.getString("blockchain_name"));
|
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"));
|
entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes"));
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ public class BlockEntry {
|
|||||||
private byte[] dataItemId;
|
private byte[] dataItemId;
|
||||||
private boolean arweavePublishPending;
|
private boolean arweavePublishPending;
|
||||||
private Long arweavePublishedAtMs;
|
private Long arweavePublishedAtMs;
|
||||||
private String arweaveRootTxId;
|
|
||||||
|
|
||||||
private Integer editedByBlockNumber;
|
private Integer editedByBlockNumber;
|
||||||
|
|
||||||
@@ -95,8 +94,6 @@ public class BlockEntry {
|
|||||||
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
|
public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; }
|
||||||
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
|
public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; }
|
||||||
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
|
public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; }
|
||||||
public String getArweaveRootTxId() { return arweaveRootTxId; }
|
|
||||||
public void setArweaveRootTxId(String arweaveRootTxId) { this.arweaveRootTxId = arweaveRootTxId; }
|
|
||||||
|
|
||||||
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
|
public Integer getEditedByBlockNumber() { return editedByBlockNumber; }
|
||||||
public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = 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,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)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 24, 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
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
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_name TEXT NOT NULL,
|
||||||
blockchain_key TEXT NOT NULL,
|
blockchain_key TEXT NOT NULL,
|
||||||
paid_limit_bytes BIGINT NOT NULL,
|
paid_limit_bytes BIGINT NOT NULL,
|
||||||
|
blockchain_forks_json TEXT NOT NULL DEFAULT '[]',
|
||||||
used_bytes BIGINT NOT NULL,
|
used_bytes BIGINT NOT NULL,
|
||||||
last_block_number INTEGER NOT NULL,
|
last_block_number INTEGER NOT NULL,
|
||||||
last_block_hash TEXT NOT NULL,
|
last_block_hash TEXT NOT NULL,
|
||||||
@@ -2080,8 +2081,111 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending
|
|||||||
ON solana_user_pda_current(is_server, archive_imported)
|
ON solana_user_pda_current(is_server, archive_imported)
|
||||||
WHERE archive_head_tx_id <> '';
|
WHERE archive_head_tx_id <> '';
|
||||||
|
|
||||||
|
-- Server-local key rotation state machine (schema v26).
|
||||||
|
-- Эти поля не являются частью Solana PDA и не перезаписываются Solana sync upsert-ом.
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_status TEXT NOT NULL DEFAULT 'NONE';
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD COLUMN IF NOT EXISTS rotation_session_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_solana_user_pda_current_rotation_status;
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT chk_solana_user_pda_current_rotation_status CHECK (
|
||||||
|
rotation_status IN (
|
||||||
|
'NONE','COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_rotation_active
|
||||||
|
ON solana_user_pda_current(rotation_status)
|
||||||
|
WHERE rotation_status <> 'NONE';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_sessions (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
login TEXT NOT NULL REFERENCES solana_user_pda_current(login) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL CHECK (
|
||||||
|
status IN (
|
||||||
|
'COPYING_CHAIN','CHAIN_READY','ROTATING_PDA','PDA_ROTATED',
|
||||||
|
'REBUILDING_SERVER','WALLET_MIGRATION','MESSAGE_MIGRATION','FINALIZING',
|
||||||
|
'COMPLETE','ABORTED'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
source_blockchain_name TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
old_root_key TEXT NOT NULL,
|
||||||
|
old_blockchain_key TEXT NOT NULL,
|
||||||
|
old_client_key TEXT NOT NULL,
|
||||||
|
new_root_key TEXT NOT NULL,
|
||||||
|
new_blockchain_key TEXT NOT NULL,
|
||||||
|
new_client_key TEXT NOT NULL,
|
||||||
|
fork_from_block INTEGER NOT NULL CHECK (fork_from_block >= 0),
|
||||||
|
fork_from_hash BYTEA NOT NULL CHECK (octet_length(fork_from_hash) = 32),
|
||||||
|
source_tip_block INTEGER NOT NULL CHECK (source_tip_block >= 0),
|
||||||
|
source_tip_hash BYTEA NOT NULL CHECK (octet_length(source_tip_hash) = 32),
|
||||||
|
reason_code SMALLINT NOT NULL CHECK (reason_code BETWEEN 1 AND 4),
|
||||||
|
comment TEXT NOT NULL DEFAULT '' CHECK (octet_length(convert_to(comment, 'UTF8')) <= 1024),
|
||||||
|
progress_current INTEGER NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
|
||||||
|
progress_total INTEGER NOT NULL DEFAULT 0 CHECK (progress_total >= 0),
|
||||||
|
pda_rotation_signature TEXT,
|
||||||
|
wallet_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
wallet_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
message_migration_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (
|
||||||
|
message_migration_status IN ('PENDING','COMPLETE','SKIPPED','NOT_IMPLEMENTED')
|
||||||
|
),
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_at_ms BIGINT,
|
||||||
|
retry_count INTEGER NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
completed_at_ms BIGINT,
|
||||||
|
aborted_at_ms BIGINT,
|
||||||
|
CHECK (progress_current <= progress_total),
|
||||||
|
CHECK (fork_from_block <= source_tip_block)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_key_rotation_sessions_active_login
|
||||||
|
ON key_rotation_sessions(login)
|
||||||
|
WHERE status NOT IN ('COMPLETE','ABORTED');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_login_created
|
||||||
|
ON key_rotation_sessions(login, created_at_ms DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_sessions_status
|
||||||
|
ON key_rotation_sessions(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS key_rotation_candidate_blocks (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
rotation_session_id BIGINT NOT NULL REFERENCES key_rotation_sessions(id) ON DELETE CASCADE,
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
candidate_blockchain_name TEXT NOT NULL,
|
||||||
|
block_number INTEGER NOT NULL CHECK (block_number >= 0),
|
||||||
|
block_hash BYTEA NOT NULL CHECK (octet_length(block_hash) = 32),
|
||||||
|
data_item_id BYTEA NOT NULL CHECK (octet_length(data_item_id) = 32),
|
||||||
|
block_bytes BYTEA NOT NULL,
|
||||||
|
arweave_publish_pending BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
arweave_published_at_ms BIGINT,
|
||||||
|
created_at_ms BIGINT NOT NULL,
|
||||||
|
UNIQUE (rotation_session_id, block_number),
|
||||||
|
UNIQUE (rotation_session_id, data_item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_pending
|
||||||
|
ON key_rotation_candidate_blocks(id)
|
||||||
|
WHERE arweave_publish_pending = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_key_rotation_candidate_blocks_session
|
||||||
|
ON key_rotation_candidate_blocks(rotation_session_id, block_number);
|
||||||
|
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
DROP CONSTRAINT IF EXISTS fk_solana_user_pda_current_rotation_session;
|
||||||
|
ALTER TABLE solana_user_pda_current
|
||||||
|
ADD CONSTRAINT fk_solana_user_pda_current_rotation_session
|
||||||
|
FOREIGN KEY (rotation_session_id)
|
||||||
|
REFERENCES key_rotation_sessions(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||||
VALUES(1,24,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;
|
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+37
@@ -42,8 +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_AddBlock_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetBlockchainBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.Net_GetMyBlockchain_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStart_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAddBlock_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationFinishChain_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationRotatePda_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationStatus_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationAbort_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.Net_KeyRotationContinue_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStart_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAddBlock_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationFinishChain_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationRotatePda_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationStatus_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationAbort_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.keyRotation.entyties.Net_KeyRotationContinue_Request;
|
||||||
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request;
|
||||||
@@ -191,6 +208,16 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
Map.entry("AddBlock", new Net_AddBlock_Handler()),
|
||||||
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
Map.entry("GetBlockchainBlock", new Net_GetBlockchainBlock_Handler()),
|
||||||
|
Map.entry("GetMyBlockchain", new Net_GetMyBlockchain_Handler()),
|
||||||
|
|
||||||
|
// --- key rotation ---
|
||||||
|
Map.entry("KeyRotationStart", new Net_KeyRotationStart_Handler()),
|
||||||
|
Map.entry("KeyRotationStatus", new Net_KeyRotationStatus_Handler()),
|
||||||
|
Map.entry("KeyRotationAddBlock", new Net_KeyRotationAddBlock_Handler()),
|
||||||
|
Map.entry("KeyRotationFinishChain", new Net_KeyRotationFinishChain_Handler()),
|
||||||
|
Map.entry("KeyRotationRotatePda", new Net_KeyRotationRotatePda_Handler()),
|
||||||
|
Map.entry("KeyRotationContinue", new Net_KeyRotationContinue_Handler()),
|
||||||
|
Map.entry("KeyRotationAbort", new Net_KeyRotationAbort_Handler()),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
Map.entry("UpsertUserParam", new Net_UpsertUserParam_Handler()),
|
||||||
@@ -283,6 +310,16 @@ public final class JsonHandlerRegistry {
|
|||||||
// --- blockchain ---
|
// --- blockchain ---
|
||||||
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
Map.entry("AddBlock", Net_AddBlock_Request.class),
|
||||||
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
Map.entry("GetBlockchainBlock", Net_GetBlockchainBlock_Request.class),
|
||||||
|
Map.entry("GetMyBlockchain", Net_GetMyBlockchain_Request.class),
|
||||||
|
|
||||||
|
// --- key rotation ---
|
||||||
|
Map.entry("KeyRotationStart", Net_KeyRotationStart_Request.class),
|
||||||
|
Map.entry("KeyRotationStatus", Net_KeyRotationStatus_Request.class),
|
||||||
|
Map.entry("KeyRotationAddBlock", Net_KeyRotationAddBlock_Request.class),
|
||||||
|
Map.entry("KeyRotationFinishChain", Net_KeyRotationFinishChain_Request.class),
|
||||||
|
Map.entry("KeyRotationRotatePda", Net_KeyRotationRotatePda_Request.class),
|
||||||
|
Map.entry("KeyRotationContinue", Net_KeyRotationContinue_Request.class),
|
||||||
|
Map.entry("KeyRotationAbort", Net_KeyRotationAbort_Request.class),
|
||||||
|
|
||||||
// --- userParams ---
|
// --- userParams ---
|
||||||
Map.entry("UpsertUserParam", Net_UpsertUserParam_Request.class),
|
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 HttpClient HTTP = HttpClient.newHttpClient();
|
||||||
private static final String MAGIC = "SHiNE";
|
private static final String MAGIC = "SHiNE";
|
||||||
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1;
|
||||||
private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64;
|
|
||||||
|
|
||||||
private SolanaUserPdaImportService() {}
|
private SolanaUserPdaImportService() {}
|
||||||
|
|
||||||
@@ -55,8 +54,7 @@ public final class SolanaUserPdaImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Чтение server PDA по логину сервера. Используется сервером при старте,
|
* Чтение server PDA 1.2 по логину сервера. В 1.2 разрешён ровно один endpoint.
|
||||||
* чтобы получить актуальный server_address и список sync_servers.
|
|
||||||
*/
|
*/
|
||||||
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
|
public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception {
|
||||||
String login = normalizeLogin(loginRaw);
|
String login = normalizeLogin(loginRaw);
|
||||||
@@ -156,9 +154,16 @@ public final class SolanaUserPdaImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ParsedSolanaUser parseUserPda(byte[] raw) {
|
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;
|
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);
|
int recordLen = u16le(raw, 7);
|
||||||
if (recordLen < 73 || recordLen > raw.length) return null;
|
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||||
|
|
||||||
@@ -167,228 +172,166 @@ public final class SolanaUserPdaImportService {
|
|||||||
c += 8; // updated_at_ms
|
c += 8; // updated_at_ms
|
||||||
c += 4; // record_number
|
c += 4; // record_number
|
||||||
c += 32; // prev_record_hash
|
c += 32; // prev_record_hash
|
||||||
|
if (c >= recordLen) return null;
|
||||||
|
|
||||||
int loginLen = u8(raw, c++);
|
int loginLen = u8(raw, c++);
|
||||||
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||||
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||||
c += loginLen;
|
c += loginLen;
|
||||||
|
if (c >= recordLen) return null;
|
||||||
|
|
||||||
int blocksCount = u8(raw, c++);
|
int blocksCount = u8(raw, c++);
|
||||||
String blockchainName = null;
|
|
||||||
byte[] blockchainKey32 = null;
|
byte[] blockchainKey32 = null;
|
||||||
byte[] clientKey32 = null;
|
byte[] clientKey32 = null;
|
||||||
long paidLimitBytes = 0L;
|
long paidLimitBytes = 0L;
|
||||||
List<ParsedSessionRecord> sessions = new ArrayList<>();
|
int forkCount = 0;
|
||||||
List<String> accessServers = new ArrayList<>();
|
List<String> accessServers = new ArrayList<>();
|
||||||
|
|
||||||
for (int i = 0; i < blocksCount; i++) {
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
|
if (c + 2 > recordLen) return null;
|
||||||
int blockType = u8(raw, c++);
|
int blockType = u8(raw, c++);
|
||||||
int blockVer = u8(raw, c++);
|
int blockVer = u8(raw, c++);
|
||||||
if (blockVer != 0) return null;
|
if (blockVer != 0) return null;
|
||||||
|
|
||||||
if (blockType == 0) {
|
if (blockType == 1) {
|
||||||
c += 32; // recovery_key
|
if (c + 32 > recordLen) return null;
|
||||||
} else if (blockType == 1) {
|
c += 32; // root_key
|
||||||
c += 32;
|
continue;
|
||||||
} else if (blockType == 2) {
|
}
|
||||||
|
if (blockType == 2) {
|
||||||
|
if (c + 32 > recordLen) return null;
|
||||||
clientKey32 = slice(raw, c, 32);
|
clientKey32 = slice(raw, c, 32);
|
||||||
c += 32;
|
c += 32;
|
||||||
} else if (blockType == 3) {
|
continue;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
return new ParsedSolanaUser(
|
||||||
login,
|
login,
|
||||||
blockchainName,
|
blockchainName,
|
||||||
Base64.getEncoder().encodeToString(blockchainKey32),
|
Base64.getEncoder().encodeToString(blockchainKey32),
|
||||||
Base64.getEncoder().encodeToString(clientKey32),
|
Base64.getEncoder().encodeToString(clientKey32),
|
||||||
paidLimitBytes,
|
paidLimitBytes,
|
||||||
sessions,
|
List.of(),
|
||||||
accessServers
|
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);
|
int recordLen = u16le(raw, 7);
|
||||||
if (recordLen < 73 || recordLen > raw.length) return null;
|
if (recordLen < 73 || recordLen > raw.length) return null;
|
||||||
|
|
||||||
int c = 9;
|
int c = 9 + 8 + 8 + 4 + 32;
|
||||||
c += 8; // created_at_ms
|
if (c >= recordLen) return null;
|
||||||
c += 8; // updated_at_ms
|
|
||||||
c += 4; // record_number
|
|
||||||
c += 32; // prev_record_hash
|
|
||||||
|
|
||||||
int loginLen = u8(raw, c++);
|
int loginLen = u8(raw, c++);
|
||||||
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
if (loginLen <= 0 || c + loginLen > recordLen) return null;
|
||||||
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
String login = new String(raw, c, loginLen, StandardCharsets.UTF_8);
|
||||||
c += loginLen;
|
c += loginLen;
|
||||||
|
if (c >= recordLen) return null;
|
||||||
int blocksCount = u8(raw, c++);
|
int blocksCount = u8(raw, c++);
|
||||||
|
|
||||||
boolean isServer = false;
|
boolean isServer = false;
|
||||||
String serverAddress = "";
|
String serverAddress = "";
|
||||||
List<String> syncServers = new ArrayList<>();
|
|
||||||
|
|
||||||
for (int i = 0; i < blocksCount; i++) {
|
for (int i = 0; i < blocksCount; i++) {
|
||||||
|
if (c + 2 > recordLen) return null;
|
||||||
int blockType = u8(raw, c++);
|
int blockType = u8(raw, c++);
|
||||||
int blockVer = u8(raw, c++);
|
int blockVer = u8(raw, c++);
|
||||||
if (blockVer != 0) return null;
|
if (blockVer != 0) return null;
|
||||||
|
if (blockType == 1 || blockType == 2) {
|
||||||
if (blockType == 0 || blockType == 1 || blockType == 2) {
|
if (c + 32 > recordLen) return null;
|
||||||
c += 32;
|
c += 32;
|
||||||
} else if (blockType == 3) {
|
continue;
|
||||||
int count = u8(raw, c++);
|
}
|
||||||
for (int j = 0; j < count; j++) {
|
if (c + 2 > recordLen) return null;
|
||||||
c += 1; // blockchain_type
|
int payloadLen = u16le(raw, c);
|
||||||
int bchLen = u8(raw, c++);
|
c += 2;
|
||||||
c += bchLen;
|
int payloadEnd = c + payloadLen;
|
||||||
c += 32; // blockchain pubkey
|
if (payloadEnd < c || payloadEnd > recordLen) return null;
|
||||||
c += 8; // paid_limit_bytes
|
|
||||||
c += 8; // used_bytes
|
if (blockType == 30) {
|
||||||
c += 4; // last_block_number
|
if (c >= payloadEnd) return null;
|
||||||
c += 32; // last_block_hash
|
int addressCount = u8(raw, c++);
|
||||||
c += 64; // last_block_signature
|
if (addressCount != 1) return null;
|
||||||
int arweavePresent = u8(raw, c++);
|
for (int j = 0; j < addressCount; j++) {
|
||||||
if (arweavePresent == 1) {
|
if (c + 3 > payloadEnd) return null;
|
||||||
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;
|
|
||||||
c += 1; // address_format_type
|
c += 1; // address_format_type
|
||||||
c += 1; // address_format_version
|
c += 1; // address_format_version
|
||||||
int addrLen = u8(raw, c++);
|
int n = u8(raw, c++);
|
||||||
serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8);
|
if (c + n > payloadEnd) return null;
|
||||||
c += addrLen;
|
String address = new String(raw, c, n, StandardCharsets.UTF_8);
|
||||||
int syncCount = u8(raw, c++);
|
c += n;
|
||||||
for (int j = 0; j < syncCount; j++) {
|
if (!isServer) {
|
||||||
int n = u8(raw, c++);
|
isServer = true;
|
||||||
String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8);
|
serverAddress = address;
|
||||||
c += n;
|
|
||||||
syncServers.add(normalizeLogin(syncLogin));
|
|
||||||
}
|
}
|
||||||
} else if (isServerValue != 0) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
} else if (blockType == 40) {
|
if (c != payloadEnd) return null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
c = payloadEnd;
|
||||||
if (c > recordLen) return null;
|
|
||||||
}
|
}
|
||||||
|
return new ParsedServerProfile(login, isServer, serverAddress, List.of());
|
||||||
return new ParsedServerProfile(login, isServer, serverAddress, syncServers);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private static String normalizeLogin(String login) {
|
private static String normalizeLogin(String login) {
|
||||||
if (login == null) return null;
|
if (login == null) return null;
|
||||||
String s = login.trim();
|
String s = login.trim();
|
||||||
@@ -400,6 +343,12 @@ public final class SolanaUserPdaImportService {
|
|||||||
private static int u16le(byte[] b, int o) {
|
private static int u16le(byte[] b, int o) {
|
||||||
return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8);
|
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) {
|
private static long u64le(byte[] b, int o) {
|
||||||
long out = 0L;
|
long out = 0L;
|
||||||
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
|
for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i);
|
||||||
|
|||||||
+33
-2
@@ -98,6 +98,18 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
ReentrantLock lock = BlockchainLocks.lockFor(blockchainName);
|
||||||
lock.lock();
|
lock.lock();
|
||||||
try {
|
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(
|
AddBlockResult r = addBlock(
|
||||||
blockchainName,
|
blockchainName,
|
||||||
req.getBlockNumber(), // старое поле, пока оставляем
|
req.getBlockNumber(), // старое поле, пока оставляем
|
||||||
@@ -152,6 +164,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return resp;
|
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) {
|
private static String humanMessage(String code) {
|
||||||
if (code == null) return "Ошибка добавления блока";
|
if (code == null) return "Ошибка добавления блока";
|
||||||
return switch (code) {
|
return switch (code) {
|
||||||
@@ -184,6 +213,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
|
||||||
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
|
||||||
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
|
||||||
|
case "key_rotation_in_progress" -> "Сейчас выполняется смена ключей; обычные новые блоки временно запрещены";
|
||||||
|
case "key_rotation_check_failed" -> "Не удалось проверить состояние смены ключей";
|
||||||
default -> "Ошибка: " + code;
|
default -> "Ошибка: " + code;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -409,10 +440,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel DataItems are indexed by a signed canonical channel slug tag: c=<slug>.
|
// Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=<slug>.
|
||||||
try {
|
try {
|
||||||
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
|
String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry);
|
||||||
String actualChannelSlug = block.getDataItem().tagValue("c");
|
String actualChannelSlug = block.getDataItem().tagValue("c_test5590");
|
||||||
if (expectedChannelSlug != null) {
|
if (expectedChannelSlug != null) {
|
||||||
if (!expectedChannelSlug.equals(actualChannelSlug)) {
|
if (!expectedChannelSlug.equals(actualChannelSlug)) {
|
||||||
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex);
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain;
|
||||||
|
|
||||||
|
import blockchain.BchBlockEntry;
|
||||||
|
import blockchain.body.BodyHasTarget;
|
||||||
|
import server.logic.ws_protocol.Base64Ws;
|
||||||
|
import server.logic.ws_protocol.JSON.ConnectionContext;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetMyBlockchain_Response;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.BlocksDAO;
|
||||||
|
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||||
|
import shine.db.entities.BlockEntry;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Authenticated, paginated current-chain view used by "Мой блокчейн" and key rotation. */
|
||||||
|
public final class Net_GetMyBlockchain_Handler implements JsonMessageHandler {
|
||||||
|
private final BlocksDAO blocks = BlocksDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
|
||||||
|
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) {
|
||||||
|
Net_GetMyBlockchain_Request req = (Net_GetMyBlockchain_Request) baseReq;
|
||||||
|
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 401, "AUTH_REQUIRED", "Нужна авторизованная пользовательская сессия");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String login = ctx.getLogin().trim();
|
||||||
|
SolanaUserPdaCurrentEntry user = users.getByLogin(login);
|
||||||
|
if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 404, "BLOCKCHAIN_NOT_FOUND", "Текущий блокчейн пользователя не найден");
|
||||||
|
}
|
||||||
|
String bch = user.getBlockchainName();
|
||||||
|
BlockchainStateEntry state = states.getByBlockchainName(bch);
|
||||||
|
int tip = state == null ? -1 : state.getLastBlockNumber();
|
||||||
|
String tipHash = state == null ? null : hex(state.getLastBlockHash());
|
||||||
|
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(100, req.getLimit()));
|
||||||
|
int before = req.getBeforeBlock() == null || req.getBeforeBlock() < 0 ? tip : Math.min(tip, req.getBeforeBlock());
|
||||||
|
boolean includeBytes = Boolean.TRUE.equals(req.getIncludeBlockBytes());
|
||||||
|
|
||||||
|
List<BlockEntry> rows = before < 0
|
||||||
|
? List.of()
|
||||||
|
: blocks.listRangeByNumber(bch, Math.max(0, before - limit + 1), before);
|
||||||
|
Collections.reverse(rows);
|
||||||
|
List<Net_GetMyBlockchain_Response.BlockItem> items = new ArrayList<>(rows.size());
|
||||||
|
for (BlockEntry row : rows) {
|
||||||
|
BchBlockEntry parsed = new BchBlockEntry(row.getBlockBytes());
|
||||||
|
Net_GetMyBlockchain_Response.BlockItem item = new Net_GetMyBlockchain_Response.BlockItem();
|
||||||
|
item.setBlockNumber(row.getBlockNumber());
|
||||||
|
item.setBlockHash(hex(row.getBlockHash()));
|
||||||
|
item.setPrevBlockHash(hex(parsed.prevHash32));
|
||||||
|
item.setTimestampMs(parsed.timestamp * 1000L);
|
||||||
|
item.setMsgType(Short.toUnsignedInt(parsed.type));
|
||||||
|
item.setMsgSubType(Short.toUnsignedInt(parsed.subType));
|
||||||
|
item.setMsgVersion(Short.toUnsignedInt(parsed.version));
|
||||||
|
if (parsed.body instanceof BodyHasTarget target) {
|
||||||
|
item.setToLogin(target.toLogin());
|
||||||
|
item.setToBlockNumber(target.toBlockGlobalNumber());
|
||||||
|
item.setToBlockHash(hex(target.toBlockHashBytes()));
|
||||||
|
}
|
||||||
|
if (includeBytes) item.setBlockBytesB64(Base64Ws.encode(row.getBlockBytes()));
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Net_GetMyBlockchain_Response resp = new Net_GetMyBlockchain_Response();
|
||||||
|
resp.setOp(req.getOp());
|
||||||
|
resp.setRequestId(req.getRequestId());
|
||||||
|
resp.setStatus(WireCodes.Status.OK);
|
||||||
|
resp.setLogin(login);
|
||||||
|
resp.setBlockchainName(bch);
|
||||||
|
resp.setTipBlockNumber(tip);
|
||||||
|
resp.setTipBlockHash(tipHash);
|
||||||
|
resp.setBlocks(items);
|
||||||
|
int lowest = rows.isEmpty() ? -1 : rows.get(rows.size() - 1).getBlockNumber();
|
||||||
|
resp.setNextBeforeBlock(lowest > 0 ? lowest - 1 : null);
|
||||||
|
return resp;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return NetExceptionResponseFactory.error(req, 500, "GET_MY_BLOCKCHAIN_FAILED", "Не удалось прочитать блокчейн пользователя");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String hex(byte[] bytes) {
|
||||||
|
if (bytes == null) return null;
|
||||||
|
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||||
|
for (byte b : bytes) sb.append(String.format("%02x", b & 0xff));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
|
/** Paginated read of the authenticated user's current materialized blockchain. */
|
||||||
|
public final class Net_GetMyBlockchain_Request extends Net_Request {
|
||||||
|
/** Inclusive highest block number to return. Null/<0 means current tip. */
|
||||||
|
private Integer beforeBlock;
|
||||||
|
/** 1..100, default 50. */
|
||||||
|
private Integer limit;
|
||||||
|
/** Include complete serialized ANS-104 DataItem for rotation/UI inspection. */
|
||||||
|
private Boolean includeBlockBytes;
|
||||||
|
|
||||||
|
public Integer getBeforeBlock() { return beforeBlock; }
|
||||||
|
public void setBeforeBlock(Integer beforeBlock) { this.beforeBlock = beforeBlock; }
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public Boolean getIncludeBlockBytes() { return includeBlockBytes; }
|
||||||
|
public void setIncludeBlockBytes(Boolean includeBlockBytes) { this.includeBlockBytes = includeBlockBytes; }
|
||||||
|
}
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.blockchain.entyties;
|
||||||
|
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class Net_GetMyBlockchain_Response extends Net_Response {
|
||||||
|
private String login;
|
||||||
|
private String blockchainName;
|
||||||
|
private int tipBlockNumber;
|
||||||
|
private String tipBlockHash;
|
||||||
|
private Integer nextBeforeBlock;
|
||||||
|
private List<BlockItem> blocks = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getLogin() { return login; }
|
||||||
|
public void setLogin(String login) { this.login = login; }
|
||||||
|
public String getBlockchainName() { return blockchainName; }
|
||||||
|
public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; }
|
||||||
|
public int getTipBlockNumber() { return tipBlockNumber; }
|
||||||
|
public void setTipBlockNumber(int tipBlockNumber) { this.tipBlockNumber = tipBlockNumber; }
|
||||||
|
public String getTipBlockHash() { return tipBlockHash; }
|
||||||
|
public void setTipBlockHash(String tipBlockHash) { this.tipBlockHash = tipBlockHash; }
|
||||||
|
public Integer getNextBeforeBlock() { return nextBeforeBlock; }
|
||||||
|
public void setNextBeforeBlock(Integer nextBeforeBlock) { this.nextBeforeBlock = nextBeforeBlock; }
|
||||||
|
public List<BlockItem> getBlocks() { return blocks; }
|
||||||
|
public void setBlocks(List<BlockItem> blocks) { this.blocks = blocks; }
|
||||||
|
|
||||||
|
public static final class BlockItem {
|
||||||
|
private int blockNumber;
|
||||||
|
private String blockHash;
|
||||||
|
private String prevBlockHash;
|
||||||
|
private long timestampMs;
|
||||||
|
private int msgType;
|
||||||
|
private int msgSubType;
|
||||||
|
private int msgVersion;
|
||||||
|
private String toLogin;
|
||||||
|
private Integer toBlockNumber;
|
||||||
|
private String toBlockHash;
|
||||||
|
private String blockBytesB64;
|
||||||
|
|
||||||
|
public int getBlockNumber() { return blockNumber; }
|
||||||
|
public void setBlockNumber(int blockNumber) { this.blockNumber = blockNumber; }
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public void setBlockHash(String blockHash) { this.blockHash = blockHash; }
|
||||||
|
public String getPrevBlockHash() { return prevBlockHash; }
|
||||||
|
public void setPrevBlockHash(String prevBlockHash) { this.prevBlockHash = prevBlockHash; }
|
||||||
|
public long getTimestampMs() { return timestampMs; }
|
||||||
|
public void setTimestampMs(long timestampMs) { this.timestampMs = timestampMs; }
|
||||||
|
public int getMsgType() { return msgType; }
|
||||||
|
public void setMsgType(int msgType) { this.msgType = msgType; }
|
||||||
|
public int getMsgSubType() { return msgSubType; }
|
||||||
|
public void setMsgSubType(int msgSubType) { this.msgSubType = msgSubType; }
|
||||||
|
public int getMsgVersion() { return msgVersion; }
|
||||||
|
public void setMsgVersion(int msgVersion) { this.msgVersion = msgVersion; }
|
||||||
|
public String getToLogin() { return toLogin; }
|
||||||
|
public void setToLogin(String toLogin) { this.toLogin = toLogin; }
|
||||||
|
public Integer getToBlockNumber() { return toBlockNumber; }
|
||||||
|
public void setToBlockNumber(Integer toBlockNumber) { this.toBlockNumber = toBlockNumber; }
|
||||||
|
public String getToBlockHash() { return toBlockHash; }
|
||||||
|
public void setToBlockHash(String toBlockHash) { this.toBlockHash = toBlockHash; }
|
||||||
|
public String getBlockBytesB64() { return blockBytesB64; }
|
||||||
|
public void setBlockBytesB64(String blockBytesB64) { this.blockBytesB64 = blockBytesB64; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-6
@@ -105,7 +105,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
String order = asc ? "ASC" : "DESC";
|
String order = asc ? "ASC" : "DESC";
|
||||||
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
List<Net_GetChannelMessages_Response.MessageItem> out = new ArrayList<>();
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
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
|
FROM blocks
|
||||||
WHERE login = ? AND msg_type = ?
|
WHERE login = ? AND msg_type = ?
|
||||||
ORDER BY block_number
|
ORDER BY block_number
|
||||||
@@ -132,9 +133,9 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
item.setLikedByMe(false);
|
item.setLikedByMe(false);
|
||||||
item.setRepliesCount(0);
|
item.setRepliesCount(0);
|
||||||
item.setRatingsCount(0);
|
item.setRatingsCount(0);
|
||||||
item.setTargetBlockchainName(statusBody.toBchName());
|
item.setTargetBlockchainName(rs.getString("to_bch_name"));
|
||||||
item.setTargetBlockNumber(statusBody.toBlockGlobalNumber());
|
item.setTargetBlockNumber((Integer) rs.getObject("to_block_number"));
|
||||||
item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes()));
|
item.setTargetBlockHash(ChannelsReadSupport.toHex(rs.getBytes("to_block_hash")));
|
||||||
|
|
||||||
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
List<Net_GetChannelMessages_Response.VersionItem> versions = loadVersionsForDiaryItem(
|
||||||
c,
|
c,
|
||||||
@@ -148,7 +149,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
item.setVersionsTotal(versions.size());
|
item.setVersionsTotal(versions.size());
|
||||||
item.setText(versions.get(versions.size() - 1).getText());
|
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);
|
out.add(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +217,8 @@ public class Net_GetPersonalDiary_Handler implements JsonMessageHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try (PreparedStatement ps = c.prepareStatement("""
|
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
|
FROM blocks
|
||||||
WHERE bch_name = ? AND block_number = ?
|
WHERE bch_name = ? AND block_number = ?
|
||||||
LIMIT 1
|
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 {
|
||||||
|
}
|
||||||
+11
@@ -10,6 +10,7 @@ import shine.db.dao.SyncServersDAO;
|
|||||||
import shine.db.entities.BlockEntry;
|
import shine.db.entities.BlockEntry;
|
||||||
import shine.db.entities.SyncServerEntry;
|
import shine.db.entities.SyncServerEntry;
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
import utils.blockchain.BlockchainNameUtil;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -46,6 +47,11 @@ public final class AddBlockSyncService {
|
|||||||
private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance();
|
private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance();
|
||||||
|
|
||||||
public void replicateAsync(String blockchainName, int blockNumber) {
|
public void replicateAsync(String blockchainName, int blockNumber) {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
log.debug("AddBlock sync skipped: blockchain.sync.enabled=false blockchainName={} blockNumber={}",
|
||||||
|
blockchainName, blockNumber);
|
||||||
|
return;
|
||||||
|
}
|
||||||
EXECUTOR.execute(() -> {
|
EXECUTOR.execute(() -> {
|
||||||
try {
|
try {
|
||||||
replicate(blockchainName, blockNumber);
|
replicate(blockchainName, blockNumber);
|
||||||
@@ -56,6 +62,11 @@ public final class AddBlockSyncService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isEnabled() {
|
||||||
|
// Legacy peer replication is disabled in PDA 1.2; Arweave is the synchronization source.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private void replicate(String blockchainName, int blockNumber) throws Exception {
|
private void replicate(String blockchainName, int blockNumber) throws Exception {
|
||||||
String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName));
|
String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName));
|
||||||
if (ownerLogin == null) {
|
if (ownerLogin == null) {
|
||||||
|
|||||||
+287
-958
File diff suppressed because it is too large
Load Diff
+50
-70
@@ -327,6 +327,25 @@ public final class SolanaUsersSyncService
|
|||||||
state.lastSeenSignature()
|
state.lastSeenSignature()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (state.lastSeenSignature() == null) {
|
||||||
|
log.warn(
|
||||||
|
"History checkpoint is empty. Running current-state full snapshot bootstrap instead of replaying all historical transactions."
|
||||||
|
);
|
||||||
|
|
||||||
|
runFullSnapshotFallback(
|
||||||
|
state,
|
||||||
|
fetchResult,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
markReadyAfterSync(
|
||||||
|
state,
|
||||||
|
nowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.lastSeenSignature() != null
|
if (state.lastSeenSignature() != null
|
||||||
&& !fetchResult.anchorFound()) {
|
&& !fetchResult.anchorFound()) {
|
||||||
|
|
||||||
@@ -396,9 +415,12 @@ public final class SolanaUsersSyncService
|
|||||||
List<ParsedTxEnvelope> envelopes =
|
List<ParsedTxEnvelope> envelopes =
|
||||||
new ArrayList<>();
|
new ArrayList<>();
|
||||||
|
|
||||||
Set<String> updatePdaAddresses =
|
Set<String> exactFetchPdaAddresses =
|
||||||
new LinkedHashSet<>();
|
new LinkedHashSet<>();
|
||||||
|
|
||||||
|
Map<String, String> latestRelevantSignatureByPda =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
|
||||||
for (SolanaRpcClient.SignatureRecord signatureRecord : chronologicalSignatures) {
|
for (SolanaRpcClient.SignatureRecord signatureRecord : chronologicalSignatures) {
|
||||||
|
|
||||||
JsonNode transaction =
|
JsonNode transaction =
|
||||||
@@ -417,20 +439,14 @@ public final class SolanaUsersSyncService
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (envelope.parsedInstruction() != null
|
if (envelope.parsedInstruction() != null
|
||||||
&& envelope.parsedInstruction().kind() == ShineUsersCodec.TxKind.UPDATE_USER_PDA
|
&& envelope.parsedInstruction().affectedPdaAddress() != null
|
||||||
&& envelope.parsedInstruction().affectedPdaAddress() != null) {
|
&& envelope.parsedInstruction().relevant()) {
|
||||||
updatePdaAddresses.add(
|
String changedPda = envelope.parsedInstruction().affectedPdaAddress();
|
||||||
envelope.parsedInstruction()
|
latestRelevantSignatureByPda.put(changedPda, envelope.signatureRecord().signature());
|
||||||
.affectedPdaAddress()
|
exactFetchPdaAddresses.add(changedPda);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, ShineUsersCodec.UserPdaSnapshot> currentSnapshots =
|
|
||||||
storage.getCurrentSnapshots(
|
|
||||||
updatePdaAddresses
|
|
||||||
);
|
|
||||||
|
|
||||||
List<PostgresStorageRepository.TxHistoryEntry> txEntries =
|
List<PostgresStorageRepository.TxHistoryEntry> txEntries =
|
||||||
new ArrayList<>();
|
new ArrayList<>();
|
||||||
|
|
||||||
@@ -473,71 +489,16 @@ public final class SolanaUsersSyncService
|
|||||||
parsedInstruction.economyConfigState();
|
parsedInstruction.economyConfigState();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedInstruction.relevant()
|
if (parsedInstruction.relevant()) {
|
||||||
&& parsedInstruction.userPdaMutation() != null) {
|
|
||||||
|
|
||||||
relevant = true;
|
relevant = true;
|
||||||
affectedPdaAddress = parsedInstruction.affectedPdaAddress();
|
affectedPdaAddress = parsedInstruction.affectedPdaAddress();
|
||||||
affectedLogin = parsedInstruction.affectedLogin();
|
affectedLogin = parsedInstruction.affectedLogin();
|
||||||
|
|
||||||
ShineUsersCodec.UserPdaSnapshot snapshot;
|
lastRelevantSignature = envelope.signatureRecord().signature();
|
||||||
|
lastRelevantSlot = envelope.signatureRecord().slot();
|
||||||
|
|
||||||
if (parsedInstruction.kind() == ShineUsersCodec.TxKind.CREATE_USER_PDA) {
|
|
||||||
|
|
||||||
if (economyState == null) {
|
|
||||||
economyState =
|
|
||||||
ShineUsersCodec.EconomyConfigState.initial();
|
|
||||||
log.warn(
|
|
||||||
"Economy config state was absent while processing create tx {}. Falling back to initial constants.",
|
|
||||||
envelope.signatureRecord().signature()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot =
|
|
||||||
ShineUsersCodec.buildCreateSnapshot(
|
|
||||||
parsedInstruction.userPdaMutation(),
|
|
||||||
economyState,
|
|
||||||
envelope.signatureRecord().signature(),
|
|
||||||
envelope.signatureRecord().slot()
|
|
||||||
);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
ShineUsersCodec.UserPdaSnapshot previous =
|
|
||||||
currentSnapshots.get(
|
|
||||||
affectedPdaAddress
|
|
||||||
);
|
|
||||||
|
|
||||||
if (previous == null) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"Missing previous snapshot for update PDA " +
|
|
||||||
affectedPdaAddress
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot =
|
|
||||||
ShineUsersCodec.buildUpdateSnapshot(
|
|
||||||
parsedInstruction.userPdaMutation(),
|
|
||||||
previous,
|
|
||||||
envelope.signatureRecord().signature(),
|
|
||||||
envelope.signatureRecord().slot()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSnapshots.put(
|
|
||||||
snapshot.pdaAddress(),
|
|
||||||
snapshot
|
|
||||||
);
|
|
||||||
|
|
||||||
snapshotsToPersist.add(
|
|
||||||
snapshot
|
|
||||||
);
|
|
||||||
|
|
||||||
lastRelevantSignature =
|
|
||||||
envelope.signatureRecord().signature();
|
|
||||||
|
|
||||||
lastRelevantSlot =
|
|
||||||
envelope.signatureRecord().slot();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,6 +521,25 @@ public final class SolanaUsersSyncService
|
|||||||
fetchResult.signatures()
|
fetchResult.signatures()
|
||||||
.get(0);
|
.get(0);
|
||||||
|
|
||||||
|
// PDA 1.2 содержит поля, вычисляемые программой и подписанные как единый документ.
|
||||||
|
// Поэтому для нового формата не реконструируем байты из instruction args, а читаем
|
||||||
|
// фактическое текущее состояние затронутых PDA из Solana.
|
||||||
|
if (!exactFetchPdaAddresses.isEmpty()) {
|
||||||
|
SolanaRpcClient.AccountBatchResult exactAccounts =
|
||||||
|
rpcClient.getCurrentAccounts(exactFetchPdaAddresses, newestSeen.slot());
|
||||||
|
for (ProgramAccountUpdate account : exactAccounts.updates()) {
|
||||||
|
ShineUsersCodec.UserPdaSnapshot snapshot =
|
||||||
|
ShineUsersCodec.parseUserPdaAccount(
|
||||||
|
account.address(),
|
||||||
|
account.slot(),
|
||||||
|
account.dataBase64(),
|
||||||
|
latestRelevantSignatureByPda.getOrDefault(account.address(), "")
|
||||||
|
);
|
||||||
|
snapshotsToPersist.removeIf(existing -> existing.pdaAddress().equals(snapshot.pdaAddress()));
|
||||||
|
snapshotsToPersist.add(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PostgresStorageRepository.SyncStateSnapshot newState =
|
PostgresStorageRepository.SyncStateSnapshot newState =
|
||||||
new PostgresStorageRepository.SyncStateSnapshot(
|
new PostgresStorageRepository.SyncStateSnapshot(
|
||||||
"READY",
|
"READY",
|
||||||
|
|||||||
+114
-3
@@ -1,5 +1,6 @@
|
|||||||
package sync.storage.postgres;
|
package sync.storage.postgres;
|
||||||
|
|
||||||
|
import sync.util.Base58Util;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -272,6 +273,11 @@ public final class PostgresStorageRepository
|
|||||||
snapshots
|
snapshots
|
||||||
);
|
);
|
||||||
|
|
||||||
|
reconcileConfirmedKeyRotations(
|
||||||
|
connection,
|
||||||
|
snapshots
|
||||||
|
);
|
||||||
|
|
||||||
upsertSyncState(
|
upsertSyncState(
|
||||||
connection,
|
connection,
|
||||||
newState
|
newState
|
||||||
@@ -305,6 +311,11 @@ public final class PostgresStorageRepository
|
|||||||
snapshots
|
snapshots
|
||||||
);
|
);
|
||||||
|
|
||||||
|
reconcileConfirmedKeyRotations(
|
||||||
|
connection,
|
||||||
|
snapshots
|
||||||
|
);
|
||||||
|
|
||||||
upsertSyncState(
|
upsertSyncState(
|
||||||
connection,
|
connection,
|
||||||
newState
|
newState
|
||||||
@@ -462,7 +473,7 @@ public final class PostgresStorageRepository
|
|||||||
"INSERT INTO solana_user_pda_current (" +
|
"INSERT INTO solana_user_pda_current (" +
|
||||||
"pda_address, login, normalized_login, record_number, slot, last_tx_signature, " +
|
"pda_address, login, normalized_login, record_number, slot, last_tx_signature, " +
|
||||||
"recovery_key, root_key, client_key, blockchain_name, " +
|
"recovery_key, root_key, client_key, blockchain_name, " +
|
||||||
"blockchain_key, paid_limit_bytes, used_bytes, " +
|
"blockchain_key, paid_limit_bytes, blockchain_forks_json, used_bytes, " +
|
||||||
"last_block_number, last_block_hash, last_block_signature, " +
|
"last_block_number, last_block_hash, last_block_signature, " +
|
||||||
"arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, address_format_type, " +
|
"arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, address_format_type, " +
|
||||||
"address_format_version, server_address, sync_servers_json, " +
|
"address_format_version, server_address, sync_servers_json, " +
|
||||||
@@ -470,7 +481,7 @@ public final class PostgresStorageRepository
|
|||||||
"trusted_count, created_at_ms, updated_at_ms, " +
|
"trusted_count, created_at_ms, updated_at_ms, " +
|
||||||
"prev_record_hash, record_signature, raw_data_base64, " +
|
"prev_record_hash, record_signature, raw_data_base64, " +
|
||||||
"first_seen_at_ms, last_synced_at_ms" +
|
"first_seen_at_ms, last_synced_at_ms" +
|
||||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||||
"ON CONFLICT (pda_address) DO UPDATE SET " +
|
"ON CONFLICT (pda_address) DO UPDATE SET " +
|
||||||
"login = EXCLUDED.login, " +
|
"login = EXCLUDED.login, " +
|
||||||
"normalized_login = EXCLUDED.normalized_login, " +
|
"normalized_login = EXCLUDED.normalized_login, " +
|
||||||
@@ -483,6 +494,7 @@ public final class PostgresStorageRepository
|
|||||||
"blockchain_name = EXCLUDED.blockchain_name, " +
|
"blockchain_name = EXCLUDED.blockchain_name, " +
|
||||||
"blockchain_key = EXCLUDED.blockchain_key, " +
|
"blockchain_key = EXCLUDED.blockchain_key, " +
|
||||||
"paid_limit_bytes = EXCLUDED.paid_limit_bytes, " +
|
"paid_limit_bytes = EXCLUDED.paid_limit_bytes, " +
|
||||||
|
"blockchain_forks_json = EXCLUDED.blockchain_forks_json, " +
|
||||||
"used_bytes = EXCLUDED.used_bytes, " +
|
"used_bytes = EXCLUDED.used_bytes, " +
|
||||||
"last_block_number = EXCLUDED.last_block_number, " +
|
"last_block_number = EXCLUDED.last_block_number, " +
|
||||||
"last_block_hash = EXCLUDED.last_block_hash, " +
|
"last_block_hash = EXCLUDED.last_block_hash, " +
|
||||||
@@ -535,6 +547,90 @@ public final class PostgresStorageRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solana является источником истины для момента завершения ротации PDA.
|
||||||
|
* После upsert current-снимка сверяем все три новых public key и новый blockchainName.
|
||||||
|
* Совпадение переводит локальную машину ROTATING_PDA -> PDA_ROTATED в той же DB-транзакции.
|
||||||
|
*/
|
||||||
|
private void reconcileConfirmedKeyRotations(
|
||||||
|
Connection connection,
|
||||||
|
List<ShineUsersCodec.UserPdaSnapshot> snapshots
|
||||||
|
) throws Exception {
|
||||||
|
if (snapshots == null || snapshots.isEmpty()) return;
|
||||||
|
|
||||||
|
String select = """
|
||||||
|
SELECT id, new_root_key, new_blockchain_key, new_client_key, candidate_blockchain_name
|
||||||
|
FROM key_rotation_sessions
|
||||||
|
WHERE login = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE
|
||||||
|
""";
|
||||||
|
|
||||||
|
for (ShineUsersCodec.UserPdaSnapshot snapshot : snapshots) {
|
||||||
|
if (snapshot == null || snapshot.login() == null || snapshot.login().isBlank()) continue;
|
||||||
|
|
||||||
|
try (PreparedStatement ps = connection.prepareStatement(select)) {
|
||||||
|
ps.setString(1, snapshot.login());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (!rs.next()) continue;
|
||||||
|
|
||||||
|
long rotationId = rs.getLong("id");
|
||||||
|
String expectedRoot = rs.getString("new_root_key");
|
||||||
|
String expectedBlockchain = rs.getString("new_blockchain_key");
|
||||||
|
String expectedClient = rs.getString("new_client_key");
|
||||||
|
String expectedBlockchainName = rs.getString("candidate_blockchain_name");
|
||||||
|
|
||||||
|
String actualRoot = keyToBase64(snapshot.rootKey());
|
||||||
|
String actualBlockchain = keyToBase64(snapshot.blockchainKey());
|
||||||
|
String actualClient = keyToBase64(snapshot.clientKey());
|
||||||
|
|
||||||
|
if (!expectedRoot.equals(actualRoot)
|
||||||
|
|| !expectedBlockchain.equals(actualBlockchain)
|
||||||
|
|| !expectedClient.equals(actualClient)
|
||||||
|
|| !expectedBlockchainName.equals(snapshot.blockchainName())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
try (PreparedStatement update = connection.prepareStatement("""
|
||||||
|
UPDATE key_rotation_sessions
|
||||||
|
SET status = 'PDA_ROTATED',
|
||||||
|
pda_rotation_signature = COALESCE(pda_rotation_signature, ?),
|
||||||
|
updated_at_ms = ?,
|
||||||
|
last_error = NULL,
|
||||||
|
last_error_at_ms = NULL
|
||||||
|
WHERE id = ? AND status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||||
|
""")) {
|
||||||
|
update.setString(1, snapshot.lastTxSignature() == null || snapshot.lastTxSignature().isBlank() ? null : snapshot.lastTxSignature());
|
||||||
|
update.setLong(2, now);
|
||||||
|
update.setLong(3, rotationId);
|
||||||
|
if (update.executeUpdate() != 1) continue;
|
||||||
|
}
|
||||||
|
try (PreparedStatement update = connection.prepareStatement("""
|
||||||
|
UPDATE solana_user_pda_current
|
||||||
|
SET rotation_status = 'PDA_ROTATED', rotation_session_id = ?
|
||||||
|
WHERE login = ? AND rotation_session_id = ? AND rotation_status IN ('CHAIN_READY', 'ROTATING_PDA')
|
||||||
|
""")) {
|
||||||
|
update.setLong(1, rotationId);
|
||||||
|
update.setString(2, snapshot.login());
|
||||||
|
update.setLong(3, rotationId);
|
||||||
|
if (update.executeUpdate() != 1) {
|
||||||
|
throw new SQLException("Rotation session is not attached to synced login=" + snapshot.login());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String keyToBase64(String base58) {
|
||||||
|
if (base58 == null || base58.isBlank()) return "";
|
||||||
|
byte[] decoded = Base58Util.decode(base58);
|
||||||
|
if (decoded.length != 32) return "";
|
||||||
|
return java.util.Base64.getEncoder().encodeToString(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
private void bindSnapshot(
|
private void bindSnapshot(
|
||||||
PreparedStatement statement,
|
PreparedStatement statement,
|
||||||
ShineUsersCodec.UserPdaSnapshot snapshot,
|
ShineUsersCodec.UserPdaSnapshot snapshot,
|
||||||
@@ -595,6 +691,7 @@ public final class PostgresStorageRepository
|
|||||||
statement.setString(i++, snapshot.blockchainName());
|
statement.setString(i++, snapshot.blockchainName());
|
||||||
statement.setString(i++, snapshot.blockchainKey());
|
statement.setString(i++, snapshot.blockchainKey());
|
||||||
statement.setLong(i++, snapshot.paidLimitBytes());
|
statement.setLong(i++, snapshot.paidLimitBytes());
|
||||||
|
statement.setString(i++, writeJson(snapshot.blockchainForks()));
|
||||||
statement.setLong(i++, snapshot.usedBytes());
|
statement.setLong(i++, snapshot.usedBytes());
|
||||||
statement.setInt(i++, snapshot.lastBlockNumber());
|
statement.setInt(i++, snapshot.lastBlockNumber());
|
||||||
statement.setString(i++, snapshot.lastBlockHash());
|
statement.setString(i++, snapshot.lastBlockHash());
|
||||||
@@ -652,6 +749,17 @@ public final class PostgresStorageRepository
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
String forksJson = resultSet.getString("blockchain_forks_json");
|
||||||
|
List<ShineUsersCodec.BlockchainForkSnapshot> blockchainForks =
|
||||||
|
forksJson == null || forksJson.isBlank()
|
||||||
|
? List.of()
|
||||||
|
: Arrays.asList(
|
||||||
|
mapper.readValue(
|
||||||
|
forksJson,
|
||||||
|
ShineUsersCodec.BlockchainForkSnapshot[].class
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return new ShineUsersCodec.UserPdaSnapshot(
|
return new ShineUsersCodec.UserPdaSnapshot(
|
||||||
resultSet.getString("pda_address"),
|
resultSet.getString("pda_address"),
|
||||||
resultSet.getString("login"),
|
resultSet.getString("login"),
|
||||||
@@ -684,7 +792,8 @@ public final class PostgresStorageRepository
|
|||||||
resultSet.getLong("updated_at_ms"),
|
resultSet.getLong("updated_at_ms"),
|
||||||
resultSet.getString("prev_record_hash"),
|
resultSet.getString("prev_record_hash"),
|
||||||
resultSet.getString("record_signature"),
|
resultSet.getString("record_signature"),
|
||||||
resultSet.getString("raw_data_base64")
|
resultSet.getString("raw_data_base64"),
|
||||||
|
List.copyOf(blockchainForks)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,6 +945,7 @@ public final class PostgresStorageRepository
|
|||||||
"blockchain_name TEXT NOT NULL, " +
|
"blockchain_name TEXT NOT NULL, " +
|
||||||
"blockchain_key TEXT NOT NULL, " +
|
"blockchain_key TEXT NOT NULL, " +
|
||||||
"paid_limit_bytes BIGINT NOT NULL, " +
|
"paid_limit_bytes BIGINT NOT NULL, " +
|
||||||
|
"blockchain_forks_json TEXT NOT NULL DEFAULT '[]', " +
|
||||||
"used_bytes BIGINT NOT NULL, " +
|
"used_bytes BIGINT NOT NULL, " +
|
||||||
"last_block_number INTEGER NOT NULL, " +
|
"last_block_number INTEGER NOT NULL, " +
|
||||||
"last_block_hash TEXT NOT NULL, " +
|
"last_block_hash TEXT NOT NULL, " +
|
||||||
@@ -872,6 +982,7 @@ public final class PostgresStorageRepository
|
|||||||
" OR normalized_login <> LOWER(BTRIM(login))"
|
" OR normalized_login <> LOWER(BTRIM(login))"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS blockchain_forks_json TEXT NOT NULL DEFAULT '[]'");
|
||||||
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''");
|
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''");
|
||||||
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''");
|
statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''");
|
||||||
|
|
||||||
|
|||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
package sync.codec;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ShineUsersCodecLegacyV10Test {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readsLegacyV10ServerPdaForBootstrap() {
|
||||||
|
byte[] raw = buildLegacyServerPda();
|
||||||
|
ShineUsersCodec.UserPdaSnapshot snapshot = ShineUsersCodec.parseUserPdaAccount(
|
||||||
|
"legacy-pda", 123L, Base64.getEncoder().encodeToString(raw), "legacy-tx"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals("legacy_server", snapshot.login());
|
||||||
|
assertEquals("legacy_server-001", snapshot.blockchainName());
|
||||||
|
assertEquals(100_000L, snapshot.paidLimitBytes());
|
||||||
|
assertEquals(12_345L, snapshot.usedBytes());
|
||||||
|
assertEquals(7, snapshot.lastBlockNumber());
|
||||||
|
assertTrue(snapshot.isServer());
|
||||||
|
assertEquals(1, snapshot.addressFormatType());
|
||||||
|
assertEquals(0, snapshot.addressFormatVersion());
|
||||||
|
assertEquals("wss://legacy.example/ws", snapshot.serverAddress());
|
||||||
|
assertEquals(java.util.List.of("sync-old"), snapshot.syncServers());
|
||||||
|
assertEquals(java.util.List.of("access-old"), snapshot.accessServers());
|
||||||
|
assertEquals(1, snapshot.blockchainForks().size());
|
||||||
|
assertEquals(snapshot.blockchainKey(), snapshot.blockchainForks().get(0).blockchainKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] buildLegacyServerPda() {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
bytes(out, "SHiNE".getBytes(StandardCharsets.UTF_8));
|
||||||
|
u8(out, 1);
|
||||||
|
u8(out, 0);
|
||||||
|
u16(out, 0); // record_len patch later
|
||||||
|
u64(out, 1_700_000_000_000L);
|
||||||
|
u64(out, 1_700_000_001_000L);
|
||||||
|
u32(out, 3);
|
||||||
|
bytes(out, repeat(0x11, 32));
|
||||||
|
str(out, "legacy_server");
|
||||||
|
u8(out, 7); // recovery, root, client, blockchain, server, access, trusted
|
||||||
|
|
||||||
|
u8(out, 0); u8(out, 0); bytes(out, repeat(0x21, 32));
|
||||||
|
u8(out, 1); u8(out, 0); bytes(out, repeat(0x22, 32));
|
||||||
|
u8(out, 2); u8(out, 0); bytes(out, repeat(0x23, 32));
|
||||||
|
|
||||||
|
u8(out, 3); u8(out, 0);
|
||||||
|
u8(out, 1);
|
||||||
|
u8(out, 1);
|
||||||
|
str(out, "legacy_server-001");
|
||||||
|
bytes(out, repeat(0x24, 32));
|
||||||
|
u64(out, 100_000L);
|
||||||
|
u64(out, 12_345L);
|
||||||
|
u32(out, 7);
|
||||||
|
bytes(out, repeat(0x25, 32));
|
||||||
|
bytes(out, repeat(0x26, 64));
|
||||||
|
u8(out, 1);
|
||||||
|
str(out, "legacy-arweave-tx");
|
||||||
|
|
||||||
|
u8(out, 30); u8(out, 0);
|
||||||
|
u8(out, 1);
|
||||||
|
u8(out, 1);
|
||||||
|
u8(out, 0);
|
||||||
|
str(out, "wss://legacy.example/ws");
|
||||||
|
u8(out, 1);
|
||||||
|
str(out, "sync-old");
|
||||||
|
|
||||||
|
u8(out, 40); u8(out, 0);
|
||||||
|
u8(out, 1);
|
||||||
|
str(out, "access-old");
|
||||||
|
|
||||||
|
u8(out, 70); u8(out, 0); u8(out, 2);
|
||||||
|
|
||||||
|
bytes(out, repeat(0x27, 64));
|
||||||
|
|
||||||
|
byte[] record = out.toByteArray();
|
||||||
|
record[7] = (byte) (record.length & 0xff);
|
||||||
|
record[8] = (byte) ((record.length >>> 8) & 0xff);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] repeat(int value, int count) {
|
||||||
|
byte[] bytes = new byte[count];
|
||||||
|
java.util.Arrays.fill(bytes, (byte) value);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void str(ByteArrayOutputStream out, String value) {
|
||||||
|
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||||
|
u8(out, bytes.length);
|
||||||
|
bytes(out, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void bytes(ByteArrayOutputStream out, byte[] value) {
|
||||||
|
out.writeBytes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u8(ByteArrayOutputStream out, long value) {
|
||||||
|
out.write((int) value & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u16(ByteArrayOutputStream out, long value) {
|
||||||
|
u8(out, value);
|
||||||
|
u8(out, value >>> 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u32(ByteArrayOutputStream out, long value) {
|
||||||
|
for (int i = 0; i < 4; i++) u8(out, value >>> (8 * i));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void u64(ByteArrayOutputStream out, long value) {
|
||||||
|
for (int i = 0; i < 8; i++) u8(out, value >>> (8 * i));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package server.keyrotation;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Небольшой background worker, который продолжает rebuild даже если UI был закрыт. */
|
||||||
|
public final class KeyRotationRebuildScheduler {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildScheduler.class);
|
||||||
|
private static final AtomicBoolean STARTED = new AtomicBoolean(false);
|
||||||
|
private static final KeyRotationRebuildService SERVICE = new KeyRotationRebuildService();
|
||||||
|
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||||
|
@Override public Thread newThread(Runnable r) {
|
||||||
|
Thread t = new Thread(r, "key-rotation-rebuild");
|
||||||
|
t.setDaemon(true);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
private KeyRotationRebuildScheduler() { }
|
||||||
|
|
||||||
|
public static void startOrLog() {
|
||||||
|
if (!STARTED.compareAndSet(false, true)) return;
|
||||||
|
EXECUTOR.scheduleWithFixedDelay(() -> {
|
||||||
|
try { SERVICE.runReady(4); }
|
||||||
|
catch (Exception e) { log.warn("Key rotation rebuild cycle failed", e); }
|
||||||
|
}, 1, 2, TimeUnit.SECONDS);
|
||||||
|
log.info("Key rotation rebuild scheduler started");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package server.keyrotation;
|
||||||
|
|
||||||
|
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 server.sync.BlockchainResyncGuard;
|
||||||
|
import shine.db.dao.BlockchainResyncCleanupDAO;
|
||||||
|
import shine.db.dao.BlockchainStateDAO;
|
||||||
|
import shine.db.dao.KeyRotationCandidateBlocksDAO;
|
||||||
|
import shine.db.dao.KeyRotationSessionsDAO;
|
||||||
|
import shine.db.dao.SolanaUserPdaCurrentDAO;
|
||||||
|
import shine.db.entities.BlockchainStateEntry;
|
||||||
|
import shine.db.entities.KeyRotationCandidateBlockEntry;
|
||||||
|
import shine.db.entities.KeyRotationSessionEntry;
|
||||||
|
import shine.db.entities.KeyRotationStatus;
|
||||||
|
import shine.db.entities.SolanaUserPdaCurrentEntry;
|
||||||
|
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Автоматически materialize-ит candidate fork после того, как Solana sync подтвердил новое PDA.
|
||||||
|
* Candidate-блоки уже опубликованы в Arweave; здесь они только становятся единственной рабочей
|
||||||
|
* цепочкой PostgreSQL и повторно проходят обычный AddBlock validation/projection path.
|
||||||
|
*/
|
||||||
|
public final class KeyRotationRebuildService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(KeyRotationRebuildService.class);
|
||||||
|
|
||||||
|
private final KeyRotationSessionsDAO rotations = KeyRotationSessionsDAO.getInstance();
|
||||||
|
private final KeyRotationCandidateBlocksDAO candidates = KeyRotationCandidateBlocksDAO.getInstance();
|
||||||
|
private final BlockchainResyncCleanupDAO cleanup = BlockchainResyncCleanupDAO.getInstance();
|
||||||
|
private final BlockchainStateDAO states = BlockchainStateDAO.getInstance();
|
||||||
|
private final SolanaUserPdaCurrentDAO users = SolanaUserPdaCurrentDAO.getInstance();
|
||||||
|
private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler();
|
||||||
|
|
||||||
|
public int runReady(int limit) {
|
||||||
|
int processed = 0;
|
||||||
|
try {
|
||||||
|
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.PDA_ROTATED, limit)) {
|
||||||
|
if (tryRebuild(session)) processed++;
|
||||||
|
}
|
||||||
|
for (KeyRotationSessionEntry session : rotations.listByStatus(KeyRotationStatus.REBUILDING_SERVER, Math.max(1, limit - processed))) {
|
||||||
|
if (tryRebuild(session)) processed++;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Key rotation rebuild scan failed", e);
|
||||||
|
}
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean tryRebuild(KeyRotationSessionEntry session) {
|
||||||
|
if (session == null) return false;
|
||||||
|
try {
|
||||||
|
KeyRotationSessionEntry current = rotations.getById(session.getId());
|
||||||
|
if (current == null) return false;
|
||||||
|
if (current.getStatus() == KeyRotationStatus.PDA_ROTATED) {
|
||||||
|
current = rotations.transition(current.getId(), KeyRotationStatus.PDA_ROTATED, KeyRotationStatus.REBUILDING_SERVER);
|
||||||
|
}
|
||||||
|
if (current.getStatus() != KeyRotationStatus.REBUILDING_SERVER) return false;
|
||||||
|
rebuild(current);
|
||||||
|
rotations.clearError(current.getId());
|
||||||
|
rotations.transition(current.getId(), KeyRotationStatus.REBUILDING_SERVER, KeyRotationStatus.WALLET_MIGRATION);
|
||||||
|
log.info("Key rotation rebuild complete: login={} {} -> {}", current.getLogin(), current.getSourceBlockchainName(), current.getCandidateBlockchainName());
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
try { rotations.recordError(session.getId(), "REBUILDING_SERVER: " + safeMessage(e)); } catch (Exception ignored) { }
|
||||||
|
log.warn("Key rotation rebuild failed: id={} login={}", session.getId(), session.getLogin(), e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rebuild(KeyRotationSessionEntry session) throws Exception {
|
||||||
|
verifyCurrentPda(session);
|
||||||
|
List<KeyRotationCandidateBlockEntry> candidateBlocks;
|
||||||
|
try (var c = shine.db.DbController.getInstance().getConnection()) {
|
||||||
|
candidateBlocks = candidates.listBySession(c, session.getId());
|
||||||
|
}
|
||||||
|
if (candidateBlocks.size() != session.getProgressTotal()) {
|
||||||
|
throw new IllegalStateException("candidate count mismatch: " + candidateBlocks.size() + "/" + session.getProgressTotal());
|
||||||
|
}
|
||||||
|
for (KeyRotationCandidateBlockEntry block : candidateBlocks) {
|
||||||
|
if (block.isArweavePublishPending()) {
|
||||||
|
throw new IllegalStateException("candidate block is not published: #" + block.getBlockNumber());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String source = session.getSourceBlockchainName();
|
||||||
|
String target = session.getCandidateBlockchainName();
|
||||||
|
ReentrantLock first = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? source : target);
|
||||||
|
ReentrantLock second = BlockchainLocks.lockFor(source.compareTo(target) <= 0 ? target : source);
|
||||||
|
first.lock();
|
||||||
|
second.lock();
|
||||||
|
boolean sourceGuard = false;
|
||||||
|
boolean targetGuard = false;
|
||||||
|
try {
|
||||||
|
sourceGuard = BlockchainResyncGuard.tryBegin(source);
|
||||||
|
targetGuard = BlockchainResyncGuard.tryBegin(target);
|
||||||
|
if (!sourceGuard || !targetGuard) {
|
||||||
|
throw new IllegalStateException("another resync/rebuild is active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повторный запуск безопасен: сначала убираем любую частично materialized candidate-цепочку.
|
||||||
|
if (states.getByBlockchainName(target) != null) {
|
||||||
|
cleanup.cleanupBlockchainForFullResync(target);
|
||||||
|
}
|
||||||
|
if (states.getByBlockchainName(source) != null) {
|
||||||
|
cleanup.cleanupBlockchainForFullResync(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
createCandidateState(session);
|
||||||
|
BlockchainResyncGuard.withBypass(target, () -> {
|
||||||
|
for (KeyRotationCandidateBlockEntry candidate : candidateBlocks) {
|
||||||
|
Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(target, candidate.getBlockBytes());
|
||||||
|
if (!result.ok()) {
|
||||||
|
throw new IllegalStateException("candidate replay rejected at #" + candidate.getBlockNumber() + ": " + result.reasonCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Чужие подписанные ссылки сохраняют логическую identity login+number+hash.
|
||||||
|
// Здесь меняется только их локальный cache физического fork.
|
||||||
|
cleanup.refreshLogicalTargetBlockchainCache(session.getLogin(), source, target);
|
||||||
|
} finally {
|
||||||
|
if (targetGuard) BlockchainResyncGuard.end(target);
|
||||||
|
if (sourceGuard) BlockchainResyncGuard.end(source);
|
||||||
|
second.unlock();
|
||||||
|
first.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifyCurrentPda(KeyRotationSessionEntry session) throws Exception {
|
||||||
|
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
|
||||||
|
if (user == null) throw new IllegalStateException("current PDA not found");
|
||||||
|
if (!session.getCandidateBlockchainName().equals(user.getBlockchainName())) {
|
||||||
|
throw new IllegalStateException("current PDA blockchainName mismatch");
|
||||||
|
}
|
||||||
|
if (!session.getNewBlockchainKey().equals(user.getBlockchainKey())) {
|
||||||
|
throw new IllegalStateException("current PDA blockchain key mismatch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createCandidateState(KeyRotationSessionEntry session) throws Exception {
|
||||||
|
SolanaUserPdaCurrentEntry user = users.getByLogin(session.getLogin());
|
||||||
|
byte[] key = Base64.getDecoder().decode(session.getNewBlockchainKey());
|
||||||
|
if (key.length != 32) throw new IllegalStateException("new blockchain key length != 32");
|
||||||
|
|
||||||
|
BlockchainStateEntry state = new BlockchainStateEntry();
|
||||||
|
state.setBlockchainName(session.getCandidateBlockchainName());
|
||||||
|
state.setLogin(session.getLogin());
|
||||||
|
state.setBlockchainKey(session.getNewBlockchainKey());
|
||||||
|
state.setSizeLimit(user != null && user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L);
|
||||||
|
state.setFileSizeBytes(0L);
|
||||||
|
state.setLastBlockNumber(-1);
|
||||||
|
state.setLastBlockHash(null);
|
||||||
|
state.setUpdatedAtMs(System.currentTimeMillis());
|
||||||
|
states.insertIfMissing(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeMessage(Throwable t) {
|
||||||
|
String value = t == null ? "unknown" : String.valueOf(t.getMessage());
|
||||||
|
if (value.length() > 1000) value = value.substring(0, 1000);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import shine.db.entities.SolanaUserPdaCurrentEntry;
|
|||||||
import shine.db.entities.SyncServerEntry;
|
import shine.db.entities.SyncServerEntry;
|
||||||
import server.sync.BlockchainResyncGuard;
|
import server.sync.BlockchainResyncGuard;
|
||||||
import utils.blockchain.BlockchainNameUtil;
|
import utils.blockchain.BlockchainNameUtil;
|
||||||
|
import utils.config.AppConfig;
|
||||||
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -56,6 +57,10 @@ public final class PeriodicBlockchainSyncService {
|
|||||||
private PeriodicBlockchainSyncService() {}
|
private PeriodicBlockchainSyncService() {}
|
||||||
|
|
||||||
public static void startOrLog() {
|
public static void startOrLog() {
|
||||||
|
if (!isEnabled()) {
|
||||||
|
log.info("Periodic blockchain sync disabled by blockchain.sync.enabled=false");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!STARTED.compareAndSet(false, true)) {
|
if (!STARTED.compareAndSet(false, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,6 +73,11 @@ public final class PeriodicBlockchainSyncService {
|
|||||||
log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS);
|
log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isEnabled() {
|
||||||
|
// PDA 1.2 удаляет sync_servers: пользовательские блокчейны синхронизируются через Arweave.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static void runCycleSafe() {
|
private static void runCycleSafe() {
|
||||||
try {
|
try {
|
||||||
runCycle();
|
runCycle();
|
||||||
|
|||||||
@@ -22,54 +22,11 @@ public final class SyncServersBootstrapService {
|
|||||||
private SyncServersBootstrapService() {}
|
private SyncServersBootstrapService() {}
|
||||||
|
|
||||||
public static void refreshFromSolanaOrLog() {
|
public static void refreshFromSolanaOrLog() {
|
||||||
String serverLogin = normalize(AppConfig.getInstance().getParam(CONFIG_KEY));
|
|
||||||
if (serverLogin == null) {
|
|
||||||
log.warn("Sync bootstrap skipped: параметр {} не задан", CONFIG_KEY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
SolanaUserPdaImportService.ParsedServerProfile own =
|
SyncServersDAO.getInstance().replaceAll(List.of());
|
||||||
SolanaUserPdaImportService.fetchServerProfileByLogin(serverLogin);
|
log.info("Legacy sync_servers disabled: blockchain synchronization uses Arweave");
|
||||||
if (own == null) {
|
|
||||||
log.warn("Sync bootstrap skipped: server PDA не найдена для login={}", serverLogin);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!own.isServer()) {
|
|
||||||
log.warn("Sync bootstrap skipped: PDA login={} не помечена как server", serverLogin);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SyncServerEntry> entries = new ArrayList<>();
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
for (String partnerLogin : own.syncServers()) {
|
|
||||||
String normalizedPartnerLogin = normalize(partnerLogin);
|
|
||||||
if (normalizedPartnerLogin == null) continue;
|
|
||||||
|
|
||||||
SolanaUserPdaImportService.ParsedServerProfile partner =
|
|
||||||
SolanaUserPdaImportService.fetchServerProfileByLogin(normalizedPartnerLogin);
|
|
||||||
if (partner == null) {
|
|
||||||
log.warn("Sync bootstrap: partner PDA не найдена для login={}", normalizedPartnerLogin);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!partner.isServer()) {
|
|
||||||
log.warn("Sync bootstrap: partner login={} не является server PDA", normalizedPartnerLogin);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String serverAddress = safe(partner.serverAddress());
|
|
||||||
if (serverAddress.isBlank()) {
|
|
||||||
log.warn("Sync bootstrap: у partner login={} пустой server_address", normalizedPartnerLogin);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.add(new SyncServerEntry(normalizedPartnerLogin, serverAddress, now));
|
|
||||||
}
|
|
||||||
|
|
||||||
SyncServersDAO.getInstance().replaceAll(entries);
|
|
||||||
log.info("Sync bootstrap: сохранено {} серверов синхронизации для login={}", entries.size(), serverLogin);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Sync bootstrap failed while loading server PDA and sync_servers from Solana", e);
|
log.warn("Failed to clear legacy sync_servers table", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package server.ws;
|
package server.ws;
|
||||||
|
|
||||||
|
import server.keyrotation.KeyRotationRebuildScheduler;
|
||||||
|
|
||||||
import org.eclipse.jetty.server.Server;
|
import org.eclipse.jetty.server.Server;
|
||||||
import org.eclipse.jetty.servlet.ServletContextHandler;
|
import org.eclipse.jetty.servlet.ServletContextHandler;
|
||||||
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer;
|
||||||
@@ -61,6 +63,7 @@ public final class WsServer {
|
|||||||
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
|
// ANS-104: publish locally-created signed user blocks and discover blocks published by other servers.
|
||||||
ArweaveBlockPublisherScheduler.startOrLog();
|
ArweaveBlockPublisherScheduler.startOrLog();
|
||||||
ArweaveBlockSyncScheduler.startOrLog();
|
ArweaveBlockSyncScheduler.startOrLog();
|
||||||
|
KeyRotationRebuildScheduler.startOrLog();
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 2) Запуск Jetty WS
|
// 2) Запуск Jetty WS
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ solana.users.sync.pollIntervalSeconds=300
|
|||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
sync.importUserProfileFromPartner.enabled=false
|
sync.importUserProfileFromPartner.enabled=false
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# Legacy peer-to-peer blockchain sync. В PDA 1.2 sync_servers удалён,
|
||||||
|
# пользовательские блокчейны синхронизируются через Arweave.
|
||||||
|
# Параметр оставлен временно для совместимости конфигураций и не активирует legacy sync.
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
blockchain.sync.enabled=false
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Server public info
|
# Server public info
|
||||||
# Эти поля используются JSON-операцией GetServerInfo.
|
# Эти поля используются JSON-операцией GetServerInfo.
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ public final class AddBlockSender {
|
|||||||
List<Ans104DataItem.Tag> tags = new ArrayList<>();
|
List<Ans104DataItem.Tag> tags = new ArrayList<>();
|
||||||
tags.add(new Ans104DataItem.Tag("App", "test5590"));
|
tags.add(new Ans104DataItem.Tag("App", "test5590"));
|
||||||
String channelSlug = channelSlugFor(body);
|
String channelSlug = channelSlugFor(body);
|
||||||
if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c", channelSlug));
|
if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c_test5590", channelSlug));
|
||||||
|
|
||||||
byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame);
|
byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame);
|
||||||
byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey);
|
byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import blockchain.MsgSubType;
|
|||||||
import blockchain.body.ConnectionBody;
|
import blockchain.body.ConnectionBody;
|
||||||
import blockchain.body.CreateChannelBody;
|
import blockchain.body.CreateChannelBody;
|
||||||
import blockchain.body.HeaderBody;
|
import blockchain.body.HeaderBody;
|
||||||
import blockchain.body.TextBody;
|
import blockchain.body.TextLineBody;
|
||||||
|
import blockchain.body.TextReplyBody;
|
||||||
import shine.db.DbController;
|
import shine.db.DbController;
|
||||||
import test.it.blockchain.AddBlockSender;
|
import test.it.blockchain.AddBlockSender;
|
||||||
import test.it.blockchain.ChainState;
|
import test.it.blockchain.ChainState;
|
||||||
@@ -27,7 +28,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
* CONNECTION (type=3):
|
* CONNECTION (type=3):
|
||||||
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
|
* - всегда имеет hasLine (lineCode+prevLineNumber+prevLineHash32+thisLineNumber)
|
||||||
* - всегда имеет target:
|
* - всегда имеет target:
|
||||||
* toBlockchainName + toBlockGlobalNumber + toBlockHash32
|
* toLogin + toBlockGlobalNumber + toBlockHash32
|
||||||
*
|
*
|
||||||
* Правило target для связей/подписок:
|
* Правило target для связей/подписок:
|
||||||
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
|
* - FRIEND/CONTACT -> target = HEADER цели (blockNumber=0)
|
||||||
@@ -88,12 +89,12 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
// POST в канал "0"
|
// POST в канал "0"
|
||||||
{
|
{
|
||||||
var ln = st1.nextTextLineByRoot(root0);
|
var ln = st1.nextTextLineByRoot(root0);
|
||||||
sender1.send(new TextBody(
|
sender1.send(new TextLineBody(
|
||||||
MsgSubType.TEXT_POST,
|
|
||||||
root0,
|
root0,
|
||||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||||
"U1: story/post in channel 0",
|
MsgSubType.TEXT_POST,
|
||||||
null, null, null
|
null, null, null,
|
||||||
|
"U1: story/post in channel 0"
|
||||||
), t);
|
), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,12 +149,12 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
byte[] newsPost0Hash;
|
byte[] newsPost0Hash;
|
||||||
{
|
{
|
||||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||||
sender1.send(new TextBody(
|
sender1.send(new TextLineBody(
|
||||||
MsgSubType.TEXT_POST,
|
|
||||||
newsRootBlock,
|
newsRootBlock,
|
||||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||||
"U1: News post #0",
|
MsgSubType.TEXT_POST,
|
||||||
null, null, null
|
null, null, null,
|
||||||
|
"U1: News post #0"
|
||||||
), t);
|
), t);
|
||||||
|
|
||||||
newsPost0Block = st1.lastBlockNumber();
|
newsPost0Block = st1.lastBlockNumber();
|
||||||
@@ -164,26 +165,26 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
// POST #1 в канал "News"
|
// POST #1 в канал "News"
|
||||||
{
|
{
|
||||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||||
sender1.send(new TextBody(
|
sender1.send(new TextLineBody(
|
||||||
MsgSubType.TEXT_POST,
|
|
||||||
newsRootBlock,
|
newsRootBlock,
|
||||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||||
"U1: News post #1",
|
MsgSubType.TEXT_POST,
|
||||||
null, null, null
|
null, null, null,
|
||||||
|
"U1: News post #1"
|
||||||
), t);
|
), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EDIT_POST (в линии канала) -> target на ОРИГИНАЛЬНЫЙ POST (без toBlockchainName)
|
// EDIT_POST -> target на ОРИГИНАЛЬНЫЙ POST по login + number + hash
|
||||||
{
|
{
|
||||||
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
var ln = st1.nextTextLineByRoot(newsRootBlock);
|
||||||
sender1.send(new TextBody(
|
sender1.send(new TextLineBody(
|
||||||
MsgSubType.TEXT_EDIT_POST,
|
|
||||||
newsRootBlock,
|
newsRootBlock,
|
||||||
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
ln.prevLineNumber, ln.prevLineHash32, ln.thisLineNumber,
|
||||||
"U1: News post #0 (EDIT)",
|
MsgSubType.TEXT_EDIT_POST,
|
||||||
null,
|
|
||||||
newsPost0Block,
|
newsPost0Block,
|
||||||
newsPost0Hash
|
newsPost0Hash,
|
||||||
|
u1,
|
||||||
|
"U1: News post #0 (EDIT)"
|
||||||
), t);
|
), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,17 +207,17 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
|
// 1) U1 подписался на U2 (FOLLOW на пользователя -> target=HEADER U2)
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_FOLLOW,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
u2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 follows U2 (target=U2 HEADER)", t);
|
"U1 follows U2 (target=U2 HEADER)", t);
|
||||||
|
|
||||||
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
|
// 2) U2 подписался на канал U1 "News" (FOLLOW на канал -> target=root CREATE_CHANNEL U1)
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||||
bch1, newsRootBlock, newsRootHash,
|
u1, newsRootBlock, newsRootHash,
|
||||||
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
"U2 follows U1 channel 'News' (target=U1 CREATE_CHANNEL root)", t);
|
||||||
|
|
||||||
// 3) U2 подписался на второй канал U1 "Updates"
|
// 3) U2 подписался на второй канал U1 "Updates"
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_FOLLOW,
|
||||||
bch1, updatesRootBlock, updatesRootHash,
|
u1, updatesRootBlock, updatesRootHash,
|
||||||
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
|
"U2 follows U1 channel 'Updates' (target=U1 CREATE_CHANNEL root)", t);
|
||||||
|
|
||||||
assertEquals(2, countConnectionsByOwner(u2, u1),
|
assertEquals(2, countConnectionsByOwner(u2, u1),
|
||||||
@@ -230,30 +231,31 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// 4) FRIEND взаимно (на HEADER)
|
// 4) FRIEND взаимно (на HEADER)
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
u2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: FRIEND", t);
|
"U1 -> U2: FRIEND", t);
|
||||||
|
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_CLOSE_FRIEND,
|
||||||
bch1, u1HeaderBlock, u1HeaderHash,
|
u1, u1HeaderBlock, u1HeaderHash,
|
||||||
"U2 -> U1: FRIEND", t);
|
"U2 -> U1: FRIEND", t);
|
||||||
|
|
||||||
// 5) CONTACT несколько
|
// 5) CONTACT несколько
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
u2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: CONTACT", t);
|
"U1 -> U2: CONTACT", t);
|
||||||
|
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_CONTACT,
|
||||||
bch1, u1HeaderBlock, u1HeaderHash,
|
u1, u1HeaderBlock, u1HeaderHash,
|
||||||
"U2 -> U1: CONTACT", t);
|
"U2 -> U1: CONTACT", t);
|
||||||
|
|
||||||
// =========================
|
// =========================
|
||||||
// USER2 REPLY (ответ в чужой канал)
|
// USER2 REPLY (ответ в чужой канал)
|
||||||
// =========================
|
// =========================
|
||||||
{
|
{
|
||||||
sender2.send(TextBody.newReply(
|
sender2.send(new TextReplyBody(
|
||||||
bch1,
|
MsgSubType.TEXT_REPLY,
|
||||||
newsPost0Block,
|
newsPost0Block,
|
||||||
newsPost0Hash,
|
newsPost0Hash,
|
||||||
|
u1,
|
||||||
"U2: reply to U1 News post #0 (cross-chain)"
|
"U2: reply to U1 News post #0 (cross-chain)"
|
||||||
), t);
|
), t);
|
||||||
}
|
}
|
||||||
@@ -273,12 +275,12 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// U1 -> U3: CONTACT
|
// U1 -> U3: CONTACT
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_CONTACT,
|
||||||
bch3, u3HeaderBlock, u3HeaderHash,
|
u3, u3HeaderBlock, u3HeaderHash,
|
||||||
"U1 -> U3: CONTACT", t);
|
"U1 -> U3: CONTACT", t);
|
||||||
|
|
||||||
// 6) U2 отписывается только от News
|
// 6) U2 отписывается только от News
|
||||||
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
|
sendConnection(sender2, st2, MsgSubType.CONNECTION_UNFOLLOW,
|
||||||
bch1, newsRootBlock, newsRootHash,
|
u1, newsRootBlock, newsRootHash,
|
||||||
"U2 unfollows U1 channel 'News'", t);
|
"U2 unfollows U1 channel 'News'", t);
|
||||||
|
|
||||||
assertEquals(1, countConnectionsByOwner(u2, u1),
|
assertEquals(1, countConnectionsByOwner(u2, u1),
|
||||||
@@ -292,7 +294,7 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// 7) U1 убирает U2 из контактов (UNCONTACT)
|
// 7) U1 убирает U2 из контактов (UNCONTACT)
|
||||||
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
|
sendConnection(sender1, st1, MsgSubType.CONNECTION_UNCONTACT,
|
||||||
bch2, u2HeaderBlock, u2HeaderHash,
|
u2, u2HeaderBlock, u2HeaderHash,
|
||||||
"U1 -> U2: UNCONTACT", t);
|
"U1 -> U2: UNCONTACT", t);
|
||||||
|
|
||||||
r.ok("IT_03 сценарий блоков + connections выполнен");
|
r.ok("IT_03 сценарий блоков + connections выполнен");
|
||||||
@@ -313,7 +315,7 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
private static void sendConnection(AddBlockSender sender,
|
private static void sendConnection(AddBlockSender sender,
|
||||||
ChainState st,
|
ChainState st,
|
||||||
short subType,
|
short subType,
|
||||||
String toBlockchainName,
|
String toLogin,
|
||||||
int toBlockNumber,
|
int toBlockNumber,
|
||||||
byte[] toBlockHash32,
|
byte[] toBlockHash32,
|
||||||
String logNote,
|
String logNote,
|
||||||
@@ -321,7 +323,7 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
if (TestConfig.DEBUG()) {
|
if (TestConfig.DEBUG()) {
|
||||||
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
|
TestLog.info("CONNECTION: subType=" + (subType & 0xFFFF)
|
||||||
+ " to=" + toBlockchainName
|
+ " to=" + toLogin
|
||||||
+ " targetBlock=" + toBlockNumber
|
+ " targetBlock=" + toBlockNumber
|
||||||
+ " note=" + logNote);
|
+ " note=" + logNote);
|
||||||
}
|
}
|
||||||
@@ -330,14 +332,14 @@ public class IT_03_AddBlock_NoAuth {
|
|||||||
|
|
||||||
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
|
// КОНСТРУКТОР ИЗ ТВОЕГО КОДА:
|
||||||
// ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
|
// ConnectionBody(int lineCode, int prevLineNumber, byte[] prevLineHash32, int thisLineNumber,
|
||||||
// short subType, String toBlockchainName, int toBlockGlobalNumber, byte[] toBlockHash32)
|
// short subType, String toLogin, int toBlockGlobalNumber, byte[] toBlockHash32)
|
||||||
sender.send(new ConnectionBody(
|
sender.send(new ConnectionBody(
|
||||||
0, // lineCode для connection линии
|
0, // lineCode для connection линии
|
||||||
ln.prevLineNumber,
|
ln.prevLineNumber,
|
||||||
ln.prevLineHash32,
|
ln.prevLineHash32,
|
||||||
ln.thisLineNumber,
|
ln.thisLineNumber,
|
||||||
subType,
|
subType,
|
||||||
toBlockchainName,
|
toLogin,
|
||||||
toBlockNumber,
|
toBlockNumber,
|
||||||
toBlockHash32
|
toBlockHash32
|
||||||
), timeout);
|
), timeout);
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ public final class SeedDataPopulationHelper {
|
|||||||
line.prevLineHash32,
|
line.prevLineHash32,
|
||||||
line.thisLineNumber,
|
line.thisLineNumber,
|
||||||
relationSubType,
|
relationSubType,
|
||||||
bch(to),
|
to,
|
||||||
0,
|
0,
|
||||||
targetHeaderHash
|
targetHeaderHash
|
||||||
), timeout);
|
), timeout);
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.12.21
|
client.version=1.14.0
|
||||||
server.version=1.10.8
|
server.version=1.12.0
|
||||||
|
|||||||
@@ -43,6 +43,24 @@
|
|||||||
- `scripts/deploy_server.sh` — обновить существующий серверный jar и перезапустить systemd service.
|
- `scripts/deploy_server.sh` — обновить существующий серверный jar и перезапустить systemd service.
|
||||||
- `scripts/deploy_ui.sh` — обновить существующий UI, проверить Caddy root и подставить `deploy-config.js`.
|
- `scripts/deploy_ui.sh` — обновить существующий UI, проверить Caddy root и подставить `deploy-config.js`.
|
||||||
|
|
||||||
|
## Временные статические сайты
|
||||||
|
|
||||||
|
Для дизайн-примеров, standalone-viewer'ов и временных тестовых страниц используется папка:
|
||||||
|
|
||||||
|
```text
|
||||||
|
shine-UI/static-sites/
|
||||||
|
```
|
||||||
|
|
||||||
|
Она деплоится обычным UI deploy вместе со всем `shine-UI`. Если Caddy root указывает на UI-каталог и используется стандартный fallback `try_files {path} /index.html`, отдельный Caddy-route для каждой новой подпапки не нужен.
|
||||||
|
|
||||||
|
Примеры URL после deploy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://<host>/static-sites/
|
||||||
|
https://<host>/static-sites/design-examples/channels-v1/
|
||||||
|
https://<host>/static-sites/arweave-viewer/
|
||||||
|
```
|
||||||
|
|
||||||
Production wrappers:
|
Production wrappers:
|
||||||
|
|
||||||
- `scripts/production_shineupme_server.sh`
|
- `scripts/production_shineupme_server.sh`
|
||||||
|
|||||||
@@ -104,6 +104,22 @@ cp /path/to/SHiNE-product/application.properties ./application.properties
|
|||||||
|
|
||||||
## Что пока остаётся как есть
|
## Что пока остаётся как есть
|
||||||
|
|
||||||
- `sync_servers` сервер по-прежнему загружает из server PDA в Solana;
|
- runtime-сервер работает с PostgreSQL;
|
||||||
- runtime-сервер уже работает только с PostgreSQL;
|
- межсерверная доставка DM остаётся отдельным механизмом и не связана с blockchain sync;
|
||||||
- дальнейшим отдельным шагом остаются зачистка legacy-документации, переименования и перенос оставшихся прямых SQL-запросов в DAO/service.
|
- прямой blockchain sync через `sync_servers` отключён: пользовательские блоки синхронизируются через Arweave.
|
||||||
|
|
||||||
|
## Совместимость с user PDA 1.2
|
||||||
|
|
||||||
|
После обновления `shine_users` серверный модуль `shine-server-solana-users-sync` должен обновляться вместе с программой: текущий codec принимает только PDA 1.2. Legacy PDA 1.0 не мигрируются; тестовые legacy-записи можно закрыть временной инструкцией `close_legacy_pda`.
|
||||||
|
|
||||||
|
Миграция PostgreSQL v25 добавляет `blockchain_forks_json`. Старые compatibility-колонки продолжают содержать активный (последний) fork, а полный список ключей fork сохраняется отдельно и используется для поиска владельца Arweave-записей по любому историческому blockchain key.
|
||||||
|
|
||||||
|
## PostgreSQL migration v26: key rotation runtime state
|
||||||
|
|
||||||
|
Migration v26 добавляет server-local поля `rotation_status` / `rotation_session_id` в `solana_user_pda_current` и таблицу `key_rotation_sessions`.
|
||||||
|
|
||||||
|
Это локальное состояние длительной смены ключей, а не часть Solana PDA. Solana users sync продолжает обновлять только PDA-поля и не должен затирать rotation-state. После обновления сервера миграция применяется стандартным `DatabaseInitializer` автоматически.
|
||||||
|
|
||||||
|
## Миграции key rotation
|
||||||
|
|
||||||
|
При обновлении сервера DatabaseInitializer последовательно применяет migration v26 (state machine смены ключей) и v27 (отдельные candidate-блоки будущего fork). Ручного создания таблиц не требуется. Candidate-блоки не входят в текущую таблицу `blocks` до финального переключения fork.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
|
|||||||
|
|
||||||
## Operational-нюанс
|
## Operational-нюанс
|
||||||
|
|
||||||
Общий Helius devnet endpoint тоже может начать ограничивать запросы, если несколько инстансов одновременно делают тяжёлый bootstrap. Если после деплоя какой-то инстанс не подтянул `sync_servers` или завис на startup sync, рестартовать сервисы по одному с паузой.
|
Общий Helius devnet endpoint тоже может начать ограничивать запросы, если несколько инстансов одновременно делают тяжёлый bootstrap. Если после деплоя какой-то инстанс завис на bootstrap Arweave/Solana sync, рестартовать сервисы по одному с паузой.
|
||||||
|
|
||||||
Отдельный нюанс по web push:
|
Отдельный нюанс по web push:
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
- `bad_block_number` — нарушена последовательность;
|
- `bad_block_number` — нарушена последовательность;
|
||||||
- `bad_prev_hash` — нарушена SHiNE hash chain;
|
- `bad_prev_hash` — нарушена SHiNE hash chain;
|
||||||
- `bad_block_bytes` — DataItem/Frame не парсится.
|
- `bad_block_bytes` — DataItem/Frame не парсится.
|
||||||
|
- `key_rotation_in_progress` (HTTP/status `423`) — для пользователя уже запущена смена ключей; обычный `AddBlock` временно запрещён до завершения/отмены ротации;
|
||||||
|
- `key_rotation_check_failed` — сервер не смог проверить локальный rotation status и отклонил запись fail-closed.
|
||||||
|
|
||||||
## Storage/publish
|
## Storage/publish
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
|
||||||
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |
|
||||||
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
|
| `GetBlockchainBlock` | `04_Add_Block_to_Blockchain_API.md` | чтение одного блока блокчейна |
|
||||||
|
| `KeyRotationStart` | `19_Key_Rotation_API.md` | начать смену ключей сразу в состоянии `COPYING_CHAIN` |
|
||||||
|
| `KeyRotationStatus` | `19_Key_Rotation_API.md` | получить текущий этап и прогресс смены ключей |
|
||||||
|
| `KeyRotationAddBlock` | `19_Key_Rotation_API.md` | добавить подписанный новым blockchain key candidate-блок будущего fork |
|
||||||
|
| `KeyRotationFinishChain` | `19_Key_Rotation_API.md` | проверить полную публикацию candidate-chain и перевести её в `CHAIN_READY` |
|
||||||
|
| `KeyRotationRotatePda` | `19_Key_Rotation_API.md` | зафиксировать отправленную Solana-ротацию PDA и ждать подтверждения sync |
|
||||||
|
| `KeyRotationContinue` | `19_Key_Rotation_API.md` | продолжить интерактивный post-PDA этап; сейчас wallet/DM отмечаются как `NOT_IMPLEMENTED` и пропускаются |
|
||||||
|
| `KeyRotationAbort` | `19_Key_Rotation_API.md` | прервать ротацию до отправки Solana PDA-ротации |
|
||||||
|
| `GetMyBlockchain` | `20_Get_My_Blockchain_API.md` | постранично читать текущую активную собственную цепочку для аудита и выбора fork point |
|
||||||
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
|
| `ServerHello` | `16_Server_Connection_Pool_API.md` | объявление server-to-server соединения и возможностей peer без криптографической проверки |
|
||||||
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
|
| `Ping` | `05_Technical_Requests_API.md` | keep-alive |
|
||||||
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
| `GetServerInfo` | `05_Technical_Requests_API.md` | публичная информация о сервере |
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
- физическое соединение создаётся одно на `serverLogin`;
|
- физическое соединение создаётся одно на `serverLogin`;
|
||||||
- логические операции DM, settings и blockchain используют один WSS;
|
- логические операции DM, settings и blockchain используют один WSS;
|
||||||
- завершение `RemoteSyncSession` не закрывает физический сокет;
|
- завершение `RemoteSyncSession` не закрывает физический сокет;
|
||||||
- известные peer берутся из `sync_servers` и первых действующих маршрутов
|
- известные peer берутся из актуальных server PDA/access-server маршрутов
|
||||||
`user_access_servers_current`;
|
`user_access_servers_current`;
|
||||||
- список перечитывается каждые 30 секунд;
|
- список перечитывается каждые 30 секунд;
|
||||||
- при изменении URL соединение пересоздаётся;
|
- при изменении URL соединение пересоздаётся;
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
# Key Rotation API
|
||||||
|
|
||||||
|
Этот раздел описывает публичные JSON/WebSocket операции мастера смены ключей пользователя.
|
||||||
|
|
||||||
|
Публично доступны:
|
||||||
|
|
||||||
|
- `KeyRotationStart`
|
||||||
|
- `KeyRotationStatus`
|
||||||
|
- `KeyRotationAddBlock`
|
||||||
|
- `KeyRotationFinishChain`
|
||||||
|
- `KeyRotationRotatePda`
|
||||||
|
- `KeyRotationContinue`
|
||||||
|
- `KeyRotationAbort`
|
||||||
|
|
||||||
|
После `PDA_ROTATED` отдельный фоновый worker автоматически переводит ротацию в `REBUILDING_SERVER`, делает новую candidate-chain единственной рабочей цепочкой PostgreSQL и затем переводит процесс в `WALLET_MIGRATION`.
|
||||||
|
|
||||||
|
## Общие правила
|
||||||
|
|
||||||
|
- операции доступны только авторизованному LOCAL-пользователю своего access server;
|
||||||
|
- login берётся из авторизованной сессии и не передаётся в payload;
|
||||||
|
- приватные ключи и пароли серверу не передаются никогда;
|
||||||
|
- в БД сохраняются только старые/новые публичные ключи;
|
||||||
|
- первая серверная запись появляется сразу в состоянии `COPYING_CHAIN`; состояния `PREPARING` в БД нет;
|
||||||
|
- во время `KeyRotationStart` сервер захватывает тот же per-blockchain lock, что использует обычный `AddBlock`, и фиксирует непротиворечивый source tip.
|
||||||
|
|
||||||
|
## `KeyRotationStart`
|
||||||
|
|
||||||
|
Создаёт новую серверную сессию ротации.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationStart",
|
||||||
|
"requestId": "kr-1",
|
||||||
|
"payload": {
|
||||||
|
"newRootKey": "<Base58 или Base64 публичного ключа 32B>",
|
||||||
|
"newBlockchainKey": "<Base58 или Base64 публичного ключа 32B>",
|
||||||
|
"newClientKey": "<Base58 или Base64 публичного ключа 32B>",
|
||||||
|
"forkFromBlock": 120,
|
||||||
|
"forkFromHash": "<64 hex>",
|
||||||
|
"reasonCode": 1,
|
||||||
|
"comment": "Плановая смена пароля"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`reasonCode`:
|
||||||
|
|
||||||
|
1. обычная ротация;
|
||||||
|
2. возможная компрометация;
|
||||||
|
3. подтверждённая компрометация / rollback;
|
||||||
|
4. recovery.
|
||||||
|
|
||||||
|
Сервер самостоятельно берёт из текущего PDA:
|
||||||
|
|
||||||
|
- `oldRootKey`;
|
||||||
|
- `oldBlockchainKey`;
|
||||||
|
- `oldClientKey`;
|
||||||
|
- текущий `sourceBlockchainName`.
|
||||||
|
|
||||||
|
Также сервер самостоятельно фиксирует текущий tip цепочки. Клиент не может подменить эти значения в запросе.
|
||||||
|
|
||||||
|
`forkFromBlock/forkFromHash` обязаны указывать на реально существующий блок текущей активной цепочки.
|
||||||
|
|
||||||
|
Новые root/blockchain/client keys должны быть валидными 32-байтовыми публичными ключами, отличаться от соответствующих старых ключей и друг от друга.
|
||||||
|
|
||||||
|
После успеха создаётся `key_rotation_sessions` со статусом `COPYING_CHAIN`. `progressTotal` равен количеству будущих candidate-блоков: копия `0..forkFromBlock` плюс `TECH_FORK`.
|
||||||
|
|
||||||
|
### Success response
|
||||||
|
|
||||||
|
Ответ использует тот же payload состояния, что и `KeyRotationStatus`.
|
||||||
|
|
||||||
|
## `KeyRotationStatus`
|
||||||
|
|
||||||
|
Возвращает текущее состояние ротации авторизованного пользователя.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationStatus",
|
||||||
|
"requestId": "kr-status-1",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если активной ротации нет:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationStatus",
|
||||||
|
"requestId": "kr-status-1",
|
||||||
|
"status": 200,
|
||||||
|
"ok": true,
|
||||||
|
"payload": {
|
||||||
|
"rotationStatus": "NONE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Если ротация есть, payload содержит:
|
||||||
|
|
||||||
|
- `rotationSessionId`;
|
||||||
|
- `rotationStatus`;
|
||||||
|
- `sourceBlockchainName` / `candidateBlockchainName`;
|
||||||
|
- старые и новые публичные root/blockchain/client keys;
|
||||||
|
- `forkFromBlock` / `forkFromHash`;
|
||||||
|
- `sourceTipBlock` / `sourceTipHash`;
|
||||||
|
- `reasonCode` / `comment`;
|
||||||
|
- `progressCurrent` / `progressTotal`;
|
||||||
|
- `pdaRotationSignature` (когда появится на последующем этапе);
|
||||||
|
- `walletMigrationStatus`;
|
||||||
|
- `messageMigrationStatus`;
|
||||||
|
- `lastError` / `retryCount`;
|
||||||
|
- `createdAtMs` / `updatedAtMs`.
|
||||||
|
|
||||||
|
Любая авторизованная сессия пользователя может читать этот статус. Состояние ротации принадлежит аккаунту, а не конкретному WebSocket-сеансу.
|
||||||
|
|
||||||
|
|
||||||
|
## `KeyRotationAddBlock`
|
||||||
|
|
||||||
|
Принимает один ANS-104 DataItem будущего fork на этапе `COPYING_CHAIN`. Candidate-блоки хранятся отдельно от обычной таблицы `blocks`, поэтому до переключения fork они **не создают лайки, ответы, каналы, связи и другие materialized effects**.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationAddBlock",
|
||||||
|
"requestId": "kr-block-1",
|
||||||
|
"payload": {
|
||||||
|
"blockNumber": 0,
|
||||||
|
"prevBlockHash": "",
|
||||||
|
"blockBytesB64": "<полный ANS-104 DataItem в Base64>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- операция доступна только при `rotationStatus=COPYING_CHAIN`;
|
||||||
|
- блоки принимаются строго последовательно: `0..forkFromBlock`, затем один `TECH_FORK` с номером `forkFromBlock+1`;
|
||||||
|
- каждый DataItem обязан быть подписан `newBlockchainKey`;
|
||||||
|
- для копируемых блоков Frame, ANS-104 tags, target и anchor должны полностью совпадать с исходным блоком; меняются только owner/signature/DataItem id;
|
||||||
|
- последний блок обязан быть `TECH_FORK`, а его parent key, fork point, старый tip, reason/comment и число отброшенных блоков должны совпадать с `KeyRotationStart`;
|
||||||
|
- повтор уже принятого идентичного блока безопасен и возвращает success; другой DataItem/hash на том же номере возвращает conflict;
|
||||||
|
- candidate-блоки попадают в отдельную очередь Arweave/Turbo publisher-а и имеют приоритет перед обычными блоками;
|
||||||
|
- `progressCurrent` увеличивается **только после фактической успешной публикации** DataItem в Arweave/Turbo. Поэтому `KeyRotationStatus` показывает реальный прогресс публикации, а не только приём сервером.
|
||||||
|
|
||||||
|
### Основные ошибки
|
||||||
|
|
||||||
|
- `KEY_ROTATION_NOT_COPYING` — ротация не находится в `COPYING_CHAIN`;
|
||||||
|
- `KEY_ROTATION_BLOCK_OUT_OF_ORDER` — пропущен предыдущий candidate-блок;
|
||||||
|
- `KEY_ROTATION_BLOCK_CONFLICT` — на этом номере уже сохранён другой candidate-блок;
|
||||||
|
- `KEY_ROTATION_BAD_SIGNATURE` — DataItem подписан не новым blockchain key;
|
||||||
|
- `KEY_ROTATION_FRAME_MISMATCH` — копируемый Frame отличается от исходной цепочки;
|
||||||
|
- `KEY_ROTATION_TAGS_MISMATCH` — изменены ANS-104 tags копируемого блока;
|
||||||
|
- `KEY_ROTATION_TECH_FORK_REQUIRED` — вместо финального `TECH_FORK` передан другой блок;
|
||||||
|
- ошибки `KEY_ROTATION_TECH_FORK_*` — поля `TECH_FORK` не соответствуют зафиксированной rotation session.
|
||||||
|
|
||||||
|
## Основные ошибки `KeyRotationStart`
|
||||||
|
|
||||||
|
- `AUTH_REQUIRED` — нет авторизованной сессии;
|
||||||
|
- `KEY_ROTATION_ALREADY_ACTIVE` — для login уже выполняется ротация;
|
||||||
|
- `KEY_ROTATION_BAD_FIELDS` — некорректные public keys/hash;
|
||||||
|
- `KEY_ROTATION_BAD_NEW_KEYS` — ключи не изменились либо новые ключи совпадают друг с другом;
|
||||||
|
- `KEY_ROTATION_BAD_REASON` — reason вне диапазона `1..4`;
|
||||||
|
- `KEY_ROTATION_COMMENT_TOO_LONG` — комментарий больше 1024 UTF-8 байт;
|
||||||
|
- `KEY_ROTATION_BAD_FORK_POINT` — неверная точка fork;
|
||||||
|
- `KEY_ROTATION_FORK_BLOCK_NOT_FOUND` — выбранный блок отсутствует;
|
||||||
|
- `KEY_ROTATION_FORK_HASH_MISMATCH` — переданный hash не совпадает с сервером;
|
||||||
|
- `KEY_ROTATION_SESSION_STALE` — авторизованная сессия содержит уже неактуальный blockchainName.
|
||||||
|
|
||||||
|
|
||||||
|
## `KeyRotationFinishChain`
|
||||||
|
|
||||||
|
Финализирует этап построения candidate-chain. Операция **не меняет PDA** и не переключает активную цепочку пользователя: она только доказывает, что будущий fork уже полностью сохранён и опубликован в Arweave/Turbo.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationFinishChain",
|
||||||
|
"requestId": "kr-finish-chain-1",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед переходом в `CHAIN_READY` сервер повторно проверяет:
|
||||||
|
|
||||||
|
- количество candidate-блоков равно `progressTotal` (`0..forkFromBlock` + один `TECH_FORK`);
|
||||||
|
- каждый candidate-блок уже подтверждён Arweave/Turbo publisher-ом;
|
||||||
|
- номера идут строго `0,1,2,...` без дырок;
|
||||||
|
- `blockHash` и `DataItem id` совпадают с сохранёнными значениями;
|
||||||
|
- каждый DataItem подписан `newBlockchainKey`;
|
||||||
|
- `block 0` имеет нулевой `prevHash`, а каждый следующий блок ссылается на hash предыдущего Frame;
|
||||||
|
- копируемые блоки всё ещё байт-в-байт совпадают с выбранным префиксом исходной цепочки по Frame, tags, target и anchor;
|
||||||
|
- последний блок является корректным `TECH_FORK` и повторяет зафиксированные fork point, parent tip, reason/comment и число отброшенных блоков.
|
||||||
|
|
||||||
|
Только после успешной финальной проверки выполняется:
|
||||||
|
|
||||||
|
`COPYING_CHAIN -> CHAIN_READY`.
|
||||||
|
|
||||||
|
Повторный `KeyRotationFinishChain`, когда ротация уже находится в `CHAIN_READY`, идемпотентно возвращает success. Это позволяет безопасно вызывать операцию из нескольких сессий или повторить её после потери ответа.
|
||||||
|
|
||||||
|
### Основные ошибки
|
||||||
|
|
||||||
|
- `KEY_ROTATION_NOT_ACTIVE` — активная ротация отсутствует;
|
||||||
|
- `KEY_ROTATION_NOT_COPYING` — текущий этап уже не позволяет завершать candidate-chain;
|
||||||
|
- `KEY_ROTATION_CHAIN_INCOMPLETE` — сервер получил не все candidate-блоки;
|
||||||
|
- `KEY_ROTATION_CHAIN_NOT_PUBLISHED` — не все DataItem подтверждены Arweave/Turbo publisher-ом;
|
||||||
|
- `KEY_ROTATION_CANDIDATE_GAP` / `KEY_ROTATION_CHAIN_HASH_MISMATCH` — нарушена последовательность candidate-chain;
|
||||||
|
- `KEY_ROTATION_BAD_SIGNATURE` — сохранённый candidate не подтверждается новым blockchain key;
|
||||||
|
- `KEY_ROTATION_TECH_FORK_*` — финальный `TECH_FORK` больше не соответствует rotation session;
|
||||||
|
- `KEY_ROTATION_FINISH_CHAIN_RACE` — состояние ротации было одновременно изменено другой операцией.
|
||||||
|
|
||||||
|
|
||||||
|
## `KeyRotationRotatePda`
|
||||||
|
|
||||||
|
Фиксирует, что клиент уже локально подписал и отправил в Solana транзакцию полной ротации PDA.
|
||||||
|
|
||||||
|
Сервер **не получает приватные ключи и не подписывает транзакцию**. Клиент передаёт только публичную Solana transaction signature.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationRotatePda",
|
||||||
|
"requestId": "kr-rotate-pda-1",
|
||||||
|
"payload": {
|
||||||
|
"pdaRotationSignature": "<Base58 Solana transaction signature>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Операция разрешена только после `CHAIN_READY`. Сервер атомарно сохраняет signature и переводит:
|
||||||
|
|
||||||
|
`CHAIN_READY -> ROTATING_PDA`.
|
||||||
|
|
||||||
|
После этого отмена ротации уже запрещена: отправленная Solana-транзакция может подтвердиться позже, даже если клиент потерял соединение.
|
||||||
|
|
||||||
|
Подтверждением успешной ротации служит **не сам факт наличия signature**, а фактический current PDA, увиденный Solana sync. Sync требует одновременного совпадения:
|
||||||
|
|
||||||
|
- `rootKey == newRootKey`;
|
||||||
|
- `blockchainKey == newBlockchainKey`;
|
||||||
|
- `clientKey == newClientKey`;
|
||||||
|
- `blockchainName == candidateBlockchainName`.
|
||||||
|
|
||||||
|
Только после этого сервер атомарно переводит:
|
||||||
|
|
||||||
|
`ROTATING_PDA -> PDA_ROTATED`.
|
||||||
|
|
||||||
|
Если Solana sync успел увидеть новое PDA раньше вызова `KeyRotationRotatePda`, он умеет подтвердить ожидаемые ключи прямо из `CHAIN_READY`. Поэтому потеря клиентского запроса после уже подтверждённой Solana-транзакции не оставляет ротацию зависшей. Если API-вызов всё же приходит, handler идемпотентно возвращает текущее подтверждённое состояние.
|
||||||
|
|
||||||
|
Повтор с той же signature идемпотентен. Другая signature для уже начатого `ROTATING_PDA` возвращает conflict.
|
||||||
|
|
||||||
|
### Основные ошибки
|
||||||
|
|
||||||
|
- `KEY_ROTATION_NOT_ACTIVE` — активной ротации нет;
|
||||||
|
- `KEY_ROTATION_NOT_CHAIN_READY` — candidate-chain ещё не подтверждена;
|
||||||
|
- `KEY_ROTATION_BAD_SOLANA_SIGNATURE` — signature отсутствует или не Base58;
|
||||||
|
- `KEY_ROTATION_PDA_SIGNATURE_CONFLICT` — для этой ротации уже сохранена другая signature;
|
||||||
|
- `KEY_ROTATION_ROTATE_PDA_FAILED` — внутренняя ошибка фиксации этапа.
|
||||||
|
|
||||||
|
|
||||||
|
## Автоматический rebuild после `PDA_ROTATED`
|
||||||
|
|
||||||
|
После подтверждения нового current PDA сервер сам выполняет:
|
||||||
|
|
||||||
|
`PDA_ROTATED -> REBUILDING_SERVER -> WALLET_MIGRATION`.
|
||||||
|
|
||||||
|
Во время rebuild:
|
||||||
|
|
||||||
|
- проверяется, что current PDA действительно указывает на `candidateBlockchainName/newBlockchainKey`;
|
||||||
|
- старая активная цепочка удаляется из рабочих PostgreSQL-таблиц;
|
||||||
|
- candidate-блоки повторно проходят обычный `AddBlock` validation/projection path;
|
||||||
|
- runtime-cache `to_bch_name` у логических ссылок `login + blockNumber + blockHash` перепривязывается к новому fork;
|
||||||
|
- входящие `likes_count/replies_count` пересчитываются;
|
||||||
|
- после `COMPLETE` временные строки candidate-chain удаляются из PostgreSQL.
|
||||||
|
|
||||||
|
Если rebuild прерывается, `REBUILDING_SERVER` остаётся активным, ошибка записывается в `lastError/retryCount`, а worker безопасно повторяет rebuild.
|
||||||
|
|
||||||
|
## `KeyRotationContinue`
|
||||||
|
|
||||||
|
Продолжает интерактивные post-PDA этапы. В текущей версии два будущих этапа являются честными заглушками:
|
||||||
|
|
||||||
|
- `WALLET_MIGRATION`: `walletMigrationStatus = NOT_IMPLEMENTED`, затем переход в `MESSAGE_MIGRATION`;
|
||||||
|
- `MESSAGE_MIGRATION`: `messageMigrationStatus = NOT_IMPLEMENTED`, затем `FINALIZING -> COMPLETE`.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationContinue",
|
||||||
|
"requestId": "kr-continue-1",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`KeyRotationContinue` не переводит деньги и не перешифровывает DM. Это специально оставленные точки расширения для будущей реализации.
|
||||||
|
|
||||||
|
## `KeyRotationAbort`
|
||||||
|
|
||||||
|
Прерывает ротацию только пока Solana-ротация PDA ещё не могла быть отправлена:
|
||||||
|
|
||||||
|
- разрешено из `COPYING_CHAIN`;
|
||||||
|
- разрешено из `CHAIN_READY`;
|
||||||
|
- начиная с `ROTATING_PDA` отмена запрещена, процесс можно только довести вперёд.
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "KeyRotationAbort",
|
||||||
|
"requestId": "kr-abort-1",
|
||||||
|
"payload": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
При `ABORTED` временные candidate-строки удаляются из PostgreSQL. Уже опубликованные Arweave DataItem остаются неизменяемым сиротским историческим следом и не становятся активной цепочкой, поскольку PDA не переключён.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# GetMyBlockchain API
|
||||||
|
|
||||||
|
`GetMyBlockchain` — авторизованное постраничное чтение **только текущей активной версии собственного блокчейна**. API используется постоянным экраном «Мой блокчейн» и мастером смены ключей для выбора последнего доверенного блока.
|
||||||
|
|
||||||
|
Login и активный `blockchainName` сервер определяет из авторизованной сессии/current PDA; клиент не может запросить этим методом чужую цепочку.
|
||||||
|
|
||||||
|
## Request
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"op": "GetMyBlockchain",
|
||||||
|
"requestId": "my-bch-1",
|
||||||
|
"payload": {
|
||||||
|
"beforeBlock": 500,
|
||||||
|
"limit": 50,
|
||||||
|
"includeBlockBytes": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
|
||||||
|
- `beforeBlock` — необязательный номер верхней границы страницы; если отсутствует, чтение начинается с current tip;
|
||||||
|
- `limit` — `1..100`, default `50`;
|
||||||
|
- `includeBlockBytes` — при `true` дополнительно вернуть полный ANS-104 DataItem в Base64.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
Payload содержит:
|
||||||
|
|
||||||
|
- `login`;
|
||||||
|
- `blockchainName`;
|
||||||
|
- `tipBlockNumber` / `tipBlockHash`;
|
||||||
|
- `nextBeforeBlock` для следующей страницы;
|
||||||
|
- `blocks[]` в порядке от новых к старым.
|
||||||
|
|
||||||
|
Каждый элемент `blocks[]` содержит:
|
||||||
|
|
||||||
|
- `blockNumber`;
|
||||||
|
- `blockHash` / `prevBlockHash`;
|
||||||
|
- `timestampMs`;
|
||||||
|
- `msgType` / `msgSubType` / `msgVersion`;
|
||||||
|
- для target-блоков: `toLogin + toBlockNumber + toBlockHash`;
|
||||||
|
- `blockBytesB64`, только если запрошен `includeBlockBytes=true`.
|
||||||
|
|
||||||
|
После fork API показывает только новую активную ветку PostgreSQL. Исторические fork при необходимости восстанавливаются из Arweave/PDA history отдельным будущим viewer-механизмом.
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## Быстрая карта типов
|
## Быстрая карта типов
|
||||||
|
|
||||||
- `type=0` — TECH: HEADER, CREATE_CHANNEL.
|
- `type=0` — TECH: HEADER, CREATE_CHANNEL, FORK.
|
||||||
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
|
- `type=1` — TEXT: POST/EDIT_POST/REPLY/EDIT_REPLY/RATING/REPOST/CHANNEL_META/ENTRYPOINT/EXERCISE/SERVICE/COURSE.
|
||||||
- `type=2` — REACTION: LIKE/UNLIKE.
|
- `type=2` — REACTION: LIKE/UNLIKE.
|
||||||
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ App = test5590
|
|||||||
Если блок относится к конкретному каналу, он дополнительно содержит:
|
Если блок относится к конкретному каналу, он дополнительно содержит:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
c = <canonical_channel_slug>
|
c_test5590 = <canonical_channel_slug>
|
||||||
```
|
```
|
||||||
|
|
||||||
Slug входит в подпись DataItem и не может быть изменён сервером после подписи.
|
Slug входит в подпись DataItem и не может быть изменён сервером после подписи.
|
||||||
@@ -107,7 +107,7 @@ Slug входит в подпись DataItem и не может быть изм
|
|||||||
|
|
||||||
1. распарсить полный ANS-104 DataItem;
|
1. распарсить полный ANS-104 DataItem;
|
||||||
2. проверить `App=test5590`;
|
2. проверить `App=test5590`;
|
||||||
3. проверить `c`, если тип блока требует канал;
|
3. проверить `c_test5590`, если тип блока требует канал;
|
||||||
4. проверить ANS-104 Ed25519 подпись;
|
4. проверить ANS-104 Ed25519 подпись;
|
||||||
5. проверить, что `owner` равен текущему blockchain public key пользователя;
|
5. проверить, что `owner` равен текущему blockchain public key пользователя;
|
||||||
6. распарсить Frame v1 и body;
|
6. распарсить Frame v1 и body;
|
||||||
|
|||||||
@@ -12,7 +12,43 @@ TECH-тип покрывает системные записи цепочки.
|
|||||||
- создание нового канала;
|
- создание нового канала;
|
||||||
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
|
- хранит line-поля + `channelName` + `channelDescription` + `channelType` + `channelTypeVersion`.
|
||||||
|
|
||||||
|
3. `subType=2` — `TECH_FORK`
|
||||||
|
- первый новый блок после точной перепубликации выбранного префикса предыдущего fork новым blockchain key;
|
||||||
|
- связывает новую активную цепочку с предыдущей и фиксирует точку rollback/продолжения.
|
||||||
|
|
||||||
|
### `TECH_FORK` body (`version=1`)
|
||||||
|
|
||||||
|
Big-endian:
|
||||||
|
|
||||||
|
- `parentBlockchainKey[32]` — public key предыдущего fork;
|
||||||
|
- `forkPointBlockNumber[4]` — последний блок старой цепочки, сохранённый в новом fork;
|
||||||
|
- `forkPointBlockHash32[32]`;
|
||||||
|
- `forkPointTimestampMs[8]`;
|
||||||
|
- `parentTipBlockNumber[4]` — tip старой цепочки на момент начала ротации;
|
||||||
|
- `parentTipBlockHash32[32]`;
|
||||||
|
- `parentTipTimestampMs[8]`;
|
||||||
|
- `discardedBlocksCount[4]` — `parentTipBlockNumber - forkPointBlockNumber`;
|
||||||
|
- `reasonCode[1]`;
|
||||||
|
- `commentUtf8Length[2]`;
|
||||||
|
- `comment[N]` — произвольный комментарий пользователя, максимум 1024 UTF-8 байт.
|
||||||
|
|
||||||
|
`reasonCode`:
|
||||||
|
|
||||||
|
- `1` — `ROUTINE_ROTATION`: обычная смена пароля/ключей, компрометация не предполагается;
|
||||||
|
- `2` — `POSSIBLE_COMPROMISE`: возможная компрометация, неизвестные записи не подтверждены;
|
||||||
|
- `3` — `CONFIRMED_COMPROMISE_ROLLBACK`: обнаружены нежелательные/чужие записи и выполнен rollback;
|
||||||
|
- `4` — `RECOVERY`: восстановление доступа recovery-механизмом.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- блоки `0..forkPointBlockNumber` в новом fork должны быть точными Frame-копиями выбранного префикса предыдущей цепочки;
|
||||||
|
- `TECH_FORK` идёт сразу после этого префикса и является первым действительно новым Frame нового fork;
|
||||||
|
- если история сохранена полностью, `forkPointBlockNumber == parentTipBlockNumber` и `discardedBlocksCount == 0`;
|
||||||
|
- если сохраняется только genesis, новый fork содержит прежний `block 0`, а `block 1` является `TECH_FORK`;
|
||||||
|
- новый blockchain key в body не дублируется: он определяется owner/signature нового ANS-104 DataItem.
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
- инициализация блокчейна;
|
- инициализация блокчейна;
|
||||||
- управление набором каналов пользователя.
|
- управление набором каналов пользователя;
|
||||||
|
- фиксация происхождения нового fork и причины ротации/rollback.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
|
|
||||||
3. `subType=20` — `TEXT_REPLY`
|
3. `subType=20` — `TEXT_REPLY`
|
||||||
- ответ на сообщение;
|
- ответ на сообщение;
|
||||||
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
|
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст.
|
||||||
|
|
||||||
4. `subType=21` — `TEXT_EDIT_REPLY`
|
4. `subType=21` — `TEXT_EDIT_REPLY`
|
||||||
- редактирование ответа;
|
- редактирование ответа;
|
||||||
@@ -23,7 +23,7 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
|
|
||||||
5. `subType=30` — `TEXT_RATING`
|
5. `subType=30` — `TEXT_RATING`
|
||||||
- target-based отзыв на конкретный блок;
|
- target-based отзыв на конкретный блок;
|
||||||
- содержит target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
- содержит target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`) + текст отзыва;
|
||||||
- не является сообщением линии канала.
|
- не является сообщением линии канала.
|
||||||
|
|
||||||
6. `subType=50` — `TEXT_REPOST`
|
6. `subType=50` — `TEXT_REPOST`
|
||||||
@@ -57,6 +57,20 @@ TEXT-тип хранит сообщения, материалы и редакт
|
|||||||
|
|
||||||
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
Подробная спецификация: [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md).
|
||||||
|
|
||||||
|
|
||||||
|
## Общий target-формат TEXT
|
||||||
|
|
||||||
|
Для `TEXT_EDIT_POST`, `TEXT_REPLY`, `TEXT_EDIT_REPLY`, `TEXT_RATING` и `TEXT_REPOST` ссылка на цель хранится как:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[1] toLoginLen (uint8)
|
||||||
|
[N] toLogin UTF-8
|
||||||
|
[4] toBlockGlobalNumber
|
||||||
|
[32] toBlockHash32
|
||||||
|
```
|
||||||
|
|
||||||
|
`blockchainName`/номер fork в подписываемые байты target не входит. Одинаковые `login + blockNumber + blockHash` считаются одной логической целью после перепубликации сохранённого префикса при fork.
|
||||||
|
|
||||||
## Правило для edit
|
## Правило для edit
|
||||||
|
|
||||||
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
`EDIT_POST` и `EDIT_REPLY` должны ссылаться на **оригинальный** блок, а не на предыдущий edit.
|
||||||
|
|||||||
@@ -4,11 +4,25 @@
|
|||||||
|
|
||||||
1. `subType=1` — `REACTION_LIKE`
|
1. `subType=1` — `REACTION_LIKE`
|
||||||
- лайк на целевой блок;
|
- лайк на целевой блок;
|
||||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||||
2. `subType=2` — `REACTION_UNLIKE`
|
2. `subType=2` — `REACTION_UNLIKE`
|
||||||
- снятие лайка с целевого блока;
|
- снятие лайка с целевого блока;
|
||||||
- хранит target: `toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`.
|
- хранит target: `toLogin`, `toBlockGlobalNumber`, `toBlockHash32`.
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|
||||||
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
|
- реакция на текстовые сообщения (и потенциально другие target-блоки, если это разрешено бизнес-логикой).
|
||||||
|
|
||||||
|
|
||||||
|
## Формат target
|
||||||
|
|
||||||
|
В подписанных байтах target больше не хранит имя fork/blockchain. Формат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[1] toLoginLen (uint8)
|
||||||
|
[N] toLogin UTF-8
|
||||||
|
[4] toBlockGlobalNumber
|
||||||
|
[32] toBlockHash32
|
||||||
|
```
|
||||||
|
|
||||||
|
Логическая идентичность цели: `toLogin + toBlockGlobalNumber + toBlockHash32`. Поэтому ссылка остаётся той же после fork, если номер и hash исходного блока сохранены.
|
||||||
|
|||||||
@@ -18,7 +18,18 @@ CONNECTION-тип описывает социальные связи и подп
|
|||||||
## Общий формат payload
|
## Общий формат payload
|
||||||
|
|
||||||
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
|
- line-поля (`lineCode`, `prevLineNumber`, `prevLineHash32`, `thisLineNumber`)
|
||||||
- target (`toBlockchainName`, `toBlockGlobalNumber`, `toBlockHash32`)
|
- target (`toLogin`, `toBlockGlobalNumber`, `toBlockHash32`)
|
||||||
|
|
||||||
|
## Бинарный target
|
||||||
|
|
||||||
|
```text
|
||||||
|
[1] toLoginLen (uint8)
|
||||||
|
[N] toLogin UTF-8
|
||||||
|
[4] toBlockGlobalNumber
|
||||||
|
[32] toBlockHash32
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя fork/blockchain в target не хранится.
|
||||||
|
|
||||||
## Правила target
|
## Правила target
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
|
Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
[1] toBlockchainNameLen (uint8)
|
[1] toLoginLen (uint8)
|
||||||
[N] toBlockchainName UTF-8
|
[N] toLogin UTF-8
|
||||||
[4] toBlockGlobalNumber
|
[4] toBlockGlobalNumber
|
||||||
[32] toBlockHash32
|
[32] toBlockHash32
|
||||||
[2] textLenBytes (uint16)
|
[2] textLenBytes (uint16)
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
Где:
|
Где:
|
||||||
|
|
||||||
- `toBlockchainName` — блокчейн, в котором находится целевой материал;
|
- `toLogin` — login владельца целевого блока; номер fork в target не хранится;
|
||||||
- `toBlockGlobalNumber` — номер целевого блока;
|
- `toBlockGlobalNumber` — номер целевого блока;
|
||||||
- `toBlockHash32` — хэш целевого блока;
|
- `toBlockHash32` — хэш целевого блока;
|
||||||
- `text` — опциональное пояснение пользователя к статусу.
|
- `text` — опциональное пояснение пользователя к статусу.
|
||||||
|
|||||||
@@ -2,96 +2,155 @@
|
|||||||
|
|
||||||
## Цель
|
## Цель
|
||||||
|
|
||||||
Каждый пользовательский блок уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер не переподписывает пользовательский контент: он проверяет его, хранит в PostgreSQL и объединяет готовые DataItems в стандартный ANS-104 bundle.
|
Каждый пользовательский блок SHiNE уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер проверяет и хранит **точно эти signed bytes** и может публиковать их одним из двух транспортов: через Turbo по одному DataItem либо через прямую Arweave L1-транзакцию в составе стандартного большого ANS-104 bundle.
|
||||||
|
|
||||||
## Child DataItem tags
|
Способ публикации — локальная политика конкретного сервера. Формат пользовательского блока и импорт от него не зависят.
|
||||||
|
|
||||||
Обязательно для тестового контура:
|
## User DataItem tags
|
||||||
|
|
||||||
|
Для тестового контура обязательно:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
App=test5590
|
App=test5590
|
||||||
```
|
```
|
||||||
|
|
||||||
Дополнительно для блоков конкретного канала:
|
Для блоков конкретного канала дополнительно:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
c=<canonical_channel_slug>
|
c_test5590=<canonical_channel_slug>
|
||||||
```
|
```
|
||||||
|
|
||||||
Теги входят в ANS-104 подпись пользователя.
|
Теги входят в ANS-104 подпись пользователя. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag.
|
||||||
|
|
||||||
## Publisher
|
## Publisher modes
|
||||||
|
|
||||||
По умолчанию цикл — раз в 15 минут.
|
Настройка:
|
||||||
|
|
||||||
|
```text
|
||||||
|
arweave.blocks.publish.mode=turbo | arweave | none
|
||||||
|
```
|
||||||
|
|
||||||
|
### `turbo`
|
||||||
|
|
||||||
```text
|
```text
|
||||||
blocks.arweave_publish_pending=true
|
blocks.arweave_publish_pending=true
|
||||||
↓
|
↓
|
||||||
готовые serialized DataItems
|
готовый signed user DataItem из blocks.block_bytes
|
||||||
↓
|
↓
|
||||||
ANS-104 binary bundle
|
POST в Turbo как application/octet-stream
|
||||||
↓
|
↓
|
||||||
обычная Arweave L1 transaction
|
Turbo bundling / Arweave
|
||||||
```
|
```
|
||||||
|
|
||||||
Если pending-блоков нет, транзакция не создаётся.
|
DataItem **не переподписывается** сервером. Его `data_item_id = SHA-256(user signature)` до и после загрузки должен оставаться тем же.
|
||||||
|
|
||||||
Root transaction содержит стандартные bundle tags:
|
Для Turbo можно задать публичный payer address напрямую:
|
||||||
|
|
||||||
|
```text
|
||||||
|
arweave.blocks.publish.turbo.paidByAddress=...
|
||||||
|
```
|
||||||
|
|
||||||
|
либо путь к серверному Arweave JWK:
|
||||||
|
|
||||||
|
```text
|
||||||
|
arweave.blocks.publish.turbo.walletJwkPath=/path/to/server-turbo-wallet.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Из JWK локально вычисляется только публичный Arweave address для `x-paid-by`; приватный ключ Turbo upload endpoint не получает.
|
||||||
|
|
||||||
|
Важно: если upload уже требует оплаты, а signed DataItem принадлежит другому signer, Turbo Credits серверного кошелька используются через Credit Share Approval в пользу signer-адреса. Для маленьких DataItem, попадающих под действующий free tier Turbo, payer может не понадобиться. Код не должен рассчитывать на вечное существование free tier: HTTP `402` считается ошибкой оплаты и блок остаётся pending.
|
||||||
|
|
||||||
|
### `arweave`
|
||||||
|
|
||||||
|
Сохраняется прежний fallback:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pending user DataItems
|
||||||
|
↓
|
||||||
|
standard ANS-104 binary bundle
|
||||||
|
↓
|
||||||
|
server Arweave RSA/JWK signature
|
||||||
|
↓
|
||||||
|
Arweave L1
|
||||||
|
```
|
||||||
|
|
||||||
|
Root transaction содержит только стандартные bundle tags:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Bundle-Format=binary
|
Bundle-Format=binary
|
||||||
Bundle-Version=2.0.0
|
Bundle-Version=2.0.0
|
||||||
Content-Type=application/octet-stream
|
Content-Type=application/octet-stream
|
||||||
App=test5590-batch
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`App=test5590-batch` намеренно отличается от child `App=test5590`, чтобы discovery-запрос находил пользовательские блоки, а не root bundles.
|
Специальный `App=test5590-batch` больше не используется. Важны вложенные user DataItems, у которых уже есть `App=test5590`.
|
||||||
|
|
||||||
После успешной L1-загрузки сервер ставит child-блокам:
|
### `none`
|
||||||
|
|
||||||
|
Сервер принимает и хранит блоки локально, но publisher не отправляет их в Arweave/Turbo. Importer при этом может работать независимо.
|
||||||
|
|
||||||
|
## Состояние публикации в БД
|
||||||
|
|
||||||
|
После успешной публикации:
|
||||||
|
|
||||||
- `arweave_publish_pending=false`;
|
- `arweave_publish_pending=false`;
|
||||||
- `arweave_published_at_ms`;
|
- заполняется `arweave_published_at_ms`.
|
||||||
- `arweave_root_tx_id`.
|
|
||||||
|
|
||||||
## Importer
|
`arweave_root_tx_id` больше не хранится: один и тот же пользовательский DataItem может быть физически упакован разными bundler-ами, а стабильным сетевым идентификатором SHiNE является именно `data_item_id`.
|
||||||
|
|
||||||
Каждый сервер может независимо искать:
|
## Importer: только individual DataItems
|
||||||
|
|
||||||
|
Importer всегда выполняет один discovery-запрос:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
App=test5590
|
App=test5590
|
||||||
```
|
```
|
||||||
|
|
||||||
через GraphQL gateway с cursor pagination.
|
Он **не ищет root bundles** и не зависит от `publish.mode`.
|
||||||
|
|
||||||
Для каждого нового DataItem:
|
Это одинаково работает для:
|
||||||
|
|
||||||
1. взять `id` и `bundledIn.id`;
|
- DataItem, отправленного через Turbo;
|
||||||
2. получить root bundle;
|
- DataItem, находящегося внутри большого direct-Arweave ANS-104 bundle сервера.
|
||||||
3. извлечь точные serialized bytes child DataItem по bundle index;
|
|
||||||
4. проверить `dataItemId == SHA256(signature)`;
|
|
||||||
5. проверить ANS-104 Ed25519 подпись;
|
|
||||||
6. определить пользователя по `owner`;
|
|
||||||
7. применить обычные проверки `AddBlock`;
|
|
||||||
8. записать в PostgreSQL с `arweave_publish_pending=false`.
|
|
||||||
|
|
||||||
### Блоки могут прийти не по порядку
|
После того как AR.IO gateway распаковал/indexed bundle, child DataItem присутствует в GraphQL как отдельная сущность со своим `id` и собственными tags.
|
||||||
|
|
||||||
Discovery/import использует persistent queue `arweave_block_import_queue`. Если, например, block 102 увиден раньше block 101, block 102 остаётся `PENDING`; после появления 101 очередь повторно проигрывается.
|
### Получение полного signed DataItem
|
||||||
|
|
||||||
## Дедупликация и несколько серверов
|
Обычная выдача DataItem по gateway URL может представлять только payload, а SHiNE для криптографической проверки нужны полные serialized ANS-104 bytes.
|
||||||
|
|
||||||
Один и тот же готовый DataItem имеет один `data_item_id = SHA256(signature)`. Если несколько серверов включили его в разные root bundles, локально это всё равно один логический блок: `blocks.data_item_id` уникален.
|
Поэтому importer:
|
||||||
|
|
||||||
Импортированный из Arweave блок **не ставится обратно в publish queue**. Это предотвращает бесконечное переархивирование между серверами.
|
1. получает `data_item_id` через GraphQL `App=test5590`;
|
||||||
|
2. запрашивает `GET /ar-io/offsets/{data_item_id}`;
|
||||||
|
3. получает `rootTxId`, `rootOffset`, `size`;
|
||||||
|
4. делает range-read `GET /raw/{rootTxId}` ровно по этому диапазону;
|
||||||
|
5. разбирает полученные bytes как `Ans104DataItem`;
|
||||||
|
6. проверяет, что `SHA-256(signature) == data_item_id`;
|
||||||
|
7. проверяет Ed25519 ANS-104 signature;
|
||||||
|
8. определяет пользователя по `owner`;
|
||||||
|
9. импортирует через обычную логику `AddBlock` без повторной публикации.
|
||||||
|
|
||||||
|
Если GraphQL уже увидел DataItem, но gateway ещё не подготовил offsets, checkpoint не продвигается за этот height и DataItem будет повторён в следующем цикле.
|
||||||
|
|
||||||
|
## Очередь и порядок блоков
|
||||||
|
|
||||||
|
`arweave_block_import_queue` хранит:
|
||||||
|
|
||||||
|
- `data_item_id`;
|
||||||
|
- `block_height`;
|
||||||
|
- полный `raw_data_item`;
|
||||||
|
- status/error/timestamps.
|
||||||
|
|
||||||
|
`root_tx_id` очереди больше не нужен.
|
||||||
|
|
||||||
|
Если block N+1 увиден раньше N, он остаётся `PENDING`; после появления предыдущего блока очередь повторно проигрывается.
|
||||||
|
|
||||||
|
## Дедупликация
|
||||||
|
|
||||||
|
`blocks.data_item_id` уникален. Один signed DataItem остаётся одним логическим SHiNE-блоком независимо от того, сколько серверов или bundler-ов физически включили его в Arweave.
|
||||||
|
|
||||||
|
Импортированный блок записывается через `AddBlock` с отключённой повторной публикацией, поэтому серверы не создают цикл переархивирования.
|
||||||
|
|
||||||
## Локальное хранение
|
## Локальное хранение
|
||||||
|
|
||||||
Пользовательские blockchain-файлы на диске больше не используются. Полный serialized DataItem находится в `blocks.block_bytes` PostgreSQL.
|
Полный serialized signed DataItem хранится в `blocks.block_bytes` PostgreSQL. Пользовательские `.bch`-файлы не являются источником истины.
|
||||||
|
|
||||||
## Настройки
|
|
||||||
|
|
||||||
См. `application.properties` и `CODEX_APPLY_ANS104_TEST5590_PATCH.md`.
|
|
||||||
|
|
||||||
## Что намеренно не входит в этот патч
|
|
||||||
|
|
||||||
Remote/homeserver signing path, связанный с внешним homeserver/ESP32 signer, не мигрируется этим патчем. Каталог `ESP32/` не изменяется. До отдельной миграции новый Frame v1/ANS-104 production path рассчитан на клиент, у которого локально доступен blockchain Ed25519 key.
|
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
|
## 2026-09-27 — TECH_FORK v1
|
||||||
|
|
||||||
|
- Добавлен `type=0 / subType=2 / version=1` (`TECH_FORK`).
|
||||||
|
- `TECH_FORK` фиксирует parent blockchain key, точку сохранённой истории, старый tip, число отброшенных блоков, reason code и пользовательский комментарий.
|
||||||
|
- Новый blockchain key не дублируется в body: он определяется подписью нового ANS-104 DataItem.
|
||||||
|
- Зафиксированы четыре причины: обычная ротация, возможная компрометация, подтверждённая компрометация с rollback и recovery.
|
||||||
|
|
||||||
# История изменений документации блокчейна
|
# История изменений документации блокчейна
|
||||||
|
|
||||||
|
## 2026-09-23 — Turbo transport для individual ANS-104 DataItems
|
||||||
|
- Базовый коммит-ориентир: `3483a0a`; изменения подготовлены как patch без нового git-коммита.
|
||||||
|
- Publisher получил режимы `turbo | arweave | none`: Turbo отправляет каждый исходный user-signed DataItem отдельно, direct Arweave fallback сохраняет standard ANS-104 bundle, `none` отключает внешнюю публикацию.
|
||||||
|
- Удалён технический namespace `App=test5590-batch`: importer всегда ищет только individual `App=test5590` DataItems независимо от способа их физической упаковки.
|
||||||
|
- Channel tag тестового контура изменён с `c` на `c_test5590`; новый тег является частью пользовательской ANS-104 подписи.
|
||||||
|
- Importer получает точные serialized signed DataItem bytes через AR.IO offsets + range-read root transaction и проверяет `data_item_id`/Ed25519 signature перед `AddBlock`.
|
||||||
|
- Из PostgreSQL удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`; добавлена migration v25.
|
||||||
|
|
||||||
|
## 2026-09-23 — Тестовые каналы и Arweave-only синхронизация
|
||||||
|
- Базовый коммит-ориентир: `3483a0a`.
|
||||||
|
- Добавлены тестовые каналы и publisher для генерации пользовательских POST-блоков через обычный `AddBlock`.
|
||||||
|
- Добавлена возможность отключать прямую межсерверную синхронизацию блоков настройкой `blockchain.sync.enabled=false`.
|
||||||
|
- Arweave-импорт переведён на root ANS-104 bundle discovery: сервер находит batch-транзакции `App=test5590-batch`, разбирает вложенные signed DataItem и импортирует SHiNE-блоки с дедупликацией по `data_item_id`.
|
||||||
|
- Проверен тестовый сценарий Arweave-only: t3 поднял блоки из Arweave без прямой межсерверной синхронизации с t2.
|
||||||
|
|
||||||
## 2026-08-25 19:45:18 +0400
|
## 2026-08-25 19:45:18 +0400
|
||||||
- Базовый коммит-ориентир: `3a58519`.
|
- Базовый коммит-ориентир: `3a58519`.
|
||||||
- Добавлены runtime-агрегаты статистики:
|
- Добавлены runtime-агрегаты статистики:
|
||||||
|
|||||||
@@ -1,108 +1,79 @@
|
|||||||
# Инструкция Codex: применить ANS-104 test5590 patch
|
# Применение patch: Turbo + direct Arweave для `App=test5590`
|
||||||
|
|
||||||
## Цель
|
## Что меняется
|
||||||
|
|
||||||
Перевести пользовательский blockchain SHiNE на Frame v1 внутри готовых ANS-104 DataItems и убрать старый SHINE-ARCHIVE/файловое хранение цепочек.
|
- `arweave.blocks.publish.mode=turbo|arweave|none` вместо boolean publisher switch.
|
||||||
|
- `turbo`: каждый готовый user-signed ANS-104 DataItem отправляется в Turbo отдельно.
|
||||||
|
- `arweave`: сохранён прямой L1 fallback — несколько user DataItems собираются в standard ANS-104 bundle.
|
||||||
|
- `none`: наружу ничего не публикуется.
|
||||||
|
- Importer всегда ищет только individual `App=test5590` DataItems.
|
||||||
|
- `App=test5590-batch` больше не используется.
|
||||||
|
- Channel tag: `c_test5590=<canonical_channel_slug>` вместо `c=...`.
|
||||||
|
- Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`.
|
||||||
|
- Схема PostgreSQL: v25.
|
||||||
|
|
||||||
## Жёсткое ограничение
|
## Минимальная настройка Turbo
|
||||||
|
|
||||||
**Не изменять ничего в `ESP32/`.** В этом patch нет ни одного файла `ESP32/**`.
|
|
||||||
|
|
||||||
Remote/homeserver signer, завязанный на устройство, намеренно не мигрирован. Не пытаться «заодно исправить» его в рамках этого patch.
|
|
||||||
|
|
||||||
## Применение
|
|
||||||
|
|
||||||
1. Распаковать patch поверх корня репозитория, сохраняя относительные пути.
|
|
||||||
2. Удалить все пути из корневого `DELETE_FILES.txt`.
|
|
||||||
3. Проверить, что `git diff -- ESP32` пуст.
|
|
||||||
4. Использовать чистую/dev test DB. `migration_v24.sql` намеренно откажется мигрировать непустую blockchain DB, потому что backward compatibility со старым block format не требуется.
|
|
||||||
|
|
||||||
## Arweave config
|
|
||||||
|
|
||||||
Минимально для публикации:
|
|
||||||
|
|
||||||
```properties
|
```properties
|
||||||
arweave.blocks.publish.enabled=true
|
arweave.blocks.publish.mode=turbo
|
||||||
arweave.blocks.publish.intervalMinutes=15
|
arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx
|
||||||
arweave.blocks.publish.gateway=https://arweave.net
|
|
||||||
arweave.blocks.publish.walletJwkPath=/ABSOLUTE/SECRET/PATH/arweave-wallet.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
JWK не коммитить.
|
Для действующего free tier маленьких DataItem этого может быть достаточно.
|
||||||
|
|
||||||
Для discovery/import:
|
Если upload платный и расходы должны идти с server Turbo Credits:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
arweave.blocks.publish.turbo.walletJwkPath=/home/player/SHiNE/secrets/turbo-wallet.json
|
||||||
|
# либо вместо JWK сразу публичный адрес:
|
||||||
|
# arweave.blocks.publish.turbo.paidByAddress=<server payer address>
|
||||||
|
```
|
||||||
|
|
||||||
|
JWK не отправляется Turbo: из него вычисляется публичный address для `x-paid-by`.
|
||||||
|
Для чужого signed DataItem платные Turbo Credits требуют действующего Credit Share Approval от server payer к signer-адресу DataItem. Если его нет, Turbo вернёт HTTP 402, а блок останется pending для повторной попытки.
|
||||||
|
|
||||||
|
## Direct Arweave fallback
|
||||||
|
|
||||||
|
```properties
|
||||||
|
arweave.blocks.publish.mode=arweave
|
||||||
|
arweave.blocks.publish.walletJwkPath=/home/player/SHiNE/secrets/arweave-wallet.json
|
||||||
|
arweave.blocks.publish.gateway=https://arweave.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Root bundle больше не получает `App=test5590-batch`; child DataItems уже содержат `App=test5590` и именно их индексирует importer.
|
||||||
|
|
||||||
|
## Отключение публикации
|
||||||
|
|
||||||
|
```properties
|
||||||
|
arweave.blocks.publish.mode=none
|
||||||
|
```
|
||||||
|
|
||||||
|
Это не отключает `arweave.blocks.sync.enabled`: read/import и publish независимы.
|
||||||
|
|
||||||
|
## Importer
|
||||||
|
|
||||||
```properties
|
```properties
|
||||||
arweave.blocks.sync.enabled=true
|
arweave.blocks.sync.enabled=true
|
||||||
arweave.blocks.sync.intervalMinutes=15
|
|
||||||
arweave.blocks.sync.gateway=https://turbo-gateway.com
|
arweave.blocks.sync.gateway=https://turbo-gateway.com
|
||||||
arweave.blocks.sync.startBlockHeight=0
|
arweave.blocks.sync.maxDataItemBytes=8388608
|
||||||
```
|
```
|
||||||
|
|
||||||
На тестах желательно установить `startBlockHeight` на высоту начала `test5590`, чтобы не сканировать лишнюю историю.
|
Importer:
|
||||||
|
|
||||||
## Test namespace
|
1. GraphQL `App=test5590`;
|
||||||
|
2. `/ar-io/offsets/<dataItemId>`;
|
||||||
|
3. range `GET /raw/<rootTxId>`;
|
||||||
|
4. проверка exact signed DataItem ID + signature;
|
||||||
|
5. обычный `AddBlock` import.
|
||||||
|
|
||||||
Child DataItem:
|
## Миграция БД
|
||||||
|
|
||||||
```text
|
При старте schema v24 автоматически применит `migration_v25.sql`, которая удаляет два root-tx поля и ставит version 25.
|
||||||
App=test5590
|
|
||||||
```
|
|
||||||
|
|
||||||
Channel child:
|
## Проверка после применения
|
||||||
|
|
||||||
```text
|
1. Создать новый channel/post и проверить signed tag `c_test5590=<canonical slug>`.
|
||||||
App=test5590
|
2. В `mode=turbo` убедиться, что `blocks.data_item_id` совпадает с Turbo response `id` и pending становится false.
|
||||||
c=<canonical_channel_slug>
|
3. На втором сервере включить sync и убедиться, что DataItem находится GraphQL-запросом `App=test5590` и импортируется без прямой server-to-server связи.
|
||||||
```
|
4. Переключить первый сервер в `mode=arweave`, создать ещё несколько блоков и убедиться, что тот же importer второго сервера видит child DataItems без знания root bundle ID.
|
||||||
|
5. Проверить `mode=none`: новые локальные блоки остаются pending, наружу ничего не отправляется.
|
||||||
Root bundle:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Bundle-Format=binary
|
|
||||||
Bundle-Version=2.0.0
|
|
||||||
App=test5590-batch
|
|
||||||
```
|
|
||||||
|
|
||||||
Перед production-start test namespace должен быть заменён отдельным осознанным изменением.
|
|
||||||
|
|
||||||
## Проверки после применения
|
|
||||||
|
|
||||||
Из корня репозитория:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --check shine-UI/js/services/ans104-data-item.js
|
|
||||||
node --check shine-UI/js/services/auth-service.js
|
|
||||||
node --check shine-UI/js/app.js
|
|
||||||
node --check shine-UI/js/pages/settings-view.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Java/Gradle:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew testClasses
|
|
||||||
./gradlew test
|
|
||||||
```
|
|
||||||
|
|
||||||
Затем локальный smoke test по штатной инструкции проекта, например `./gradlew startLocal`.
|
|
||||||
|
|
||||||
В среде, где готовился patch, Gradle wrapper не смог скачать Gradle 8.14 из-за отсутствия внешнего сетевого доступа к `services.gradle.org`. Поэтому полный Gradle compile/test обязательно прогнать после применения в обычной dev-среде.
|
|
||||||
|
|
||||||
## Smoke scenario
|
|
||||||
|
|
||||||
1. Создать/использовать тестового пользователя с локальным blockchain Ed25519 key.
|
|
||||||
2. Добавить обычный block и убедиться, что `blocks.block_bytes` начинается с ANS-104 DataItem, а `data_item_id` заполнен.
|
|
||||||
3. Создать channel и post; проверить `c=<canonical slug>`.
|
|
||||||
4. Включить publisher, дождаться цикла или вызвать сервис тестом; проверить root Arweave tx.
|
|
||||||
5. На второй чистой test DB включить importer и убедиться, что `App=test5590` blocks восстанавливаются в правильном порядке.
|
|
||||||
6. Убедиться, что imported blocks имеют `arweave_publish_pending=false`.
|
|
||||||
7. Проверить, что повторный discovery не создаёт дублей.
|
|
||||||
|
|
||||||
## Не делать в этом patch
|
|
||||||
|
|
||||||
- не добавлять backward compatibility Frame v0;
|
|
||||||
- не возвращать `.bch` storage;
|
|
||||||
- не возвращать SHINE-ARCHIVE;
|
|
||||||
- не менять ESP32;
|
|
||||||
- не мигрировать remote/homeserver signing без отдельного решения пользователя;
|
|
||||||
- не заменять `prevHash` на Arweave DataItem ID.
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
```text
|
```text
|
||||||
User
|
User
|
||||||
-> создаёт Frame v1
|
-> создаёт Frame v1
|
||||||
-> tags: App=test5590, при канале c=<slug>
|
-> tags: App=test5590, при канале c_test5590=<slug>
|
||||||
-> Ed25519 подписывает ANS-104 deep-hash
|
-> Ed25519 подписывает ANS-104 deep-hash
|
||||||
-> готовый DataItem
|
-> готовый DataItem
|
||||||
-> AddBlock
|
-> AddBlock
|
||||||
@@ -22,8 +22,9 @@ User
|
|||||||
Server
|
Server
|
||||||
-> verify DataItem + SHiNE chain
|
-> verify DataItem + SHiNE chain
|
||||||
-> PostgreSQL
|
-> PostgreSQL
|
||||||
-> каждые ~15 минут ANS-104 bundle
|
-> publish.mode=turbo: каждый signed DataItem через Turbo
|
||||||
-> Arweave L1
|
ИЛИ publish.mode=arweave: большой standard ANS-104 bundle -> Arweave L1
|
||||||
|
ИЛИ publish.mode=none: наружу не публиковать
|
||||||
|
|
||||||
Other servers
|
Other servers
|
||||||
-> GraphQL App=test5590
|
-> GraphQL App=test5590
|
||||||
|
|||||||
@@ -22,6 +22,20 @@ PostgreSQL является единственным локальным хран
|
|||||||
4. последовательно проигрывает удалённую цепочку с блока 0;
|
4. последовательно проигрывает удалённую цепочку с блока 0;
|
||||||
5. никаких файловых swap/recovery операций нет.
|
5. никаких файловых swap/recovery операций нет.
|
||||||
|
|
||||||
|
Этот прямой sync включается параметром:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
blockchain.sync.enabled=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Для проверки режима без прямых server-to-server связей его можно выключить в override-конфиге сервера:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
blockchain.sync.enabled=false
|
||||||
|
```
|
||||||
|
|
||||||
|
При выключенном прямом sync сервер по-прежнему может синхронизировать пользователей из Solana и импортировать пользовательские блоки через Arweave, если включён `arweave.blocks.sync.enabled`.
|
||||||
|
|
||||||
## Arweave sync
|
## Arweave sync
|
||||||
|
|
||||||
Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`.
|
Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`.
|
||||||
|
|||||||
+22
-23
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
|
> **Статус: ИСТОЧНИК ИСТИНЫ (single source of truth) по конкретной деривации.**
|
||||||
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
|
> Этот файл описывает, как из пароля получается секрет и как из секрета выводятся
|
||||||
> все ключи (root, blockchain, device/Solana, homeserver) — формулами, байт-в-байт.
|
> все ключи (root, blockchain, client, homeserver) — формулами, байт-в-байт.
|
||||||
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
|
> Если в коде меняется деривация (формула секрета, параметры Argon2id, соль, формула
|
||||||
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
|
> ключа, разделитель `|`, набор/имена суффиксов, формат homeserver-ключа, связь
|
||||||
> dev-ключ ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
|
> blockchain key ↔ Solana-адрес) — **в том же изменении обязательно править этот документ**.
|
||||||
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
|
> Роли и назначение ключей описаны отдельно в `docs/Keys/README.md` (архитектура).
|
||||||
> Здесь — только механика. Документ намеренно краткий.
|
> Здесь — только механика. Документ намеренно краткий.
|
||||||
|
|
||||||
@@ -55,29 +55,29 @@ seed(32) = SHA-256(material)
|
|||||||
| Ключ | Суффикс | Назначение (кратко) |
|
| Ключ | Суффикс | Назначение (кратко) |
|
||||||
|------|---------|---------------------|
|
|------|---------|---------------------|
|
||||||
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
|
| root | `root.key` | Личность. Подписывает unsigned-часть PDA-записи (`RootKeyBlock`). |
|
||||||
| blockchain | `bch.key` | Подписывает `LastBlockState` персонального блокчейна (`blockchain_public_key`). |
|
| blockchain | `blockchain.key` | Подписывает пользовательские блоки/ANS-104 DataItem и является текущим Solana-wallet/fee payer. |
|
||||||
| device / **Solana** | `client.key` | Ключ устройства = Solana-ключ. Fee payer и подпись Solana-транзакций; адрес кошелька = `base58(clientPub)`. См. §3. |
|
| client | `client.key` | Общий клиентский ключ для DM/устройств и derivation отдельного Arweave SAWD-кошелька; не является текущим Solana-wallet. |
|
||||||
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
|
| homeserver | `homeserver.key:<имя>` | Ключ homeserver-устройства, по одному на каждый homeserver (различитель — имя). См. §4. |
|
||||||
|
|
||||||
Полные роли каждого ключа — в `docs/Keys/README.md`.
|
Полные роли каждого ключа — в `docs/Keys/README.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Solana-ключ
|
## 3. Solana-кошелёк и authority
|
||||||
|
|
||||||
Отдельного «солана-ключа» нет. На Solana работают два ключа:
|
Отдельного «solana.key» нет. В актуальной модели Solana-wallet пользователя — **активный `blockchain.key`**:
|
||||||
|
|
||||||
- **`client.key` (device) — пополняемый кошелёк и fee payer.** Solana-адрес = `base58(clientPub)`.
|
- адрес кошелька = `base58(activeBlockchainPub)`;
|
||||||
Этим ключом оплачиваются и подписываются `create_user_pda` / `update_user_pda`.
|
- им оплачивается регистрация (`create_user_pda`) и обычные `update_user_pda`;
|
||||||
Пополнять SOL нужно именно на этот адрес.
|
- обычное обновление PDA авторизуется активным blockchain key;
|
||||||
- **`root.key` — авторитет записи**, подписывает unsigned-часть PDA через Ed25519-инструкцию, но **не** является fee payer.
|
- новый fork подписывает новое PDA новым blockchain key, а старый активный blockchain key разрешает обычный переход;
|
||||||
|
- полная смена пароля/ключей — особый recovery-переход: старый `root.key` дополнительно разрешает одно новое unsigned PDA state, а **новый blockchain key** подписывает тот же hash и становится новым wallet/authority.
|
||||||
|
|
||||||
Соответствует формату PDA `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` §2.1
|
`root.key` не является fee payer. Он используется как холодное дополнительное разрешение только для полной ротации root + client + blockchain fork.
|
||||||
(«create/update оплачиваются с `client_key`», «root_key — не fee payer»).
|
|
||||||
|
|
||||||
Кратко про роли на Solana: `root.key` — это **главный (master) ключ**: им управляют PDA-записью
|
`client.key` больше не используется как Solana-wallet/fee payer. Он остаётся клиентским криптографическим ключом (DM/устройства) и входом в отдельный протокол derivation Arweave-кошелька.
|
||||||
(`create/update`) и через это можно заменить все остальные ключи; `client.key` — это **пополняемый
|
|
||||||
кошелёк и плательщик комиссий**. Полное описание ролей — `docs/Keys/README.md`.
|
При fork Solana-адрес меняется вместе с blockchain key. Перенос SOL со старого blockchain-wallet на новый является отдельным необязательным этапом ротации; в текущей первой реализации этот этап оставлен явной заглушкой.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -100,8 +100,7 @@ homeserver.key:home-a -> ключ A
|
|||||||
homeserver.key:home-b -> ключ B
|
homeserver.key:home-b -> ключ B
|
||||||
```
|
```
|
||||||
|
|
||||||
Публичный ключ homeserver-а публикуется в `SessionsBlock` пользовательской PDA как
|
В PDA 1.2 `SessionsBlock` удалён, поэтому homeserver-ключи больше не публикуются через user PDA. Сама детерминированная деривация ключа сохранена как отдельный механизм устройства; способ его будущей публикации/авторизации определяется отдельно от PDA 1.2.
|
||||||
`session_pub_key` с `session_type = 100`, имя — в `session_name` (формат PDA §13).
|
|
||||||
|
|
||||||
> Это переименование прежней схемы `subserver.key:<имя>` → `homeserver.key:<имя>`.
|
> Это переименование прежней схемы `subserver.key:<имя>` → `homeserver.key:<имя>`.
|
||||||
> Термин «саб-сервер» по проекту заменяется на «homeserver».
|
> Термин «саб-сервер» по проекту заменяется на «homeserver».
|
||||||
@@ -118,9 +117,9 @@ homeserver.key:home-b -> ключ B
|
|||||||
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160).
|
- `shine-UI/server-ui/js/server-ui-shared.js` — те же root/bch/dev для серверного UI (~147–160).
|
||||||
|
|
||||||
### Solana-ключ / адрес кошелька (UI)
|
### Solana-ключ / адрес кошелька (UI)
|
||||||
- `shine-UI/js/pages/registration-payment-view.js` — `deriveUserWalletAddress`: адрес = `base58(clientPub)` (~113).
|
- `shine-UI/js/pages/registration-payment-view.js` — адрес пополнения = `base58(blockchainPub)`.
|
||||||
- `shine-UI/js/pages/topup-view.js` — `clientWalletAddressFromBundle`: тот же канонический адрес из `preGeneratedKeyBundle.clientPair`.
|
- `shine-UI/js/pages/topup-view.js` — тот же адрес из `preGeneratedKeyBundle.blockchainPair`.
|
||||||
Прежний расходящийся путь `deriveWalletFromPassword` (прямой Argon2 по `client.key`, мимо `masterSecret`) удалён.
|
- `shine-UI/js/services/solana-wallet-service.js` — текущий пользовательский Solana-wallet загружается из сохранённого `blockchainKey`.
|
||||||
|
|
||||||
### Деривация ключей (прошивка ESP32)
|
### Деривация ключей (прошивка ESP32)
|
||||||
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
|
- `ESP32/esp32/ESP32-S3-Touch-AMOLED-2.16/main-device/shine_homeserver_main/shine_homeserver_main.ino`
|
||||||
@@ -130,8 +129,8 @@ homeserver.key:home-b -> ключ B
|
|||||||
- старый тестовый вариант; оставлен как legacy-скетч для сравнения и диагностики.
|
- старый тестовый вариант; оставлен как legacy-скетч для сравнения и диагностики.
|
||||||
|
|
||||||
### Формат PDA (куда попадают ключи)
|
### Формат PDA (куда попадают ключи)
|
||||||
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`
|
- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md`
|
||||||
— `RootKeyBlock` §6, `ClientKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1.
|
— `RootKeyBlock`, `ClientKeyBlock`, append-only `BlockchainRegistryBlock` и экономика лимита. `SessionsBlock` в PDA 1.2 отсутствует.
|
||||||
|
|
||||||
### Сервер (тестовый seed)
|
### Сервер (тестовый seed)
|
||||||
- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) —
|
- `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) —
|
||||||
@@ -148,7 +147,7 @@ homeserver.key:home-b -> ключ B
|
|||||||
|
|
||||||
1. Этот документ — источник истины по деривации секрета и ключей.
|
1. Этот документ — источник истины по деривации секрета и ключей.
|
||||||
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
|
2. Любое изменение кода, затрагивающее формулу секрета, параметры Argon2id, соль, формулу ключа,
|
||||||
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь dev-ключ ↔ Solana-адрес —
|
разделитель `|`, набор/имена суффиксов, формат homeserver-ключа или связь blockchain key ↔ Solana-адрес —
|
||||||
**обязательно** отражать здесь в том же изменении.
|
**обязательно** отражать здесь в том же изменении.
|
||||||
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
|
3. Пункты, помеченные ⚠️, — это долг к устранению, а не норма.
|
||||||
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
|
4. Нельзя сознательно оставлять код и этот документ в рассинхроне без отдельной явной договорённости.
|
||||||
|
|||||||
+11
-14
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
В SHiNE у пользователя есть несколько уровней ключей:
|
В SHiNE у пользователя есть несколько уровней ключей:
|
||||||
|
|
||||||
- `root key` - главный (master) ключ пользователя: тот, кто им владеет, управляет пользовательской PDA в Solana и может заменить все остальные ключи. Это не пополняемый кошелёк (комиссии платит `client key`).
|
- `root key` - холодный recovery-ключ: при полной ротации дополнительно разрешает замену root + client + blockchain fork. Это не кошелёк.
|
||||||
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн пользователя.
|
- `blockchain key` - ключ записи в персональный SHiNE-блокчейн и текущий Solana-wallet/fee payer пользователя.
|
||||||
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков, DM и мелких платежей.
|
- `client key` - общий ключ пользовательских устройств для повседневной работы, звонков и DM; Solana-wallet им больше не является.
|
||||||
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
|
- `session key` - ключ конкретной сессии или конкретного устройства для авторизации на сервере.
|
||||||
|
|
||||||
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
|
Главная идея: самые важные ключи можно держать на доверенном серверном или аппаратном устройстве, а обычные клиентские устройства получают только ключи, нужные для текущей работы.
|
||||||
@@ -21,16 +21,13 @@
|
|||||||
|
|
||||||
Назначение:
|
Назначение:
|
||||||
|
|
||||||
- регистрация пользователя в Solana;
|
- холодное recovery-разрешение для полной смены ключей;
|
||||||
- создание и обновление пользовательской PDA-записи;
|
- подтверждение атомарной ротации `root + client + новый blockchain fork`;
|
||||||
- вызов критически важных Solana-функций;
|
- восстановительные сценарии повышенного уровня доверия.
|
||||||
- изменение главных настроек пользователя;
|
|
||||||
- управление остальными ключами;
|
|
||||||
- подтверждение операций, которые должны иметь максимальный уровень доверия.
|
|
||||||
|
|
||||||
`root key` — это **главный (master) ключ** в следующем смысле: зная `root key`, можно управлять пользовательской PDA-записью в Solana (`create_user_pda` / `update_user_pda`) и тем самым **заменить все остальные ключи** пользователя (device, blockchain, homeserver). Поэтому компрометация `root key` равносильна компрометации всей личности пользователя.
|
Обычные PDA-update **не требуют root key**: их выполняет активный blockchain key. При полной ротации старый root подписывает тот же hash нового unsigned PDA state, который подписывает новый blockchain key. Так root разрешает переход, не становясь повседневным ключом.
|
||||||
|
|
||||||
Важно не путать авторитет и кошелёк: `root key` — это авторитет над PDA-записью, а **SOL-комиссии за create/update платит `client key`** (он же fee payer и адрес для пополнения). Подробнее о том, какой ключ за что отвечает на Solana, — в `docs/Keys/DERIVATION.md`, §3.
|
Важно не путать recovery-authority и кошелёк: `root key` не является fee payer. Текущий Solana-wallet/fee payer — активный `blockchain key`. Подробнее — `docs/Keys/DERIVATION.md`, §3.
|
||||||
|
|
||||||
## `blockchain key`
|
## `blockchain key`
|
||||||
|
|
||||||
@@ -40,7 +37,8 @@
|
|||||||
|
|
||||||
- подпись записей в персональном блокчейне пользователя;
|
- подпись записей в персональном блокчейне пользователя;
|
||||||
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
|
- подтверждение действий, которые должны попасть в SHiNE-блокчейн;
|
||||||
- разделение полномочий между главным Solana-ключом и ключом ежедневной записи.
|
- обычные обновления пользовательской PDA;
|
||||||
|
- текущий Solana-wallet/fee payer (`base58(active blockchain public key)`).
|
||||||
|
|
||||||
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
|
У пользователя может быть несколько персональных блокчейнов или веток. При смене `blockchain key` фактически создаётся новая ветка записи:
|
||||||
|
|
||||||
@@ -59,7 +57,6 @@
|
|||||||
- повседневные входящие и исходящие личные сообщения;
|
- повседневные входящие и исходящие личные сообщения;
|
||||||
- звонки и связанные с ними сообщения;
|
- звонки и связанные с ними сообщения;
|
||||||
- self-messages, то есть внутренние сообщения пользователя самому себе;
|
- self-messages, то есть внутренние сообщения пользователя самому себе;
|
||||||
- мелкие Solana-расходы на текущие операции;
|
|
||||||
- derivation Arweave-кошелька;
|
- derivation Arweave-кошелька;
|
||||||
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
|
- оплата или подготовка добавления данных в Arweave-кошелек по отдельному протоколу.
|
||||||
|
|
||||||
@@ -158,7 +155,7 @@ Self-message - это сообщение пользователя самому
|
|||||||
|
|
||||||
## Связанные документы
|
## Связанные документы
|
||||||
|
|
||||||
- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`bch.key`/`client.key`/`homeserver.key:<имя>`, Solana-ключ, ссылки на код).
|
- `docs/Keys/DERIVATION.md` - **источник истины по конкретной деривации** секрета и ключей (формулы Argon2id, `base64|suffix→SHA-256→Ed25519`, суффиксы `root.key`/`blockchain.key`/`client.key`/`homeserver.key:<имя>`, Solana-wallet, ссылки на код).
|
||||||
- `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений.
|
- `docs/Personal_Messages/Протокол_DM_v1.md` - текущая логическая документация личных сообщений.
|
||||||
- `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений.
|
- `docs/Personal_Messages/Формат_DM_v1.md` - точный байтовый формат личных сообщений.
|
||||||
- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
|
- `docs/Blockchain/README.md` - точка входа по форматам SHiNE-блокчейна.
|
||||||
|
|||||||
@@ -14,11 +14,10 @@
|
|||||||
- сервер проверяет формат, пользователей и подпись до сохранения;
|
- сервер проверяет формат, пользователей и подпись до сохранения;
|
||||||
- повторная доставка одной ревизии идемпотентна;
|
- повторная доставка одной ревизии идемпотентна;
|
||||||
- более старая ревизия не заменяет новую;
|
- более старая ревизия не заменяет новую;
|
||||||
- у каждого пользователя действует только access_servers[0];
|
- PDA 1.2 допускает максимум один access server; если он задан, используется единственная запись;
|
||||||
- дополнительные элементы старой PDA игнорируются без fallback;
|
- старые PDA 1.0 не участвуют в текущем runtime-протоколе;
|
||||||
- DM и настройки не реплицируются между access-серверами одного пользователя;
|
- DM и настройки не реплицируются между access-серверами одного пользователя;
|
||||||
- sync_servers серверного PDA используются только для синхронизации
|
- пользовательские blockchain синхронизируются через Arweave; `sync_servers` в PDA 1.2 отсутствует.
|
||||||
пользовательских блокчейнов.
|
|
||||||
|
|
||||||
## 3. Типы DM
|
## 3. Типы DM
|
||||||
|
|
||||||
|
|||||||
@@ -567,3 +567,45 @@ SYNC_POLL_INTERVAL_SECONDS=300
|
|||||||
7. добавить запись в `current` и `history`;
|
7. добавить запись в `current` и `history`;
|
||||||
8. добавить periodic guard раз в 5 минут;
|
8. добавить periodic guard раз в 5 минут;
|
||||||
9. сохранить отдельный `main` для запуска как процесса.
|
9. сохранить отдельный `main` для запуска как процесса.
|
||||||
|
|
||||||
|
## PDA 1.2 (2026-09-25)
|
||||||
|
|
||||||
|
Модуль синхронизации принимает только текущую PDA 1.2. Legacy PDA 1.0 не проецируются в runtime-state и не мигрируются: их можно закрыть отдельной временной инструкцией `close_legacy_pda`.
|
||||||
|
|
||||||
|
Для PDA 1.2:
|
||||||
|
|
||||||
|
- `RecoveryKeyBlock`, sessions, trusted state, archive head и `sync_servers` отсутствуют;
|
||||||
|
- `BlockchainRegistryBlock` содержит append-only список fork в порядке `blockchain_key + created_at_ms + paid_limit_bytes`;
|
||||||
|
- `ServerProfileBlock` в 1.2 допускает один адрес сервера, `AccessServersBlock` — 0 или 1 access server;
|
||||||
|
- в compatibility SQL-поля проецируется **последний** fork как активный `blockchain_key/paid_limit_bytes`;
|
||||||
|
- полный список fork сохраняется в `blockchain_forks_json`, поэтому поиск пользователя по старому blockchain key остаётся возможным;
|
||||||
|
- `blockchain_name` для compatibility view вычисляется как `<normalized_login>-NNN`, где `NNN` соответствует индексу fork + 1;
|
||||||
|
- удалённые tip-поля (`used_bytes`, `last_block_*`, Arweave tx id) в PDA 1.2 больше не являются источником истины и в compatibility snapshot заполняются нейтральными значениями;
|
||||||
|
- server profile считается присутствующим, если опубликован один server address; в старые SQL-поля временно проецируется этот адрес.
|
||||||
|
|
||||||
|
Create/update транзакции больше не реконструируются байт-в-байт из instruction args. После обнаружения изменения sync-модуль перечитывает фактическую текущую PDA через Solana RPC и декодирует её. Это исключает дублирование on-chain сериализации. `close_legacy_pda` не создаёт новое состояние PDA и для runtime-sync не является пользовательским update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-local key rotation state (PostgreSQL v26)
|
||||||
|
|
||||||
|
Начиная с migration v26 сервер хранит локальное состояние длительной смены ключей отдельно от данных Solana PDA.
|
||||||
|
|
||||||
|
В `solana_user_pda_current` добавлены локальные поля:
|
||||||
|
|
||||||
|
- `rotation_status` — быстрый текущий статус (`NONE` в обычном режиме);
|
||||||
|
- `rotation_session_id` — ссылка на текущую запись `key_rotation_sessions`.
|
||||||
|
|
||||||
|
Эти поля **не являются частью PDA**, не приходят из Solana и не должны перезаписываться обычным Solana sync upsert-ом.
|
||||||
|
|
||||||
|
Подробный прогресс хранится в `key_rotation_sessions`: только публичные old/new root/blockchain/client keys, выбранная точка fork, старый tip, reason/comment, прогресс, ошибка/retry и статусы необязательных wallet/DM этапов. Пароли и приватные ключи в PostgreSQL не сохраняются.
|
||||||
|
|
||||||
|
Первая серверная запись создаётся сразу в `COPYING_CHAIN`; состояния `PREPARING` в БД нет. Завершённые `COMPLETE`/`ABORTED` sessions остаются как журнал, а `solana_user_pda_current.rotation_status` возвращается в `NONE`.
|
||||||
|
|
||||||
|
### Candidate blocks ротации (PostgreSQL v27)
|
||||||
|
|
||||||
|
Начиная с migration v27 будущая ветка во время `COPYING_CHAIN` хранится в отдельной таблице `key_rotation_candidate_blocks`. Она не является частью текущего materialized blockchain state и не должна попадать в обычную `blocks` до финального переключения fork.
|
||||||
|
|
||||||
|
Для каждого candidate DataItem сохраняются rotation session, login, candidate blockchain name, block number/hash, полный ANS-104 DataItem, DataItem id и статус публикации. Уникальность `(rotation_session_id, block_number)` запрещает две разные версии одного candidate-блока.
|
||||||
|
|
||||||
|
Arweave/Turbo publisher обрабатывает candidate-блоки приоритетно. `key_rotation_sessions.progress_current` отражает число DataItem, уже реально опубликованных publisher-ом, а не число принятых API-сервером.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user