Переписать shine_users и shine_login_guard на чистый Rust

This commit is contained in:
AidarKC 2026-06-04 23:05:45 +04:00
parent 60049442f1
commit 832eea5889
17 changed files with 1355 additions and 1621 deletions

View File

@ -0,0 +1,33 @@
# Pure Rust `shine_users` и `shine_login_guard`
Статус: `pending`
## Что сделано
- `shine_login_guard` переписан без Anchor на чистый Rust/Solana SDK.
- `shine_users` переписан без Anchor на чистый Rust/Solana SDK.
- Для `shine_users` введён новый instruction ABI без Anchor discriminator'ов.
- Для `shine_users` используются новые seed'ы:
- `user_login=` для `user_pda`
- `shine_users_economy_config_v2` для economy PDA
- Формат блоков PDA синхронизирован:
- `SessionsBlock = 50`
- `TrustedStateBlock = 70`
- UI JS-модуль и Java lazy-import обновлены под новые seeds/ABI/коды блоков.
## Что проверить руками
1. В обычном UI выполнить регистрацию нового пользователя в Solana.
2. Проверить, что после регистрации читается новая `user_pda`.
3. В server UI выполнить создание server PDA.
4. В server UI выполнить update server PDA.
5. Проверить, что после update растёт `record_number`.
6. Проверить, что lazy-import на сервере читает новый формат PDA без ошибок.
7. Проверить, что старые Anchor discriminator'ы больше нигде не требуются.
## Ожидаемый результат
- Регистрация и update работают на новых чисто-rust программах.
- UI не использует старый Anchor ABI.
- Серверный Java parser читает новый формат PDA.
- Ошибок `out of memory` и anchor-specific падений больше нет.

View File

