From c997057a379c78c10f0df4855c2aa5fba2c8a4f6f859bf8410245ed897fd455f Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sat, 26 Sep 2026 11:01:14 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=20Solana=20PDA=201.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 5 +- .../js/lib/shine-server-resolver.js | 117 +- SHiNE-server/AGENTS.md | 22 +- .../main/java/shine/db/KeyEncodingUtil.java | 51 + .../shine/db/dao/SolanaUserPdaCurrentDAO.java | 28 +- .../main/resources/postgres/migration_v25.sql | 15 +- .../src/main/resources/postgres/schema_v1.sql | 5 +- .../auth/SolanaUserPdaImportService.java | 287 ++- .../java/server/sync/AddBlockSyncService.java | 3 +- .../main/java/sync/codec/ShineUsersCodec.java | 1137 ++---------- .../sync/service/SolanaUsersSyncService.java | 101 +- .../postgres/PostgresStorageRepository.java | 22 +- .../sync/PeriodicBlockchainSyncService.java | 3 +- .../sync/SyncServersBootstrapService.java | 49 +- .../src/main/resources/application.properties | 25 +- VERSION.properties | 4 +- deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md | 12 +- deploy/TEST_SERVERS.md | 2 +- docs/API/16_Server_Connection_Pool_API.md | 2 +- docs/Keys/DERIVATION.md | 7 +- docs/Personal_Messages/Протокол_DM_v1.md | 7 +- .../Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md | 17 + docs/Solana/user_pda/README.md | 520 +----- .../details/shine_users.md | 159 +- shine-UI/js/pages/access-servers-view.js | 3 + shine-UI/js/pages/register-view.js | 2 +- .../js/pages/registration-draft-keys-view.js | 20 +- shine-UI/js/pages/registration-faq-view.js | 2 +- shine-UI/js/services/auth-service.js | 7 +- .../js/services/shine-user-pda-service.js | 1550 ++++++----------- shine-UI/server-ui/create-server-pda.html | 19 +- .../server-ui/js/create-server-pda-page.js | 3 - shine-UI/server-ui/js/read-pda-page.js | 105 +- shine-UI/server-ui/js/server-ui-shared.js | 31 +- .../server-ui/js/update-server-pda-page.js | 11 +- shine-UI/server-ui/update-server-pda.html | 18 +- .../static-sites/arweave-viewer/index.html | 142 +- shine-solana-arweave-viewer-v2/index.html | 138 +- shine-solana-arweave-viewer-v3/index.html | 138 +- shine-solana-arweave-viewer/index.html | 136 +- shine-solana/shine/AGENTS.md | 3 +- .../formats/shine-user-pda-format-v.1.0.md | 6 +- .../formats/shine-user-pda-format-v.1.2.md | 184 ++ .../shine/doc/programs/shine_users.md | 822 +-------- .../shine/programs/shine_users/src/lib.rs | 887 +++++----- shine-solana/shine/tests/pda-v1-2-codec.mjs | 42 + shine-solana/shine/tests/shine.ts | 3 +- 47 files changed, 2392 insertions(+), 4480 deletions(-) create mode 100644 shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md create mode 100644 shine-solana/shine/tests/pda-v1-2-codec.mjs diff --git a/AGENTS.md b/AGENTS.md index 251b22aa..967fed5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,9 @@ - Этот файл считать основной справкой (single source of truth) по деплою и первичной инициализации Solana-регистрации в текущем проекте. - Актуальная архитектурная справка по устройству Solana-программ, PDA-счетам, ролям DAO и движению средств находится в: - `docs/Solana_Architecture/README.md` -- Документ формата пользовательской PDA-записи `shine_users` находится в: - - `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` +- Документы формата пользовательской PDA-записи `shine_users`: + - текущий: `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md`; + - legacy: `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md`. - Актуальная документация по серверному модулю синхронизации Solana users находится в: - `docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md` - При любом изменении логики серверной синхронизации `shine_users`, её таблиц PostgreSQL, checkpoint-механизма, startup/lifecycle или deploy-настроек обязательно обновлять: diff --git a/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js b/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js index b21aac6c..0ade8dc0 100644 --- a/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js +++ b/SHiNE-browser-plugin-wallet/js/lib/shine-server-resolver.js @@ -52,113 +52,84 @@ function readStrU8(bytes, cursorRef) { function parseServerFieldsFromUserPda(dataBytes) { const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []); - if (bytes.length < 5) throw new Error('Некорректный формат PDA'); + if (bytes.length < 9) throw new Error('Некорректный формат PDA'); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const cursorRef = { value: 0 }; + const readU16 = () => { if (cursorRef.value + 2 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getUint16(cursorRef.value, true); cursorRef.value += 2; return v; }; + const readU32 = () => { if (cursorRef.value + 4 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getUint32(cursorRef.value, true); cursorRef.value += 4; return v; }; + const readU64 = () => { if (cursorRef.value + 8 > bytes.length) throw new Error('Повреждённый формат PDA'); const v = view.getBigUint64(cursorRef.value, true); cursorRef.value += 8; return v; }; const magic = new TextDecoder().decode(readBytes(bytes, cursorRef, 5)); if (magic !== 'SHiNE') throw new Error('Некорректный формат PDA'); - cursorRef.value += 1; // format_major - cursorRef.value += 1; // format_minor - cursorRef.value += 2; // record_len + const formatMajor = readU8(bytes, cursorRef); + const formatMinor = readU8(bytes, cursorRef); + if (formatMajor !== 1 || formatMinor !== 2) throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`); + const recordLen = readU16(); + if (recordLen < 73 || recordLen > bytes.length) throw new Error('Некорректный record_len PDA'); cursorRef.value += 8; // created_at_ms cursorRef.value += 8; // updated_at_ms cursorRef.value += 4; // record_number cursorRef.value += 32; // prev_record_hash - readStrU8(bytes, cursorRef); // login + const login = readStrU8(bytes, cursorRef); const blocksCount = readU8(bytes, cursorRef); - let isServer = false; let serverAddress = ''; let accessServers = []; - let recoveryKey32 = null; let rootKey32 = null; let clientKey32 = null; let blockchainKey32 = null; - let blockchainName = ''; - let homeserverSessions = []; + let forkCount = 0; for (let i = 0; i < blocksCount; i += 1) { const blockType = readU8(bytes, cursorRef); - cursorRef.value += 1; // block_version - - if (blockType === 0 || blockType === 1 || blockType === 2) { + const blockVersion = readU8(bytes, cursorRef); + if (blockVersion !== 0) throw new Error('Неподдерживаемая версия блока PDA'); + if (blockType === 1 || blockType === 2) { const key32 = readBytes(bytes, cursorRef, 32); - if (blockType === 0) recoveryKey32 = key32; if (blockType === 1) rootKey32 = key32; - if (blockType === 2) clientKey32 = key32; + else clientKey32 = key32; continue; } + + const payloadLen = readU16(); + const payloadEnd = cursorRef.value + payloadLen; + if (payloadEnd > recordLen - 64) throw new Error('Повреждённый variable block PDA'); + if (blockType === 3) { - const count = readU8(bytes, cursorRef); - for (let j = 0; j < count; j += 1) { - cursorRef.value += 1; - const currentBlockchainName = readStrU8(bytes, cursorRef); - const currentBlockchainKey32 = readBytes(bytes, cursorRef, 32); - if (!blockchainKey32) { - blockchainKey32 = currentBlockchainKey32; - blockchainName = currentBlockchainName; - } - cursorRef.value += 8 + 8 + 4 + 32 + 64; - const arPresent = readU8(bytes, cursorRef); - if (arPresent === 1) readStrU8(bytes, cursorRef); + forkCount = readU16(); + if (forkCount < 1) throw new Error('BlockchainRegistry пуст'); + for (let j = 0; j < forkCount; j += 1) { + blockchainKey32 = readBytes(bytes, cursorRef, 32); + readU64(); // created_at_ms fork + readU32(); // paid_limit_bytes } - continue; - } - if (blockType === 30) { - isServer = readU8(bytes, cursorRef) === 1; - if (isServer) { - cursorRef.value += 1; // address_format_type - cursorRef.value += 1; // address_format_version - serverAddress = readStrU8(bytes, cursorRef); - const syncCount = readU8(bytes, cursorRef); - for (let j = 0; j < syncCount; j += 1) readStrU8(bytes, cursorRef); - } - continue; - } - if (blockType === 40) { + } else if (blockType === 30) { + const addressCount = readU8(bytes, cursorRef); + if (addressCount !== 1) throw new Error('PDA 1.2 допускает ровно один адрес сервера'); + readU8(bytes, cursorRef); // address_format_type + readU8(bytes, cursorRef); // address_format_version + serverAddress = readStrU8(bytes, cursorRef); + } else if (blockType === 40) { const accessCount = readU8(bytes, cursorRef); + if (accessCount > 1) throw new Error('PDA 1.2 допускает максимум один access server'); accessServers = []; for (let j = 0; j < accessCount; j += 1) accessServers.push(readStrU8(bytes, cursorRef)); - continue; } - if (blockType === 50) { - cursorRef.value += 1; - const sessionsCount = readU8(bytes, cursorRef); - for (let j = 0; j < sessionsCount; j += 1) { - const sessionType = readU8(bytes, cursorRef); - const sessionVersion = readU8(bytes, cursorRef); - const sessionName = readStrU8(bytes, cursorRef); - const sessionPubKey32 = readBytes(bytes, cursorRef, 32); - if (sessionType === 100) { - homeserverSessions.push({ - sessionType, - sessionVersion, - sessionName, - sessionPubKeyBase58: new PublicKey(sessionPubKey32).toBase58(), - sessionPubKeyB64: `ed25519/${btoa(String.fromCharCode(...sessionPubKey32))}`, - }); - } - } - continue; - } - if (blockType === 70) { - cursorRef.value += 1; - continue; - } - throw new Error(`Неизвестный блок PDA: ${blockType}`); + cursorRef.value = payloadEnd; } + if (!rootKey32 || !clientKey32 || !blockchainKey32) throw new Error('PDA 1.2 не содержит обязательные ключи'); return { - isServer, + isServer: Boolean(serverAddress), serverAddress: normalizeHostLike(serverAddress), accessServers: accessServers.map((value) => normalizeServerLogin(value)).filter(Boolean), publicKeys: { - recoveryKeyBase58: recoveryKey32 ? new PublicKey(recoveryKey32).toBase58() : '', - rootKeyBase58: rootKey32 ? new PublicKey(rootKey32).toBase58() : '', - clientKeyBase58: clientKey32 ? new PublicKey(clientKey32).toBase58() : '', - blockchainKeyBase58: blockchainKey32 ? new PublicKey(blockchainKey32).toBase58() : '', - blockchainName, + recoveryKeyBase58: '', + rootKeyBase58: new PublicKey(rootKey32).toBase58(), + clientKeyBase58: new PublicKey(clientKey32).toBase58(), + blockchainKeyBase58: new PublicKey(blockchainKey32).toBase58(), + blockchainName: `${normalizeServerLogin(login)}-${String(forkCount).padStart(3, '0')}`, }, - homeserverSessions, + homeserverSessions: [], }; } diff --git a/SHiNE-server/AGENTS.md b/SHiNE-server/AGENTS.md index cd7d0d9f..395a6d2a 100644 --- a/SHiNE-server/AGENTS.md +++ b/SHiNE-server/AGENTS.md @@ -18,15 +18,11 @@ SHiNE-server — серверная часть мессенджера SHiNE: Web ## Настройка сервера в Solana (Solana PDA) -Серверный аккаунт SHiNE регистрируется в Solana в виде `user_pda` с флагом `is_server=true`. -В PDA хранятся: +Серверный аккаунт SHiNE регистрируется как обычный `user_pda` формата 1.2 с присутствующим `ServerProfileBlock`. Отдельного `is_server` в PDA нет: наличие server profile означает, что аккаунт объявляет серверный endpoint. -- **адрес сервера** (URL WebSocket/HTTPS, например `https://shineup.me/ws`); -- **список серверов синхронизации** (`sync_servers`) — логины SHiNE-аккаунтов серверов-партнёров, - с которыми синхронизируются пользовательские блокчейны; -- **корневой ключ** сервера (`root_key`). +PDA 1.2 сейчас допускает **один** адрес сервера (`address_format_type + address_format_version + address`). Бинарный блок использует count/array, чтобы будущая версия протокола могла увеличить лимит без смены структуры блока. Прямой список `sync_servers` удалён: пользовательские blockchain синхронизируются через Arweave. -Клиенты читают PDA напрямую из Solana, чтобы узнать адрес сервера и при необходимости подключиться. +Клиенты читают server PDA из Solana, получают опубликованный endpoint и подключаются к нему. Межсерверная доставка DM через access-server routing остаётся отдельным механизмом. **Управление серверной PDA выполняется через Web-панель администратора:** @@ -36,10 +32,9 @@ shine-UI/server-ui.html Страницы: - `shine-UI/server-ui/create-server-pda.html` — первичная регистрация серверного аккаунта; -- `shine-UI/server-ui/update-server-pda.html` — обновление адреса или списка sync_servers. +- `shine-UI/server-ui/update-server-pda.html` — обновление адреса сервера. -Для регистрации нужен полный keyBundle (root + device + blockchain). -Для обновления — только root + device (blockchain-ключ не нужен). +PDA 1.2 использует три постоянные роли ключей: `root` (cold recovery), `blockchain` (обычный authority и подпись блоков) и `client` (клиент/кошелёк). Обычный update авторизуется активным последним blockchain key; root используется для recovery и может менять root. Актуальные адреса программ Solana (devnet): - `shine_users`: `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` @@ -47,12 +42,9 @@ shine-UI/server-ui.html Подробнее: `docs/Инициализация_Solana_регистрации/README.md` -## Синхронизация с партнёрскими серверами +## Синхронизация пользовательских blockchain -Сервер должен синхронизировать блоки пользовательских блокчейнов с -серверами-партнёрами из `sync_servers`. DM между партнёрами не реплицируются: -они доставляются только на первый access-сервер получателя. -Детали: `docs/Blockchain/sync-between-servers.md` +Прямой peer-to-peer blockchain sync через `sync_servers` отключён. Сервер восстанавливает и синхронизирует пользовательские записи через Arweave. Код старого peer-sync пока может оставаться как inert legacy, но не должен включаться в runtime. ## Деплой diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java index 867bd394..57f8930a 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/KeyEncodingUtil.java @@ -23,6 +23,46 @@ public final class KeyEncodingUtil { private KeyEncodingUtil() {} + public static String encodeBase58(byte[] input) { + if (input == null || input.length == 0) { + return ""; + } + byte[] copy = java.util.Arrays.copyOf(input, input.length); + int zeros = 0; + while (zeros < copy.length && copy[zeros] == 0) { + zeros++; + } + byte[] encoded = new byte[copy.length * 2]; + int outputStart = encoded.length; + int inputStart = zeros; + while (inputStart < copy.length) { + int remainder = divmod58(copy, inputStart); + if (copy[inputStart] == 0) { + inputStart++; + } + encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(remainder); + } + while (outputStart < encoded.length && encoded[outputStart] == (byte) BASE58_ALPHABET.charAt(0)) { + outputStart++; + } + while (--zeros >= 0) { + encoded[--outputStart] = (byte) BASE58_ALPHABET.charAt(0); + } + return new String(encoded, outputStart, encoded.length - outputStart, java.nio.charset.StandardCharsets.US_ASCII); + } + + public static String base58KeyToBase64_32(String rawKey) { + if (rawKey == null) { + return null; + } + String value = rawKey.trim(); + if (value.isEmpty()) { + return value; + } + byte[] decoded = tryDecodeBase58_32(value); + return decoded == null ? value : Base64.getEncoder().encodeToString(decoded); + } + public static String normalizeKeyToBase64_32(String rawKey) { if (rawKey == null) { return null; @@ -102,6 +142,17 @@ public final class KeyEncodingUtil { return result; } + private static int divmod58(byte[] number, int startAt) { + int remainder = 0; + for (int i = startAt; i < number.length; i++) { + int digit256 = number[i] & 0xFF; + int temp = remainder * 256 + digit256; + number[i] = (byte) (temp / 58); + remainder = temp % 58; + } + return remainder; + } + private static int divmod256(byte[] number58, int startAt) { int remainder = 0; for (int i = startAt; i < number58.length; i++) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java index 560945a4..e98d887e 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java @@ -8,7 +8,6 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.Base64; /** * Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем. @@ -61,28 +60,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 { if (owner32 == null || owner32.length != 32) return null; - String wanted = Base64.getEncoder().encodeToString(owner32); + String wanted = KeyEncodingUtil.encodeBase58(owner32); String sql = """ - SELECT login, blockchain_name, blockchain_key, paid_limit_bytes - FROM solana_user_pda_current + SELECT + u.login, + u.login || '-' || LPAD(((fork_item.value ->> 'forkIndex')::integer + 1)::text, 3, '0') AS blockchain_name, + fork_item.value ->> 'blockchainKey' AS blockchain_key, + (fork_item.value ->> 'paidLimitBytes')::bigint AS paid_limit_bytes + FROM solana_user_pda_current u + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(NULLIF(u.blockchain_forks_json, ''), '[]')::jsonb) AS fork_item(value) + WHERE fork_item.value ->> 'blockchainKey' = ? + LIMIT 1 """; - try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - SolanaUserPdaCurrentEntry e = mapRow(rs); - if (wanted.equals(e.getBlockchainKey())) return e; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, wanted); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return mapRow(rs); } } - return null; } private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException { SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry(); entry.setLogin(rs.getString("login")); entry.setBlockchainName(rs.getString("blockchain_name")); - entry.setBlockchainKey(KeyEncodingUtil.normalizeKeyToBase64_32(rs.getString("blockchain_key"))); + entry.setBlockchainKey(KeyEncodingUtil.base58KeyToBase64_32(rs.getString("blockchain_key"))); entry.setPaidLimitBytes(rs.getLong("paid_limit_bytes")); return entry; } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql index 8c0484d9..1dd933d1 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql @@ -1,11 +1,8 @@ --- Turbo/direct-Arweave transport no longer stores root transaction IDs for user DataItems. -ALTER TABLE blocks - DROP COLUMN IF EXISTS arweave_root_tx_id; - -ALTER TABLE arweave_block_import_queue - DROP COLUMN IF EXISTS root_tx_id; +-- 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; +SET schema_version = 25, + updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT) +WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index f13a37a4..17216960 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -84,6 +84,7 @@ CREATE TABLE IF NOT EXISTS solana_user_pda_current ( blockchain_name TEXT NOT NULL, blockchain_key TEXT NOT NULL, paid_limit_bytes BIGINT NOT NULL, + blockchain_forks_json TEXT NOT NULL DEFAULT '[]', used_bytes BIGINT NOT NULL, last_block_number INTEGER NOT NULL, last_block_hash TEXT NOT NULL, @@ -500,6 +501,7 @@ CREATE TABLE IF NOT EXISTS blocks ( data_item_id BYTEA NOT NULL, arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE, arweave_published_at_ms BIGINT, + arweave_root_tx_id TEXT, edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0), line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0), prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0), @@ -534,6 +536,7 @@ VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING; CREATE TABLE IF NOT EXISTS arweave_block_import_queue ( data_item_id BYTEA PRIMARY KEY, + root_tx_id TEXT NOT NULL, block_height BIGINT NOT NULL, raw_data_item BYTEA NOT NULL, status TEXT NOT NULL, @@ -2079,7 +2082,7 @@ CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending WHERE archive_head_tx_id <> ''; INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,25,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,24,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 58ea1aa6..00d4a33f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -28,7 +28,6 @@ public final class SolanaUserPdaImportService { private static final HttpClient HTTP = HttpClient.newHttpClient(); private static final String MAGIC = "SHiNE"; private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1; - private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64; private SolanaUserPdaImportService() {} @@ -55,8 +54,7 @@ public final class SolanaUserPdaImportService { } /** - * Чтение server PDA по логину сервера. Используется сервером при старте, - * чтобы получить актуальный server_address и список sync_servers. + * Чтение server PDA 1.2 по логину сервера. В 1.2 разрешён ровно один endpoint. */ public static ParsedServerProfile fetchServerProfileByLogin(String loginRaw) throws Exception { String login = normalizeLogin(loginRaw); @@ -156,9 +154,16 @@ public final class SolanaUserPdaImportService { } private static ParsedSolanaUser parseUserPda(byte[] raw) { - if (raw == null || raw.length < 128) return null; + if (raw == null || raw.length < 9) return null; if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null; + int major = u8(raw, 5); + int minor = u8(raw, 6); + if (major != 1 || minor != 2) return null; + return parseUserPdaV12(raw); + } + private static ParsedSolanaUser parseUserPdaV12(byte[] raw) { + if (raw == null || raw.length < 128) return null; int recordLen = u16le(raw, 7); if (recordLen < 73 || recordLen > raw.length) return null; @@ -167,228 +172,166 @@ public final class SolanaUserPdaImportService { c += 8; // updated_at_ms c += 4; // record_number c += 32; // prev_record_hash + if (c >= recordLen) return null; int loginLen = u8(raw, c++); if (loginLen <= 0 || c + loginLen > recordLen) return null; String login = new String(raw, c, loginLen, StandardCharsets.UTF_8); c += loginLen; + if (c >= recordLen) return null; int blocksCount = u8(raw, c++); - String blockchainName = null; byte[] blockchainKey32 = null; byte[] clientKey32 = null; long paidLimitBytes = 0L; - List sessions = new ArrayList<>(); + int forkCount = 0; List accessServers = new ArrayList<>(); for (int i = 0; i < blocksCount; i++) { + if (c + 2 > recordLen) return null; int blockType = u8(raw, c++); int blockVer = u8(raw, c++); if (blockVer != 0) return null; - if (blockType == 0) { - c += 32; // recovery_key - } else if (blockType == 1) { - c += 32; - } else if (blockType == 2) { + if (blockType == 1) { + if (c + 32 > recordLen) return null; + c += 32; // root_key + continue; + } + if (blockType == 2) { + if (c + 32 > recordLen) return null; clientKey32 = slice(raw, c, 32); c += 32; - } else if (blockType == 3) { - int count = u8(raw, c++); - for (int j = 0; j < count; j++) { - c += 1; // blockchain_type - int bchLen = u8(raw, c++); - blockchainName = new String(raw, c, bchLen, StandardCharsets.UTF_8); - c += bchLen; - blockchainKey32 = slice(raw, c, 32); - c += 32; - paidLimitBytes = u64le(raw, c); - c += 8; - c += 8; // used_bytes - c += 4; // last_block_number - c += 32; // last_block_hash - c += 64; // last_block_signature - int arweavePresent = u8(raw, c++); - if (arweavePresent == 1) { - int arLen = u8(raw, c++); - c += arLen; - } else if (arweavePresent != 0) { - return null; - } - } - } else if (blockType == 30) { - int isServer = u8(raw, c++); - if (isServer == 1) { - c += 1; // address_format_type - c += 1; // address_format_version - int addrLen = u8(raw, c++); - c += addrLen; - int syncCount = u8(raw, c++); - for (int j = 0; j < syncCount; j++) { - int n = u8(raw, c++); - c += n; - } - } else if (isServer != 0) { - return null; - } - } else if (blockType == 40) { - int accessCount = u8(raw, c++); - for (int j = 0; j < accessCount; j++) { - int n = u8(raw, c++); - String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8); - c += n; - String normalizedAccessServerLogin = normalizeLogin(accessServerLogin); - if (normalizedAccessServerLogin == null || accessServers.contains(normalizedAccessServerLogin)) { - continue; - } - if (accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) { - accessServers.add(normalizedAccessServerLogin); - } - } - } else if (blockType == 50) { - int sessionsMode = u8(raw, c++); - if (sessionsMode != 1 && sessionsMode != 10) return null; - int sessionsCount = u8(raw, c++); - if (sessionsCount > 64) return null; - for (int j = 0; j < sessionsCount; j++) { - int sessionType = u8(raw, c++); - int sessionVersion = u8(raw, c++); - int n = u8(raw, c++); - String sessionName = new String(raw, c, n, StandardCharsets.UTF_8); - c += n; - byte[] sessionPubKey32 = slice(raw, c, 32); - c += 32; - sessions.add(new ParsedSessionRecord( - sessionType, - sessionVersion, - sessionName, - sessionPubKey32 - )); - } - } else if (blockType == 70) { - c += 1; - } else if (blockType == 100) { - c += ARCHIVE_HEAD_PAYLOAD_BYTES; - } else { - return null; + continue; } - if (c > recordLen) return null; + if (c + 2 > recordLen) return null; + int payloadLen = u16le(raw, c); + c += 2; + int payloadEnd = c + payloadLen; + if (payloadEnd < c || payloadEnd > recordLen) return null; + + if (blockType == 3) { + if (c + 2 > payloadEnd) return null; + forkCount = u16le(raw, c); + c += 2; + if (forkCount <= 0) return null; + for (int j = 0; j < forkCount; j++) { + if (c + 44 > payloadEnd) return null; + blockchainKey32 = slice(raw, c, 32); + c += 32; + c += 8; // fork created_at_ms + paidLimitBytes = u32le(raw, c); + c += 4; + } + if (c != payloadEnd) return null; + } else if (blockType == 30) { + // Server profile is not needed for auth parsing; validate/skip its payload. + c = payloadEnd; + } else if (blockType == 40) { + if (c >= payloadEnd) return null; + int accessCount = u8(raw, c++); + if (accessCount > 1) return null; + for (int j = 0; j < accessCount; j++) { + if (c >= payloadEnd) return null; + int n = u8(raw, c++); + if (c + n > payloadEnd) return null; + String accessServerLogin = new String(raw, c, n, StandardCharsets.UTF_8); + c += n; + String normalized = normalizeLogin(accessServerLogin); + if (normalized != null && !accessServers.contains(normalized) + && accessServers.size() < MAX_EFFECTIVE_ACCESS_SERVERS) { + accessServers.add(normalized); + } + } + if (c != payloadEnd) return null; + } else { + // Unknown variable PDA 1.2 block: forward-compatible skip by payload_len. + c = payloadEnd; + } + c = payloadEnd; } - if (blockchainName == null || blockchainKey32 == null || clientKey32 == null) return null; + if (blockchainKey32 == null || clientKey32 == null || forkCount <= 0) return null; + String blockchainName = login + "-" + String.format(Locale.ROOT, "%03d", forkCount); return new ParsedSolanaUser( login, blockchainName, Base64.getEncoder().encodeToString(blockchainKey32), Base64.getEncoder().encodeToString(clientKey32), paidLimitBytes, - sessions, + List.of(), accessServers ); } - private static ParsedServerProfile parseServerProfile(byte[] raw) { - if (raw == null || raw.length < 128) return null; - if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null; + private static ParsedServerProfile parseServerProfile(byte[] raw) { + if (raw == null || raw.length < 9) return null; + if (!MAGIC.equals(new String(raw, 0, 5, StandardCharsets.UTF_8))) return null; + int major = u8(raw, 5); + int minor = u8(raw, 6); + if (major != 1 || minor != 2) return null; + return parseServerProfileV12(raw); + } + + private static ParsedServerProfile parseServerProfileV12(byte[] raw) { + if (raw == null || raw.length < 128) return null; int recordLen = u16le(raw, 7); if (recordLen < 73 || recordLen > raw.length) return null; - int c = 9; - c += 8; // created_at_ms - c += 8; // updated_at_ms - c += 4; // record_number - c += 32; // prev_record_hash - + int c = 9 + 8 + 8 + 4 + 32; + if (c >= recordLen) return null; int loginLen = u8(raw, c++); if (loginLen <= 0 || c + loginLen > recordLen) return null; String login = new String(raw, c, loginLen, StandardCharsets.UTF_8); c += loginLen; - + if (c >= recordLen) return null; int blocksCount = u8(raw, c++); + boolean isServer = false; String serverAddress = ""; - List syncServers = new ArrayList<>(); - for (int i = 0; i < blocksCount; i++) { + if (c + 2 > recordLen) return null; int blockType = u8(raw, c++); int blockVer = u8(raw, c++); if (blockVer != 0) return null; - - if (blockType == 0 || blockType == 1 || blockType == 2) { + if (blockType == 1 || blockType == 2) { + if (c + 32 > recordLen) return null; c += 32; - } else if (blockType == 3) { - int count = u8(raw, c++); - for (int j = 0; j < count; j++) { - c += 1; // blockchain_type - int bchLen = u8(raw, c++); - c += bchLen; - c += 32; // blockchain pubkey - c += 8; // paid_limit_bytes - c += 8; // used_bytes - c += 4; // last_block_number - c += 32; // last_block_hash - c += 64; // last_block_signature - int arweavePresent = u8(raw, c++); - if (arweavePresent == 1) { - int arLen = u8(raw, c++); - c += arLen; - } else if (arweavePresent != 0) { - return null; - } - } - } else if (blockType == 30) { - int isServerValue = u8(raw, c++); - if (isServerValue == 1) { - isServer = true; + continue; + } + if (c + 2 > recordLen) return null; + int payloadLen = u16le(raw, c); + c += 2; + int payloadEnd = c + payloadLen; + if (payloadEnd < c || payloadEnd > recordLen) return null; + + if (blockType == 30) { + if (c >= payloadEnd) return null; + int addressCount = u8(raw, c++); + if (addressCount != 1) return null; + for (int j = 0; j < addressCount; j++) { + if (c + 3 > payloadEnd) return null; c += 1; // address_format_type c += 1; // address_format_version - int addrLen = u8(raw, c++); - serverAddress = new String(raw, c, addrLen, StandardCharsets.UTF_8); - c += addrLen; - int syncCount = u8(raw, c++); - for (int j = 0; j < syncCount; j++) { - int n = u8(raw, c++); - String syncLogin = new String(raw, c, n, StandardCharsets.UTF_8); - c += n; - syncServers.add(normalizeLogin(syncLogin)); + int n = u8(raw, c++); + if (c + n > payloadEnd) return null; + String address = new String(raw, c, n, StandardCharsets.UTF_8); + c += n; + if (!isServer) { + isServer = true; + serverAddress = address; } - } else if (isServerValue != 0) { - return null; } - } else if (blockType == 40) { - int accessCount = u8(raw, c++); - for (int j = 0; j < accessCount; j++) { - int n = u8(raw, c++); - c += n; - } - } else if (blockType == 50) { - int sessionsMode = u8(raw, c++); - if (sessionsMode != 1 && sessionsMode != 10) return null; - int sessionsCount = u8(raw, c++); - if (sessionsCount > 64) return null; - for (int j = 0; j < sessionsCount; j++) { - c += 1; // session_type - c += 1; // session_version - int n = u8(raw, c++); - c += n; - c += 32; - } - } else if (blockType == 70) { - c += 1; - } else if (blockType == 100) { - c += ARCHIVE_HEAD_PAYLOAD_BYTES; - } else { - return null; + if (c != payloadEnd) return null; } - - if (c > recordLen) return null; + c = payloadEnd; } - - return new ParsedServerProfile(login, isServer, serverAddress, syncServers); + return new ParsedServerProfile(login, isServer, serverAddress, List.of()); } + private static String normalizeLogin(String login) { if (login == null) return null; String s = login.trim(); @@ -400,6 +343,12 @@ public final class SolanaUserPdaImportService { private static int u16le(byte[] b, int o) { return (b[o] & 0xFF) | ((b[o + 1] & 0xFF) << 8); } + private static long u32le(byte[] b, int o) { + return ((long) b[o] & 0xFFL) + | (((long) b[o + 1] & 0xFFL) << 8) + | (((long) b[o + 2] & 0xFFL) << 16) + | (((long) b[o + 3] & 0xFFL) << 24); + } private static long u64le(byte[] b, int o) { long out = 0L; for (int i = 0; i < 8; i++) out |= ((long) (b[o + i] & 0xFF)) << (8 * i); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java index 1f1a9e55..917fd4c5 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java @@ -63,7 +63,8 @@ public final class AddBlockSyncService { } private static boolean isEnabled() { - return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true); + // 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 { diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java index 0b17c1e6..a3ab87f5 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java @@ -26,6 +26,9 @@ public final class ShineUsersCodec { public static final int IX_UPDATE_USER_PDA = 4; + public static final int IX_CLOSE_LEGACY_USER_PDA = + 6; + public static final long START_REGISTRATION_FEE_LAMPORTS = 10_000_000L; @@ -149,15 +152,23 @@ public final class ShineUsersCodec { ); case IX_CREATE_USER_PDA -> - parseCreateInstruction( - reader, - accounts + parseV12PdaMutationInstruction( + TxKind.CREATE_USER_PDA, reader, accounts ); case IX_UPDATE_USER_PDA -> - parseUpdateInstruction( - reader, - accounts + parseV12PdaMutationInstruction( + TxKind.UPDATE_USER_PDA, reader, accounts + ); + + case IX_CLOSE_LEGACY_USER_PDA -> + new ParsedInstruction( + TxKind.CLOSE_LEGACY_USER_PDA, + false, + null, + reader.readStringU8(), + null, + null ); default -> @@ -203,946 +214,131 @@ public final class ShineUsersCodec { ); } - int recordLen = - u16le( - raw, - 7 - ); - - if (recordLen > raw.length) { - throw new IllegalArgumentException( - "Invalid PDA record length" - ); + int formatMajor = raw[5] & 0xFF; + int formatMinor = raw[6] & 0xFF; + if (formatMajor != 1 || formatMinor != 2) { + throw new IllegalArgumentException("Unsupported PDA format " + formatMajor + "." + formatMinor); } + return parseUserPdaAccountV12(pdaAddress, slot, rawDataBase64, lastTxSignature, raw); + } - byte[] useful = - new byte[recordLen]; - - System.arraycopy( - raw, - 0, - useful, - 0, - recordLen + private static ParsedInstruction parseV12PdaMutationInstruction( + TxKind kind, + Reader reader, + List accounts + ) { + String login = reader.readStringU8(); + return new ParsedInstruction( + kind, + true, + accountAt(accounts, 1), + login, + null, + null ); + } - Reader reader = - new Reader(useful); - + private static UserPdaSnapshot parseUserPdaAccountV12( + String pdaAddress, + long slot, + String rawDataBase64, + String lastTxSignature, + byte[] raw + ) { + int recordLen = u16le(raw, 7); + if (recordLen < 9 + 64 || recordLen > raw.length) { + throw new IllegalArgumentException("Invalid PDA 1.2 record length"); + } + byte[] useful = new byte[recordLen]; + System.arraycopy(raw, 0, useful, 0, recordLen); + Reader reader = new Reader(useful); reader.skip(9); + long createdAtMs = reader.readU64(); + long updatedAtMs = reader.readU64(); + int recordNumber = (int) reader.readU32(); + String prevHash = toHex(reader.readFixed(32)); + String login = reader.readStringU8(); + int blocksCount = reader.readU8(); - long createdAtMs = - reader.readU64(); - - long updatedAtMs = - reader.readU64(); - - int recordNumber = - (int) reader.readU32(); - - byte[] prevHash = - reader.readFixed(32); - - String login = - reader.readStringU8(); - - int blocksCount = - reader.readU8(); - - String recoveryKey = - null; - - String rootKey = - null; - - String clientKey = - null; - - String blockchainName = - null; - - String blockchainKey = - null; - - long paidLimitBytes = - 0L; - - long usedBytes = - 0L; - - int lastBlockNumber = - 0; - - String lastBlockHash = - ""; - - String lastBlockSignature = - ""; - - String arweaveTxId = - ""; - - boolean isServer = - false; - - int addressFormatType = - 0; - - int addressFormatVersion = - 0; - - String serverAddress = - ""; - - List syncServers = - new ArrayList<>(); - - List accessServers = - new ArrayList<>(); - - int sessionsMode = - 1; - - List sessions = - new ArrayList<>(); - - int trustedCount = - 0; - - String archiveHeadTxId = - ""; - - String archiveHeadHash = - ""; + String rootKey = null; + String clientKey = null; + String blockchainKey = null; + long paidLimitBytes = 0L; + int forkCount = 0; + List blockchainForks = new ArrayList<>(); + boolean isServer = false; + int addressFormatType = 0; + int addressFormatVersion = 0; + String serverAddress = ""; + List accessServers = new ArrayList<>(); for (int i = 0; i < blocksCount; i++) { - - int blockType = - reader.readU8(); - - int blockVersion = - reader.readU8(); - - if (blockVersion != BLOCK_VERSION_0) { - throw new IllegalArgumentException( - "Unsupported block version" - ); + int blockType = reader.readU8(); + int blockVersion = reader.readU8(); + if (blockType == BLOCK_TYPE_ROOT_KEY) { + if (blockVersion != BLOCK_VERSION_0 || rootKey != null) throw new IllegalArgumentException("Invalid RootKeyBlock"); + rootKey = Base58Util.encode(reader.readFixed(32)); + continue; } - - switch (blockType) { - - case BLOCK_TYPE_RECOVERY_KEY -> - recoveryKey = - Base58Util.encode( - reader.readFixed(32) - ); - - case BLOCK_TYPE_ROOT_KEY -> - rootKey = - Base58Util.encode( - reader.readFixed(32) - ); - - case BLOCK_TYPE_CLIENT_KEY -> - clientKey = - Base58Util.encode( - reader.readFixed(32) - ); - - case BLOCK_TYPE_BLOCKCHAIN_REGISTRY -> { - int count = - reader.readU8(); - - if (count != 1) { - throw new IllegalArgumentException( - "Unexpected blockchain count" - ); - } - - int blockchainType = - reader.readU8(); - - if (blockchainType != BLOCKCHAIN_TYPE_MAIN_USER) { - throw new IllegalArgumentException( - "Unsupported blockchain type" - ); - } - - blockchainName = - reader.readStringU8(); - - blockchainKey = - Base58Util.encode( - reader.readFixed(32) - ); - - paidLimitBytes = - reader.readU64(); - - usedBytes = - reader.readU64(); - - lastBlockNumber = - (int) reader.readU32(); - - lastBlockHash = - toHex( - reader.readFixed(32) - ); - - lastBlockSignature = - Base58Util.encode( - reader.readFixed(64) - ); - - int arweavePresent = - reader.readU8(); - - if (arweavePresent == 1) { - arweaveTxId = - reader.readStringU8(); - } else if (arweavePresent != 0) { - throw new IllegalArgumentException( - "Invalid arweave marker" - ); - } - } - - case BLOCK_TYPE_SERVER_PROFILE -> { - isServer = - reader.readU8() == 1; - - if (isServer) { - addressFormatType = - reader.readU8(); - addressFormatVersion = - reader.readU8(); - serverAddress = - reader.readStringU8(); - int syncCount = - reader.readU8(); - for (int j = 0; j < syncCount; j++) { - syncServers.add( - reader.readStringU8() - ); - } - } - } - - case BLOCK_TYPE_ACCESS_SERVERS -> { - int accessCount = - reader.readU8(); - for (int j = 0; j < accessCount; j++) { - accessServers.add( - reader.readStringU8() - ); - } - } - - case BLOCK_TYPE_SESSIONS -> { - sessionsMode = - reader.readU8(); - - int sessionCount = - reader.readU8(); - - for (int j = 0; j < sessionCount; j++) { - sessions.add( - new UserSessionSnapshot( - reader.readU8(), - reader.readU8(), - reader.readStringU8(), - Base58Util.encode( - reader.readFixed(32) - ) - ) - ); - } - } - - case BLOCK_TYPE_TRUSTED_STATE -> - trustedCount = - reader.readU8(); - - case BLOCK_TYPE_ARCHIVE_HEAD -> { - archiveHeadTxId = - Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); - archiveHeadHash = - toHex(reader.readFixed(32)); - } - - default -> - throw new IllegalArgumentException( - "Unsupported block type: " + blockType - ); + if (blockType == BLOCK_TYPE_CLIENT_KEY) { + if (blockVersion != BLOCK_VERSION_0 || clientKey != null) throw new IllegalArgumentException("Invalid ClientKeyBlock"); + clientKey = Base58Util.encode(reader.readFixed(32)); + continue; } + int payloadLen = reader.readU16(); + int payloadEnd = reader.cursor + payloadLen; + if (payloadEnd > recordLen - 64) throw new IllegalArgumentException("Invalid variable block length"); + if (blockVersion == BLOCK_VERSION_0 && blockType == BLOCK_TYPE_BLOCKCHAIN_REGISTRY) { + int count = reader.readU16(); + if (count < 1) throw new IllegalArgumentException("Empty BlockchainRegistry"); + forkCount = count; + for (int j = 0; j < count; j++) { + blockchainKey = Base58Util.encode(reader.readFixed(32)); + long forkCreatedAtMs = reader.readU64(); + paidLimitBytes = reader.readU32(); + blockchainForks.add(new BlockchainForkSnapshot( + j, blockchainKey, forkCreatedAtMs, paidLimitBytes + )); + } + } else if (blockVersion == BLOCK_VERSION_0 && blockType == BLOCK_TYPE_SERVER_PROFILE) { + int count = reader.readU8(); + if (count != 1) throw new IllegalArgumentException("PDA 1.2 supports exactly one server address"); + isServer = true; + for (int j = 0; j < count; j++) { + int type = reader.readU8(); + int version = reader.readU8(); + String address = reader.readStringU8(); + if (j == 0) { + addressFormatType = type; + addressFormatVersion = version; + serverAddress = address; + } + } + } else if (blockVersion == BLOCK_VERSION_0 && blockType == BLOCK_TYPE_ACCESS_SERVERS) { + int count = reader.readU8(); + if (count > 1) throw new IllegalArgumentException("PDA 1.2 supports at most one access server"); + for (int j = 0; j < count; j++) accessServers.add(reader.readStringU8()); + } + reader.cursor = payloadEnd; } - - String signature = - Base58Util.encode( - reader.readFixed(64) - ); - + String signature = Base58Util.encode(reader.readFixed(64)); + if (rootKey == null || clientKey == null || blockchainKey == null) { + throw new IllegalArgumentException("PDA 1.2 misses required blocks"); + } + String blockchainName = normalizeLogin(login) + "-" + String.format("%03d", forkCount); return new UserPdaSnapshot( - pdaAddress, - login, - recordNumber, - slot, - lastTxSignature, - recoveryKey, - rootKey, - clientKey, - blockchainName, - blockchainKey, - paidLimitBytes, - usedBytes, - lastBlockNumber, - lastBlockHash, - lastBlockSignature, - arweaveTxId, - archiveHeadTxId, - archiveHeadHash, - isServer, - addressFormatType, - addressFormatVersion, - serverAddress, - List.copyOf(syncServers), - List.copyOf(accessServers), - sessionsMode, - List.copyOf(sessions), - trustedCount, - createdAtMs, - updatedAtMs, - toHex(prevHash), - signature, - rawDataBase64 + pdaAddress, login, recordNumber, slot, lastTxSignature, + "", rootKey, clientKey, blockchainName, blockchainKey, paidLimitBytes, + 0L, 0, "", "", "", "", "", + isServer, addressFormatType, addressFormatVersion, serverAddress, + List.of(), List.copyOf(accessServers), 1, List.of(), 0, + createdAtMs, updatedAtMs, prevHash, signature, rawDataBase64, + List.copyOf(blockchainForks) ); } - public static UserPdaSnapshot buildCreateSnapshot( - UserPdaMutation mutation, - EconomyConfigState economyConfigState, - String txSignature, - long slot - ) { - - long paidLimitBytes = - economyConfigState.startBonusLimit() - + mutation.additionalLimit(); - - return buildSnapshot( - mutation, - 0, - slot, - txSignature, - mutation.createdAtMs(), - mutation.createdAtMs(), - toHex(ZERO_HASH), - paidLimitBytes, - mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadTxId() : "", - mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadHash() : "" - ); - } - - public static UserPdaSnapshot buildUpdateSnapshot( - UserPdaMutation mutation, - UserPdaSnapshot previous, - String txSignature, - long slot - ) { - - long paidLimitBytes = - previous.paidLimitBytes() - + mutation.additionalLimit(); - - String archiveTx = mutation.fields().archiveHeadSupplied() - ? mutation.fields().archiveHeadTxId() - : previous.archiveHeadTxId(); - String archiveHash = mutation.fields().archiveHeadSupplied() - ? mutation.fields().archiveHeadHash() - : previous.archiveHeadHash(); - - return buildSnapshot( - mutation, - mutation.version(), - slot, - txSignature, - mutation.createdAtMs(), - mutation.updatedAtMs(), - toHex(mutation.prevHash()), - paidLimitBytes, - archiveTx, - archiveHash - ); - } - - public static String serializeSnapshotToBase64( - UserPdaSnapshot snapshot - ) { - - byte[] recoveryKey = - Base58Util.decode( - snapshot.recoveryKey() - ); - - byte[] rootKey = - Base58Util.decode( - snapshot.rootKey() - ); - - byte[] clientKey = - Base58Util.decode( - snapshot.clientKey() - ); - - byte[] blockchainKey = - Base58Util.decode( - snapshot.blockchainKey() - ); - - byte[] lastBlockHash = - fromHex( - snapshot.lastBlockHash() - ); - - byte[] lastBlockSignature = - Base58Util.decode( - snapshot.lastBlockSignature() - ); - - byte[] prevHash = - fromHex( - snapshot.prevRecordHash() - ); - - byte[] recordSignature = - Base58Util.decode( - snapshot.recordSignature() - ); - - List syncServers = - snapshot.syncServers(); - - List accessServers = - snapshot.accessServers(); - - List sessions = - snapshot.sessions(); - - byte[] loginBytes = - snapshot.login() - .getBytes(StandardCharsets.UTF_8); - - List out = - new ArrayList<>(); - - pushFixed(out, MAGIC.getBytes(StandardCharsets.UTF_8)); - out.add((byte) 1); - out.add((byte) 0); - pushU16(out, 0); - pushU64(out, snapshot.createdAtMs()); - pushU64(out, snapshot.updatedAtMs()); - pushU32(out, snapshot.recordNumber()); - pushFixed(out, prevHash); - pushStringU8(out, loginBytes); - - boolean hasArchiveHead = - snapshot.archiveHeadTxId() != null - && !snapshot.archiveHeadTxId().isBlank(); - - int blocksCount = - (snapshot.isServer() ? 8 : 7) - + (hasArchiveHead ? 1 : 0); - - out.add((byte) blocksCount); - - out.add((byte) BLOCK_TYPE_RECOVERY_KEY); - out.add((byte) BLOCK_VERSION_0); - pushFixed(out, recoveryKey); - - out.add((byte) BLOCK_TYPE_ROOT_KEY); - out.add((byte) BLOCK_VERSION_0); - pushFixed(out, rootKey); - - out.add((byte) BLOCK_TYPE_CLIENT_KEY); - out.add((byte) BLOCK_VERSION_0); - pushFixed(out, clientKey); - - out.add((byte) BLOCK_TYPE_BLOCKCHAIN_REGISTRY); - out.add((byte) BLOCK_VERSION_0); - out.add((byte) 1); - out.add((byte) BLOCKCHAIN_TYPE_MAIN_USER); - pushString(out, snapshot.blockchainName()); - pushFixed(out, blockchainKey); - pushU64(out, snapshot.paidLimitBytes()); - pushU64(out, snapshot.usedBytes()); - pushU32(out, snapshot.lastBlockNumber()); - pushFixed(out, lastBlockHash); - pushFixed(out, lastBlockSignature); - - if (snapshot.arweaveTxId().isBlank()) { - out.add((byte) 0); - } else { - out.add((byte) 1); - pushString(out, snapshot.arweaveTxId()); - } - - if (snapshot.isServer()) { - out.add((byte) BLOCK_TYPE_SERVER_PROFILE); - out.add((byte) BLOCK_VERSION_0); - out.add((byte) 1); - out.add((byte) snapshot.addressFormatType()); - out.add((byte) snapshot.addressFormatVersion()); - pushString(out, snapshot.serverAddress()); - out.add((byte) syncServers.size()); - for (String syncServer : syncServers) { - pushString(out, syncServer); - } - } - - out.add((byte) BLOCK_TYPE_ACCESS_SERVERS); - out.add((byte) BLOCK_VERSION_0); - out.add((byte) accessServers.size()); - for (String accessServer : accessServers) { - pushString(out, accessServer); - } - - out.add((byte) BLOCK_TYPE_SESSIONS); - out.add((byte) BLOCK_VERSION_0); - out.add((byte) snapshot.sessionsMode()); - out.add((byte) sessions.size()); - for (UserSessionSnapshot session : sessions) { - out.add((byte) session.sessionType()); - out.add((byte) session.sessionVersion()); - pushString(out, session.sessionName()); - pushFixed( - out, - Base58Util.decode( - session.sessionPubKey() - ) - ); - } - - out.add((byte) BLOCK_TYPE_TRUSTED_STATE); - out.add((byte) BLOCK_VERSION_0); - out.add((byte) snapshot.trustedCount()); - - if (hasArchiveHead) { - byte[] txId = Base64.getUrlDecoder().decode(snapshot.archiveHeadTxId()); - byte[] archiveHash = fromHex(snapshot.archiveHeadHash()); - if (txId.length != 32 || archiveHash.length != 32) { - throw new IllegalArgumentException("Archive head должен содержать txId/hash по 32 байта"); - } - out.add((byte) BLOCK_TYPE_ARCHIVE_HEAD); - out.add((byte) BLOCK_VERSION_0); - pushFixed(out, txId); - pushFixed(out, archiveHash); - } - - int recordLength = - out.size() + 64; - - byte[] recordLengthBytes = - new byte[]{ - (byte) (recordLength & 0xFF), - (byte) ((recordLength >> 8) & 0xFF) - }; - - out.set(7, recordLengthBytes[0]); - out.set(8, recordLengthBytes[1]); - - pushFixed(out, recordSignature); - - byte[] bytes = - new byte[out.size()]; - - for (int i = 0; i < out.size(); i++) { - bytes[i] = out.get(i); - } - - return Base64.getEncoder() - .encodeToString(bytes); - } - - private static ParsedInstruction parseCreateInstruction( - Reader reader, - List accounts - ) { - - CreateOrUpdateArgs args = - parseCreateArgs(reader); - - return new ParsedInstruction( - TxKind.CREATE_USER_PDA, - true, - accountAt( - accounts, - 1 - ), - args.login(), - null, - new UserPdaMutation( - true, - accountAt( - accounts, - 1 - ), - args.login(), - args.recoveryKey(), - args.rootKey(), - args.createdAtMs(), - args.createdAtMs(), - 0, - ZERO_HASH.clone(), - args.additionalLimit(), - args.fields(), - args.recordSignature() - ) - ); - } - - private static ParsedInstruction parseUpdateInstruction( - Reader reader, - List accounts - ) { - - UpdateArgs args = - parseUpdateArgs(reader); - - return new ParsedInstruction( - TxKind.UPDATE_USER_PDA, - true, - accountAt( - accounts, - 1 - ), - args.login(), - null, - new UserPdaMutation( - false, - accountAt( - accounts, - 1 - ), - args.login(), - args.recoveryKey(), - args.rootKey(), - args.createdAtMs(), - args.updatedAtMs(), - args.version(), - args.prevHash(), - args.additionalLimit(), - args.fields(), - args.recordSignature() - ) - ); - } - - private static CreateOrUpdateArgs parseCreateArgs( - Reader reader - ) { - - String login = - reader.readStringU8(); - - String recoveryKey = - Base58Util.encode( - reader.readFixed(32) - ); - - String rootKey = - Base58Util.encode( - reader.readFixed(32) - ); - - long createdAtMs = - reader.readU64(); - - long additionalLimit = - reader.readU64(); - - UserFields fields = - parseFields(reader); - - String recordSignature = - Base58Util.encode( - reader.readFixed(64) - ); - - return new CreateOrUpdateArgs( - login, - recoveryKey, - rootKey, - createdAtMs, - additionalLimit, - fields, - recordSignature - ); - } - - private static UpdateArgs parseUpdateArgs( - Reader reader - ) { - - String login = - reader.readStringU8(); - - String recoveryKey = - Base58Util.encode( - reader.readFixed(32) - ); - - String rootKey = - Base58Util.encode( - reader.readFixed(32) - ); - - long createdAtMs = - reader.readU64(); - - long updatedAtMs = - reader.readU64(); - - int version = - (int) reader.readU32(); - - byte[] prevHash = - reader.readFixed(32); - - long additionalLimit = - reader.readU64(); - - UserFields fields = - parseFields(reader); - - fields = parseOptionalArchiveExtension(reader, fields); - - String recordSignature = - Base58Util.encode( - reader.readFixed(64) - ); - - return new UpdateArgs( - login, - recoveryKey, - rootKey, - createdAtMs, - updatedAtMs, - version, - prevHash, - additionalLimit, - fields, - recordSignature - ); - } - - private static UserFields parseFields( - Reader reader - ) { - - String clientKey = - Base58Util.encode( - reader.readFixed(32) - ); - - String blockchainKey = - Base58Util.encode( - reader.readFixed(32) - ); - - String blockchainName = - reader.readStringU8(); - - long usedBytes = - reader.readU64(); - - int lastBlockNumber = - (int) reader.readU32(); - - String lastBlockHash = - toHex( - reader.readFixed(32) - ); - - String lastBlockSignature = - Base58Util.encode( - reader.readFixed(64) - ); - - String arweaveTxId = - reader.readStringU8(); - - boolean isServer = - reader.readU8() == 1; - - int addressFormatType = - 0; - int addressFormatVersion = - 0; - String serverAddress = - ""; - List syncServers = - new ArrayList<>(); - - if (isServer) { - addressFormatType = - reader.readU8(); - addressFormatVersion = - reader.readU8(); - serverAddress = - reader.readStringU8(); - int syncCount = - reader.readU8(); - for (int i = 0; i < syncCount; i++) { - syncServers.add( - reader.readStringU8() - ); - } - } - - int accessCount = - reader.readU8(); - - List accessServers = - new ArrayList<>(); - - for (int i = 0; i < accessCount; i++) { - accessServers.add( - reader.readStringU8() - ); - } - - int sessionsMode = - reader.readU8(); - - int sessionsCount = - reader.readU8(); - - List sessions = - new ArrayList<>(); - - for (int i = 0; i < sessionsCount; i++) { - sessions.add( - new UserSessionSnapshot( - reader.readU8(), - reader.readU8(), - reader.readStringU8(), - Base58Util.encode( - reader.readFixed(32) - ) - ) - ); - } - - int trustedCount = - reader.readU8(); - - return new UserFields( - clientKey, - blockchainKey, - blockchainName, - usedBytes, - lastBlockNumber, - lastBlockHash, - lastBlockSignature, - arweaveTxId, - isServer, - addressFormatType, - addressFormatVersion, - serverAddress, - List.copyOf(syncServers), - List.copyOf(accessServers), - sessionsMode, - List.copyOf(sessions), - trustedCount, - false, - "", - "" - ); - } - - private static UserFields parseOptionalArchiveExtension(Reader reader, UserFields fields) { - if (reader.remaining() == 64) { - return fields; - } - if (reader.remaining() < 65) { - throw new IllegalArgumentException("Некорректная archive extension в update_user_pda"); - } - int present = reader.readU8(); - String tx = ""; - String hash = ""; - if (present == 1) { - tx = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); - hash = toHex(reader.readFixed(32)); - } else if (present != 0) { - throw new IllegalArgumentException("Некорректный archive head marker: " + present); - } - return new UserFields( - fields.clientKey(), fields.blockchainKey(), fields.blockchainName(), fields.usedBytes(), - fields.lastBlockNumber(), fields.lastBlockHash(), fields.lastBlockSignature(), fields.arweaveTxId(), - fields.isServer(), fields.addressFormatType(), fields.addressFormatVersion(), fields.serverAddress(), - fields.syncServers(), fields.accessServers(), fields.sessionsMode(), fields.sessions(), fields.trustedCount(), - true, tx, hash - ); - } - - private static UserPdaSnapshot buildSnapshot( - UserPdaMutation mutation, - int recordNumber, - long slot, - String txSignature, - long createdAtMs, - long updatedAtMs, - String prevRecordHash, - long paidLimitBytes, - String archiveHeadTxId, - String archiveHeadHash - ) { - - UserFields fields = - mutation.fields(); - - UserPdaSnapshot snapshot = - new UserPdaSnapshot( - mutation.pdaAddress(), - mutation.login(), - recordNumber, - slot, - txSignature, - mutation.recoveryKey(), - mutation.rootKey(), - fields.clientKey(), - fields.blockchainName(), - fields.blockchainKey(), - paidLimitBytes, - fields.usedBytes(), - fields.lastBlockNumber(), - fields.lastBlockHash(), - fields.lastBlockSignature(), - fields.arweaveTxId(), - archiveHeadTxId == null ? "" : archiveHeadTxId, - archiveHeadHash == null ? "" : archiveHeadHash, - fields.isServer(), - fields.addressFormatType(), - fields.addressFormatVersion(), - fields.serverAddress(), - fields.syncServers(), - fields.accessServers(), - fields.sessionsMode(), - fields.sessions(), - fields.trustedCount(), - createdAtMs, - updatedAtMs, - prevRecordHash, - mutation.recordSignature(), - "" - ); - - return snapshot.withRawDataBase64( - serializeSnapshotToBase64( - snapshot - ) - ); + private static String normalizeLogin(String value) { + return value == null ? "" : value.trim().toLowerCase(java.util.Locale.ROOT); } private static List readInstructionAccounts( @@ -1289,6 +485,12 @@ public final class ShineUsersCodec { return data[cursor++] & 0xFF; } + private int readU16() { + int value = (data[cursor] & 0xFF) | ((data[cursor + 1] & 0xFF) << 8); + cursor += 2; + return value; + } + private long readU32() { long value = (data[cursor] & 0xFFL) @@ -1355,6 +557,7 @@ public final class ShineUsersCodec { UPDATE_USERS_ECONOMY_CONFIG, CREATE_USER_PDA, UPDATE_USER_PDA, + CLOSE_LEGACY_USER_PDA, OTHER } @@ -1392,68 +595,14 @@ public final class ShineUsersCodec { ) { } - public record UserFields( - String clientKey, + public record UserPdaMutation() { + } + + public record BlockchainForkSnapshot( + int forkIndex, String blockchainKey, - String blockchainName, - long usedBytes, - int lastBlockNumber, - String lastBlockHash, - String lastBlockSignature, - String arweaveTxId, - boolean isServer, - int addressFormatType, - int addressFormatVersion, - String serverAddress, - List syncServers, - List accessServers, - int sessionsMode, - List sessions, - int trustedCount, - boolean archiveHeadSupplied, - String archiveHeadTxId, - String archiveHeadHash - ) { - } - - private record CreateOrUpdateArgs( - String login, - String recoveryKey, - String rootKey, long createdAtMs, - long additionalLimit, - UserFields fields, - String recordSignature - ) { - } - - private record UpdateArgs( - String login, - String recoveryKey, - String rootKey, - long createdAtMs, - long updatedAtMs, - int version, - byte[] prevHash, - long additionalLimit, - UserFields fields, - String recordSignature - ) { - } - - public record UserPdaMutation( - boolean create, - String pdaAddress, - String login, - String recoveryKey, - String rootKey, - long createdAtMs, - long updatedAtMs, - int version, - byte[] prevHash, - long additionalLimit, - UserFields fields, - String recordSignature + long paidLimitBytes ) { } @@ -1489,7 +638,8 @@ public final class ShineUsersCodec { long updatedAtMs, String prevRecordHash, String recordSignature, - String rawDataBase64 + String rawDataBase64, + List blockchainForks ) { public UserPdaSnapshot withRawDataBase64( String value @@ -1526,7 +676,8 @@ public final class ShineUsersCodec { updatedAtMs, prevRecordHash, recordSignature, - value + value, + blockchainForks ); } } diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java index ccfc16ff..c1c745da 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java @@ -415,9 +415,12 @@ public final class SolanaUsersSyncService List envelopes = new ArrayList<>(); - Set updatePdaAddresses = + Set exactFetchPdaAddresses = new LinkedHashSet<>(); + Map latestRelevantSignatureByPda = + new LinkedHashMap<>(); + for (SolanaRpcClient.SignatureRecord signatureRecord : chronologicalSignatures) { JsonNode transaction = @@ -436,20 +439,14 @@ public final class SolanaUsersSyncService ); if (envelope.parsedInstruction() != null - && envelope.parsedInstruction().kind() == ShineUsersCodec.TxKind.UPDATE_USER_PDA - && envelope.parsedInstruction().affectedPdaAddress() != null) { - updatePdaAddresses.add( - envelope.parsedInstruction() - .affectedPdaAddress() - ); + && envelope.parsedInstruction().affectedPdaAddress() != null + && envelope.parsedInstruction().relevant()) { + String changedPda = envelope.parsedInstruction().affectedPdaAddress(); + latestRelevantSignatureByPda.put(changedPda, envelope.signatureRecord().signature()); + exactFetchPdaAddresses.add(changedPda); } } - Map currentSnapshots = - storage.getCurrentSnapshots( - updatePdaAddresses - ); - List txEntries = new ArrayList<>(); @@ -492,71 +489,16 @@ public final class SolanaUsersSyncService parsedInstruction.economyConfigState(); } - if (parsedInstruction.relevant() - && parsedInstruction.userPdaMutation() != null) { + if (parsedInstruction.relevant()) { relevant = true; affectedPdaAddress = parsedInstruction.affectedPdaAddress(); 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(); } } @@ -579,6 +521,25 @@ public final class SolanaUsersSyncService fetchResult.signatures() .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 = new PostgresStorageRepository.SyncStateSnapshot( "READY", diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index c8cbf1aa..7a1b4d1e 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -462,7 +462,7 @@ public final class PostgresStorageRepository "INSERT INTO solana_user_pda_current (" + "pda_address, login, normalized_login, record_number, slot, last_tx_signature, " + "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, " + "arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, address_format_type, " + "address_format_version, server_address, sync_servers_json, " + @@ -470,7 +470,7 @@ public final class PostgresStorageRepository "trusted_count, created_at_ms, updated_at_ms, " + "prev_record_hash, record_signature, raw_data_base64, " + "first_seen_at_ms, last_synced_at_ms" + - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (pda_address) DO UPDATE SET " + "login = EXCLUDED.login, " + "normalized_login = EXCLUDED.normalized_login, " + @@ -483,6 +483,7 @@ public final class PostgresStorageRepository "blockchain_name = EXCLUDED.blockchain_name, " + "blockchain_key = EXCLUDED.blockchain_key, " + "paid_limit_bytes = EXCLUDED.paid_limit_bytes, " + + "blockchain_forks_json = EXCLUDED.blockchain_forks_json, " + "used_bytes = EXCLUDED.used_bytes, " + "last_block_number = EXCLUDED.last_block_number, " + "last_block_hash = EXCLUDED.last_block_hash, " + @@ -595,6 +596,7 @@ public final class PostgresStorageRepository statement.setString(i++, snapshot.blockchainName()); statement.setString(i++, snapshot.blockchainKey()); statement.setLong(i++, snapshot.paidLimitBytes()); + statement.setString(i++, writeJson(snapshot.blockchainForks())); statement.setLong(i++, snapshot.usedBytes()); statement.setInt(i++, snapshot.lastBlockNumber()); statement.setString(i++, snapshot.lastBlockHash()); @@ -652,6 +654,17 @@ public final class PostgresStorageRepository ) ); + String forksJson = resultSet.getString("blockchain_forks_json"); + List blockchainForks = + forksJson == null || forksJson.isBlank() + ? List.of() + : Arrays.asList( + mapper.readValue( + forksJson, + ShineUsersCodec.BlockchainForkSnapshot[].class + ) + ); + return new ShineUsersCodec.UserPdaSnapshot( resultSet.getString("pda_address"), resultSet.getString("login"), @@ -684,7 +697,8 @@ public final class PostgresStorageRepository resultSet.getLong("updated_at_ms"), resultSet.getString("prev_record_hash"), resultSet.getString("record_signature"), - resultSet.getString("raw_data_base64") + resultSet.getString("raw_data_base64"), + List.copyOf(blockchainForks) ); } @@ -836,6 +850,7 @@ public final class PostgresStorageRepository "blockchain_name TEXT NOT NULL, " + "blockchain_key TEXT NOT NULL, " + "paid_limit_bytes BIGINT NOT NULL, " + + "blockchain_forks_json TEXT NOT NULL DEFAULT '[]', " + "used_bytes BIGINT NOT NULL, " + "last_block_number INTEGER NOT NULL, " + "last_block_hash TEXT NOT NULL, " + @@ -872,6 +887,7 @@ public final class PostgresStorageRepository " 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_hash TEXT NOT NULL DEFAULT ''"); diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java index 0ed5955d..9fdc7d15 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java @@ -74,7 +74,8 @@ public final class PeriodicBlockchainSyncService { } private static boolean isEnabled() { - return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true); + // PDA 1.2 удаляет sync_servers: пользовательские блокчейны синхронизируются через Arweave. + return false; } private static void runCycleSafe() { diff --git a/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java b/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java index a7fe1e4c..804d83b4 100644 --- a/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java +++ b/SHiNE-server/src/main/java/server/sync/SyncServersBootstrapService.java @@ -22,54 +22,11 @@ public final class SyncServersBootstrapService { private SyncServersBootstrapService() {} public static void refreshFromSolanaOrLog() { - String serverLogin = normalize(AppConfig.getInstance().getParam(CONFIG_KEY)); - if (serverLogin == null) { - log.warn("Sync bootstrap skipped: параметр {} не задан", CONFIG_KEY); - return; - } - try { - SolanaUserPdaImportService.ParsedServerProfile own = - SolanaUserPdaImportService.fetchServerProfileByLogin(serverLogin); - 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 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); + SyncServersDAO.getInstance().replaceAll(List.of()); + log.info("Legacy sync_servers disabled: blockchain synchronization uses Arweave"); } 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); } } diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 12fe9f9e..cf011901 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -28,11 +28,11 @@ solana.users.sync.pollIntervalSeconds=300 sync.importUserProfileFromPartner.enabled=false # ------------------------------------------------------------ -# Прямая межсерверная синхронизация пользовательских блокчейнов -# через sync_servers: ListBlockchainHeads/GetBlockchainBlock -> локальный AddBlock. -# Для проверки режима Solana + Arweave без прямых связей серверов можно выключить. +# Legacy peer-to-peer blockchain sync. В PDA 1.2 sync_servers удалён, +# пользовательские блокчейны синхронизируются через Arweave. +# Параметр оставлен временно для совместимости конфигураций и не активирует legacy sync. # ------------------------------------------------------------ -blockchain.sync.enabled=true +blockchain.sync.enabled=false # ------------------------------------------------------------ # Server public info @@ -138,14 +138,11 @@ test.freeAvatar.walletJwkPath= # ============================================================ # Arweave per-user-block transport (ANS-104) # Test namespace: each user DataItem is signed with App=test5590. -# Channel DataItems additionally contain c_test5590=. -# publish.mode: turbo | arweave | none +# Channel DataItems additionally contain c=. # ============================================================ -arweave.blocks.publish.mode=none +arweave.blocks.publish.enabled=false arweave.blocks.publish.intervalMinutes=15 arweave.blocks.publish.maxItems=10000 - -# Direct Arweave L1 fallback: server combines user DataItems into one standard ANS-104 bundle. arweave.blocks.publish.maxBundleBytes=134217728 arweave.blocks.publish.gateway=https://arweave.net arweave.blocks.publish.walletJwkPath= @@ -153,18 +150,10 @@ arweave.blocks.publish.minConfirmations=0 arweave.blocks.publish.confirmPollSeconds=30 arweave.blocks.publish.confirmTimeoutMinutes=180 -# Turbo: uploads each already user-signed DataItem separately, without re-signing it. -# paidByAddress is the public Turbo payer address. If it is empty and turbo.walletJwkPath is set, -# the Arweave payer address is derived locally from that JWK. The private key is never sent to Turbo. -# For paid uploads of someone else's signed DataItem, Turbo Credit Share Approval must exist for its signer. -arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx -arweave.blocks.publish.turbo.paidByAddress= -arweave.blocks.publish.turbo.walletJwkPath= - arweave.blocks.sync.enabled=false arweave.blocks.sync.intervalMinutes=15 arweave.blocks.sync.gateway=https://turbo-gateway.com arweave.blocks.sync.pageSize=100 arweave.blocks.sync.queueBatchSize=10000 arweave.blocks.sync.startBlockHeight=0 -arweave.blocks.sync.maxDataItemBytes=8388608 +arweave.blocks.sync.maxRootBundleBytes=268435456 diff --git a/VERSION.properties b/VERSION.properties index edd0c34e..aba3165e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.13.0 -server.version=1.11.0 +client.version=1.14.0 +server.version=1.12.0 diff --git a/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md index 66e80e0d..e8105a06 100644 --- a/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md +++ b/deploy/SOLANA_USERS_SYNC_SERVER_SETUP.md @@ -104,6 +104,12 @@ cp /path/to/SHiNE-product/application.properties ./application.properties ## Что пока остаётся как есть -- `sync_servers` сервер по-прежнему загружает из server PDA в Solana; -- runtime-сервер уже работает только с PostgreSQL; -- дальнейшим отдельным шагом остаются зачистка legacy-документации, переименования и перенос оставшихся прямых SQL-запросов в DAO/service. +- runtime-сервер работает с PostgreSQL; +- межсерверная доставка DM остаётся отдельным механизмом и не связана с blockchain sync; +- прямой 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. diff --git a/deploy/TEST_SERVERS.md b/deploy/TEST_SERVERS.md index 164f737c..b602ef1d 100644 --- a/deploy/TEST_SERVERS.md +++ b/deploy/TEST_SERVERS.md @@ -72,7 +72,7 @@ sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' ## Operational-нюанс -Общий Helius devnet endpoint тоже может начать ограничивать запросы, если несколько инстансов одновременно делают тяжёлый bootstrap. Если после деплоя какой-то инстанс не подтянул `sync_servers` или завис на startup sync, рестартовать сервисы по одному с паузой. +Общий Helius devnet endpoint тоже может начать ограничивать запросы, если несколько инстансов одновременно делают тяжёлый bootstrap. Если после деплоя какой-то инстанс завис на bootstrap Arweave/Solana sync, рестартовать сервисы по одному с паузой. Отдельный нюанс по web push: diff --git a/docs/API/16_Server_Connection_Pool_API.md b/docs/API/16_Server_Connection_Pool_API.md index 333ca2ab..5589748c 100644 --- a/docs/API/16_Server_Connection_Pool_API.md +++ b/docs/API/16_Server_Connection_Pool_API.md @@ -55,7 +55,7 @@ - физическое соединение создаётся одно на `serverLogin`; - логические операции DM, settings и blockchain используют один WSS; - завершение `RemoteSyncSession` не закрывает физический сокет; -- известные peer берутся из `sync_servers` и первых действующих маршрутов +- известные peer берутся из актуальных server PDA/access-server маршрутов `user_access_servers_current`; - список перечитывается каждые 30 секунд; - при изменении URL соединение пересоздаётся; diff --git a/docs/Keys/DERIVATION.md b/docs/Keys/DERIVATION.md index e15b14c9..468994ce 100644 --- a/docs/Keys/DERIVATION.md +++ b/docs/Keys/DERIVATION.md @@ -100,8 +100,7 @@ homeserver.key:home-a -> ключ A homeserver.key:home-b -> ключ B ``` -Публичный ключ homeserver-а публикуется в `SessionsBlock` пользовательской PDA как -`session_pub_key` с `session_type = 100`, имя — в `session_name` (формат PDA §13). +В PDA 1.2 `SessionsBlock` удалён, поэтому homeserver-ключи больше не публикуются через user PDA. Сама детерминированная деривация ключа сохранена как отдельный механизм устройства; способ его будущей публикации/авторизации определяется отдельно от PDA 1.2. > Это переименование прежней схемы `subserver.key:<имя>` → `homeserver.key:<имя>`. > Термин «саб-сервер» по проекту заменяется на «homeserver». @@ -130,8 +129,8 @@ homeserver.key:home-b -> ключ B - старый тестовый вариант; оставлен как legacy-скетч для сравнения и диагностики. ### Формат PDA (куда попадают ключи) -- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md` - — `RootKeyBlock` §6, `ClientKeyBlock` §7, `blockchain_public_key` §9, `SessionsBlock`/`session_type=100` §13, оплата §2.1. +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md` + — `RootKeyBlock`, `ClientKeyBlock`, append-only `BlockchainRegistryBlock` и экономика лимита. `SessionsBlock` в PDA 1.2 отсутствует. ### Сервер (тестовый seed) - `SHiNE-server/src/test/java/test/it/cases/SeedDataPopulationHelper.java` `deriveKeysFromPassword` (~246) — diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 5430673b..36bb8c59 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -14,11 +14,10 @@ - сервер проверяет формат, пользователей и подпись до сохранения; - повторная доставка одной ревизии идемпотентна; - более старая ревизия не заменяет новую; -- у каждого пользователя действует только access_servers[0]; -- дополнительные элементы старой PDA игнорируются без fallback; +- PDA 1.2 допускает максимум один access server; если он задан, используется единственная запись; +- старые PDA 1.0 не участвуют в текущем runtime-протоколе; - DM и настройки не реплицируются между access-серверами одного пользователя; -- sync_servers серверного PDA используются только для синхронизации - пользовательских блокчейнов. +- пользовательские blockchain синхронизируются через Arweave; `sync_servers` в PDA 1.2 отсутствует. ## 3. Типы DM diff --git a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md index f1020abf..0bcad4c4 100644 --- a/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md +++ b/docs/Solana/SOLANA_USERS_SYNC_MODULE_DESIGN.md @@ -567,3 +567,20 @@ SYNC_POLL_INTERVAL_SECONDS=300 7. добавить запись в `current` и `history`; 8. добавить periodic guard раз в 5 минут; 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 вычисляется как `-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. diff --git a/docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md index aa1b07b7..9d30ed2b 100644 --- a/docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -1,512 +1,20 @@ -# Solana user_pda: итоговый целевой формат пользовательской записи +# SHiNE User PDA -Документ описывает целевой формат пользовательской PDA-записи `user_pda` для Solana-программы `shine_users`. +Актуальный формат: **1.2**. -Это не формат основного блокчейна SHiNE и не документация по `AddBlock`. Основной блокчейн SHiNE описан отдельно в `docs/Blockchain/`. +Каноническая спецификация находится в: -Статус документа: итоговый согласованный формат, к которому приведены `create_user_pda`, `update_user_pda` и тестовый сериализатор Solana-модуля. +`shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md` -## 1. Назначение user_pda +Ключевые отличия от тестового 1.0: -`user_pda` хранит публичное состояние пользователя в Solana: +- удалены Recovery/Sessions/Trusted/ArchiveHead и `sync_servers`; +- root — cold recovery authority; +- последний blockchain key — обычный authority; +- BlockchainRegistry хранит append-only fork history: `key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; +- ServerProfile допускает один адрес в 1.2, AccessServers — 0/1 сервер; +- variable blocks имеют `payload_len:u16` для forward compatibility; +- create/update работают только с 1.2; +- legacy 1.0 можно только временно закрыть `close_legacy_pda` и вернуть lamports caller-у. -- логин пользователя; -- неизменяемые параметры создания записи; -- публичный recovery-ключ пользователя; -- корневой публичный ключ пользователя; -- клиентский публичный ключ пользователя; -- данные одного или нескольких пользовательских блокчейнов SHiNE; -- серверные данные пользователя, если пользователь выступает сервером; -- серверы доступа пользователя; -- счетчики/лимиты; -- подпись записи. - -На первом этапе поддерживается один пользовательский блокчейн SHiNE, но формат блока блокчейна сразу допускает повторение таких блоков в будущем. - -## 2. Адрес PDA - -Адрес пользовательской PDA вычисляется по логину: - -- seed prefix: `user_login=`; -- второй seed: нормализованный логин в нижнем регистре; -- program id: программа `shine_users`. - -Один логин соответствует одной `user_pda`. - -## 2.1. Кто оплачивает create/update PDA - -- Инструкции `create_user_pda` и `update_user_pda` оплачиваются с `client_key`. -- `root_key` используется для подписи unsigned части записи через Ed25519 instruction и не является fee payer. -- Для server PDA это правило то же самое: пополнять SOL нужно на адрес `client_key`. - -## 3. Общие правила кодирования - -- Числа кодируются в Little Endian. -- `u8`, `u16`, `u32`, `u64` имеют обычный фиксированный размер. -- Публичный ключ Solana/Ed25519: 32 байта. -- Ed25519-подпись: 64 байта. -- SHA-256/Solana hash: 32 байта. -- Строка переменной длины: `len: u8` + `bytes[len]` в UTF-8. -- Arweave `tx_id`: строка переменной длины. Ожидаемая практическая длина base64url tx id - 43 байта, но формат хранит длину явно. -- Все типизированные блоки после фиксированного заголовка начинаются с `block_type: u8` и `block_version: u8`. -- Отдельный `block_len` у типизированных блоков не хранится: блоки парсятся по известным полям, счетчикам и строкам с `len: u8`. - -## 4. Верхний формат записи - -Первые 9 полей фиксированы и идут строго в указанном порядке. Это общий заголовок записи. - -| N | Поле | Тип | Размер | Правило | -|---|------|-----|--------|---------| -| 1 | `magic` | bytes | 5 | Всегда `SHiNE`. | -| 2 | `format_major` | `u8` | 1 | Для первого формата: `1`. | -| 3 | `format_minor` | `u8` | 1 | Для первой версии нового формата: `0`. | -| 4 | `record_len` | `u16` | 2 | Длина полезной записи от `magic` до `signature` включительно, без padding. | -| 5 | `created_at_ms` | `u64` | 8 | Время создания записи, Unix time в миллисекундах. Не меняется. | -| 6 | `updated_at_ms` | `u64` | 8 | Время последнего обновления записи. | -| 7 | `record_number` | `u32` | 4 | Номер версии записи пользователя. При создании `0`, при обновлении +1. | -| 8 | `prev_record_hash` | bytes | 32 | Хэш unsigned-части предыдущей записи. При создании 32 нулевых байта. | -| 9 | `login` | string | `1 + len` | Логин пользователя. Не меняется. | - -После первых 9 полей идет набор типизированных блоков: - -```text -UserPdaRecordV1 -- fixed_header: поля 1..9 -- blocks_count: u8 -- blocks: TypedBlock[blocks_count] -- signature: [u8; 64] -- padding: bytes до размера PDA, если нужен -``` - -`blocks_count` входит в unsigned-часть записи и подписывается. - -## 5. Типы блоков - -Зарезервированные значения `block_type`: - -| block_type | Блок | Назначение | -|------------|------|------------| -| `0` | `RecoveryKeyBlock` | Ключ восстановления пользователя. | -| `1` | `RootKeyBlock` | Корневой ключ пользователя. | -| `2` | `ClientKeyBlock` | Клиентский ключ пользователя. | -| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. | -| `30` | `ServerProfileBlock` | Серверные данные пользователя. | -| `40` | `AccessServersBlock` | Серверы доступа/relay. | -| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | -| `70` | `TrustedStateBlock` | Счетчик trusted-связей. | -| `100` | `ArchiveHeadBlock` | Legacy/reserved. Новый per-block ANS-104 transport не использует это поле для пользовательской истории. | -| `255` | `ReservedBlock` | Зарезервировано, пока не используется. | - -Правила: - -- неизвестный `block_type` в `format_major = 1` считается ошибкой; -- обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; -- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`; -- каждый обязательный блок должен встречаться ровно один раз; -- порядок блоков в записи фиксируется для простоты проверки: - `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`. - -## 6. RecoveryKeyBlock - -Recovery-ключ нужен для будущих сценариев восстановления и ротации остальных ключей. В текущей версии он только публикуется в записи и не меняется через обычный `update_user_pda`. - -```text -RecoveryKeyBlock -- block_type: u8 = 0 -- block_version: u8 = 0 -- recovery_key: [u8; 32] -``` - -Правила: - -- при создании задается публичный recovery-ключ пользователя; -- при обновлении `recovery_key` должен совпадать с предыдущей записью; -- приватный `recovery.key` в PDA не хранится; -- отдельная ротация recovery-ключа будет отдельным форматом/сценарием в будущем. - -## 7. RootKeyBlock - -Смена `root_key` пока не проектируется и не реализуется. Блок фиксирует только стадию `0`. - -```text -RootKeyBlock -- block_type: u8 = 1 -- block_version: u8 = 0 -- root_key: [u8; 32] -``` - -Правила: - -- при создании задается корневой публичный ключ пользователя; -- при обновлении `root_key` должен совпадать с предыдущей записью; -- ротация root-key будет отдельным форматом/сценарием в будущем. - -## 8. ClientKeyBlock - -Смена `client_key` пока также не проектируется как отдельная ротация. В версии `0` хранится один клиентский ключ пользователя. - -```text -ClientKeyBlock -- block_type: u8 = 2 -- block_version: u8 = 0 -- client_key: [u8; 32] -``` - -Правила: - -- при создании задается текущий клиентский публичный ключ пользователя; -- при обновлении `client_key` должен совпадать с предыдущей записью; -- история устройств и несколько клиентских ключей в этом формате не хранятся. - -## 9. BlockchainRegistryBlock - -Блок хранит данные пользовательских блокчейнов SHiNE. Сейчас используется один блокчейн, но структура сразу сделана как список. - -```text -BlockchainRegistryBlock -- block_type: u8 = 3 -- block_version: u8 = 0 -- blockchain_count: u8 -- blockchain_records: BlockchainRecord[blockchain_count] -``` - -Правила: - -- на первом этапе `blockchain_count = 1`; -- в будущем можно увеличить количество записей без изменения смысла `BlockchainRecord`; -- каждый `BlockchainRecord` описывает один пользовательский SHiNE-блокчейн. - -## 10. BlockchainRecord - -```text -BlockchainRecord -- blockchain_type: u8 -- blockchain_name: string -- blockchain_public_key: [u8; 32] -- paid_limit_bytes: u64 -- used_bytes: u64 -- last_block_number: u32 -- last_block_hash: [u8; 32] -- last_block_signature: [u8; 64] -- arweave_present: u8 -- arweave_tx_id: string, только если arweave_present = 1 -``` - -`blockchain_type`: - -| Значение | Смысл | -|----------|-------| -| `1` | Основной пользовательский SHiNE-блокчейн. | - -Поля: - -- `blockchain_name` - строковое имя пользовательского блокчейна, например `login-001`. На первом этапе для основного блокчейна пользователя используется имя вида `-001`, потому что это первый блокчейн этого пользователя. -- `blockchain_public_key` - публичный ключ блокчейна пользователя. -- `paid_limit_bytes` - оплаченный лимит хранения/записей в байтах. -- `used_bytes` - сколько байт уже занято в пользовательском SHiNE-блокчейне. -- `last_block_number` - номер последнего известного блока пользовательского блокчейна. -- `last_block_hash` - хэш последнего известного блока. -- `last_block_signature` - подпись хэша специального сообщения о вершине блокчейна ключом `blockchain_public_key`. -- `arweave_present` - `0`, если ссылки нет; `1`, если ссылка есть. -- `arweave_tx_id` - Arweave transaction id, где лежит выгруженный пользовательский канал/состояние. - -Arweave `tx_id` - обычное поле внутри записи конкретного блокчейна. Solana-программа не проверяет, что такой Arweave transaction действительно существует и содержит корректные данные; это ответственность клиента/сервера/пользователя. - -## 11. Правила обновления BlockchainRecord - -При обновлении записи: - -- `blockchain_type` для существующей записи не меняется; -- `blockchain_public_key` пока не ротируется автоматически; смена ключа требует отдельного согласованного сценария; -- `paid_limit_bytes` может только увеличиваться или оставаться прежним; -- при увеличении `paid_limit_bytes` пользователь платит комиссию в Solana по тарифам программы; -- `used_bytes` может только увеличиваться или оставаться прежним; -- `last_block_number` может только увеличиваться или оставаться прежним; -- `used_bytes <= paid_limit_bytes`; -- если `last_block_number` увеличился, то должны быть переданы новый `last_block_hash` и новая `last_block_signature`; -- `last_block_signature` проверяется через Ed25519-инструкцию Solana: подпись должна соответствовать хэшу сообщения `LastBlockState` и `blockchain_public_key`; -- в транзакции `create_user_pda` / `update_user_pda` две Ed25519-инструкции должны идти непосредственно перед вызовом `shine_users`: сначала подпись `root_key`, затем подпись `blockchain_public_key`; -- `arweave_tx_id` можно добавить или заменить на новый, если пользователь выгрузил более актуальное состояние в Arweave; -- уменьшать лимит, число блоков или занятый размер нельзя. - -Сообщение `LastBlockState`, которое хэшируется и подписывается ключом `blockchain_public_key`: - -```text -LastBlockState -- constant: bytes = "SHiNE_LAST_BLOCK" -- login: string -- blockchain_name: string -- last_block_number: u32 -- last_block_hash: [u8; 32] -- used_bytes: u64 -``` - -Алгоритм: - -```text -message = SHA-256(LastBlockState bytes) -last_block_signature = Ed25519(blockchain_public_key, message) -``` - -Причина проверки подписи `LastBlockState`: `root_key` управляет Solana-записью пользователя, а `blockchain_public_key` подтверждает состояние конкретного пользовательского блокчейна. Подписывается не голый хэш, а связка логина, имени блокчейна, номера последнего блока, хэша последнего блока и занятого размера. - -## 12. ServerProfileBlock - -Блок присутствует, если пользователь выступает сервером. - -```text -ServerProfileBlock -- block_type: u8 = 30 -- block_version: u8 = 0 -- is_server: u8 -- address_format_type: u8, только если is_server = 1 -- address_format_version: u8, только если is_server = 1 -- server_address: string, только если is_server = 1 -- sync_servers_count: u8, только если is_server = 1 -- sync_servers: string[sync_servers_count], только если is_server = 1 -``` - -Правила: - -- `is_server = 0` означает, что серверных данных нет; -- `is_server = 1` означает, что пользователь публикует серверный профиль; -- `address_format_type` — тип формата адреса сервера: `1` = URL-строка (например `https://shineup.me/ws`); -- `address_format_version` — версия формата адреса, сейчас `0`; -- `sync_servers_count` максимум `32`; -- `server_address` - строковый адрес сервера в соответствии с `address_format_type`; -- `sync_servers` - логины SHiNE-пользователей, зарегистрированных как серверы, с которыми этот сервер синхронизирует серверные данные и пользовательские блокчейны SHiNE. Это server-to-server список партнёров самого серверного узла, а не список серверов доставки личных сообщений для обычного пользователя. Solana-программа не обязана проверять, что эти логины действительно зарегистрированы как серверы. - -## 13. AccessServersBlock - -Блок хранит серверы доступа/relay для пользователя. - -```text -AccessServersBlock -- block_type: u8 = 40 -- block_version: u8 = 0 -- access_servers_count: u8 -- access_servers: string[access_servers_count] -``` - -Правила: - -- блок может отсутствовать, если серверы доступа не заданы; -- список может обновляться при изменении маршрутизации пользователя; -- `access_servers` — массив логинов серверов доступа/relay. Формат массива - сохранён для совместимости, но текущая клиентская и серверная логика использует - только первый элемент `access_servers[0]`; -- остальные элементы игнорируются без fallback; -- официальный UI записывает ровно один сервер и позволяет только заменить его; -- Solana-программа не обязана проверять, что логин действительно зарегистрирован - как сервер. - -## 14. SessionsBlock - -Блок хранит опубликованные пользовательские сессии. На текущем этапе регистрация пользователя не добавляет туда записи автоматически, поэтому стандартный create/update продолжает работать с пустым списком. - -```text -SessionsBlock -- block_type: u8 = 50 -- block_version: u8 = 0 -- sessions_mode: u8 -- sessions_count: u8 -- sessions: SessionRecord[sessions_count] -``` - -`sessions_mode`: - -| Значение | Смысл | -|----------|-------| -| `1` | Можно использовать и сессии, зарегистрированные в PDA, и сессии, созданные вне PDA. | -| `10` | Зарезервировано на будущее: можно использовать только сессии, опубликованные в PDA. | - -Сейчас рабочий режим по умолчанию: `sessions_mode = 1`. Серверная логика пока не реализует особое поведение для `10`; это задел под будущее расширение. - -```text -SessionRecord -- session_type: u8 -- session_version: u8 -- session_name: string -- session_pub_key: [u8; 32] -``` - -`session_type`: - -| Значение | Смысл | -|----------|-------| -| `1` | Обычная пользовательская сессия. | -| `50` | Кошелёк пользователя. | -| `100` | Homeserver пользователя. | - -Правила: - -- максимум `64` записей на пользователя; -- `session_name` не пустой, максимум `64` байта; -- `session_name` может содержать только символы `[A-Za-z0-9_]`; -- `session_version` сейчас должна быть равна `1`; -- внутри одного блока должны быть уникальны и `session_name`, и `session_pub_key`; -- на текущем этапе UI и регистрация не обязаны добавлять туда записи автоматически. - -## 15. TrustedStateBlock - -Пока trusted-логика не реализована полностью, поэтому блок хранит только счетчик. - -```text -TrustedStateBlock -- block_type: u8 = 70 -- block_version: u8 = 0 -- trusted_count: u8 = 0 -``` - -Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. - -## 15.1. ArchiveHeadBlock - -Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA. - -```text -ArchiveHeadBlock -- block_type: u8 = 100 -- block_version: u8 = 0 -- archive_tx_id: [u8; 32] -- archive_hash: [u8; 32] -``` - -Семантика: - -- `archive_tx_id` — legacy/reserved поле старой archive-head схемы; новый per-block ANS-104 transport его не обновляет; -- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`; -- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head; -- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**; -- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет. - -`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи. - -## 16. Подпись user_pda - -Подписывается не вся PDA целиком, а unsigned-часть записи: - -- от `magic` до последнего байта последнего типизированного блока включительно; -- включая `record_len`, `blocks_count`, все заголовки блоков и тела блоков; -- без поля `signature`; -- без padding. - -Алгоритм: - -```text -message = hash(unsigned_record_bytes) -signature = Ed25519(root_key, message) -``` - -Solana-программа проверяет подпись через встроенную Ed25519-инструкцию. Подписантом должен быть `root_key` из `RootKeyBlock`. -Для `shine_users` эта инструкция должна стоять в транзакции сразу перед Ed25519-инструкцией `last_block_signature` и непосредственно перед самой `create/update`-инструкцией программы. - -Смену формата подписи сейчас не трогаем. - -## 17. Регистрация пользователя - -При регистрации: - -- PDA еще не должна существовать; -- логин проходит проверку формата и login guard; -- `record_number = 0`; -- `prev_record_hash = 0x00...00`; -- `created_at_ms = updated_at_ms`; -- обязательные блоки присутствуют; -- создается минимум один `BlockchainRecord`; -- новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`; -- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже; -- стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит; -- `used_bytes <= paid_limit_bytes`; -- пользователь платит регистрационную комиссию; -- если покупается дополнительный лимит, пользователь платит комиссию за этот лимит; -- вся unsigned-часть записи подписана `root_key`. - -## 18. Обновление пользователя - -При обновлении: - -- PDA должна существовать; -- `login`, `created_at_ms`, `recovery_key`, `root_key`, `client_key` не меняются; -- `record_number = previous_record_number + 1`; -- `prev_record_hash` равен хэшу unsigned-части предыдущей записи; -- `updated_at_ms` обновляется; -- unsigned-часть новой записи подписана `root_key`; -- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`; -- лимиты блокчейнов могут только увеличиваться; -- занятый размер и номер последнего блока не могут уменьшаться; -- при увеличении оплаченного лимита пользователь доплачивает комиссию; -- Arweave `tx_id` может быть пустым или обновленным, но его содержимое Solana не валидирует. - -## 19. Отличия от старого линейного формата - -Старый формат после `login` хранил поля линейно: - -- `root_key_status`; -- `root_key`; -- `blockchain_key_status`; -- `blockchain_key`; -- `client_key_status`; -- `client_key`; -- `chain_number`; -- `balance`; -- серверные поля; -- access-серверы; -- `trusted_count`; -- `reserved`; -- `signature`. - -Новый целевой формат сохраняет первые 9 фиксированных полей как заголовок, но дальше переходит на типизированные блоки: - -- recovery-ключ становится отдельным обязательным блоком; -- ключи становятся отдельными блоками; -- данные блокчейна становятся расширенным блоком со своим публичным ключом, лимитом, занятым размером, вершиной цепочки и Arweave `tx_id`; -- серверные данные и access-серверы отделяются от данных блокчейна; -- расширение формата делается добавлением новых версий блоков или новых `block_type`, а не вставкой полей в середину линейной записи. - -## 20. Деривация ключей из master secret - -Сама Solana-программа не вычисляет ключи из секрета и не хранит приватные ключи. Но текущая согласованная клиентская схема деривации для публичной версии формата фиксируется здесь как reference для UI/ESP32/внешних клиентов. - -Базовая формула: - -```text -seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || suffix_utf8) -``` - -Где: - -- `master_secret32` — 32-байтовый master secret пользователя; -- `suffix_utf8` — строка назначения ключа. - -Согласованные suffix: - -```text -"recovery.key" -"root.key" -"blockchain.key" -"client.key" -``` - -Соответствие: - -```text -recovery.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "recovery.key") -root.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "root.key") -blockchain.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "blockchain.key") -client.seed = SHA-256("SHiNE-key" || 0x00 || master_secret32 || 0x00 || "client.key") -``` - -Далее каждая строка `seed` интерпретируется off-chain как `seed32` для отдельной пары Ed25519. - -## 21. Что пока не входит в формат - -Пока не проектируем: - -- ротацию `recovery_key`; -- ротацию `root_key`; -- сложную ротацию `client_key`; -- ротацию `blockchain_public_key`; -- проверку содержимого Arweave transaction; -- хранение полной истории пользовательского блокчейна внутри Solana; -- подключение Solana-модуля к сборке/деплою основного сервера SHiNE. +Формат пользовательских SHiNE/Arweave блоков этим изменением не меняется. diff --git a/docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md index cc55f570..d3b92704 100644 --- a/docs/Solana_Architecture/details/shine_users.md +++ b/docs/Solana_Architecture/details/shine_users.md @@ -2,156 +2,33 @@ ## Кратко -`shine_users` — вторая программа Solana-модуля SHiNE. Она отвечает за создание и обновление пользовательской PDA-записи, проверку подписи записи, проверку логина через `shine_login_guard` и оплату регистрации/дополнительного лимита. +`shine_users` создаёт и обновляет пользовательскую PDA SHiNE, проверяет authority/signature, логин через `shine_login_guard` и оплату регистрации/дополнительного лимита через `shine_payments`. -Папка программы: `shine-solana/shine/programs/shine_users/`. +Текущий формат пользовательской записи — **PDA 1.2**. Каноническая бинарная спецификация находится в: -## Текущие функции +- `shine-solana/shine/doc/formats/shine-user-pda-format-v.1.2.md` +- `shine-solana/shine/doc/programs/shine_users.md` -1. `init_users_economy_config` - - Создает PDA с экономическими настройками пользователей. - - Записывает стартовую регистрационную комиссию, цену шага лимита и стартовый бонус лимита. +Эти документы являются источником истины; здесь фиксируется только архитектурное резюме. -2. `update_users_economy_config` - - Обновляет экономические настройки. - - Требует подпись `DAO_AUTHORITY` из общего deploy-конфига. +## PDA 1.2 -3. `create_user_pda` - - Проверяет логин через `shine_login_guard`. - - Проверяет структуру полей пользователя. - - Проверяет подпись записи root-ключом пользователя. - - Создает `user_pda` по seed `login=`. - - Переводит оплату регистрации и дополнительного лимита в `shine_payments::inflow_vault_pda`. +Основные данные: -4. `update_user_pda` - - Проверяет неизменяемые поля пользователя. - - Проверяет `prev_hash`, новую подпись и новое состояние последнего блока. - - При необходимости расширяет PDA. - - Переводит оплату дополнительного лимита в `shine_payments::inflow_vault_pda`. +- `RootKeyBlock` — cold recovery authority; +- `ClientKeyBlock` — клиентский/кошелёчный ключ; +- `BlockchainRegistryBlock` — append-only список fork: `blockchain_key[32] + created_at_ms:u64 + paid_limit_bytes:u32`; последний fork активен; +- необязательный `ServerProfileBlock` — в 1.2 ровно один адрес сервера; +- необязательный `AccessServersBlock` — в 1.2 максимум один access server. -## Аргументы инструкций +Из нового формата удалены `RecoveryKeyBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`, `sync_servers` и поля вершины пользовательского blockchain. Сами пользовательские блоки и их история живут вне PDA; долговременное хранение/синхронизация выполняется через Arweave. -`init_users_economy_config` аргументов не принимает. +Обычный update выполняется активным последним blockchain key и не может менять root. Root используется для recovery и может менять root. Внутри PDA сохраняются `record_number`, `prev_record_hash` и Ed25519-подпись нового состояния. -`update_users_economy_config`: +Новый обычный fork можно добавить не раньше чем через 72 часа после `created_at_ms` текущего fork; root-recovery может обойти cooldown. Клиентский timestamp принимается только в окне ±5 минут относительно Solana Clock. -- `registration_fee_lamports: u64` -- `lamports_per_limit_step: u64` -- `start_bonus_limit: u64` +Legacy PDA 1.0 не мигрируются. Для тестовых 1.0 аккаунтов предусмотрена временная инструкция `close_legacy_pda`, возвращающая lamports вызывающему signer. -`create_user_pda`: - -- `login: String` -- `root_key: Pubkey` -- `created_at_ms: u64` -- `additional_limit: u64` -- `fields: UserMutableFields` -- `signature: Vec` - -`update_user_pda`: - -- `login: String` -- `root_key: Pubkey` -- `created_at_ms: u64` -- `updated_at_ms: u64` -- `version: u32` -- `prev_hash: Vec` -- `additional_limit: u64` -- `fields: UserMutableFields` -- `signature: Vec` - -`UserMutableFields`: - -- `client_key: Pubkey` -- `blockchain_public_key: Pubkey` -- `blockchain_name: String` -- `used_bytes: u64` -- `last_block_number: u32` -- `last_block_hash: Vec` — ровно 32 байта -- `last_block_signature: Vec` — ровно 64 байта -- `arweave_tx_id: String` -- `is_server: bool` -- `server_key: Pubkey` -- `server_address: String` -- `sync_servers: Vec` -- `access_servers: Vec` — формат остаётся массивом, но runtime - использует только первый элемент -- `trusted_count: u8` - -## Главные PDA - -1. `user_pda` - - PDA записи пользователя. - - Seed: `login=`. - - Создается отдельно для каждого логина. - - Стартовый размер: `768` байт. - - При обновлении может расширяться через `realloc`, но один auto-realloc ограничен `10_000` байт. - -2. `users_economy_config_pda` - - PDA с настройками экономики. - - Seed: `shine_users_economy_config`. - - Хранит регистрационную комиссию, цену шага лимита и стартовый бонус. - - Размер PDA: `8 + 96` байт. - -## Текущие параметры экономики - -Параметры initial config из `programs/shine_users/src/settings.rs`: - -| Поле | Значение | Смысл | -| --- | --- | --- | -| `START_REGISTRATION_FEE_LAMPORTS` | `10_000_000` | стартовая комиссия регистрации, 0.01 SOL | -| `LIMIT_STEP` | `10_000` | шаг `additional_limit` | -| `START_LAMPORTS_PER_LIMIT_STEP` | `100_000` | 0.0001 SOL за один шаг лимита | -| `START_BONUS_LIMIT` | `100_000` | стартовый бесплатный лимит при регистрации | - -`additional_limit` в create/update должен быть кратен `LIMIT_STEP`. - -## Связь с другими программами - -`shine_users` зависит от: - -- `shine_login_guard` — для проверки логина при создании пользователя; -- `shine_payments` — для вычисления и проверки `inflow_vault_pda`, куда уходят платежи. - -`create_user_pda` делает CPI-вызов `shine_login_guard::classify_login` и принимает только результат `0`. Premium/trademark логины сейчас отклоняются ошибками `PremiumLogin` или `TrademarkLoginRequiresReview`. - -Подпись `user_pda` и подпись состояния последнего блока проверяются через встроенную Solana Ed25519-инструкцию, которая должна идти раньше инструкции `shine_users` в той же транзакции. - -## Деньги - -Деньги из `shine_users` идут только в `inflow_vault_pda` программы `shine_payments`. - -Потоки: - -- `create_user_pda`: регистрационная комиссия + оплата `additional_limit`; -- `update_user_pda`: оплата `additional_limit`, если она больше нуля. - -## Ключи и управление - -На старте удобно считать, что у программы есть отдельный управляющий ключ `key_2`. - -Целевая модель: - -- economy-настройки меняет DAO-authority; -- upgrade-authority программы после проверки передается DAO; -- пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате. - -## ArchiveHeadBlock (legacy/reserved) - -Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`): - -```text -archive_tx_id [32] -archive_hash [32] -``` - -Поле `ArchiveHeadBlock` осталось в Solana/PDA как legacy/reserved для совместимости формата PDA. Новый transport пользовательских блоков не использует server-level SHINE-ARCHIVE или archive head: каждый пользовательский block публикуется как ANS-104 DataItem внутри стандартных bundles. - -Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим: - -- legacy payload без archive extension сохраняет старый block `100`; -- новый payload может заменить/очистить archive head; -- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью. - -Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а. +## Экономика +Параметры экономики хранятся в `users_economy_config_pda`. `paid_limit_bytes` каждого fork — `u32`; старые fork-записи неизменяемы, пополняется только активный fork. Подробные формулы и ограничения описаны в документации программы. diff --git a/shine-UI/js/pages/access-servers-view.js b/shine-UI/js/pages/access-servers-view.js index 55402432..98ab482b 100644 --- a/shine-UI/js/pages/access-servers-view.js +++ b/shine-UI/js/pages/access-servers-view.js @@ -544,6 +544,9 @@ export function render({navigate, chrome}) { if (operationBusy) return; const normalizedList = uniqueLogins(nextLogins); + if (normalizedList.length > 1) { + throw new Error('PDA 1.2 поддерживает только один сервер доступа.'); + } setOperationBusy(true); target.textContent = String(inFlightText || 'Обновляем сервер доступа...'); let signingMaterial = null; diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index c2901855..7ff0420d 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -333,7 +333,7 @@ export function render({ navigate }) { const subtitle = document.createElement('p'); subtitle.className = 'meta-muted'; - subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены recovery key, root key, blockchain key и client key.'; + subtitle.textContent = 'Процесс запускается сразу: из этого секрета будут вычислены root/recovery key, blockchain key и client key.'; const details = document.createElement('p'); details.className = 'meta-muted'; diff --git a/shine-UI/js/pages/registration-draft-keys-view.js b/shine-UI/js/pages/registration-draft-keys-view.js index f3d87b5c..ddc97a0c 100644 --- a/shine-UI/js/pages/registration-draft-keys-view.js +++ b/shine-UI/js/pages/registration-draft-keys-view.js @@ -109,31 +109,17 @@ export function render({ navigate }) { } card.append(makeSecretField({ label: 'Главный секрет (master secret, base58, 32 байта)', value: secretB58 })); - // Recovery key - const recoverySep = document.createElement('p'); - recoverySep.className = 'field-label'; - recoverySep.textContent = 'Recovery key'; - card.append(recoverySep); - card.append(makePublicField({ - label: 'Recovery — публичный (base58)', - value: bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64)), - })); - card.append(makeSecretField({ - label: 'Recovery — приватный (seed base58, 32 байта)', - value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.recoveryPair.privatePkcs8B64)), - })); - // Root key const rootSep = document.createElement('p'); rootSep.className = 'field-label'; - rootSep.textContent = 'Root key'; + rootSep.textContent = 'Root key (recovery)'; card.append(rootSep); card.append(makePublicField({ - label: 'Root — публичный (base58)', + label: 'Root / recovery — публичный (base58)', value: bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64)), })); card.append(makeSecretField({ - label: 'Root — приватный (seed base58, 32 байта)', + label: 'Root / recovery — приватный (seed base58, 32 байта)', value: bytesToBase58(extractSeed32FromPkcs8B64(keyBundle.rootPair.privatePkcs8B64)), })); diff --git a/shine-UI/js/pages/registration-faq-view.js b/shine-UI/js/pages/registration-faq-view.js index 8d01549b..1f138a2d 100644 --- a/shine-UI/js/pages/registration-faq-view.js +++ b/shine-UI/js/pages/registration-faq-view.js @@ -30,7 +30,7 @@ export const REGISTRATION_FAQ_TOPICS = [ title: 'Как генерируются ключи и что делает пароль?', paragraphs: [ 'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.', - 'Уже из этого секрета детерминированно строятся четыре основных ключа: recovery key, root key, blockchain key и client key.', + 'Уже из этого секрета детерминированно строятся три основных ключа: root/recovery key, blockchain key и client key.', 'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.', ], }, diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 24859bdd..dc167d3c 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1133,11 +1133,7 @@ export class AuthService { }); if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); - if (onProgress) onProgress({ percent: 93, stage: 'derive', message: 'Вычисление recovery key...' }); - const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret, 'recovery.key'); - if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); - - if (onProgress) onProgress({ percent: 95, stage: 'derive', message: 'Вычисление root key...' }); + if (onProgress) onProgress({ percent: 94, stage: 'derive', message: 'Вычисление root key...' }); const rootPair = await deriveEd25519FromMasterSecret(masterSecret, 'root.key'); if (isCancelled && isCancelled()) throw new Error('DERIVE_CANCELLED'); @@ -1149,7 +1145,6 @@ export class AuthService { const clientPair = await deriveEd25519FromMasterSecret(masterSecret, 'client.key'); const result = { masterSecretB64: bytesToBase64(masterSecret), - recoveryPair, rootPair, blockchainPair, clientPair, diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js index b98cfc93..a5c664a7 100644 --- a/shine-UI/js/services/shine-user-pda-service.js +++ b/shine-UI/js/services/shine-user-pda-service.js @@ -1,4 +1,4 @@ -import { base58ToBytes, base64ToBytes, base64UrlToBytes, bytesToBase58, bytesToBase64Url, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; +import { base58ToBytes, base64ToBytes, bytesToBase58, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; import { extractSeed32FromPkcs8B64 } from './client-key-utils.js'; import { SHINE_LOGIN_GUARD_PROGRAM_ID, @@ -9,28 +9,30 @@ import { import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js'; const MAGIC = 'SHiNE'; -const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK'; +const FORMAT_MAJOR = 1; +const FORMAT_MINOR_LEGACY = 0; +const FORMAT_MINOR_CURRENT = 2; const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault'; const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login='; const SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX = 'promo_seller='; const LIMIT_STEP = 10_000n; -const BLOCKCHAIN_TYPE_MAIN_USER = 1; const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111'; const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111'; const PROMO_SIGN_PREFIX = 'shine_promo_v1:'; -const BLOCK_TYPE_RECOVERY_KEY = 0; const BLOCK_TYPE_ROOT_KEY = 1; const BLOCK_TYPE_CLIENT_KEY = 2; const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3; const BLOCK_TYPE_SERVER_PROFILE = 30; const BLOCK_TYPE_ACCESS_SERVERS = 40; -const BLOCK_TYPE_SESSIONS = 50; -const BLOCK_TYPE_TRUSTED_STATE = 70; -const BLOCK_TYPE_ARCHIVE_HEAD = 100; -const SESSIONS_MODE_MIXED = 1; -const SESSION_TYPE_USER = 1; -const SESSION_TYPE_HOMESERVER = 100; + +const AUTH_MODE_BLOCKCHAIN = 0; +const AUTH_MODE_ROOT = 1; +const IX_CREATE_USER_PDA = 3; +const IX_UPDATE_USER_PDA = 4; +const IX_CLOSE_LEGACY_USER_PDA = 6; +const DAY_MS = 86_400_000n; +const FORK_COOLDOWN_MS = 3n * DAY_MS; const MAX_EFFECTIVE_ACCESS_SERVERS = 1; let solanaLibPromise = null; @@ -49,13 +51,28 @@ function normalizeAccessServers(values) { for (const value of Array.isArray(values) ? values : []) { const login = normalizeLogin(value); if (!login || seen.has(login)) continue; + if (result.length >= MAX_EFFECTIVE_ACCESS_SERVERS) { + throw new Error('PDA 1.2 поддерживает максимум один сервер доступа'); + } seen.add(login); result.push(login); - if (result.length >= MAX_EFFECTIVE_ACCESS_SERVERS) break; } return result; } +function toUint8Array(value, expectedLength = null) { + const bytes = value instanceof Uint8Array ? value : new Uint8Array(value || []); + if (expectedLength != null && bytes.length !== expectedLength) { + throw new Error(`Ожидалось ${expectedLength} байт, получено ${bytes.length}`); + } + return bytes; +} + +function pushU16LE(buf, value) { + const n = Number(value) & 0xffff; + buf.push(n & 0xff, (n >>> 8) & 0xff); +} + function pushU32LE(buf, value) { const n = Number(value) >>> 0; buf.push(n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff); @@ -76,34 +93,8 @@ function pushStrU8(buf, value) { for (const x of bytes) buf.push(x); } -function pushStrU32(buf, value) { - const bytes = new TextEncoder().encode(String(value || '')); - pushU32LE(buf, bytes.length); - for (const x of bytes) buf.push(x); -} - -function pushVecU8(buf, bytes) { - const data = bytes || new Uint8Array(); - pushU32LE(buf, data.length); - for (const x of data) buf.push(x); -} - -function pushVecStrU32(buf, values) { - const arr = Array.isArray(values) ? values : []; - pushU32LE(buf, arr.length); - for (const value of arr) pushStrU32(buf, value); -} - -function pushSessionRecordsU32(buf, sessions) { - const arr = Array.isArray(sessions) ? sessions : []; - pushU32LE(buf, arr.length); - for (const session of arr) { - buf.push(Number(session?.sessionType || 0) & 0xff); - buf.push(Number(session?.sessionVersion || 0) & 0xff); - pushStrU32(buf, String(session?.sessionName || '')); - const key = session?.sessionPubKey32 || new Uint8Array(32); - for (const x of key) buf.push(x); - } +function pushBytes(buf, bytes) { + for (const x of toUint8Array(bytes)) buf.push(x); } function makeReader(bytes) { @@ -112,50 +103,16 @@ function makeReader(bytes) { const ensure = (len) => { if (offset + len > bytes.length) throw new Error('Повреждённый формат PDA'); }; - const readU8 = () => { - ensure(1); - const value = view.getUint8(offset); - offset += 1; - return value; - }; - const readU16 = () => { - ensure(2); - const value = view.getUint16(offset, true); - offset += 2; - return value; - }; - const readU32 = () => { - ensure(4); - const value = view.getUint32(offset, true); - offset += 4; - return value; - }; - const readU64 = () => { - ensure(8); - const value = view.getBigUint64(offset, true); - offset += 8; - return value; - }; - const readBytes = (len) => { - ensure(len); - const out = bytes.slice(offset, offset + len); - offset += len; - return out; - }; - const readStrU8 = () => { - const len = readU8(); - return new TextDecoder().decode(readBytes(len)); - }; return { - readU8, - readU16, - readU32, - readU64, - readBytes, - readStrU8, - get offset() { - return offset; - }, + readU8() { ensure(1); return view.getUint8(offset++); }, + readU16() { ensure(2); const v = view.getUint16(offset, true); offset += 2; return v; }, + readU32() { ensure(4); const v = view.getUint32(offset, true); offset += 4; return v; }, + readU64() { ensure(8); const v = view.getBigUint64(offset, true); offset += 8; return v; }, + readBytes(len) { ensure(len); const out = bytes.slice(offset, offset + len); offset += len; return out; }, + readStrU8() { const len = this.readU8(); return new TextDecoder().decode(this.readBytes(len)); }, + skip(len) { ensure(len); offset += len; }, + get offset() { return offset; }, + get remaining() { return bytes.length - offset; }, }; } @@ -171,10 +128,12 @@ function parseUsersEconomyConfig(dataBytes) { } function buildEd25519IxData(sig64, pubkey32, messageBytes) { + const sig = toUint8Array(sig64, 64); + const pub = toUint8Array(pubkey32, 32); + const msg = toUint8Array(messageBytes); const sigOff = 16; const pkOff = sigOff + 64; const msgOff = pkOff + 32; - const msg = messageBytes instanceof Uint8Array ? messageBytes : new Uint8Array(messageBytes || []); const data = new Uint8Array(msgOff + msg.length); const view = new DataView(data.buffer); data[0] = 1; @@ -186,207 +145,288 @@ function buildEd25519IxData(sig64, pubkey32, messageBytes) { view.setUint16(10, msgOff, true); view.setUint16(12, msg.length, true); view.setUint16(14, 0xffff, true); - data.set(sig64, sigOff); - data.set(pubkey32, pkOff); + data.set(sig, sigOff); + data.set(pub, pkOff); data.set(msg, msgOff); return data; } +function normalizeServerAddresses(values, fallback = null) { + const src = Array.isArray(values) && values.length ? values : (fallback ? [fallback] : []); + if (src.length > 1) throw new Error('PDA 1.2 поддерживает только один адрес сервера'); + return src.map((item) => { + const address = String(item?.address ?? item?.serverAddress ?? '').trim(); + const bytes = new TextEncoder().encode(address); + if (!address || bytes.length > 255) throw new Error('Адрес сервера должен занимать 1..255 байт UTF-8'); + return { + addressFormatType: Number(item?.addressFormatType ?? 0) & 0xff, + addressFormatVersion: Number(item?.addressFormatVersion ?? 0) & 0xff, + address, + }; + }); +} + +function activeBlockchainCompatibility(login, forks) { + const active = forks.at(-1) || null; + if (!active) return null; + return { + blockchainType: 1, + blockchainName: `${normalizeLogin(login)}-${String(forks.length).padStart(3, '0')}`, + blockchainPublicKey: active.blockchainKey, + paidLimitBytes: BigInt(active.paidLimitBytes), + // Эти поля больше не хранятся в PDA 1.2; оставлены как compatibility view для старого UI. + usedBytes: 0n, + lastBlockNumber: 0, + lastBlockHash: new Uint8Array(32), + lastBlockSignature: new Uint8Array(64), + arweaveTxId: '', + }; +} + +function parseV12Pda(bytes, header) { + const reader = makeReader(bytes.slice(header.blocksOffset, header.recordLen - 64)); + let rootKey = null; + let clientKey = null; + let forks = null; + let serverAddresses = []; + let accessServers = []; + + for (let i = 0; i < header.blocksCount; i += 1) { + const blockType = reader.readU8(); + const blockVersion = reader.readU8(); + if (blockType === BLOCK_TYPE_ROOT_KEY) { + if (blockVersion !== 0 || rootKey) throw new Error('Некорректный RootKeyBlock'); + rootKey = reader.readBytes(32); + continue; + } + if (blockType === BLOCK_TYPE_CLIENT_KEY) { + if (blockVersion !== 0 || clientKey) throw new Error('Некорректный ClientKeyBlock'); + clientKey = reader.readBytes(32); + continue; + } + const payloadLen = reader.readU16(); + const payload = makeReader(reader.readBytes(payloadLen)); + if (blockVersion !== 0) continue; + if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) { + const count = payload.readU16(); + if (count < 1) throw new Error('BlockchainRegistry не содержит fork'); + forks = []; + for (let j = 0; j < count; j += 1) { + forks.push({ + blockchainKey: payload.readBytes(32), + createdAtMs: payload.readU64(), + paidLimitBytes: BigInt(payload.readU32()), + }); + } + if (payload.remaining !== 0) throw new Error('Повреждённый BlockchainRegistry'); + } else if (blockType === BLOCK_TYPE_SERVER_PROFILE) { + const count = payload.readU8(); + if (count !== 1) throw new Error('PDA 1.2 допускает ровно один адрес в ServerProfileBlock'); + serverAddresses = []; + for (let j = 0; j < count; j += 1) { + serverAddresses.push({ + addressFormatType: payload.readU8(), + addressFormatVersion: payload.readU8(), + address: payload.readStrU8(), + }); + } + if (payload.remaining !== 0) throw new Error('Повреждённый ServerProfileBlock'); + } else if (blockType === BLOCK_TYPE_ACCESS_SERVERS) { + const count = payload.readU8(); + if (count > 1) throw new Error('PDA 1.2 допускает максимум один access server'); + accessServers = []; + for (let j = 0; j < count; j += 1) accessServers.push(payload.readStrU8()); + if (payload.remaining !== 0) throw new Error('Повреждённый AccessServersBlock'); + } + } + if (!rootKey || !clientKey || !forks) throw new Error('PDA 1.2 не содержит обязательные блоки'); + const firstAddress = serverAddresses[0] || null; + const blockchain = activeBlockchainCompatibility(header.login, forks); + return { + ...header, + isLegacy: false, + recoveryKey: null, + rootKey, + clientKey, + forks, + blockchain, + isServer: serverAddresses.length > 0, + serverAddresses, + serverProfile: firstAddress ? { ...firstAddress, serverAddress: firstAddress.address, addresses: serverAddresses } : null, + serverData: firstAddress ? { ...firstAddress, serverAddress: firstAddress.address, addresses: serverAddresses } : null, + addressFormatType: firstAddress?.addressFormatType ?? 0, + addressFormatVersion: firstAddress?.addressFormatVersion ?? 0, + serverAddress: firstAddress?.address ?? '', + syncServers: [], + accessServers: normalizeAccessServers(accessServers), + sessionsMode: 1, + sessions: [], + trustedCount: 0, + archiveHeadTxId: '', + archiveHeadHash: new Uint8Array(32), + }; +} + +export function parseShineUserPda(dataBytes) { + const bytes = toUint8Array(dataBytes); + const reader = makeReader(bytes); + const magic = new TextDecoder().decode(reader.readBytes(5)); + if (magic !== MAGIC) throw new Error('Некорректный формат PDA'); + const formatMajor = reader.readU8(); + const formatMinor = reader.readU8(); + const recordLen = reader.readU16(); + if (formatMajor !== FORMAT_MAJOR || formatMinor !== FORMAT_MINOR_CURRENT) throw new Error(`Неподдерживаемый формат PDA ${formatMajor}.${formatMinor}`); + if (recordLen < 9 + 64 || recordLen > bytes.length) throw new Error('Некорректный record_len'); + const createdAtMs = reader.readU64(); + const updatedAtMs = reader.readU64(); + const recordNumber = reader.readU32(); + const prevRecordHash = reader.readBytes(32); + const login = reader.readStrU8(); + const blocksCount = reader.readU8(); + const blocksOffset = reader.offset; + const signature = bytes.slice(recordLen - 64, recordLen); + const unsignedBytes = bytes.slice(0, recordLen - 64); + const header = { formatMajor, formatMinor, recordLen, createdAtMs, updatedAtMs, recordNumber, prevRecordHash, login, blocksCount, blocksOffset, signature, unsignedBytes }; + return parseV12Pda(bytes, header); +} + +function writeVariableBlock(out, blockType, writer) { + out.push(blockType, 0); + const lenPos = out.length; + pushU16LE(out, 0); + const start = out.length; + writer(out); + const len = out.length - start; + if (len > 0xffff) throw new Error('Слишком большой payload блока PDA'); + out[lenPos] = len & 0xff; + out[lenPos + 1] = (len >>> 8) & 0xff; +} + +export function serializeUnsignedRecordFromState(stateLike) { + const state = stateLike || {}; + const login = String(state.login || ''); + const loginBytes = new TextEncoder().encode(login); + if (loginBytes.length > 255) throw new Error('Слишком длинный логин'); + const forks = Array.isArray(state.forks) ? state.forks : []; + if (!forks.length || forks.length > 0xffff) throw new Error('Некорректный список fork'); + const serverAddresses = normalizeServerAddresses(state.serverAddresses || []); + const accessServers = normalizeAccessServers(state.accessServers || []); + const out = []; + pushBytes(out, new TextEncoder().encode(MAGIC)); + out.push(FORMAT_MAJOR, FORMAT_MINOR_CURRENT); + pushU16LE(out, 0); + pushU64LE(out, BigInt(state.createdAtMs || 0)); + pushU64LE(out, BigInt(state.updatedAtMs || 0)); + pushU32LE(out, Number(state.recordNumber || 0)); + pushBytes(out, toUint8Array(state.prevRecordHash, 32)); + pushStrU8(out, login); + let blocksCount = 3; + if (serverAddresses.length) blocksCount += 1; + if (accessServers.length) blocksCount += 1; + out.push(blocksCount); + + out.push(BLOCK_TYPE_ROOT_KEY, 0); + pushBytes(out, toUint8Array(state.rootKey, 32)); + out.push(BLOCK_TYPE_CLIENT_KEY, 0); + pushBytes(out, toUint8Array(state.clientKey, 32)); + + writeVariableBlock(out, BLOCK_TYPE_BLOCKCHAIN_REGISTRY, (payload) => { + pushU16LE(payload, forks.length); + for (const fork of forks) { + pushBytes(payload, toUint8Array(fork.blockchainKey, 32)); + pushU64LE(payload, BigInt(fork.createdAtMs || 0)); + const limit = BigInt(fork.paidLimitBytes || 0); + if (limit < 0n || limit > 0xffffffffn) throw new Error('paid_limit_bytes не помещается в u32'); + pushU32LE(payload, Number(limit)); + } + }); + + if (serverAddresses.length) { + writeVariableBlock(out, BLOCK_TYPE_SERVER_PROFILE, (payload) => { + payload.push(serverAddresses.length); + for (const item of serverAddresses) { + payload.push(item.addressFormatType, item.addressFormatVersion); + pushStrU8(payload, item.address); + } + }); + } + if (accessServers.length) { + writeVariableBlock(out, BLOCK_TYPE_ACCESS_SERVERS, (payload) => { + payload.push(accessServers.length); + for (const value of accessServers) pushStrU8(payload, value); + }); + } + const recordLen = out.length + 64; + if (recordLen > 0xffff) throw new Error('PDA record слишком большой'); + out[7] = recordLen & 0xff; + out[8] = (recordLen >>> 8) & 0xff; + return new Uint8Array(out); +} + function serializeCreateUserPdaArgs(args) { - const buf = []; - buf.push(3); - pushStrU8(buf, args.login); - for (const x of args.recoveryKey32) buf.push(x); - for (const x of args.rootKey32) buf.push(x); - pushU64LE(buf, args.createdAtMs); - pushU64LE(buf, BigInt(args.additionalLimitBytes || 0n)); - for (const x of args.clientKey32) buf.push(x); - for (const x of args.blockchainPublicKey32) buf.push(x); - pushStrU8(buf, args.blockchainName); - pushU64LE(buf, args.usedBytes); - pushU32LE(buf, args.lastBlockNumber); - for (const x of args.lastBlockHash32) buf.push(x); - for (const x of args.lastBlockSignature64) buf.push(x); - pushStrU8(buf, args.arweaveTxId); - buf.push(args.isServer ? 1 : 0); - if (args.isServer) { - buf.push(Number(args.addressFormatType || 0) & 0xff); - buf.push(Number(args.addressFormatVersion || 0) & 0xff); - pushStrU8(buf, args.serverAddress); - const syncServers = Array.isArray(args.syncServers) ? args.syncServers : []; - buf.push(syncServers.length & 0xff); - for (const value of syncServers) pushStrU8(buf, value); + const out = [IX_CREATE_USER_PDA]; + pushStrU8(out, args.login); + pushBytes(out, toUint8Array(args.rootKey32, 32)); + pushU64LE(out, args.createdAtMs); + pushU64LE(out, BigInt(args.additionalLimitBytes || 0)); + pushBytes(out, toUint8Array(args.clientKey32, 32)); + pushBytes(out, toUint8Array(args.blockchainKey32, 32)); + const addresses = normalizeServerAddresses(args.serverAddresses || []); + out.push(addresses.length); + for (const item of addresses) { + out.push(item.addressFormatType, item.addressFormatVersion); + pushStrU8(out, item.address); } - const accessServers = normalizeAccessServers(args.accessServers); - buf.push(accessServers.length & 0xff); - for (const value of accessServers) pushStrU8(buf, value); - buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff); - const sessions = Array.isArray(args.sessions) ? args.sessions : []; - buf.push(sessions.length & 0xff); - for (const session of sessions) { - buf.push(Number(session?.sessionType || 0) & 0xff); - buf.push(Number(session?.sessionVersion || 0) & 0xff); - pushStrU8(buf, String(session?.sessionName || '')); - const key = session?.sessionPubKey32 || new Uint8Array(32); - for (const x of key) buf.push(x); - } - buf.push(Number(args.trustedCount || 0) & 0xff); - for (const x of args.rootSignature64) buf.push(x); - const promoSellerLogin = normalizeLogin(String(args.promoSellerLogin || '').trim()); - if (promoSellerLogin) { - pushStrU8(buf, promoSellerLogin); - } - return new Uint8Array(buf); + const access = normalizeAccessServers(args.accessServers || []); + out.push(access.length); + for (const value of access) pushStrU8(out, value); + pushBytes(out, toUint8Array(args.recordSignature64, 64)); + const promo = normalizeLogin(args.promoSellerLogin || ''); + if (promo) pushStrU8(out, promo); + return new Uint8Array(out); } function serializeUpdateUserPdaArgs(args) { - const buf = []; - buf.push(4); - pushStrU8(buf, args.login); - for (const x of args.recoveryKey32) buf.push(x); - for (const x of args.rootKey32) buf.push(x); - pushU64LE(buf, args.createdAtMs); - pushU64LE(buf, args.updatedAtMs); - pushU32LE(buf, args.version); - for (const x of args.prevHash32) buf.push(x); - pushU64LE(buf, args.additionalLimitBytes); - for (const x of args.clientKey32) buf.push(x); - for (const x of args.blockchainPublicKey32) buf.push(x); - pushStrU8(buf, args.blockchainName); - pushU64LE(buf, args.usedBytes); - pushU32LE(buf, args.lastBlockNumber); - for (const x of args.lastBlockHash32) buf.push(x); - for (const x of args.lastBlockSignature64) buf.push(x); - pushStrU8(buf, args.arweaveTxId); - buf.push(args.isServer ? 1 : 0); - if (args.isServer) { - buf.push(Number(args.addressFormatType || 0) & 0xff); - buf.push(Number(args.addressFormatVersion || 0) & 0xff); - pushStrU8(buf, args.serverAddress); - const syncServers = Array.isArray(args.syncServers) ? args.syncServers : []; - buf.push(syncServers.length & 0xff); - for (const value of syncServers) pushStrU8(buf, value); + const out = [IX_UPDATE_USER_PDA]; + pushStrU8(out, args.login); + pushBytes(out, toUint8Array(args.rootKey32, 32)); + pushU64LE(out, args.updatedAtMs); + pushU64LE(out, BigInt(args.additionalLimitBytes || 0)); + pushBytes(out, toUint8Array(args.clientKey32, 32)); + out.push(Number(args.authMode) & 0xff); + if (args.newBlockchainKey32) { + out.push(1); + pushBytes(out, toUint8Array(args.newBlockchainKey32, 32)); + } else out.push(0); + const addresses = normalizeServerAddresses(args.serverAddresses || []); + out.push(addresses.length); + for (const item of addresses) { + out.push(item.addressFormatType, item.addressFormatVersion); + pushStrU8(out, item.address); } - const accessServers = normalizeAccessServers(args.accessServers); - buf.push(accessServers.length & 0xff); - for (const value of accessServers) pushStrU8(buf, value); - buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff); - const sessions = Array.isArray(args.sessions) ? args.sessions : []; - buf.push(sessions.length & 0xff); - for (const session of sessions) { - buf.push(Number(session?.sessionType || 0) & 0xff); - buf.push(Number(session?.sessionVersion || 0) & 0xff); - pushStrU8(buf, String(session?.sessionName || '')); - const key = session?.sessionPubKey32 || new Uint8Array(32); - for (const x of key) buf.push(x); - } - buf.push(Number(args.trustedCount || 0) & 0xff); - for (const x of args.rootSignature64) buf.push(x); - return new Uint8Array(buf); + const access = normalizeAccessServers(args.accessServers || []); + out.push(access.length); + for (const value of access) pushStrU8(out, value); + pushBytes(out, toUint8Array(args.recordSignature64, 64)); + return new Uint8Array(out); } -function createBlockchainState({ - blockchainName, - blockchainPublicKey, - paidLimitBytes, - usedBytes, - lastBlockNumber, - lastBlockHash, - lastBlockSignature, - arweaveTxId, -}) { - return { - blockchainType: BLOCKCHAIN_TYPE_MAIN_USER, - blockchainName, - blockchainPublicKey, - paidLimitBytes, - usedBytes, - lastBlockNumber, - lastBlockHash, - lastBlockSignature, - arweaveTxId, - }; -} - -function createPdaState({ - login, - createdAtMs, - updatedAtMs, - recordNumber, - prevRecordHash, - recoveryKey, - rootKey, - clientKey, - blockchain, - isServer, - addressFormatType, - addressFormatVersion, - serverAddress, - syncServers, - accessServers, - sessionsMode, - sessions, - trustedCount, - archiveHeadTxId = '', - archiveHeadHash = null, -}) { - const serverProfile = isServer ? { - addressFormatType: Number(addressFormatType || 0), - addressFormatVersion: Number(addressFormatVersion || 0), - serverAddress: String(serverAddress || ''), - syncServers: Array.isArray(syncServers) ? [...syncServers] : [], - } : null; - return { - createdAtMs, - updatedAtMs, - recordNumber, - prevRecordHash, - login, - recoveryKey, - rootKey, - clientKey, - clientKey: clientKey, - blockchain, - isServer: Boolean(isServer), - serverProfile, - serverData: serverProfile, - addressFormatType: serverProfile?.addressFormatType ?? 0, - addressFormatVersion: serverProfile?.addressFormatVersion ?? 0, - serverAddress: serverProfile?.serverAddress ?? '', - syncServers: serverProfile?.syncServers ? [...serverProfile.syncServers] : [], - accessServers: normalizeAccessServers(accessServers), - sessionsMode: Number(sessionsMode || SESSIONS_MODE_MIXED) & 0xff, - sessions: Array.isArray(sessions) ? sessions.map((x) => ({ - sessionType: Number(x?.sessionType || 0) & 0xff, - sessionVersion: Number(x?.sessionVersion || 0) & 0xff, - sessionName: String(x?.sessionName || ''), - sessionPubKey32: x?.sessionPubKey32 instanceof Uint8Array ? x.sessionPubKey32 : new Uint8Array(x?.sessionPubKey32 || 32), - })) : [], - trustedCount: Number(trustedCount || 0) & 0xff, - archiveHeadTxId: String(archiveHeadTxId || '').trim(), - archiveHeadHash: archiveHeadHash instanceof Uint8Array ? archiveHeadHash : new Uint8Array(archiveHeadHash || 32), - }; -} - -function encodeOptionalArweave(buf, arweaveTxId) { - const value = String(arweaveTxId || '').trim(); - if (value) { - buf.push(1); - pushStrU8(buf, value); - } else { - buf.push(0); - } +function serializeCloseLegacyArgs(login) { + const out = [IX_CLOSE_LEGACY_USER_PDA]; + pushStrU8(out, login); + return new Uint8Array(out); } export function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber = 0, lastBlockHash32 = new Uint8Array(32), usedBytes = 0n) { - const buf = []; - for (const x of new TextEncoder().encode(LAST_BLOCK_STATE_PREFIX)) buf.push(x); - pushStrU8(buf, login); - pushStrU8(buf, blockchainName); - pushU32LE(buf, lastBlockNumber); - for (const x of lastBlockHash32) buf.push(x); - pushU64LE(buf, usedBytes); - return new Uint8Array(buf); + // Legacy helper kept for old UI diagnostics. PDA 1.2 no longer stores blockchain tip state. + const out = []; + pushBytes(out, new TextEncoder().encode('SHiNE_LAST_BLOCK')); + pushStrU8(out, login); + pushStrU8(out, blockchainName); + pushU32LE(out, lastBlockNumber); + pushBytes(out, toUint8Array(lastBlockHash32, 32)); + pushU64LE(out, usedBytes); + return new Uint8Array(out); } export function buildPromoMessageBytes(login) { @@ -395,13 +435,7 @@ export function buildPromoMessageBytes(login) { export function buildPromoCodeString(sellerLogin, signatureBytes) { const cleanSellerLogin = normalizeLogin(sellerLogin); - if (!cleanSellerLogin) { - throw new Error('Не указан логин продавца'); - } - const sig = signatureBytes instanceof Uint8Array ? signatureBytes : new Uint8Array(signatureBytes || []); - if (sig.length !== 64) { - throw new Error('Подпись промокода должна быть ровно 64 байта'); - } + const sig = toUint8Array(signatureBytes, 64); return `1${cleanSellerLogin}-${bytesToBase58(sig)}`; } @@ -414,390 +448,77 @@ export async function findPromoSellerPda({ sellerLogin }) { [new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanSellerLogin)], usersProgram, ); - return { - sellerLogin: cleanSellerLogin, - promoSellerPda: promoSellerPda.toBase58(), - }; -} - -export function parseShineUserPda(dataBytes) { - const bytes = dataBytes instanceof Uint8Array ? dataBytes : new Uint8Array(dataBytes || []); - const reader = makeReader(bytes); - const magic = new TextDecoder().decode(reader.readBytes(5)); - if (magic !== MAGIC) throw new Error('Некорректный формат PDA'); - reader.readU8(); - reader.readU8(); - const recordLen = reader.readU16(); - if (recordLen < 9 + 64 || recordLen > bytes.length) throw new Error('Некорректный record_len'); - - const createdAtMs = reader.readU64(); - const updatedAtMs = reader.readU64(); - const recordNumber = reader.readU32(); - const prevRecordHash = reader.readBytes(32); - const login = reader.readStrU8(); - const blocksCount = reader.readU8(); - - let recoveryKey = null; - let rootKey = null; - let clientKey = null; - let blockchain = null; - let isServer = false; - let addressFormatType = 0; - let addressFormatVersion = 0; - let serverAddress = ''; - let syncServers = []; - let accessServers = []; - let sessionsMode = SESSIONS_MODE_MIXED; - let sessions = []; - let trustedCount = 0; - let archiveHeadTxId = ''; - let archiveHeadHash = new Uint8Array(32); - - for (let i = 0; i < blocksCount; i += 1) { - const blockType = reader.readU8(); - reader.readU8(); - - if (blockType === BLOCK_TYPE_RECOVERY_KEY) { - recoveryKey = reader.readBytes(32); - continue; - } - if (blockType === BLOCK_TYPE_ROOT_KEY) { - rootKey = reader.readBytes(32); - continue; - } - if (blockType === BLOCK_TYPE_CLIENT_KEY) { - clientKey = reader.readBytes(32); - continue; - } - if (blockType === BLOCK_TYPE_BLOCKCHAIN_REGISTRY) { - const count = reader.readU8(); - for (let j = 0; j < count; j += 1) { - const blockchainType = reader.readU8(); - const blockchainName = reader.readStrU8(); - const blockchainPublicKey = reader.readBytes(32); - const paidLimitBytes = reader.readU64(); - const usedBytes = reader.readU64(); - const lastBlockNumber = reader.readU32(); - const lastBlockHash = reader.readBytes(32); - const lastBlockSignature = reader.readBytes(64); - const arweavePresent = reader.readU8(); - const arweaveTxId = arweavePresent === 1 ? reader.readStrU8() : ''; - if (!blockchain) { - blockchain = { - blockchainType, - blockchainName, - blockchainPublicKey, - paidLimitBytes, - usedBytes, - lastBlockNumber, - lastBlockHash, - lastBlockSignature, - arweaveTxId, - }; - } - } - continue; - } - if (blockType === BLOCK_TYPE_SERVER_PROFILE) { - isServer = reader.readU8() === 1; - if (isServer) { - addressFormatType = reader.readU8(); - addressFormatVersion = reader.readU8(); - serverAddress = reader.readStrU8(); - const syncCount = reader.readU8(); - syncServers = []; - for (let j = 0; j < syncCount; j += 1) syncServers.push(reader.readStrU8()); - } - continue; - } - if (blockType === BLOCK_TYPE_ACCESS_SERVERS) { - const accessCount = reader.readU8(); - accessServers = []; - for (let j = 0; j < accessCount; j += 1) accessServers.push(reader.readStrU8()); - accessServers = normalizeAccessServers(accessServers); - continue; - } - if (blockType === BLOCK_TYPE_SESSIONS) { - sessionsMode = reader.readU8(); - const sessionsCount = reader.readU8(); - sessions = []; - for (let j = 0; j < sessionsCount; j += 1) { - sessions.push({ - sessionType: reader.readU8(), - sessionVersion: reader.readU8(), - sessionName: reader.readStrU8(), - sessionPubKey32: reader.readBytes(32), - }); - } - continue; - } - if (blockType === BLOCK_TYPE_TRUSTED_STATE) { - trustedCount = reader.readU8(); - continue; - } - if (blockType === BLOCK_TYPE_ARCHIVE_HEAD) { - archiveHeadTxId = bytesToBase64Url(reader.readBytes(32)); - archiveHeadHash = reader.readBytes(32); - continue; - } - throw new Error(`Неизвестный блок PDA: ${blockType}`); - } - - if (!recoveryKey || !rootKey || !clientKey || !blockchain) { - throw new Error('В PDA отсутствуют обязательные блоки'); - } - - const signature = bytes.slice(reader.offset, reader.offset + 64); - const unsignedBytes = bytes.slice(0, recordLen - 64); - const state = createPdaState({ - login, - createdAtMs, - updatedAtMs, - recordNumber, - prevRecordHash, - recoveryKey, - rootKey, - clientKey, - blockchain, - isServer, - addressFormatType, - addressFormatVersion, - serverAddress, - syncServers, - accessServers, - sessionsMode, - sessions, - trustedCount, - archiveHeadTxId, - archiveHeadHash, - }); - - return { - ...state, - recordLen, - unsignedBytes, - signature, - }; -} - -export function serializeUnsignedRecordFromState(stateLike) { - const state = createPdaState({ - login: stateLike.login, - createdAtMs: stateLike.createdAtMs, - updatedAtMs: stateLike.updatedAtMs, - recordNumber: stateLike.recordNumber, - prevRecordHash: stateLike.prevRecordHash, - recoveryKey: stateLike.recoveryKey, - rootKey: stateLike.rootKey, - clientKey: stateLike.clientKey ?? stateLike.clientKey, - blockchain: stateLike.blockchain, - isServer: stateLike.isServer, - addressFormatType: stateLike.addressFormatType ?? stateLike.serverProfile?.addressFormatType, - addressFormatVersion: stateLike.addressFormatVersion ?? stateLike.serverProfile?.addressFormatVersion, - serverAddress: stateLike.serverAddress ?? stateLike.serverProfile?.serverAddress, - syncServers: stateLike.syncServers ?? stateLike.serverProfile?.syncServers, - accessServers: stateLike.accessServers, - sessionsMode: stateLike.sessionsMode, - sessions: stateLike.sessions, - trustedCount: stateLike.trustedCount, - archiveHeadTxId: stateLike.archiveHeadTxId, - archiveHeadHash: stateLike.archiveHeadHash, - }); - - const buf = [0x53, 0x48, 0x69, 0x4e, 0x45, 1, 0, 0, 0]; - pushU64LE(buf, state.createdAtMs); - pushU64LE(buf, state.updatedAtMs); - pushU32LE(buf, state.recordNumber); - for (const x of state.prevRecordHash) buf.push(x); - pushStrU8(buf, state.login); - const hasArchiveHead = Boolean(state.archiveHeadTxId); - buf.push((state.isServer ? 8 : 7) + (hasArchiveHead ? 1 : 0)); - - buf.push(BLOCK_TYPE_RECOVERY_KEY, 0); - for (const x of state.recoveryKey) buf.push(x); - - buf.push(BLOCK_TYPE_ROOT_KEY, 0); - for (const x of state.rootKey) buf.push(x); - - buf.push(BLOCK_TYPE_CLIENT_KEY, 0); - for (const x of state.clientKey) buf.push(x); - - buf.push(BLOCK_TYPE_BLOCKCHAIN_REGISTRY, 0, 1, state.blockchain.blockchainType); - pushStrU8(buf, state.blockchain.blockchainName); - for (const x of state.blockchain.blockchainPublicKey) buf.push(x); - pushU64LE(buf, state.blockchain.paidLimitBytes); - pushU64LE(buf, state.blockchain.usedBytes); - pushU32LE(buf, state.blockchain.lastBlockNumber); - for (const x of state.blockchain.lastBlockHash) buf.push(x); - for (const x of state.blockchain.lastBlockSignature) buf.push(x); - encodeOptionalArweave(buf, state.blockchain.arweaveTxId); - - if (state.isServer) { - buf.push(BLOCK_TYPE_SERVER_PROFILE, 0, 1); - buf.push(state.addressFormatType & 0xff); - buf.push(state.addressFormatVersion & 0xff); - pushStrU8(buf, state.serverAddress); - buf.push(state.syncServers.length & 0xff); - for (const loginValue of state.syncServers) pushStrU8(buf, loginValue); - } - - buf.push(BLOCK_TYPE_ACCESS_SERVERS, 0, state.accessServers.length & 0xff); - for (const loginValue of state.accessServers) pushStrU8(buf, loginValue); - - buf.push(BLOCK_TYPE_SESSIONS, 0, state.sessionsMode & 0xff, state.sessions.length & 0xff); - for (const session of state.sessions) { - buf.push(session.sessionType & 0xff, session.sessionVersion & 0xff); - pushStrU8(buf, session.sessionName); - for (const x of session.sessionPubKey32) buf.push(x); - } - - buf.push(BLOCK_TYPE_TRUSTED_STATE, 0, state.trustedCount & 0xff); - - if (hasArchiveHead) { - const txId32 = base64UrlToBytes(state.archiveHeadTxId); - if (txId32.length !== 32 || state.archiveHeadHash.length !== 32) { - throw new Error('Archive head должен содержать TX ID/hash по 32 байта'); - } - buf.push(BLOCK_TYPE_ARCHIVE_HEAD, 0); - for (const x of txId32) buf.push(x); - for (const x of state.archiveHeadHash) buf.push(x); - } - - const recordLen = buf.length + 64; - buf[7] = recordLen & 0xff; - buf[8] = (recordLen >>> 8) & 0xff; - return new Uint8Array(buf); + return { sellerLogin: cleanSellerLogin, promoSellerPda: promoSellerPda.toBase58() }; } export async function getShineUsersEconomyConfig({ solanaEndpoint }) { - const endpoint = String(solanaEndpoint || '').trim(); - if (!endpoint) throw new Error('Не указан Solana RPC endpoint'); const solana = await loadSolanaLib(); - const connection = new solana.Connection(endpoint, 'confirmed'); + const connection = new solana.Connection(String(solanaEndpoint || '').trim(), 'confirmed'); const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); - const [economyPda] = solana.PublicKey.findProgramAddressSync( - [new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], - usersProgram, - ); - const accountInfo = await connection.getAccountInfo(economyPda, 'confirmed'); - if (!accountInfo?.data) throw new Error('Economy config PDA не найден'); - return { endpoint, economyPda: economyPda.toBase58(), ...parseUsersEconomyConfig(accountInfo.data) }; + const [pda] = solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram); + const info = await connection.getAccountInfo(pda, 'confirmed'); + if (!info?.data) throw new Error('Economy config не инициализирован'); + return parseUsersEconomyConfig(new Uint8Array(info.data)); } export async function readShineUserPda({ login, solanaEndpoint }) { const cleanLogin = normalizeLogin(login); - const endpoint = String(solanaEndpoint || '').trim(); - if (!cleanLogin) throw new Error('Не указан логин'); - if (!endpoint) throw new Error('Не указан Solana RPC endpoint'); const solana = await loadSolanaLib(); + const endpoint = String(solanaEndpoint || '').trim(); const connection = new solana.Connection(endpoint, 'confirmed'); const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); - const enc = new TextEncoder(); - const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram); - const accountInfo = await connection.getAccountInfo(userPda, 'confirmed'); - if (!accountInfo?.data) throw new Error(`PDA не найдена для логина «${cleanLogin}»`); - return { - ...parseShineUserPda(accountInfo.data), - userPda: userPda.toBase58(), - pdaAddress: userPda.toBase58(), - endpoint, - }; + const [pda] = solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_USER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanLogin)], usersProgram); + const info = await connection.getAccountInfo(pda, 'confirmed'); + if (!info?.data) throw new Error(`PDA пользователя @${cleanLogin} не найдена`); + return { ...parseShineUserPda(new Uint8Array(info.data)), pdaAddress: pda.toBase58(), endpoint }; } export async function readShineUserPdaByAddress({ pdaAddress, solanaEndpoint }) { - const address = String(pdaAddress || '').trim(); - const endpoint = String(solanaEndpoint || '').trim(); - if (!address) throw new Error('Не указан адрес PDA'); - if (!endpoint) throw new Error('Не указан Solana RPC endpoint'); const solana = await loadSolanaLib(); + const endpoint = String(solanaEndpoint || '').trim(); const connection = new solana.Connection(endpoint, 'confirmed'); - const accountAddress = new solana.PublicKey(address); - const accountInfo = await connection.getAccountInfo(accountAddress, 'confirmed'); - if (!accountInfo?.data) throw new Error(`PDA не найдена: ${address}`); - return { - ...parseShineUserPda(accountInfo.data), - userPda: accountAddress.toBase58(), - pdaAddress: accountAddress.toBase58(), - endpoint, - }; + const pubkey = new solana.PublicKey(String(pdaAddress || '').trim()); + const info = await connection.getAccountInfo(pubkey, 'confirmed'); + if (!info?.data) throw new Error('PDA не найдена'); + return { ...parseShineUserPda(new Uint8Array(info.data)), pdaAddress: pubkey.toBase58(), endpoint }; } export async function readShineUserPdaByRef({ value, solanaEndpoint }) { - const ref = String(value || '').trim(); - if (!ref) throw new Error('Не указан логин или адрес PDA'); - try { - const bytes = base58ToBytes(ref); - if (bytes.length === 32) { - return await readShineUserPdaByAddress({ pdaAddress: ref, solanaEndpoint }); - } - } catch { - // Если это не адрес, читаем как логин. - } - return readShineUserPda({ login: ref, solanaEndpoint }); + const raw = String(value || '').trim(); + if (!raw) throw new Error('Не указан логин или PDA'); + try { return await readShineUserPdaByAddress({ pdaAddress: raw, solanaEndpoint }); } catch { return readShineUserPda({ login: raw, solanaEndpoint }); } } export async function getShineBlockchainUsage({ login, solanaEndpoint }) { - const parsed = await readShineUserPda({ login, solanaEndpoint }); - const bch = parsed.blockchain; - const leftBytes = bch.paidLimitBytes > bch.usedBytes ? (bch.paidLimitBytes - bch.usedBytes) : 0n; + const state = await readShineUserPda({ login, solanaEndpoint }); + const bch = state.blockchain; + if (!bch) throw new Error('BlockchainRegistry отсутствует'); + const usedBytes = state.isLegacy ? BigInt(bch.usedBytes) : 0n; return { - endpoint: parsed.endpoint, - userPda: parsed.userPda, - login: parsed.login, - recordNumber: parsed.recordNumber, - paidLimitBytes: bch.paidLimitBytes, - usedBytes: bch.usedBytes, - leftBytes, - blockchainName: String(bch.blockchainName || ''), - arweaveTxId: String(bch.arweaveTxId || ''), - lastBlockNumber: bch.lastBlockNumber, - lastBlockHashHex: Array.from(bch.lastBlockHash).map((x) => x.toString(16).padStart(2, '0')).join(''), + login: state.login, + blockchainName: bch.blockchainName, + blockchainPublicKey: bch.blockchainPublicKey, + paidLimitBytes: BigInt(bch.paidLimitBytes), + usedBytes, + leftBytes: BigInt(bch.paidLimitBytes) > usedBytes ? BigInt(bch.paidLimitBytes) - usedBytes : 0n, + lastBlockNumber: state.isLegacy ? bch.lastBlockNumber : 0, + lastBlockHash: state.isLegacy ? bch.lastBlockHash : new Uint8Array(32), + usageStoredInPda: state.isLegacy, + pdaState: state, }; } -function parseHex32(value) { - const clean = String(value || '').trim().toLowerCase(); - if (!clean) return null; - if (!/^[0-9a-f]+$/.test(clean) || clean.length !== 64) { - throw new Error('last block hash должен быть 32 байта в hex'); - } - const out = new Uint8Array(32); - for (let i = 0; i < 32; i += 1) { - out[i] = parseInt(clean.slice(i * 2, (i * 2) + 2), 16); - } - return out; -} - async function attachSolanaLogs(error, connection) { - if (!error || typeof error.getLogs !== 'function' || !connection) { - return error; - } - try { - const logs = await error.getLogs(connection); - if (Array.isArray(logs) && logs.length) { - error.logs = logs; - error.transactionLogs = logs; - error.simulationLogs = logs; - if (!String(error.message || '').includes('Logs:')) { - error.message = `${String(error.message || 'Solana transaction failed')} :: Logs: ${logs.join(' | ')}`; - } - } - } catch { - // Если RPC не вернул логи, оставляем исходную ошибку как есть. - } - + if (typeof error?.getLogs === 'function') error.solanaLogs = await error.getLogs(connection); + } catch {} return error; } -async function buildCreateContext({ login, keyBundle, solanaEndpoint }) { - const rawLogin = String(login || '').trim(); +async function buildCommonContext({ login, clientPrivatePkcs8B64, payerPrivatePkcs8B64 = '', solanaEndpoint }) { const cleanLogin = normalizeLogin(login); - const endpoint = String(solanaEndpoint || '').trim(); - if (!rawLogin || !cleanLogin) throw new Error('Не указан логин'); - if (!endpoint) throw new Error('Не указан Solana RPC endpoint'); - const solana = await loadSolanaLib(); + const endpoint = String(solanaEndpoint || '').trim(); const connection = new solana.Connection(endpoint, 'confirmed'); const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID); @@ -805,179 +526,80 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) { const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID); const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID); const enc = new TextEncoder(); - const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram); const [economyConfigPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram); const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram); - - const recoveryKey32 = base64ToBytes(keyBundle.recoveryPair.publicKeyB64); - const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64); - const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64); - const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64); - - const rootPrivKey = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64); - const bchPrivKey = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64); - const clientSeed32 = extractSeed32FromPkcs8B64(keyBundle.clientPair.privatePkcs8B64); - const clientKeypair = solana.Keypair.fromSeed(clientSeed32); - - return { - rawLogin, - cleanLogin, - endpoint, - solana, - connection, - usersProgram, - paymentsProgram, - loginGuardProgram, - ed25519Program, - sysvarInstructions, - userPda, - economyConfigPda, - inflowVault, - recoveryKey32, - rootKey32, - blockchainKey32, - clientKey32, - rootPrivKey, - bchPrivKey, - clientKeypair, - }; + const payerPkcs8 = String(payerPrivatePkcs8B64 || clientPrivatePkcs8B64 || ''); + if (!payerPkcs8) throw new Error('Нужен private key Solana fee payer'); + const payerSeed32 = extractSeed32FromPkcs8B64(payerPkcs8); + const payerKeypair = solana.Keypair.fromSeed(payerSeed32); + // Имя clientKeypair сохранено в возвращаемом объекте для совместимости текущих UI-callers; + // фактически это произвольный fee payer и он не обязан совпадать с ClientKeyBlock. + return { cleanLogin, endpoint, solana, connection, usersProgram, paymentsProgram, loginGuardProgram, ed25519Program, sysvarInstructions, userPda, economyConfigPda, inflowVault, clientKeypair: payerKeypair, payerKeypair }; } async function createShineUserPdaOnSolana({ login, keyBundle, solanaEndpoint, + serverAddresses = [], isServer = false, addressFormatType = 0, addressFormatVersion = 0, serverAddress = '', - syncServers = [], accessServers = [], promoCode = '', }) { - const ctx = await buildCreateContext({ login, keyBundle, solanaEndpoint }); - const ecoAccount = await ctx.connection.getAccountInfo(ctx.economyConfigPda); - if (!ecoAccount?.data) { - throw new Error('Economy config не инициализирован. Запустите init_users_economy_config.'); - } - - const cleanLogin = ctx.cleanLogin; - const displayLogin = ctx.rawLogin; - const cleanPromoCode = String(promoCode || '').trim(); - const blockchainName = `${cleanLogin}-001`; - const zeroHash32 = new Uint8Array(32); + const rawLogin = String(login || '').trim(); + const cleanLogin = normalizeLogin(rawLogin); + const rootKey32 = base64ToBytes(keyBundle.rootPair.publicKeyB64); + const blockchainKey32 = base64ToBytes(keyBundle.blockchainPair.publicKeyB64); + const clientKey32 = base64ToBytes(keyBundle.clientPair.publicKeyB64); + const rootPriv = await importPkcs8Ed25519(keyBundle.rootPair.privatePkcs8B64); + const bchPriv = await importPkcs8Ed25519(keyBundle.blockchainPair.privatePkcs8B64); + const ctx = await buildCommonContext({ login: cleanLogin, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, solanaEndpoint }); + const ecoInfo = await ctx.connection.getAccountInfo(ctx.economyConfigPda, 'confirmed'); + if (!ecoInfo?.data) throw new Error('Economy config не инициализирован.'); + const startBonusLimit = parseUsersEconomyConfig(new Uint8Array(ecoInfo.data)).startBonusLimit; + if (startBonusLimit > 0xffffffffn) throw new Error('Стартовый лимит не помещается в PDA 1.2 u32'); const createdAtMs = BigInt(Date.now()); - const startBonusLimit = parseUsersEconomyConfig(ecoAccount.data).startBonusLimit; - - const lastBlockStateBytes = buildLastBlockStateBytes(displayLogin, blockchainName, 0, zeroHash32, 0n); - const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes); - const lastBlockSig64 = await signBytes(ctx.bchPrivKey, lastBlockStateHash); - - const initialState = createPdaState({ - login: displayLogin, + const addresses = normalizeServerAddresses(serverAddresses, isServer ? { addressFormatType, addressFormatVersion, address: serverAddress } : null); + const initial = { + login: rawLogin, createdAtMs, updatedAtMs: createdAtMs, recordNumber: 0, - prevRecordHash: zeroHash32, - recoveryKey: ctx.recoveryKey32, - rootKey: ctx.rootKey32, - clientKey: ctx.clientKey32, - blockchain: createBlockchainState({ - blockchainName, - blockchainPublicKey: ctx.blockchainKey32, - paidLimitBytes: startBonusLimit, - usedBytes: 0n, - lastBlockNumber: 0, - lastBlockHash: zeroHash32, - lastBlockSignature: lastBlockSig64, - arweaveTxId: '', - }), - isServer, - addressFormatType, - addressFormatVersion, - serverAddress, - syncServers, + prevRecordHash: new Uint8Array(32), + rootKey: rootKey32, + clientKey: clientKey32, + forks: [{ blockchainKey: blockchainKey32, createdAtMs, paidLimitBytes: startBonusLimit }], + serverAddresses: addresses, accessServers, - sessionsMode: SESSIONS_MODE_MIXED, - sessions: [], - trustedCount: 0, - }); - - const unsignedRecord = serializeUnsignedRecordFromState(initialState); - const unsignedHash = await sha256Bytes(unsignedRecord); - const rootSig64 = await signBytes(ctx.rootPrivKey, unsignedHash); + }; + const unsigned = serializeUnsignedRecordFromState(initial); + const hash = await sha256Bytes(unsigned); + const rootSig = await signBytes(rootPriv, hash); + const recordSig = await signBytes(bchPriv, hash); let promoSellerPda = null; - let promoEd25519Ix = null; + let promoEdIx = null; let promoSellerLogin = ''; + const cleanPromoCode = String(promoCode || '').trim(); if (cleanPromoCode) { - const dashPos = cleanPromoCode.indexOf('-'); - if (!cleanPromoCode.startsWith('1') || dashPos <= 1 || dashPos === cleanPromoCode.length - 1) { - throw new Error('Некорректный формат промокода'); - } - const sellerLogin = normalizeLogin(cleanPromoCode.slice(1, dashPos)); - const signatureBase58 = cleanPromoCode.slice(dashPos + 1); - promoSellerLogin = sellerLogin; - const [promoSellerAddress] = ctx.solana.PublicKey.findProgramAddressSync( - [new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(sellerLogin)], - ctx.usersProgram, - ); - promoSellerPda = promoSellerAddress; - - const promoMessage = buildPromoMessageBytes(cleanLogin); - const signatureBytes = base58ToBytes(signatureBase58); - if (signatureBytes.length !== 64) { - throw new Error('Подпись промокода должна быть 64 байта в Base58'); - } - const sellerAccount = await ctx.connection.getAccountInfo(promoSellerAddress, 'confirmed'); - if (!sellerAccount?.data || sellerAccount.data.length < 42) { - throw new Error('Promo seller PDA не найдена или повреждена'); - } - const signerPubkey = new Uint8Array(sellerAccount.data.slice(10, 42)); - promoEd25519Ix = new ctx.solana.TransactionInstruction({ - programId: ctx.ed25519Program, - keys: [], - data: buildEd25519IxData(signatureBytes, signerPubkey, promoMessage), - }); + const dash = cleanPromoCode.indexOf('-'); + if (!cleanPromoCode.startsWith('1') || dash <= 1 || dash === cleanPromoCode.length - 1) throw new Error('Некорректный формат промокода'); + promoSellerLogin = normalizeLogin(cleanPromoCode.slice(1, dash)); + const sig = base58ToBytes(cleanPromoCode.slice(dash + 1)); + const [sellerPda] = ctx.solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_PROMO_SELLER_PDA_SEED_PREFIX), new TextEncoder().encode(promoSellerLogin)], ctx.usersProgram); + promoSellerPda = sellerPda; + const sellerInfo = await ctx.connection.getAccountInfo(sellerPda, 'confirmed'); + if (!sellerInfo?.data || sellerInfo.data.length < 42) throw new Error('Promo seller PDA не найдена'); + promoEdIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(sig, new Uint8Array(sellerInfo.data.slice(10, 42)), buildPromoMessageBytes(cleanLogin)) }); } - const ixData = serializeCreateUserPdaArgs({ - login: displayLogin, - recoveryKey32: ctx.recoveryKey32, - rootKey32: ctx.rootKey32, - createdAtMs, - additionalLimitBytes: 0n, - clientKey32: ctx.clientKey32, - blockchainPublicKey32: ctx.blockchainKey32, - blockchainName, - usedBytes: 0n, - lastBlockNumber: 0, - lastBlockHash32: zeroHash32, - lastBlockSignature64: lastBlockSig64, - arweaveTxId: '', - isServer, - addressFormatType: isServer ? addressFormatType : 0, - addressFormatVersion: isServer ? addressFormatVersion : 0, - serverAddress: isServer ? serverAddress : '', - syncServers: isServer ? syncServers : [], - accessServers, - sessionsMode: SESSIONS_MODE_MIXED, - sessions: [], - trustedCount: 0, - rootSignature64: rootSig64, - promoSellerLogin, - }); - const ed25519RootIx = new ctx.solana.TransactionInstruction({ - programId: ctx.ed25519Program, - keys: [], - data: buildEd25519IxData(rootSig64, ctx.rootKey32, unsignedHash), - }); - const ed25519BchIx = new ctx.solana.TransactionInstruction({ - programId: ctx.ed25519Program, - keys: [], - data: buildEd25519IxData(lastBlockSig64, ctx.blockchainKey32, lastBlockStateHash), - }); + const ixData = serializeCreateUserPdaArgs({ login: rawLogin, rootKey32, createdAtMs, additionalLimitBytes: 0n, clientKey32, blockchainKey32, serverAddresses: addresses, accessServers, recordSignature64: recordSig, promoSellerLogin }); + const rootIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(rootSig, rootKey32, hash) }); + const recordIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(recordSig, blockchainKey32, hash) }); const createIx = new ctx.solana.TransactionInstruction({ programId: ctx.usersProgram, keys: [ @@ -992,62 +614,49 @@ async function createShineUserPdaOnSolana({ ], data: ixData, }); - - let signature; try { const tx = new ctx.solana.Transaction(); - if (promoEd25519Ix) tx.add(promoEd25519Ix); - tx.add(ed25519RootIx, ed25519BchIx, createIx); - signature = await ctx.solana.sendAndConfirmTransaction( - ctx.connection, - tx, - [ctx.clientKeypair], - { commitment: 'confirmed' }, - ); - } catch (error) { - throw await attachSolanaLogs(error, ctx.connection); - } - - return { - signature, - userPda: ctx.userPda.toBase58(), - pdaAddress: ctx.userPda.toBase58(), - blockchainName, - }; + if (promoEdIx) tx.add(promoEdIx); + tx.add(rootIx, recordIx, createIx); + const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, tx, [ctx.clientKeypair], { commitment: 'confirmed' }); + return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), blockchainName: `${cleanLogin}-001` }; + } catch (error) { throw await attachSolanaLogs(error, ctx.connection); } } export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [], promoCode = '' }) { - return createShineUserPdaOnSolana({ - login, - keyBundle, - solanaEndpoint, - isServer: false, - accessServers: Array.isArray(accessServers) ? accessServers : [], - promoCode, - }); + return createShineUserPdaOnSolana({ login, keyBundle, solanaEndpoint, accessServers, promoCode }); } -export async function registerServerOnSolana({ - login, - keyBundle, - serverAddress, - addressFormatType = 1, - addressFormatVersion = 0, - syncServers = [], - accessServers = [], - solanaEndpoint, -}) { - return createShineUserPdaOnSolana({ - login, - keyBundle, - solanaEndpoint, - isServer: true, - addressFormatType, - addressFormatVersion, - serverAddress, - syncServers, - accessServers, +export async function registerServerOnSolana({ login, keyBundle, serverAddress, serverAddresses = null, addressFormatType = 1, addressFormatVersion = 0, accessServers = [], solanaEndpoint }) { + return createShineUserPdaOnSolana({ login, keyBundle, solanaEndpoint, isServer: true, serverAddress, serverAddresses: serverAddresses || [], addressFormatType, addressFormatVersion, accessServers }); +} + +export async function closeLegacyShineUserPdaOnSolana({ login, solanaEndpoint, callerPrivatePkcs8B64 }) { + const cleanLogin = normalizeLogin(login); + if (!cleanLogin || !callerPrivatePkcs8B64) throw new Error('Нужны login и private key вызывающего'); + const solana = await loadSolanaLib(); + const endpoint = String(solanaEndpoint || '').trim(); + const connection = new solana.Connection(endpoint, 'confirmed'); + const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); + const [userPda] = solana.PublicKey.findProgramAddressSync([new TextEncoder().encode(SHINE_USERS_USER_PDA_SEED_PREFIX), new TextEncoder().encode(cleanLogin)], usersProgram); + const info = await connection.getAccountInfo(userPda, 'confirmed'); + if (!info) throw new Error('PDA не найдена'); + const raw = new Uint8Array(info.data); + if (raw.length < 7 || new TextDecoder().decode(raw.slice(0, 5)) !== MAGIC || raw[5] !== FORMAT_MAJOR || raw[6] !== FORMAT_MINOR_LEGACY) { + throw new Error('Закрывать этой инструкцией можно только legacy PDA 1.0'); + } + const seed32 = extractSeed32FromPkcs8B64(callerPrivatePkcs8B64); + const caller = solana.Keypair.fromSeed(seed32); + const ix = new solana.TransactionInstruction({ + programId: usersProgram, + keys: [ + { pubkey: caller.publicKey, isSigner: true, isWritable: true }, + { pubkey: userPda, isSigner: false, isWritable: true }, + ], + data: serializeCloseLegacyArgs(cleanLogin), }); + const signature = await solana.sendAndConfirmTransaction(connection, new solana.Transaction().add(ix), [caller], { commitment: 'confirmed' }); + return { signature, pdaAddress: userPda.toBase58() }; } export async function updateShineUserPdaOnSolana({ @@ -1055,232 +664,131 @@ export async function updateShineUserPdaOnSolana({ solanaEndpoint, rootPrivatePkcs8B64, clientPrivatePkcs8B64, + payerPrivatePkcs8B64 = '', blockchainPrivatePkcs8B64, + authorityMode, additionalLimitBytes = 0n, - nextUsedBytes, - nextLastBlockNumber, - nextLastBlockHashHex, - nextArweaveTxId, + newRootPublicKey32 = null, + newRootPrivatePkcs8B64 = '', + nextClientPublicKey32 = null, + newBlockchainPublicKey32 = null, + newBlockchainPrivatePkcs8B64 = '', serverProfile, + serverAddresses, accessServers, - trustedCount, - archiveHeadTxId = '', - archiveHeadHash = null, }) { const current = await readShineUserPda({ login, solanaEndpoint }); - const cleanLogin = current.login; - const endpoint = current.endpoint; - const currentBch = current.blockchain; + const ctx = await buildCommonContext({ login: current.login, clientPrivatePkcs8B64, payerPrivatePkcs8B64, solanaEndpoint }); const addLimit = BigInt(additionalLimitBytes || 0); - if (addLimit < 0n) throw new Error('Нельзя уменьшать лимит'); - if (addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`); - - const effectiveUsed = nextUsedBytes == null ? currentBch.usedBytes : BigInt(nextUsedBytes); - const effectiveLastNum = nextLastBlockNumber == null ? currentBch.lastBlockNumber : Number(nextLastBlockNumber); - const effectiveLastHash = parseHex32(nextLastBlockHashHex) || currentBch.lastBlockHash; - const effectiveArweaveTxId = nextArweaveTxId == null ? currentBch.arweaveTxId : String(nextArweaveTxId || '').trim(); - if (effectiveLastHash.length !== 32) throw new Error('last block hash должен быть 32 байта'); - - const rootPriv = await importPkcs8Ed25519(rootPrivatePkcs8B64); - const solana = await loadSolanaLib(); - const connection = new solana.Connection(endpoint, 'confirmed'); - const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); - const paymentsProgram = new solana.PublicKey(SHINE_PAYMENTS_PROGRAM_ID); - const ed25519Program = new solana.PublicKey(ED25519_PROGRAM_ID); - const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID); - const enc = new TextEncoder(); - const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram); - const [economyPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_ECONOMY_CONFIG_SEED)], usersProgram); - const [inflowVault] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_PAYMENTS_INFLOW_VAULT_SEED)], paymentsProgram); - const clientSeed32 = extractSeed32FromPkcs8B64(clientPrivatePkcs8B64); - const clientKeypair = solana.Keypair.fromSeed(clientSeed32); - - const lastBlockStateBytes = buildLastBlockStateBytes( - current.login, - currentBch.blockchainName, - effectiveLastNum, - effectiveLastHash, - effectiveUsed, - ); - const lastBlockStateHash = await sha256Bytes(lastBlockStateBytes); - - const blockchainStateWillChange = ( - nextUsedBytes != null - || nextLastBlockNumber != null - || nextLastBlockHashHex != null - ); - - let lastBlockSig64 = currentBch.lastBlockSignature; - if (blockchainPrivatePkcs8B64) { - const bchPriv = await importPkcs8Ed25519(blockchainPrivatePkcs8B64); - lastBlockSig64 = await signBytes(bchPriv, lastBlockStateHash); - } else if (blockchainStateWillChange) { - throw new Error('Для изменения last block state нужен blockchain-ключ'); - } + if (addLimit < 0n || addLimit % LIMIT_STEP !== 0n) throw new Error(`Лимит можно увеличивать только шагом ${LIMIT_STEP}`); + const authMode = authorityMode === 'blockchain' || authorityMode === AUTH_MODE_BLOCKCHAIN ? AUTH_MODE_BLOCKCHAIN : AUTH_MODE_ROOT; + const rootKey = newRootPublicKey32 ? toUint8Array(newRootPublicKey32, 32) : current.rootKey; + const clientKey = nextClientPublicKey32 ? toUint8Array(nextClientPublicKey32, 32) : current.clientKey; + const rootChanged = bytesToBase58(rootKey) !== bytesToBase58(current.rootKey); + if (authMode === AUTH_MODE_BLOCKCHAIN && rootChanged) throw new Error('Blockchain authority не может менять root key'); + if (rootChanged && newBlockchainPublicKey32) throw new Error('Root rotation и blockchain fork нужно делать разными транзакциями'); const updatedAtMs = BigInt(Date.now()); - const newPaid = currentBch.paidLimitBytes + addLimit; - const newRecordNumber = current.recordNumber + 1; - // Для prev_hash нужно хэшировать точную unsigned-часть текущей PDA, - // а не пересобирать её из распарсенного состояния: иначе можно получить - // несовпадение байт и InvalidPrevHash в on-chain программе. - const prevHash = await sha256Bytes(current.unsignedBytes || serializeUnsignedRecordFromState(current)); - - const nextServerProfile = serverProfile - ? { - addressFormatType: Number(serverProfile.addressFormatType ?? current.addressFormatType ?? 0), - addressFormatVersion: Number(serverProfile.addressFormatVersion ?? current.addressFormatVersion ?? 0), - serverAddress: String(serverProfile.serverAddress ?? current.serverAddress ?? ''), - syncServers: Array.isArray(serverProfile.syncServers) ? [...serverProfile.syncServers] : [...current.syncServers], - } - : current.serverProfile; - - const nextState = createPdaState({ - login: current.login, - createdAtMs: current.createdAtMs, - updatedAtMs, - recordNumber: newRecordNumber, - prevRecordHash: prevHash, - recoveryKey: current.recoveryKey, - rootKey: current.rootKey, - clientKey: current.clientKey ?? current.clientKey, - blockchain: createBlockchainState({ - blockchainName: currentBch.blockchainName, - blockchainPublicKey: currentBch.blockchainPublicKey, - paidLimitBytes: newPaid, - usedBytes: effectiveUsed, - lastBlockNumber: effectiveLastNum, - lastBlockHash: effectiveLastHash, - lastBlockSignature: lastBlockSig64, - arweaveTxId: effectiveArweaveTxId, - }), - isServer: Boolean(nextServerProfile), - addressFormatType: nextServerProfile?.addressFormatType ?? 0, - addressFormatVersion: nextServerProfile?.addressFormatVersion ?? 0, - serverAddress: nextServerProfile?.serverAddress ?? '', - syncServers: nextServerProfile?.syncServers ?? [], - accessServers: accessServers == null ? current.accessServers : accessServers, - sessionsMode: current.sessionsMode, - sessions: current.sessions, - trustedCount: trustedCount == null ? current.trustedCount : trustedCount, - archiveHeadTxId: current.archiveHeadTxId, - archiveHeadHash: current.archiveHeadHash, - }); - - const unsignedNext = serializeUnsignedRecordFromState(nextState); - const unsignedNextHash = await sha256Bytes(unsignedNext); - const rootSig64 = await signBytes(rootPriv, unsignedNextHash); - - const ixData = serializeUpdateUserPdaArgs({ - login: current.login, - recoveryKey32: current.recoveryKey, - rootKey32: current.rootKey, - createdAtMs: current.createdAtMs, - updatedAtMs, - version: newRecordNumber, - prevHash32: prevHash, - additionalLimitBytes: addLimit, - clientKey32: current.clientKey, - blockchainPublicKey32: currentBch.blockchainPublicKey, - blockchainName: currentBch.blockchainName, - usedBytes: effectiveUsed, - lastBlockNumber: effectiveLastNum, - lastBlockHash32: effectiveLastHash, - lastBlockSignature64: lastBlockSig64, - arweaveTxId: effectiveArweaveTxId, - isServer: nextState.isServer, - addressFormatType: nextState.addressFormatType, - addressFormatVersion: nextState.addressFormatVersion, - serverAddress: nextState.serverAddress, - syncServers: nextState.syncServers, - accessServers: nextState.accessServers, - sessionsMode: nextState.sessionsMode, - sessions: nextState.sessions, - trustedCount: nextState.trustedCount, - rootSignature64: rootSig64, - }); - - const edIxRoot = new solana.TransactionInstruction({ - programId: ed25519Program, - keys: [], - data: buildEd25519IxData(rootSig64, current.rootKey, unsignedNextHash), - }); - const edIxBch = new solana.TransactionInstruction({ - programId: ed25519Program, - keys: [], - data: buildEd25519IxData(lastBlockSig64, currentBch.blockchainPublicKey, lastBlockStateHash), - }); - const updateIx = new solana.TransactionInstruction({ - programId: usersProgram, - keys: [ - { pubkey: clientKeypair.publicKey, isSigner: true, isWritable: true }, - { pubkey: userPda, isSigner: false, isWritable: true }, - { pubkey: solana.SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: inflowVault, isSigner: false, isWritable: true }, - { pubkey: sysvarInstructions, isSigner: false, isWritable: false }, - { pubkey: economyPda, isSigner: false, isWritable: false }, - ], - data: ixData, - }); - const computeIx = solana.ComputeBudgetProgram.setComputeUnitLimit({ units: 800_000 }); - const heapIx = solana.ComputeBudgetProgram.requestHeapFrame({ bytes: 262_144 }); - - let signature; - try { - signature = await solana.sendAndConfirmTransaction( - connection, - new solana.Transaction().add(computeIx, heapIx, edIxRoot, edIxBch, updateIx), - [clientKeypair], - { commitment: 'confirmed' }, - ); - } catch (error) { - throw await attachSolanaLogs(error, connection); + const forks = current.forks.map((fork) => ({ blockchainKey: fork.blockchainKey, createdAtMs: BigInt(fork.createdAtMs), paidLimitBytes: BigInt(fork.paidLimitBytes) })); + let appendedKey = null; + if (newBlockchainPublicKey32) { + appendedKey = toUint8Array(newBlockchainPublicKey32, 32); + if (forks.some((f) => bytesToBase58(f.blockchainKey) === bytesToBase58(appendedKey))) throw new Error('Этот blockchain key уже был в истории fork'); + const lastForkAt = BigInt(forks.at(-1).createdAtMs); + if (authMode === AUTH_MODE_BLOCKCHAIN && updatedAtMs - lastForkAt < FORK_COOLDOWN_MS) throw new Error('Новый fork обычным blockchain authority можно создавать не чаще одного раза в 72 часа'); + const newLimit = forks.at(-1).paidLimitBytes + addLimit; + if (newLimit > 0xffffffffn) throw new Error('paid_limit_bytes превышает u32'); + forks.push({ blockchainKey: appendedKey, createdAtMs: updatedAtMs, paidLimitBytes: newLimit }); + } else if (addLimit > 0n) { + const active = forks.at(-1); + active.paidLimitBytes += addLimit; + if (active.paidLimitBytes > 0xffffffffn) throw new Error('paid_limit_bytes превышает u32'); } - return { - signature, - userPda: userPda.toBase58(), - pdaAddress: userPda.toBase58(), - paidLimitBytes: newPaid, - usedBytes: effectiveUsed, - leftBytes: newPaid > effectiveUsed ? (newPaid - effectiveUsed) : 0n, - lastBlockNumber: effectiveLastNum, - lastBlockHashHex: Array.from(effectiveLastHash).map((x) => x.toString(16).padStart(2, '0')).join(''), - arweaveTxId: effectiveArweaveTxId, + let addresses = current.serverAddresses; + if (serverAddresses != null) addresses = normalizeServerAddresses(serverAddresses); + else if (serverProfile != null) { + if (Array.isArray(serverProfile.addresses)) addresses = normalizeServerAddresses(serverProfile.addresses); + else if (serverProfile.serverAddress) addresses = normalizeServerAddresses([], { addressFormatType: serverProfile.addressFormatType ?? current.addressFormatType, addressFormatVersion: serverProfile.addressFormatVersion ?? current.addressFormatVersion, address: serverProfile.serverAddress }); + else addresses = []; + } + const nextAccess = accessServers == null ? current.accessServers : normalizeAccessServers(accessServers); + const next = { + login: current.login, + createdAtMs: current.createdAtMs, + updatedAtMs, + recordNumber: current.recordNumber + 1, + prevRecordHash: await sha256Bytes(current.unsignedBytes), + rootKey, + clientKey, + forks, + serverAddresses: addresses, + accessServers: nextAccess, }; + const unsigned = serializeUnsignedRecordFromState(next); + const hash = await sha256Bytes(unsigned); + + const oldAuthorityPub = authMode === AUTH_MODE_ROOT ? current.rootKey : current.forks.at(-1).blockchainKey; + const oldAuthorityPrivB64 = authMode === AUTH_MODE_ROOT ? rootPrivatePkcs8B64 : blockchainPrivatePkcs8B64; + if (!oldAuthorityPrivB64) throw new Error(authMode === AUTH_MODE_ROOT ? 'Нужен root private key' : 'Нужен blockchain private key'); + const oldAuthorityPriv = await importPkcs8Ed25519(oldAuthorityPrivB64); + const authSig = await signBytes(oldAuthorityPriv, hash); + + let recordSignerPub; + let recordSignerPrivB64; + if (rootChanged) { + recordSignerPub = rootKey; + recordSignerPrivB64 = newRootPrivatePkcs8B64; + } else if (appendedKey) { + recordSignerPub = appendedKey; + recordSignerPrivB64 = newBlockchainPrivatePkcs8B64; + } else if (authMode === AUTH_MODE_ROOT) { + recordSignerPub = rootKey; + recordSignerPrivB64 = rootPrivatePkcs8B64; + } else { + recordSignerPub = current.forks.at(-1).blockchainKey; + recordSignerPrivB64 = blockchainPrivatePkcs8B64; + } + if (!recordSignerPrivB64) throw new Error('Нет приватного ключа нового authority для подписи новой PDA'); + const recordSignerPriv = await importPkcs8Ed25519(recordSignerPrivB64); + const recordSig = await signBytes(recordSignerPriv, hash); + + const authIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(authSig, oldAuthorityPub, hash) }); + const recordIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], data: buildEd25519IxData(recordSig, recordSignerPub, hash) }); + const updateIx = new ctx.solana.TransactionInstruction({ + programId: ctx.usersProgram, + keys: [ + { pubkey: ctx.clientKeypair.publicKey, isSigner: true, isWritable: true }, + { pubkey: ctx.userPda, isSigner: false, isWritable: true }, + { pubkey: ctx.solana.SystemProgram.programId, isSigner: false, isWritable: false }, + { pubkey: ctx.inflowVault, isSigner: false, isWritable: true }, + { pubkey: ctx.sysvarInstructions, isSigner: false, isWritable: false }, + { pubkey: ctx.economyConfigPda, isSigner: false, isWritable: false }, + ], + data: serializeUpdateUserPdaArgs({ login: current.login, rootKey32: rootKey, updatedAtMs, additionalLimitBytes: addLimit, clientKey32: clientKey, authMode, newBlockchainKey32: appendedKey, serverAddresses: addresses, accessServers: nextAccess, recordSignature64: recordSig }), + }); + try { + const signature = await ctx.solana.sendAndConfirmTransaction(ctx.connection, new ctx.solana.Transaction().add(authIx, recordIx, updateIx), [ctx.clientKeypair], { commitment: 'confirmed' }); + return { signature, userPda: ctx.userPda.toBase58(), pdaAddress: ctx.userPda.toBase58(), paidLimitBytes: forks.at(-1).paidLimitBytes, forkCount: forks.length }; + } catch (error) { throw await attachSolanaLogs(error, ctx.connection); } } -export async function updateServerOnSolana({ - login, - keyBundle, - serverAddress, - addressFormatType, - addressFormatVersion, - syncServers, - solanaEndpoint, -}) { +export async function updateServerOnSolana({ login, keyBundle, serverAddress, serverAddresses = null, addressFormatType = 1, addressFormatVersion = 0, solanaEndpoint }) { return updateShineUserPdaOnSolana({ login, solanaEndpoint, rootPrivatePkcs8B64: keyBundle.rootPair.privatePkcs8B64, clientPrivatePkcs8B64: keyBundle.clientPair.privatePkcs8B64, - serverProfile: { - addressFormatType, - addressFormatVersion, - serverAddress, - syncServers, - }, + authorityMode: 'root', + serverAddresses: serverAddresses || [{ addressFormatType, addressFormatVersion, address: serverAddress }], }); } export function calcLimitTopupPriceLamports(additionalLimitBytes, lamportsPerLimitStep) { const add = BigInt(additionalLimitBytes || 0); const pricePerStep = BigInt(lamportsPerLimitStep || 0); - if (add < 0n) throw new Error('Некорректный размер увеличения лимита'); - if (add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`); + if (add < 0n || add % LIMIT_STEP !== 0n) throw new Error(`Увеличение лимита должно быть кратно ${LIMIT_STEP} байт`); return (add / LIMIT_STEP) * pricePerStep; } -export function getLimitStepBytes() { - return LIMIT_STEP; -} +export function getLimitStepBytes() { return LIMIT_STEP; } diff --git a/shine-UI/server-ui/create-server-pda.html b/shine-UI/server-ui/create-server-pda.html index e58cbb59..06db7696 100644 --- a/shine-UI/server-ui/create-server-pda.html +++ b/shine-UI/server-ui/create-server-pda.html @@ -42,7 +42,7 @@

Регистрация серверного аккаунта

-

Создаёт user_pda в Solana с флагом is_server=true

+

Создаёт серверную user_pda в Solana (PDA 1.2)

Параметры Solana

@@ -65,12 +65,8 @@
- - -
-
- - + +
@@ -99,17 +95,12 @@
Ключевые пары (base58)
-
Recovery Key — восстановление аккаунта
-
Публичный
-
Приватный
-
-
-
Root Key — подпись PDA-записи
+
Root Key — холодное восстановление / полный контроль PDA
Публичный
Приватный
-
Blockchain Key — подпись LastBlockState
+
Blockchain Key — активный ключ блокчейна и обычный authority PDA
Публичный
Приватный
diff --git a/shine-UI/server-ui/js/create-server-pda-page.js b/shine-UI/server-ui/js/create-server-pda-page.js index 3733f063..05acbf99 100644 --- a/shine-UI/server-ui/js/create-server-pda-page.js +++ b/shine-UI/server-ui/js/create-server-pda-page.js @@ -20,8 +20,6 @@ import { defaultServerAddress, defaultServerLogin, defaultSolanaEndpoint } from const fieldMap = { masterSecret: 'masterSecret', - recoveryPub: 'recoveryPub', - recoveryPriv: 'recoveryPriv', rootPub: 'rootPub', rootPriv: 'rootPriv', bchPub: 'bchPub', @@ -113,7 +111,6 @@ $('btnCreate').addEventListener('click', async () => { login, keyBundle, serverAddress, - syncServers: parseLoginList($('syncServers').value), accessServers: parseLoginList($('accessServers').value), solanaEndpoint: endpoint, }); diff --git a/shine-UI/server-ui/js/read-pda-page.js b/shine-UI/server-ui/js/read-pda-page.js index 5c21041e..e245a925 100644 --- a/shine-UI/server-ui/js/read-pda-page.js +++ b/shine-UI/server-ui/js/read-pda-page.js @@ -112,6 +112,7 @@ function renderParsed(parsed) { renderRows([ { label: 'PDA адрес', value: parsed.pdaAddress }, + { label: 'Формат', value: `${parsed.formatMajor}.${parsed.formatMinor}` }, { label: 'Логин', value: parsed.login }, { label: 'Статус', value: parsed.isServer ? 'server' : 'not server' }, { label: 'recordNumber', value: String(parsed.recordNumber) }, @@ -126,11 +127,10 @@ function renderParsed(parsed) { const badgeLine = $('badgeLine'); badgeLine.innerHTML = ''; const badges = [ + { label: 'format=1.2', kind: 'ok' }, { label: `server=${parsed.isServer ? '1' : '0'}`, kind: parsed.isServer ? 'ok' : 'warn' }, - { label: `trusted=${Number(parsed.trustedCount || 0)}`, kind: Number(parsed.trustedCount || 0) > 0 ? 'ok' : 'warn' }, - { label: `sessions=${Array.isArray(parsed.sessions) ? parsed.sessions.length : 0}`, kind: 'info' }, + { label: `forks=${Array.isArray(parsed.forks) ? parsed.forks.length : 0}`, kind: 'info' }, { label: `access=${Array.isArray(parsed.accessServers) ? parsed.accessServers.length : 0}`, kind: 'info' }, - { label: `sync=${Array.isArray(parsed.syncServers) ? parsed.syncServers.length : 0}`, kind: 'info' }, ]; badges.forEach((item) => { const badge = document.createElement('span'); @@ -141,84 +141,55 @@ function renderParsed(parsed) { const blocks = $('blocksList'); blocks.innerHTML = ''; - blocks.appendChild(renderBlock('RecoveryKeyBlock', 'block_type=0', [ - { label: 'recoveryKey32', value: base58(parsed.recoveryKey) }, - ])); - blocks.appendChild(renderBlock('RootKeyBlock', 'block_type=1', [ + blocks.appendChild(renderBlock('RootKeyBlock', 'block_type=1, version=0', [ { label: 'rootKey32', value: base58(parsed.rootKey) }, ])); - blocks.appendChild(renderBlock('ClientKeyBlock', 'block_type=2', [ + blocks.appendChild(renderBlock('ClientKeyBlock', 'block_type=2, version=0', [ { label: 'clientKey32', value: base58(parsed.clientKey) }, ])); - blocks.appendChild(renderBlock('BlockchainRegistryBlock', 'block_type=3', [ - { label: 'blockchainType', value: String(parsed.blockchain?.blockchainType ?? 0) }, - { label: 'blockchainName', value: parsed.blockchain?.blockchainName || '—' }, - { label: 'blockchainPublicKey32', value: base58(parsed.blockchain?.blockchainPublicKey) }, - { label: 'paidLimitBytes', value: formatBigInt(parsed.blockchain?.paidLimitBytes || 0n) }, - { label: 'usedBytes', value: formatBigInt(parsed.blockchain?.usedBytes || 0n) }, - { label: 'lastBlockNumber', value: String(parsed.blockchain?.lastBlockNumber ?? 0) }, - { label: 'lastBlockHash32', value: hex(parsed.blockchain?.lastBlockHash) }, - { label: 'lastBlockSignature64', value: base58(parsed.blockchain?.lastBlockSignature) }, - { label: 'arweaveTxId', value: parsed.blockchain?.arweaveTxId || '—' }, - ])); - blocks.appendChild(renderBlock('ServerProfileBlock', 'block_type=30', [ - { label: 'isServer', value: parsed.isServer ? '1' : '0' }, - { label: 'addressFormatType', value: String(parsed.addressFormatType ?? 0) }, - { label: 'addressFormatVersion', value: String(parsed.addressFormatVersion ?? 0) }, - { label: 'serverAddress', value: parsed.serverAddress || '—' }, - { label: 'syncServersCount', value: String(parsed.syncServers?.length || 0) }, - { label: 'syncServers', value: renderArrayLine(parsed.syncServers) }, - ])); - blocks.appendChild(renderBlock('AccessServersBlock', 'block_type=40', [ - { label: 'accessServersCount', value: String(parsed.accessServers?.length || 0) }, - { label: 'accessServers', value: renderArrayLine(parsed.accessServers) }, - ])); - blocks.appendChild(renderBlock('SessionsBlock', 'block_type=50', [ - { label: 'sessionsMode', value: String(parsed.sessionsMode ?? 0) }, - { label: 'sessionsCount', value: String(parsed.sessions?.length || 0) }, - { label: 'sessions', value: parsed.sessions?.length ? 'ниже' : '—' }, - ])); - blocks.appendChild(renderBlock('TrustedStateBlock', 'block_type=70', [ - { label: 'trustedCount', value: String(parsed.trustedCount ?? 0) }, - ])); - blocks.appendChild(renderSessionList(parsed.sessions)); - $('rawJson').textContent = JSON.stringify({ + const forkLines = (parsed.forks || []).map((fork, idx) => + `#${idx}: ${base58(fork.blockchainKey)} · created=${formatTimestamp(fork.createdAtMs)} · limit=${formatBigInt(fork.paidLimitBytes)}`, + ); + blocks.appendChild(renderBlock('BlockchainRegistryBlock', 'block_type=3, version=0', [ + { label: 'forkCount', value: String(parsed.forks?.length || 0) }, + { label: 'forks', value: forkLines.length ? forkLines.join('\n') : '—' }, + ])); + + if (parsed.isServer) { + const addressLines = (parsed.serverAddresses || []).map((item) => + `type=${item.addressFormatType}, v=${item.addressFormatVersion}: ${item.address}`, + ); + blocks.appendChild(renderBlock('ServerProfileBlock', 'block_type=30, version=0', [ + { label: 'addressCount', value: String(parsed.serverAddresses?.length || 0) }, + { label: 'address', value: addressLines.length ? addressLines.join('\n') : '—' }, + ])); + } + if (Array.isArray(parsed.accessServers) && parsed.accessServers.length) { + blocks.appendChild(renderBlock('AccessServersBlock', 'block_type=40, version=0', [ + { label: 'accessServersCount', value: String(parsed.accessServers.length) }, + { label: 'accessServer', value: renderArrayLine(parsed.accessServers) }, + ])); + } + + const raw = { pdaAddress: parsed.pdaAddress, + format: `${parsed.formatMajor}.${parsed.formatMinor}`, login: parsed.login, isServer: parsed.isServer, recordNumber: parsed.recordNumber, createdAtMs: parsed.createdAtMs.toString(), updatedAtMs: parsed.updatedAtMs.toString(), recordLen: parsed.recordLen, - trustedCount: parsed.trustedCount, - addressFormatType: parsed.addressFormatType, - addressFormatVersion: parsed.addressFormatVersion, - serverAddress: parsed.serverAddress, - syncServers: parsed.syncServers, - accessServers: parsed.accessServers, - sessionsMode: parsed.sessionsMode, - sessions: parsed.sessions.map((s) => ({ - sessionType: s.sessionType, - sessionVersion: s.sessionVersion, - sessionName: s.sessionName, - sessionPubKey32: base58(s.sessionPubKey32), - })), - recoveryKey: base58(parsed.recoveryKey), + prevRecordHash: hex(parsed.prevRecordHash), rootKey: base58(parsed.rootKey), clientKey: base58(parsed.clientKey), - blockchain: { - blockchainType: parsed.blockchain.blockchainType, - blockchainName: parsed.blockchain.blockchainName, - blockchainPublicKey: base58(parsed.blockchain.blockchainPublicKey), - paidLimitBytes: parsed.blockchain.paidLimitBytes.toString(), - usedBytes: parsed.blockchain.usedBytes.toString(), - lastBlockNumber: parsed.blockchain.lastBlockNumber, - lastBlockHash: hex(parsed.blockchain.lastBlockHash), - lastBlockSignature: base58(parsed.blockchain.lastBlockSignature), - arweaveTxId: parsed.blockchain.arweaveTxId || '', - }, - }, null, 2); + serverAddresses: (parsed.serverAddresses || []).map((a) => ({ ...a })), + accessServers: parsed.accessServers || [], + signature: base58(parsed.signature), + }; + + $('rawJson').textContent = JSON.stringify(raw, null, 2); } $('btnLoad').addEventListener('click', async () => { diff --git a/shine-UI/server-ui/js/server-ui-shared.js b/shine-UI/server-ui/js/server-ui-shared.js index 5752ec31..8550ca92 100644 --- a/shine-UI/server-ui/js/server-ui-shared.js +++ b/shine-UI/server-ui/js/server-ui-shared.js @@ -111,18 +111,6 @@ async function pairFromSeedBase58(seedB58, explicitPubB58) { export async function buildKeyBundleFromForm(fieldMap, options = {}) { const requireBlockchain = options.requireBlockchain !== false; - let recovery = null; - const masterSecretValue = fieldMap.masterSecret ? String($(fieldMap.masterSecret).value || '').trim() : ''; - if (masterSecretValue) { - const masterSecret32 = ensure32Bytes(base58ToBytes(masterSecretValue)); - const recoveryPair = await deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'); - recovery = { - publicKeyB64: recoveryPair.publicKeyB64, - privatePkcs8B64: recoveryPair.privatePkcs8B64, - publicKeyB58: bytesToBase58(base64ToBytes(recoveryPair.publicKeyB64)), - privateSeedB58: bytesToBase58(base64ToBytes(recoveryPair.privatePkcs8B64).slice(-32)), - }; - } const root = await pairFromSeedBase58($(fieldMap.rootPriv).value, $(fieldMap.rootPub).value); const device = await pairFromSeedBase58($(fieldMap.devPriv).value, $(fieldMap.devPub).value); const blockchainPriv = String($(fieldMap.bchPriv).value || '').trim(); @@ -134,9 +122,6 @@ export async function buildKeyBundleFromForm(fieldMap, options = {}) { } return { keyBundle: { - recoveryPair: recovery - ? { publicKeyB64: recovery.publicKeyB64, privatePkcs8B64: recovery.privatePkcs8B64 } - : null, rootPair: { publicKeyB64: root.publicKeyB64, privatePkcs8B64: root.privatePkcs8B64 }, blockchainPair: blockchain ? { publicKeyB64: blockchain.publicKeyB64, privatePkcs8B64: blockchain.privatePkcs8B64 } @@ -144,8 +129,6 @@ export async function buildKeyBundleFromForm(fieldMap, options = {}) { clientPair: { publicKeyB64: device.publicKeyB64, privatePkcs8B64: device.privatePkcs8B64 }, }, normalized: { - recoveryPubB58: recovery?.publicKeyB58 || '', - recoveryPrivB58: recovery?.privateSeedB58 || '', rootPubB58: root.publicKeyB58, rootPrivB58: root.privateSeedB58, bchPubB58: blockchain?.publicKeyB58 || '', @@ -165,20 +148,14 @@ export async function deriveKeyBundleFromPassword({ login, password, onProgress login: cleanLogin, onProgress, }); - const [recoveryPair, rootPair, blockchainPair, clientPair] = await Promise.all([ - deriveEd25519FromMasterSecret(masterSecret32, 'recovery.key'), + const [rootPair, blockchainPair, clientPair] = await Promise.all([ deriveEd25519FromMasterSecret(masterSecret32, 'root.key'), deriveEd25519FromMasterSecret(masterSecret32, 'blockchain.key'), deriveEd25519FromMasterSecret(masterSecret32, 'client.key'), ]); return { masterSecret32, - keyBundle: { - recoveryPair, - rootPair, - blockchainPair, - clientPair, - }, + keyBundle: { rootPair, blockchainPair, clientPair }, }; } @@ -186,10 +163,6 @@ export function fillKeyFields(fieldMap, keyBundle, masterSecret32) { if (masterSecret32) { $(fieldMap.masterSecret).value = bytesToBase58(masterSecret32); } - if (fieldMap.recoveryPub && fieldMap.recoveryPriv && keyBundle.recoveryPair) { - $(fieldMap.recoveryPub).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.publicKeyB64)); - $(fieldMap.recoveryPriv).value = bytesToBase58(base64ToBytes(keyBundle.recoveryPair.privatePkcs8B64).slice(-32)); - } $(fieldMap.rootPub).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.publicKeyB64)); $(fieldMap.rootPriv).value = bytesToBase58(base64ToBytes(keyBundle.rootPair.privatePkcs8B64).slice(-32)); $(fieldMap.bchPub).value = bytesToBase58(base64ToBytes(keyBundle.blockchainPair.publicKeyB64)); diff --git a/shine-UI/server-ui/js/update-server-pda-page.js b/shine-UI/server-ui/js/update-server-pda-page.js index 69e82ebd..f55dcec0 100644 --- a/shine-UI/server-ui/js/update-server-pda-page.js +++ b/shine-UI/server-ui/js/update-server-pda-page.js @@ -10,7 +10,6 @@ import { formatBigInt, formatTimestamp, openDevnetTopup, - parseLoginList, publicKeyBytesToBase58, refreshDeviceBalance, setGenMessage, @@ -26,8 +25,6 @@ import { defaultServerAddress, defaultServerLogin, defaultSolanaEndpoint } from const fieldMap = { masterSecret: 'masterSecret', - recoveryPub: 'recoveryPub', - recoveryPriv: 'recoveryPriv', rootPub: 'rootPub', rootPriv: 'rootPriv', bchPub: 'bchPub', @@ -42,7 +39,6 @@ let currentPda = null; function resetExpectedKeysUi() { $('expectedKeysBox').style.display = 'none'; - setText('expectedRecoveryPub', ''); setText('expectedRootPub', ''); setText('expectedBchPub', ''); setText('expectedDevPub', ''); @@ -52,7 +48,6 @@ function resetExpectedKeysUi() { function renderExpectedKeys(parsed) { $('expectedKeysBox').style.display = 'block'; - setText('expectedRecoveryPub', publicKeyBytesToBase58(parsed.recoveryKey)); setText('expectedRootPub', publicKeyBytesToBase58(parsed.rootKey)); setText('expectedBchPub', publicKeyBytesToBase58(parsed.blockchain.blockchainPublicKey)); setText('expectedDevPub', publicKeyBytesToBase58(parsed.clientKey)); @@ -64,7 +59,6 @@ function compareCurrentFormKeysWithPda() { const blockchainActual = String($('bchPub').value || '').trim(); return { resultMap: { - recovery: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.recoveryKey), $('recoveryPub').value), root: compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.rootKey), $('rootPub').value), blockchain: blockchainActual ? compareExpectedPublicKeys(publicKeyBytesToBase58(currentPda.blockchain.blockchainPublicKey), blockchainActual) @@ -178,17 +172,15 @@ $('btnLoad').addEventListener('click', async () => { $('iCreated').textContent = formatTimestamp(parsed.createdAtMs); $('iUpdated').textContent = formatTimestamp(parsed.updatedAtMs); $('iSrvAddr').textContent = parsed.serverAddress || '—'; - $('iSync').textContent = parsed.syncServers.length ? parsed.syncServers.join(', ') : '—'; $('iBch').textContent = parsed.blockchain.blockchainName; $('iLimit').textContent = formatBigInt(parsed.blockchain.paidLimitBytes); $('password').value = ''; $('serverAddress').value = parsed.serverAddress || ''; - $('syncServers').value = parsed.syncServers.join('\n'); $('pdaInfo').style.display = 'block'; $('updateForm').style.display = 'block'; renderExpectedKeys(parsed); - setStatus($('status'), 'PDA загружена. Можно менять адрес или sync_servers.', 'success'); + setStatus($('status'), 'PDA загружена. Можно менять адрес сервера.', 'success'); } catch (error) { setStatus($('status'), error?.message || String(error), 'error'); } finally { @@ -227,7 +219,6 @@ $('btnUpdate').addEventListener('click', async () => { serverAddress, addressFormatType: currentPda.addressFormatType ?? 1, addressFormatVersion: currentPda.addressFormatVersion ?? 0, - syncServers: parseLoginList($('syncServers').value), solanaEndpoint: endpoint, }); diff --git a/shine-UI/server-ui/update-server-pda.html b/shine-UI/server-ui/update-server-pda.html index d770e74b..366947b6 100644 --- a/shine-UI/server-ui/update-server-pda.html +++ b/shine-UI/server-ui/update-server-pda.html @@ -50,7 +50,7 @@

Обновление PDA сервера

-

Меняет адрес сервера или список серверов синхронизации

+

Меняет адрес SHiNE-сервера и другие поля PDA 1.2

Параметры Solana

@@ -76,7 +76,6 @@
Создан
Обновлён
Адрес сервера
-
sync_servers
Blockchain
Paid limit
@@ -89,10 +88,6 @@ -
- - -
@@ -120,12 +115,7 @@
Ключевые пары (base58)
-
Recovery Key — восстановление аккаунта
-
Публичный
-
Приватный
-
-
-
Root Key — подпись PDA-записи
+
Root Key — холодное восстановление / полный контроль PDA
Публичный
Приватный
@@ -150,10 +140,6 @@