@ -26,7 +26,7 @@
Адрес пользовательской PDA вычисляется по логину:
- seed prefix: `login=`;
- seed prefix: `user_login=`;
- второй seed: нормализованный логин в нижнем регистре;
- program id: программа `shine_users`.
@ -90,8 +90,8 @@ UserPdaRecordV1
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `55` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. |
| `50` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
@ -280,7 +280,7 @@ AccessServersBlock
```text
SessionsBlock
- block_type: u8 = 55
- block_type: u8 = 50
- block_version: u8 = 0
- sessions_mode: u8
- sessions_count: u8
@ -326,7 +326,7 @@ SessionRecord
```text
TrustedStateBlock
- block_type: u8 = 50
- block_type: u8 = 70
- block_version: u8 = 0
- trusted_count: u8 = 0
```

View File

@ -79,7 +79,7 @@ anchor deploy -p shine_users
Страница сама вычисляет PDA `users_economy_config` по seed:
- seed: `shine_users_economy_config`
- seed: `shine_users_economy_config_v2`
- program: `FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm`
## Кто оплачивает create/update user_pda

View File

@ -190,7 +190,7 @@ public final class SolanaUserPdaImportService {
int n = u8(raw, c++);
c += n;
}
} else if (blockType == 55) {
} else if (blockType == 50) {
int sessionsMode = u8(raw, c++);
if (sessionsMode != 1 && sessionsMode != 10) return null;
int sessionsCount = u8(raw, c++);
@ -202,7 +202,7 @@ public final class SolanaUserPdaImportService {
c += n;
c += 32; // session_pub_key
}
} else if (blockType == 50) {
} else if (blockType == 70) {
c += 1;
} else {
return null;

View File

@ -1,2 +1,2 @@
client.version=1.2.127
server.version=1.2.119
client.version=1.2.128
server.version=1.2.120

View File

@ -10,10 +10,9 @@ import {
const MAGIC = 'SHiNE';
const LAST_BLOCK_STATE_PREFIX = 'SHiNE_LAST_BLOCK';
const SHINE_PAYMENTS_INFLOW_VAULT_SEED = 'shine_payments_inflow_vault';
const SHINE_USERS_USER_PDA_SEED_PREFIX = 'user_login=';
const LIMIT_STEP = 10_000n;
const BLOCKCHAIN_TYPE_MAIN_USER = 1;
const CREATE_USER_PDA_DISCRIMINATOR = new Uint8Array([139, 157, 13, 41, 142, 174, 226, 214]);
const UPDATE_USER_PDA_DISCRIMINATOR = new Uint8Array([42, 133, 114, 232, 38, 245, 167, 234]);
const ED25519_PROGRAM_ID = 'Ed25519SigVerify111111111111111111111111111';
const SYSVAR_INSTRUCTIONS_ID = 'Sysvar1nstructions1111111111111111111111111';
@ -22,8 +21,8 @@ const BLOCK_TYPE_DEVICE_KEY = 2;
const BLOCK_TYPE_BLOCKCHAIN_REGISTRY = 3;
const BLOCK_TYPE_SERVER_PROFILE = 30;
const BLOCK_TYPE_ACCESS_SERVERS = 40;
const BLOCK_TYPE_SESSIONS = 55;
const BLOCK_TYPE_TRUSTED_STATE = 50;
const BLOCK_TYPE_SESSIONS = 50;
const BLOCK_TYPE_TRUSTED_STATE = 70;
const SESSIONS_MODE_MIXED = 1;
const SESSION_TYPE_USER = 1;
const SESSION_TYPE_SUBSERVER = 100;
@ -175,60 +174,88 @@ function buildEd25519IxData(sig64, pubkey32, msgHash32) {
function serializeCreateUserPdaArgs(args) {
const buf = [];
for (const x of CREATE_USER_PDA_DISCRIMINATOR) buf.push(x);
pushStrU32(buf, args.login);
buf.push(3);
pushStrU8(buf, args.login);
for (const x of args.rootKey32) buf.push(x);
pushU64LE(buf, args.createdAtMs);
pushU64LE(buf, 0n);
pushU64LE(buf, BigInt(args.additionalLimitBytes || 0n));
for (const x of args.deviceKey32) buf.push(x);
for (const x of args.blockchainPublicKey32) buf.push(x);
pushStrU32(buf, args.blockchainName);
pushStrU8(buf, args.blockchainName);
pushU64LE(buf, args.usedBytes);
pushU32LE(buf, args.lastBlockNumber);
pushVecU8(buf, args.lastBlockHash32);
pushVecU8(buf, args.lastBlockSignature64);
pushStrU32(buf, args.arweaveTxId);
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);
pushStrU32(buf, args.serverAddress);
pushVecStrU32(buf, args.syncServers);
pushVecStrU32(buf, args.accessServers);
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 accessServers = Array.isArray(args.accessServers) ? args.accessServers : [];
buf.push(accessServers.length & 0xff);
for (const value of accessServers) pushStrU8(buf, value);
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
pushSessionRecordsU32(buf, args.sessions);
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);
pushVecU8(buf, args.rootSignature64);
for (const x of args.rootSignature64) buf.push(x);
return new Uint8Array(buf);
}
function serializeUpdateUserPdaArgs(args) {
const buf = [];
for (const x of UPDATE_USER_PDA_DISCRIMINATOR) buf.push(x);
pushStrU32(buf, args.login);
buf.push(4);
pushStrU8(buf, args.login);
for (const x of args.rootKey32) buf.push(x);
pushU64LE(buf, args.createdAtMs);
pushU64LE(buf, args.updatedAtMs);
pushU32LE(buf, args.version);
pushVecU8(buf, args.prevHash32);
for (const x of args.prevHash32) buf.push(x);
pushU64LE(buf, args.additionalLimitBytes);
for (const x of args.deviceKey32) buf.push(x);
for (const x of args.blockchainPublicKey32) buf.push(x);
pushStrU32(buf, args.blockchainName);
pushStrU8(buf, args.blockchainName);
pushU64LE(buf, args.usedBytes);
pushU32LE(buf, args.lastBlockNumber);
pushVecU8(buf, args.lastBlockHash32);
pushVecU8(buf, args.lastBlockSignature64);
pushStrU32(buf, args.arweaveTxId);
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);
pushStrU32(buf, args.serverAddress);
pushVecStrU32(buf, args.syncServers);
pushVecStrU32(buf, args.accessServers);
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 accessServers = Array.isArray(args.accessServers) ? args.accessServers : [];
buf.push(accessServers.length & 0xff);
for (const value of accessServers) pushStrU8(buf, value);
buf.push(Number(args.sessionsMode || SESSIONS_MODE_MIXED) & 0xff);
pushSessionRecordsU32(buf, args.sessions);
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);
pushVecU8(buf, args.rootSignature64);
for (const x of args.rootSignature64) buf.push(x);
return new Uint8Array(buf);
}
@ -500,7 +527,7 @@ export function serializeUnsignedRecordFromState(stateLike) {
pushU32LE(buf, state.recordNumber);
for (const x of state.prevRecordHash) buf.push(x);
pushStrU8(buf, state.login);
buf.push(state.isServer ? 6 : 5);
buf.push(state.isServer ? 7 : 6);
buf.push(BLOCK_TYPE_ROOT_KEY, 0);
for (const x of state.rootKey) buf.push(x);
@ -569,7 +596,7 @@ export async function readShineUserPda({ login, solanaEndpoint }) {
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('login='), enc.encode(cleanLogin)], usersProgram);
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 {
@ -625,7 +652,7 @@ async function buildCreateContext({ login, keyBundle, solanaEndpoint }) {
const sysvarInstructions = new solana.PublicKey(SYSVAR_INSTRUCTIONS_ID);
const enc = new TextEncoder();
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode('login='), enc.encode(cleanLogin)], usersProgram);
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);
@ -724,6 +751,7 @@ async function createShineUserPdaOnSolana({
login: cleanLogin,
rootKey32: ctx.rootKey32,
createdAtMs,
additionalLimitBytes: 0n,
deviceKey32: ctx.deviceKey32,
blockchainPublicKey32: ctx.blockchainKey32,
blockchainName,
@ -851,7 +879,7 @@ export async function updateShineUserPdaOnSolana({
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('login='), enc.encode(cleanLogin)], usersProgram);
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 deviceSeed32 = extractSeed32FromPkcs8B64(devicePrivatePkcs8B64);

View File

@ -3,6 +3,6 @@ export const SOLANA_ENDPOINT_DEFAULT = 'https://api.devnet.solana.com';
// Программа регистрации пользователей SHiNE (shine_users), задеплоена в devnet.
export const SHINE_USERS_PROGRAM_ID = 'FZS1YctoeEhCkZ5VTjsysUFAXR8CqxYztcLboXcg2Rpm';
export const SHINE_USERS_ECONOMY_CONFIG_SEED = 'shine_users_economy_config';
export const SHINE_USERS_ECONOMY_CONFIG_SEED = 'shine_users_economy_config_v2';
export const SHINE_LOGIN_GUARD_PROGRAM_ID = '3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo';
export const SHINE_PAYMENTS_PROGRAM_ID = 'm48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR';

View File

@ -3564,7 +3564,7 @@ dependencies = [
name = "shine_login_guard"
version = "0.1.0"
dependencies = [
"anchor-lang",
"solana-program 2.2.1",
]
[[package]]
@ -3580,9 +3580,7 @@ dependencies = [
name = "shine_users"
version = "0.1.0"
dependencies = [
"anchor-lang",
"common",
"shine_login_guard",
"solana-program 2.2.1",
]
[[package]]

View File

@ -26,7 +26,7 @@
Адрес пользовательской PDA вычисляется по логину:
- seed prefix: `login=`;
- seed prefix: `user_login=`;
- второй seed: нормализованный логин в нижнем регистре;
- program id: программа `shine_users`.
@ -90,8 +90,8 @@ UserPdaRecordV1
| `3` | `BlockchainRegistryBlock` | Один или несколько блокчейнов пользователя. |
| `30` | `ServerProfileBlock` | Серверные данные пользователя. |
| `40` | `AccessServersBlock` | Серверы доступа/relay. |
| `55` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. |
| `50` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `50` | `SessionsBlock` | Опубликованные пользовательские сессии и саб-серверы. |
| `70` | `TrustedStateBlock` | Счетчик trusted-связей. |
| `255` | `ReservedBlock` | Зарезервировано, пока не используется. |
Правила:
@ -280,7 +280,7 @@ AccessServersBlock
```text
SessionsBlock
- block_type: u8 = 55
- block_type: u8 = 50
- block_version: u8 = 0
- sessions_mode: u8
- sessions_count: u8
@ -326,7 +326,7 @@ SessionRecord
```text
TrustedStateBlock
- block_type: u8 = 50
- block_type: u8 = 70
- block_version: u8 = 0
- trusted_count: u8 = 0
```

View File

@ -61,11 +61,19 @@
- `classify_login(login: String)`
Бинарный ABI инструкции:
```text
- tag: u8 = 1
- login_len: u32 LE
- login_bytes[login_len]: UTF-8
```
Аккаунты:
- signer
- аккаунты не требуются
Signer здесь технический и нужен как согласованный интерфейс вызова; бизнес-смысл подписи отсутствует.
Подпись транзакции для самой классификации не имеет бизнес-смысла. Если вызов идёт через CPI из `shine_users`, программа `shine_login_guard` не требует signer-аккаунтов вообще.
## 7. Выходные классы

View File

@ -52,26 +52,26 @@
Пользовательская запись строится так:
- seed prefix: `login=`
- seed prefix: `user_login=`
- второй seed: логин в нижнем регистре
- program id: `shine_users`
Формула:
```text
user_pda = PDA(["login=", lower(login)], shine_users_program_id)
user_pda = PDA(["user_login=", lower(login)], shine_users_program_id)
```
### 3.2. Economy config PDA
PDA экономических настроек:
- seed: `shine_users_economy_config`
- seed: `shine_users_economy_config_v2`
Формула:
```text
users_economy_config_pda = PDA(["shine_users_economy_config"], shine_users_program_id)
users_economy_config_pda = PDA(["shine_users_economy_config_v2"], shine_users_program_id)
```
## 4. Состояния программы
@ -105,8 +105,8 @@ users_economy_config_pda = PDA(["shine_users_economy_config"], shine_users_progr
Базовые значения из текущей логики:
- seed `user_pda`: `login=`
- seed economy config: `shine_users_economy_config`
- seed `user_pda`: `user_login=`
- seed economy config: `shine_users_economy_config_v2`
- стартовый размер `user_pda`: `768` байт
- `LIMIT_STEP = 10_000`
- `START_REGISTRATION_FEE_LAMPORTS = 10_000_000`
@ -199,9 +199,15 @@ On-chain инвариант только один:
### Правила
- PDA должна ещё не существовать;
- адрес PDA обязан совпадать с seed `shine_users_economy_config`;
- адрес PDA обязан совпадать с seed `shine_users_economy_config_v2`;
- в PDA записывается стартовый `UsersEconomyConfigState`.
### Бинарный ABI
```text
- tag: u8 = 1
```
## 9. Инструкция `update_users_economy_config`
### Назначение
@ -223,6 +229,15 @@ On-chain инвариант только один:
- PDA должна существовать и принадлежать программе;
- `lamports_per_limit_step > 0`.
### Бинарный ABI
```text
- tag: u8 = 2
- registration_fee_lamports: u64 LE
- lamports_per_limit_step: u64 LE
- start_bonus_limit: u64 LE
```
## 10. Инструкция `create_user_pda`
### Назначение
@ -259,6 +274,18 @@ On-chain инвариант только один:
- mutable fields записи
- `root` signature по unsigned части
### Бинарный ABI
```text
- tag: u8 = 3
- login: string_u8
- root_key: [u8; 32]
- created_at_ms: u64 LE
- additional_limit: u64 LE
- fields: UserMutableFieldsV1
- root_signature: [u8; 64]
```
### Обязательные проверки
1. Логин валиден по синтаксису.
@ -352,16 +379,20 @@ topup_fee = limit_fee(additional_limit)
Если `additional_limit = 0`, доплата не требуется.
### Важная пометка о текущем состоянии
### Бинарный ABI
Логика `update_user_pda` в текущей Anchor-версии описана именно так и должна работать именно так.
Но фактическая текущая Anchor-реализация на момент подготовки этого документа имеет runtime-проблемы в Solana BPF:
- create-сценарий работает корректно;
- update-сценарий логически реализован, но требует исправления багов реализации.
При переписи на чистый Rust нужно сохранять именно правила этого документа, а не привязываться к деталям текущей Anchor-реализации.
```text
- tag: u8 = 4
- login: string_u8
- root_key: [u8; 32]
- created_at_ms: u64 LE
- updated_at_ms: u64 LE
- version: u32 LE
- prev_hash: [u8; 32]
- additional_limit: u64 LE
- fields: UserMutableFieldsV1
- root_signature: [u8; 64]
```
## 12. Ed25519-проверки и порядок инструкций
@ -426,6 +457,36 @@ signature = Ed25519(blockchain_private_key, message_hash)
## 15. Правила серверных и сессионных полей
### UserMutableFieldsV1
```text
- device_key: [u8; 32]
- blockchain_public_key: [u8; 32]
- blockchain_name: string_u8
- used_bytes: u64 LE
- last_block_number: u32 LE
- last_block_hash: [u8; 32]
- last_block_signature: [u8; 64]
- arweave_tx_id: string_u8
- is_server: u8
- if is_server = 1:
- address_format_type: u8
- address_format_version: u8
- server_address: string_u8
- sync_servers_count: u8
- sync_servers[sync_servers_count]: string_u8[]
- access_servers_count: u8
- access_servers[access_servers_count]: string_u8[]
- sessions_mode: u8
- sessions_count: u8
- sessions[sessions_count]:
- session_type: u8
- session_version: u8
- session_name: string_u8
- session_pub_key: [u8; 32]
- trusted_count: u8
```
### Server profile
Если `is_server = false`:
@ -483,7 +544,7 @@ signature = Ed25519(blockchain_private_key, message_hash)
## 18. Что должно сохраниться при переписи без Anchor
При переписи на чистый Rust нужно сохранить без изменений:
В текущей чисто-rust реализации уже сохранены:
- те же PDA seed-правила;
- тот же формат `user_pda`;
@ -493,9 +554,9 @@ signature = Ed25519(blockchain_private_key, message_hash)
- ту же валидацию логина и CPI в `shine_login_guard`;
- ту же зависимость от inflow vault программы `shine_payments`.
Что не обязано сохраниться:
Сознательно не сохранялись:
- структура Anchor `Context`;
- Anchor discriminator'ы;
- внутренние helper-функции текущей реализации;
- текущие runtime-баги update-path.
- Anchor discriminator'ы и Anchor-ABI инструкций;
- старые seed'ы, которые конфликтовали с уже существующим Anchor-состоянием в devnet;
- внутренние helper-функции старой реализации.

View File

@ -12,15 +12,9 @@ doctest = false
bench = false
[dependencies]
anchor-lang = "0.31.1"
solana-program = "2.1.21"
[features]
default = []
no-entrypoint = []
no-idl = []
no-log-ix-name = []
anchor-debug = []
custom-heap = []
custom-panic = []
cpi = []
idl-build = ["anchor-lang/idl-build"]

View File

@ -1,39 +1,83 @@
use anchor_lang::prelude::*;
use anchor_lang::solana_program::program::set_return_data;
declare_id!("3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo");
use solana_program::{
account_info::AccountInfo,
entrypoint,
entrypoint::ProgramResult,
program::set_return_data,
program_error::ProgramError,
pubkey::Pubkey,
};
mod wordlist {
include!(concat!(env!("OUT_DIR"), "/generated_dictionary.rs"));
}
const CLASS_FREE: u32 = 0;
const CLASS_PREMIUM: u32 = 1;
const CLASS_TRADEMARK: u32 = 2;
const MAX_WORDS_PER_LOGIN: usize = 3;
pub const INSTRUCTION_CLASSIFY_LOGIN: u8 = 1;
pub const CLASS_FREE: u32 = 0;
pub const CLASS_PREMIUM: u32 = 1;
pub const CLASS_TRADEMARK: u32 = 2;
pub const MAX_WORDS_PER_LOGIN: usize = 3;
pub const MAX_LOGIN_LEN: usize = 20;
#[program]
pub mod shine_login_guard {
use super::*;
solana_program::declare_id!("3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo");
pub fn classify_login(_ctx: Context<ClassifyLogin>, login: String) -> Result<()> {
let class = classify(&login);
entrypoint!(process_instruction);
pub fn process_instruction(
_program_id: &Pubkey,
_accounts: &[AccountInfo],
instruction_data: &[u8],
) -> ProgramResult {
let class = match parse_instruction(instruction_data)? {
Instruction::ClassifyLogin { login } => classify(&login),
};
set_return_data(&class.to_le_bytes());
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Instruction {
ClassifyLogin { login: String },
}
fn parse_instruction(data: &[u8]) -> Result<Instruction, ProgramError> {
let (&tag, rest) = data
.split_first()
.ok_or(ProgramError::InvalidInstructionData)?;
match tag {
INSTRUCTION_CLASSIFY_LOGIN => {
let login = read_string_u32(rest)?;
Ok(Instruction::ClassifyLogin { login })
}
_ => Err(ProgramError::InvalidInstructionData),
}
}
#[derive(Accounts)]
pub struct ClassifyLogin<'info> {
pub signer: Signer<'info>,
fn read_string_u32(data: &[u8]) -> Result<String, ProgramError> {
if data.len() < 4 {
return Err(ProgramError::InvalidInstructionData);
}
let len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
let body = data
.get(4..4 + len)
.ok_or(ProgramError::InvalidInstructionData)?;
std::str::from_utf8(body)
.map(|s| s.to_string())
.map_err(|_| ProgramError::InvalidInstructionData)
}
fn classify(login: &str) -> u32 {
pub fn build_classify_login_instruction_data(login: &str) -> Vec<u8> {
let bytes = login.as_bytes();
let mut out = Vec::with_capacity(1 + 4 + bytes.len());
out.push(INSTRUCTION_CLASSIFY_LOGIN);
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
out.extend_from_slice(bytes);
out
}
pub fn classify(login: &str) -> u32 {
let Some(normalized) = normalize_login(login) else {
return CLASS_PREMIUM;
};
// Сначала пытаемся классифицировать по словарям (в т.ч. trademark/company),
// и только если не нашли совпадений — применяем правило короткого логина.
if let Some(v) = classify_split(&normalized) {
return v;
}
@ -44,7 +88,7 @@ fn classify(login: &str) -> u32 {
}
fn normalize_login(login: &str) -> Option<String> {
if login.is_empty() || login.len() > 20 {
if login.is_empty() || login.len() > MAX_LOGIN_LEN {
return None;
}
let mut out = String::with_capacity(login.len());
@ -57,7 +101,7 @@ fn normalize_login(login: &str) -> Option<String> {
}
out.push(ch.to_ascii_lowercase());
}
if out.is_empty() || out.len() > 20 {
if out.is_empty() || out.len() > MAX_LOGIN_LEN {
return None;
}
Some(out)
@ -74,7 +118,7 @@ fn classify_split(login: &str) -> Option<u32> {
if depth >= MAX_WORDS_PER_LOGIN {
return None;
}
let max_piece = rest.len().min(20);
let max_piece = rest.len().min(MAX_LOGIN_LEN);
let mut premium_found = false;
for i in 1..=max_piece {
let candidate = &rest[..i];
@ -87,7 +131,7 @@ fn classify_split(login: &str) -> Option<u32> {
Some(CLASS_TRADEMARK) => return Some(CLASS_TRADEMARK),
Some(CLASS_PREMIUM) => premium_found = true,
_ => {}
};
}
}
if premium_found {
Some(CLASS_PREMIUM)
@ -105,3 +149,35 @@ fn is_premium_word(word: &str) -> bool {
fn is_trademark_word(word: &str) -> bool {
wordlist::TRADEMARK_WORDS.binary_search(&word).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_removes_underscores_and_lowercases() {
assert_eq!(normalize_login("Ab_C1"), Some("abc1".to_string()));
}
#[test]
fn normalize_rejects_invalid_chars() {
assert_eq!(normalize_login("ab-c"), None);
}
#[test]
fn short_unknown_login_is_premium() {
assert_eq!(classify("abc123"), CLASS_PREMIUM);
}
#[test]
fn long_unknown_login_is_free() {
assert_eq!(classify("abcdefgh9"), CLASS_FREE);
}
#[test]
fn instruction_roundtrip() {
let data = build_classify_login_instruction_data("Alice_1");
let parsed = parse_instruction(&data).unwrap();
assert_eq!(parsed, Instruction::ClassifyLogin { login: "Alice_1".to_string() });
}
}

View File

@ -12,18 +12,10 @@ doctest = false
bench = false
[dependencies]
anchor-lang = "0.31.1"
common = { path = "../common" }
shine_login_guard = { path = "../shine_login_guard", features = ["cpi", "no-entrypoint"] }
solana-program = "2.1.21"
[features]
default = []
no-entrypoint = []
no-idl = []
no-log-ix-name = []
anchor-debug = []
custom-heap = []
custom-panic = []
cpi = []
idl-build = ["anchor-lang/idl-build"]

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,27 @@
use common::deploy_config;
/// `USER_PDA_SEED_PREFIX` — префикс seed для пользовательского PDA (`login=<...>`).
pub const USER_PDA_SEED_PREFIX: &str = "login=";
/// `USERS_ECONOMY_CONFIG_SEED` — seed PDA с экономическими параметрами программы `shine_users`.
pub const USERS_ECONOMY_CONFIG_SEED: &[u8] = b"shine_users_economy_config";
/// `USER_PDA_SPACE` — стартовый размер PDA пользователя, дальше запись может расширяться через realloc.
/// Префикс seed для пользовательского PDA новой чисто-rust реализации `shine_users`.
pub const USER_PDA_SEED_PREFIX: &str = "user_login=";
/// Seed PDA с экономическими параметрами программы `shine_users`.
pub const USERS_ECONOMY_CONFIG_SEED: &[u8] = b"shine_users_economy_config_v2";
/// Стартовый размер PDA пользователя, дальше запись может расширяться через realloc.
pub const USER_PDA_SPACE: usize = 768;
/// `USERS_ECONOMY_CONFIG_SPACE` — размер PDA с экономическими параметрами `shine_users`.
pub const USERS_ECONOMY_CONFIG_SPACE: usize = 8 + 96;
/// Размер PDA с экономическими параметрами `shine_users`.
pub const USERS_ECONOMY_CONFIG_SPACE: usize = 32;
/// `DAO_AUTHORITY` — адрес DAO-авторити, который имеет право обновлять economy-конфиг.
pub const DAO_AUTHORITY: &str = deploy_config::DAO_AUTHORITY;
/// Адрес DAO authority, который имеет право обновлять economy-конфиг.
pub const DAO_AUTHORITY: &str = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P";
/// `SHINE_PAYMENTS_PROGRAM_ID` — адрес программы `shine_payments`, от которой вычисляется PDA inflow-вольта.
pub const SHINE_PAYMENTS_PROGRAM_ID: &str = deploy_config::SHINE_PAYMENTS_PROGRAM_ID;
/// `SHINE_PAYMENTS_INFLOW_VAULT_SEED` — seed inflow-вольта в программе `shine_payments` (должен совпадать с payments settings).
/// Адрес программы `shine_payments`, от которой вычисляется PDA inflow-вольта.
pub const SHINE_PAYMENTS_PROGRAM_ID: &str = "m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR";
/// Seed inflow-вольта в программе `shine_payments`.
pub const SHINE_PAYMENTS_INFLOW_VAULT_SEED: &[u8] = b"shine_payments_inflow_vault";
/// `SHINE_LOGIN_GUARD_PROGRAM_ID` — адрес отдельной программы проверки премиальности логина.
pub const SHINE_LOGIN_GUARD_PROGRAM_ID: &str = deploy_config::SHINE_LOGIN_GUARD_PROGRAM_ID;
/// `START_REGISTRATION_FEE_LAMPORTS` — стартовая комиссия регистрации (0.01 SOL) для initial economy-конфига.
/// Адрес отдельной программы проверки премиальности логина.
pub const SHINE_LOGIN_GUARD_PROGRAM_ID: &str = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo";
/// Стартовая комиссия регистрации (0.01 SOL) для initial economy-конфига.
pub const START_REGISTRATION_FEE_LAMPORTS: u64 = 10_000_000;
/// `LIMIT_STEP` — шаг пополнения лимита; `additional_limit` должен быть кратен этому значению.
/// Шаг пополнения лимита.
pub const LIMIT_STEP: u64 = 10_000;
/// `START_LAMPORTS_PER_LIMIT_STEP` — стартовая цена одного шага лимита (0.0001 SOL за 10_000 лимита).
/// Стартовая цена одного шага лимита.
pub const START_LAMPORTS_PER_LIMIT_STEP: u64 = 100_000;
/// `START_BONUS_LIMIT` — стартовый бонус лимита, выдаваемый пользователю при создании записи.
/// Стартовый бонус лимита.
pub const START_BONUS_LIMIT: u64 = 100_000;

File diff suppressed because it is too large Load Diff