From 0a416a2f5c3a375acd1a1ef4bf4e76e308173d5589c3c40c8ccef7fe9cb6cfe6 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 30 Jun 2026 17:39:40 +0400 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20promo-=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20=D0=B8=20=D0=BE=D1=84=D1=84=D0=BB=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BF=D1=80=D0=BE=D0=BC=D0=BE=D0=BA=D0=BE=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-30_1300_promo_loginy_solana.md | 20 + VERSION.properties | 4 +- shine-UI/js/pages/register-view.js | 82 ++- shine-UI/js/pages/registration-keys-view.js | 2 + .../js/pages/registration-payment-view.js | 3 + .../js/services/shine-user-pda-service.js | 83 ++- .../js/services/solana-register-service.js | 4 +- shine-UI/js/state.js | 2 + shine-UI/promo-code-generator.html | 555 ++++++++++++++++++ .../shine/doc/programs/shine_users.md | 174 +++++- .../shine/programs/shine_users/src/lib.rs | 261 +++++++- .../programs/shine_users/src/settings.rs | 6 + 12 files changed, 1154 insertions(+), 42 deletions(-) create mode 100644 Dev_Docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md create mode 100644 shine-UI/promo-code-generator.html diff --git a/Dev_Docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md b/Dev_Docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md new file mode 100644 index 0000000..b60b090 --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-30_1300_promo_loginy_solana.md @@ -0,0 +1,20 @@ +# Promo-логины через продавцов в Solana + +- краткое описание: + - в `shine_users` добавлена поддержка PDA продавцов красивых логинов, promo-подписи `shine_promo_v1:` и отдельная автономная страница `shine-UI/promo-code-generator.html` для генерации promo-кода через браузерный кошелёк или через ручной ввод Base58 seed 32 bytes; +- что проверять: + - admin-транзакцией создать или обновить `promo_seller_pda`; + - сгенерировать promo-код на странице `promo-code-generator.html` в режиме wallet extension; + - сгенерировать promo-код на той же странице в режиме ручного Base58 seed; + - открыть HTML локально как отдельный файл без сервера и убедиться, что генерация в режиме Base58 seed продолжает работать; + - зарегистрировать короткий/premium логин по promo-коду; + - убедиться, что без promo-кода тот же логин не проходит `shine_login_guard`; + - убедиться, что после успешной регистрации `remaining_sales` уменьшается на 1; + - проверить отказ при исчерпанной квоте, неверной подписи и логине короче `min_login_length`; +- ожидаемый результат: + - promo-регистрация проходит только при валидной подписи продавца и соблюдении лимитов; + - обычная регистрация без promo-кода продолжает работать как раньше; + - страница генерации выдаёт строку формата `1seller-signatureBase58` в обоих режимах; + - single-file HTML работает локально оффлайн минимум в режиме ручного Base58 seed; +- статус: + - `pending` diff --git a/VERSION.properties b/VERSION.properties index cf0adbd..17154dc 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.290 -server.version=1.2.270 +client.version=1.2.291 +server.version=1.2.271 diff --git a/shine-UI/js/pages/register-view.js b/shine-UI/js/pages/register-view.js index ba4c6fa..2132d80 100644 --- a/shine-UI/js/pages/register-view.js +++ b/shine-UI/js/pages/register-view.js @@ -71,6 +71,7 @@ export function render({ navigate }) { let passwordMode = String(state.registrationDraft.passwordMode || 'single') === 'words' ? 'words' : 'single'; let passwordWords = normalizePasswordWords(state.registrationDraft.passwordWords); + let usePromoCode = Boolean(state.registrationDraft.usePromoCode); const loginInput = document.createElement('input'); loginInput.className = 'input'; @@ -117,6 +118,40 @@ export function render({ navigate }) { passwordModeToggle.append(passwordModeCheckbox, passwordModeLabel); + const promoToggle = document.createElement('label'); + promoToggle.className = 'registration-toggle'; + + const promoCheckbox = document.createElement('input'); + promoCheckbox.type = 'checkbox'; + promoCheckbox.checked = usePromoCode; + + const promoToggleLabel = document.createElement('span'); + promoToggleLabel.textContent = 'У меня есть промокод'; + + promoToggle.append(promoCheckbox, promoToggleLabel); + + const promoField = document.createElement('label'); + promoField.className = 'stack'; + + const promoFieldLabel = document.createElement('span'); + promoFieldLabel.className = 'field-label'; + promoFieldLabel.textContent = 'Промокод'; + + const promoInput = document.createElement('input'); + promoInput.className = 'input'; + promoInput.type = 'text'; + promoInput.autocomplete = 'off'; + promoInput.autocapitalize = 'off'; + promoInput.spellcheck = false; + promoInput.value = String(state.registrationDraft.promoCode || ''); + promoInput.placeholder = 'Вставьте промокод'; + + const promoHint = document.createElement('p'); + promoHint.className = 'meta-muted'; + promoHint.textContent = 'Если промокод включён, обычная проверка premium/trademark логина пропускается.'; + + promoField.append(promoFieldLabel, promoInput, promoHint); + const statusText = document.createElement('p'); statusText.className = 'meta-muted'; statusText.textContent = 'Проверка логина: не выполнена'; @@ -202,6 +237,12 @@ export function render({ navigate }) { state.registrationDraft.passwordMode = passwordMode; state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords); state.registrationDraft.password = getCurrentPassword(); + state.registrationDraft.usePromoCode = usePromoCode; + state.registrationDraft.promoCode = String(promoInput.value || '').trim(); + } + + function updatePromoVisibility() { + promoField.style.display = usePromoCode ? 'grid' : 'none'; } async function runAvailabilityCheck() { @@ -212,6 +253,16 @@ export function render({ navigate }) { return false; } + if (usePromoCode) { + lastCheckedLogin = login; + lastCheckedFree = true; + lastCheckedClassName = 'promo'; + statusText.textContent = 'Промокод включён: обычная проверка premium/trademark логина пропущена'; + statusText.className = 'status-line is-available'; + formError.style.display = 'none'; + return true; + } + if (login === lastCheckedLogin) { if (!lastCheckedFree) { statusText.textContent = 'Логин уже занят ❌'; @@ -320,8 +371,34 @@ export function render({ navigate }) { syncDraftState(); }); + promoCheckbox.addEventListener('change', () => { + usePromoCode = promoCheckbox.checked; + lastCheckedLogin = ''; + lastCheckedFree = false; + lastCheckedClassName = ''; + updatePromoVisibility(); + syncDraftState(); + if (usePromoCode) { + statusText.textContent = 'Промокод включён: проверка логина будет пропущена'; + statusText.className = 'status-line is-available'; + } else { + statusText.textContent = 'Проверка логина: не выполнена'; + statusText.className = 'meta-muted'; + } + }); + + promoInput.addEventListener('input', () => { + syncDraftState(); + }); + nextButton.addEventListener('click', async () => { formError.style.display = 'none'; + const promoCode = String(promoInput.value || '').trim(); + if (usePromoCode && !promoCode) { + formError.textContent = 'Если включён промокод, поле промокода должно быть заполнено.'; + formError.style.display = ''; + return; + } const isFree = await runAvailabilityCheck(); if (!isFree) return; @@ -345,6 +422,8 @@ export function render({ navigate }) { state.registrationDraft.password = nextPassword; state.registrationDraft.passwordMode = passwordMode; state.registrationDraft.passwordWords = normalizePasswordWords(passwordWords); + state.registrationDraft.usePromoCode = usePromoCode; + state.registrationDraft.promoCode = promoCode; if (credsChanged) { state.registrationDraft.preGeneratedKeyBundle = null; } @@ -363,10 +442,11 @@ export function render({ navigate }) { passwordField = form.children[1]; loginField.append(loginInput); passwordField.append(passwordInput); - form.append(passwordModeToggle, passwordLengthText, wordsSection, statusText, checkButton, formError); + form.append(passwordModeToggle, promoToggle, promoField, passwordLengthText, wordsSection, statusText, checkButton, formError); actions.innerHTML = ''; actions.append(backButton, nextButton); updatePasswordModeVisibility(); + updatePromoVisibility(); syncDraftState(); } diff --git a/shine-UI/js/pages/registration-keys-view.js b/shine-UI/js/pages/registration-keys-view.js index cd19a66..481431f 100644 --- a/shine-UI/js/pages/registration-keys-view.js +++ b/shine-UI/js/pages/registration-keys-view.js @@ -155,6 +155,8 @@ export function render({ navigate }) { state.registrationDraft.password = ''; state.registrationDraft.passwordMode = 'single'; state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice(); + state.registrationDraft.usePromoCode = false; + state.registrationDraft.promoCode = ''; state.registrationDraft.storagePwd = ''; state.registrationDraft.sessionId = ''; state.registrationDraft.pendingKeyBundle = null; diff --git a/shine-UI/js/pages/registration-payment-view.js b/shine-UI/js/pages/registration-payment-view.js index fa2ec49..c896236 100644 --- a/shine-UI/js/pages/registration-payment-view.js +++ b/shine-UI/js/pages/registration-payment-view.js @@ -82,6 +82,8 @@ async function completeRegistrationLogin({ navigate, keyBundle }) { state.registrationDraft.password = ''; state.registrationDraft.passwordMode = 'single'; state.registrationDraft.passwordWords = EMPTY_PASSWORD_WORDS.slice(); + state.registrationDraft.usePromoCode = false; + state.registrationDraft.promoCode = ''; state.registrationDraft.storagePwd = ''; state.registrationDraft.sessionId = ''; state.registrationDraft.pendingKeyBundle = null; @@ -264,6 +266,7 @@ export function render({ navigate }) { keyBundle, solanaEndpoint: state.entrySettings.solanaServer, accessServers: [state.entrySettings.shineServerLogin || defaultServerLogin], + promoCode: state.registrationDraft.usePromoCode ? state.registrationDraft.promoCode : '', }); } catch (solanaError) { const solanaMsg = formatSolanaErrorDetails(solanaError); diff --git a/shine-UI/js/services/shine-user-pda-service.js b/shine-UI/js/services/shine-user-pda-service.js index 2ebd155..5c57c32 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 { base64ToBytes, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; +import { base64ToBytes, bytesToBase58, importPkcs8Ed25519, sha256Bytes, signBytes } from './crypto-utils.js'; import { extractSeed32FromPkcs8B64 } from './client-key-utils.js'; import { SHINE_LOGIN_GUARD_PROGRAM_ID, @@ -12,10 +12,12 @@ 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 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; @@ -214,6 +216,10 @@ function serializeCreateUserPdaArgs(args) { } buf.push(Number(args.trustedCount || 0) & 0xff); for (const x of args.rootSignature64) buf.push(x); + const promoCode = String(args.promoCode || '').trim(); + if (promoCode) { + pushStrU8(buf, promoCode); + } return new Uint8Array(buf); } @@ -363,6 +369,37 @@ export function buildLastBlockStateBytes(login, blockchainName, lastBlockNumber return new Uint8Array(buf); } +export function buildPromoMessageBytes(login) { + return new TextEncoder().encode(`${PROMO_SIGN_PREFIX}${normalizeLogin(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 байта'); + } + return `1${cleanSellerLogin}-${bytesToBase58(sig)}`; +} + +export async function findPromoSellerPda({ sellerLogin }) { + const cleanSellerLogin = normalizeLogin(sellerLogin); + if (!cleanSellerLogin) throw new Error('Не указан логин продавца'); + const solana = await loadSolanaLib(); + const usersProgram = new solana.PublicKey(SHINE_USERS_PROGRAM_ID); + const [promoSellerPda] = solana.PublicKey.findProgramAddressSync( + [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); @@ -770,6 +807,7 @@ async function createShineUserPdaOnSolana({ serverAddress = '', syncServers = [], accessServers = [], + promoCode = '', }) { const ctx = await buildCreateContext({ login, keyBundle, solanaEndpoint }); const ecoAccount = await ctx.connection.getAccountInfo(ctx.economyConfigPda); @@ -778,6 +816,7 @@ async function createShineUserPdaOnSolana({ } const cleanLogin = ctx.cleanLogin; + const cleanPromoCode = String(promoCode || '').trim(); const blockchainName = `${cleanLogin}-001`; const zeroHash32 = new Uint8Array(32); const createdAtMs = BigInt(Date.now()); @@ -845,8 +884,41 @@ async function createShineUserPdaOnSolana({ sessions: [], trustedCount: 0, rootSignature64: rootSig64, + promoCode: cleanPromoCode, }); + let promoSellerPda = null; + let promoEd25519Ix = null; + 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); + 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 = ctx.solana.bs58.decode(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 ed25519RootIx = new ctx.solana.TransactionInstruction({ programId: ctx.ed25519Program, keys: [], @@ -867,15 +939,19 @@ async function createShineUserPdaOnSolana({ { pubkey: ctx.sysvarInstructions, isSigner: false, isWritable: false }, { pubkey: ctx.economyConfigPda, isSigner: false, isWritable: false }, { pubkey: ctx.loginGuardProgram, isSigner: false, isWritable: false }, + ...(promoSellerPda ? [{ pubkey: promoSellerPda, isSigner: false, isWritable: true }] : []), ], 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, - new ctx.solana.Transaction().add(ed25519RootIx, ed25519BchIx, createIx), + tx, [ctx.clientKeypair], { commitment: 'confirmed' }, ); @@ -891,13 +967,14 @@ async function createShineUserPdaOnSolana({ }; } -export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [] }) { +export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [], promoCode = '' }) { return createShineUserPdaOnSolana({ login, keyBundle, solanaEndpoint, isServer: false, accessServers: Array.isArray(accessServers) ? accessServers : [], + promoCode, }); } diff --git a/shine-UI/js/services/solana-register-service.js b/shine-UI/js/services/solana-register-service.js index f2a5f9c..a65d1cc 100644 --- a/shine-UI/js/services/solana-register-service.js +++ b/shine-UI/js/services/solana-register-service.js @@ -139,6 +139,6 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) { return { exists: !!ai, userPda: userPda.toBase58() }; } -export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers }) { - return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint, accessServers }); +export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers, promoCode = '' }) { + return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint, accessServers, promoCode }); } diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 69aa51a..f644e50 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -269,6 +269,8 @@ function createInitialState({ withStoredSession = true } = {}) { password: '', passwordMode: 'single', passwordWords: emptyPasswordWords(), + usePromoCode: false, + promoCode: '', sessionId: '', storagePwd: '', pendingKeyBundle: null, diff --git a/shine-UI/promo-code-generator.html b/shine-UI/promo-code-generator.html new file mode 100644 index 0000000..2669ebc --- /dev/null +++ b/shine-UI/promo-code-generator.html @@ -0,0 +1,555 @@ + + + + + + SHiNE Promo Code Generator + + + +
+

Генератор promo-кодов

+

+ Полностью автономный HTML-файл. Можно открыть локально без сервера и без внешних зависимостей. + Подписывается строка shine_promo_v1:<login>, а на выходе получается promo-код + формата 1seller-signatureBase58. +

+ +
+
+ Режим подписи +
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ Подключённый кошелёк +
не подключен
+
+ +
+
+ + + +
+ Подписываемое сообщение +
+
+ +
+
+ + +
+
+ +
+ + +
+
+ +

Готово. Можно работать полностью локально.

+
+ + + + diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index 75186e0..4bded3f 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -19,8 +19,10 @@ - создание `user_pda` по логину; - обновление `user_pda` без смены логина и root key; - хранение economy-конфига регистрации; +- хранение PDA продавцов promo-логинов; - взимание комиссии за регистрацию и увеличение лимита; - проверку Ed25519-подписей `root_key` и `blockchain_public_key`; +- проверку promo-подписей продавца для обхода premium/trademark login guard; - проверку связности новой версии записи с предыдущей через `prev_record_hash`. Программа не отвечает за: @@ -91,7 +93,22 @@ system-переводом. Если бы создание шло строго ч Проверки повторной инициализации (`owner == System Program` и пустые данные) остаются и не зависят от баланса аккаунта. -### 3.4. Строгий список аккаунтов (нет «лишних» аккаунтов) +### 3.4. PDA продавца promo-логинов + +PDA продавца строится так: + +- seed prefix: `promo_seller=` +- второй seed: логин продавца в нижнем регистре + +Формула: + +```text +promo_seller_pda = PDA(["promo_seller=", lower(seller_login)], shine_users_program_id) +``` + +В программе может существовать сколько угодно таких PDA, по одному на каждого продавца. + +### 3.5. Строгий список аккаунтов (нет «лишних» аккаунтов) Все инструкции `shine_users` читают строго фиксированный набор аккаунтов и после этого явно требуют, чтобы в переданном списке больше ничего не было @@ -130,17 +147,35 @@ system-переводом. Если бы создание шло строго ч Этот документ описывает именно логику программы, а не байтовую структуру блока. +### 4.3. `promo_seller_pda` + +PDA продавца красивых логинов хранит: + +- `version: u8` +- `remaining_sales: u64` +- `min_login_length: u8` +- `signer_pubkey: [u8; 32]` + +Смысл: + +- `remaining_sales` — сколько ещё красивых логинов продавец может выдать; +- `min_login_length` — минимальная длина логина, которую продавец имеет право раздавать; +- `signer_pubkey` — публичный Ed25519-ключ, которым off-chain подписываются promo-коды. + ## 5. Константы и базовые правила Базовые значения из текущей логики: - seed `user_pda`: `user_login=` - seed economy config: `shine_users_economy_config` +- seed promo seller PDA: `promo_seller=` - стартовый размер `user_pda`: `768` байт +- размер `promo_seller_pda`: `64` байта - `LIMIT_STEP = 10_000` - `START_REGISTRATION_FEE_LAMPORTS = 10_000_000` - `START_LAMPORTS_PER_LIMIT_STEP = 100_000` - `START_BONUS_LIMIT = 100_000` +- префикс promo-сообщения: `shine_promo_v1:` Правила: @@ -230,6 +265,35 @@ On-chain инвариант только один: Если `shine_login_guard` вернул что-то иное или return_data некорректны, это ошибка. +### 7.3. Promo-обход login guard + +При создании пользователя допускается опциональный `promo_code`. + +Формат строки: + +```text +1- +``` + +Где: + +- первый символ `1` — версия формата promo-кода; +- `seller_login` — логин продавца, по которому ищется `promo_seller_pda`; +- `signature_base58` — Ed25519-подпись по сообщению: + +```text +shine_promo_v1: +``` + +Если promo-код валиден, то: + +- premium/trademark-проверка через `shine_login_guard` не выполняется; +- базовая синтаксическая проверка логина всё равно остаётся обязательной; +- `remaining_sales` уменьшается на `1`; +- длина логина должна быть не меньше `min_login_length`. + +Если promo-код не передан, обычная CPI-проверка через `shine_login_guard` обязательна. + ## 8. Инструкция `init_users_economy_config` ### Назначение @@ -310,6 +374,7 @@ On-chain инвариант только один: - sysvar `instructions` - `users_economy_config_pda` - `shine_login_guard_program` +- опционально: `promo_seller_pda`, если регистрация идёт по promo-коду ### Входные данные @@ -320,6 +385,7 @@ On-chain инвариант только один: - `additional_limit` - mutable fields записи - `root` signature по unsigned части +- опционально `promo_code` ### Бинарный ABI @@ -332,20 +398,27 @@ On-chain инвариант только один: - additional_limit: u64 LE - fields: UserMutableFieldsV1 - root_signature: [u8; 64] +- optional trailing promo_code: string_u8 ``` ### Обязательные проверки 1. Логин валиден по синтаксису. -2. Логин разрешён `shine_login_guard`. -3. `additional_limit % LIMIT_STEP == 0`. -4. inflow vault совпадает с PDA программы `shine_payments`. -5. `user_pda` вычислена правильно и ещё не существует. -6. Поля блокчейна валидны. -7. Поля server/session/trusted валидны по формату. -8. `last_block_signature` соответствует `LastBlockState`. -9. `root signature` соответствует unsigned части записи. -10. Размер сериализованной записи не превышает допустимый стартовый размер PDA или иные ограничения реализации. +2. Если promo-код не передан, логин разрешён `shine_login_guard`. +3. Если promo-код передан, он должен: + - иметь формат `1-`; + - ссылаться на существующий `promo_seller_pda`; + - проходить Ed25519-проверку через `signer_pubkey` продавца; + - иметь `remaining_sales > 0`; + - удовлетворять правилу `login.len() >= min_login_length`. +4. `additional_limit % LIMIT_STEP == 0`. +5. inflow vault совпадает с PDA программы `shine_payments`. +6. `user_pda` вычислена правильно и ещё не существует. +7. Поля блокчейна валидны. +8. Поля server/session/trusted валидны по формату. +9. `last_block_signature` соответствует `LastBlockState`. +10. `root signature` соответствует unsigned части записи. +11. Размер сериализованной записи не превышает допустимый стартовый размер PDA или иные ограничения реализации. ### Экономика @@ -375,9 +448,52 @@ limit_fee(additional_limit) = (additional_limit / LIMIT_STEP) * lamports_per_lim - создаётся PDA; - в неё записывается полная запись `user_pda`; +- если используется promo-код, в `promo_seller_pda` уменьшается `remaining_sales`; - средства переводятся в inflow vault `shine_payments`. -## 11. Инструкция `update_user_pda` +## 11. Инструкция `upsert_promo_seller` + +### Назначение + +Создать нового продавца promo-логинов или полностью перезаписать настройки уже существующего продавца. + +### Авторизация + +Только `DAO_AUTHORITY`. + +### Аккаунты + +- signer +- `promo_seller_pda` +- system program + +### Входные данные + +- `seller_login` +- `remaining_sales` +- `min_login_length` +- `signer_pubkey` + +### Бинарный ABI + +```text +- tag: u8 = 5 +- seller_login: string_u8 +- remaining_sales: u64 LE +- min_login_length: u8 +- signer_pubkey: [u8; 32] +``` + +### Правила + +- signer должен совпадать с `DAO_AUTHORITY`; +- `seller_login` проходит ту же базовую проверку логина; +- `min_login_length` лежит в диапазоне `1..20`; +- адрес PDA обязан совпадать с `promo_seller=` + `lower(seller_login)`; +- если PDA ещё нет, она создаётся; +- если PDA уже есть, её состояние полностью перезаписывается. + +## 12. Инструкция `update_user_pda` ### Назначение @@ -445,9 +561,9 @@ topup_fee = limit_fee(additional_limit) - root_signature: [u8; 64] ``` -## 12. Ed25519-проверки и порядок инструкций +## 13. Ed25519-проверки и порядок инструкций -В транзакции должны стоять две встроенные Ed25519-инструкции прямо перед вызовом `shine_users`: +В обычной транзакции `create/update` должны стоять две встроенные Ed25519-инструкции прямо перед вызовом `shine_users`: 1. подпись `root_key` по unsigned записи; 2. подпись `blockchain_public_key` по `LastBlockState`. @@ -459,7 +575,19 @@ topup_fee = limit_fee(additional_limit) Это правило порядка является частью контракта между off-chain клиентом и программой. -## 13. LastBlockState +Если `create_user_pda` вызывается с promo-кодом, то перед ними добавляется ещё одна Ed25519-инструкция: + +1. подпись продавца promo-логина по строке `shine_promo_v1:`; +2. подпись `root_key` по unsigned записи; +3. подпись `blockchain_public_key` по `LastBlockState`. + +Тогда программа читает: + +- `-3` — promo-подпись; +- `-2` — `root_key`; +- `-1` — `blockchain_public_key`. + +## 14. LastBlockState Сообщение для подписи `blockchain_public_key`: @@ -479,7 +607,7 @@ message_hash = SHA-256(LastBlockState bytes) signature = Ed25519(blockchain_private_key, message_hash) ``` -## 14. Валидируемые mutable-поля записи +## 15. Валидируемые mutable-поля записи Программа допускает обновление: @@ -507,7 +635,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - `blockchain_public_key` - `blockchain_type` -## 15. Правила серверных и сессионных полей +## 16. Правила серверных и сессионных полей ### UserMutableFieldsV1 @@ -567,7 +695,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - `sessions_mode = 1` - `sessions = []` -## 16. Realloc поведения PDA +## 17. Realloc поведения PDA Запись может расти. Если новая сериализованная запись длиннее текущего размера PDA: @@ -575,7 +703,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - нельзя делать чрезмерный рост одним шагом выше внутреннего лимита реализации; - перед realloc нужно обеспечить rent для нового размера. -## 17. Ошибки и классы отказа +## 18. Ошибки и классы отказа Программа должна различать как минимум такие классы ошибок: @@ -592,9 +720,14 @@ signature = Ed25519(blockchain_private_key, message_hash) - попытка уменьшить лимит/used_bytes/block number; - overflow; - неверный inflow vault; -- неверный DAO authority для economy config. +- неверный DAO authority для economy config и promo seller update; +- неверный promo-код; +- несуществующий или повреждённый `promo_seller_pda`; +- исчерпанная promo-квота; +- promo-подпись не совпадает; +- логин короче `min_login_length`. -## 18. Что должно сохраниться при переписи без Anchor +## 19. Что должно сохраниться при переписи без Anchor В текущей чисто-rust реализации уже сохранены: @@ -604,6 +737,7 @@ signature = Ed25519(blockchain_private_key, message_hash) - тот же порядок Ed25519-инструкций; - те же immutable/mutable правила; - ту же валидацию логина и CPI в `shine_login_guard`; +- ту же promo-механику с PDA продавцов и подписью `shine_promo_v1:`; - ту же зависимость от inflow vault программы `shine_payments`. Сознательно не сохранялись: diff --git a/shine-solana/shine/programs/shine_users/src/lib.rs b/shine-solana/shine/programs/shine_users/src/lib.rs index 0b1da5a..4ec3e9e 100644 --- a/shine-solana/shine/programs/shine_users/src/lib.rs +++ b/shine-solana/shine/programs/shine_users/src/lib.rs @@ -48,7 +48,9 @@ const IX_INIT_USERS_ECONOMY_CONFIG: u8 = 1; const IX_UPDATE_USERS_ECONOMY_CONFIG: u8 = 2; const IX_CREATE_USER_PDA: u8 = 3; const IX_UPDATE_USER_PDA: u8 = 4; +const IX_UPSERT_PROMO_SELLER: u8 = 5; const LOGIN_GUARD_IX_CLASSIFY_LOGIN: u8 = 1; +const PROMO_CODE_VERSION_PREFIX: char = '1'; #[repr(u32)] #[derive(Clone, Copy, Debug)] @@ -80,6 +82,12 @@ enum ShineUsersError { InvalidSystemProgram = 25, InvalidAccountOwner = 26, InvalidAccountData = 27, + InvalidPromoCode = 28, + PromoSellerNotFound = 29, + PromoSalesExhausted = 30, + PromoLoginTooShort = 31, + InvalidPromoSellerState = 32, + PromoSignatureMismatch = 33, } impl From for ProgramError { @@ -142,6 +150,7 @@ pub struct CreateUserPdaArgs { pub additional_limit: u64, pub fields: UserMutableFields, pub signature: [u8; 64], + pub promo_code: Option, } #[derive(Clone, Debug)] @@ -173,6 +182,22 @@ pub struct UsersEconomyConfigState { pub start_bonus_limit: u64, } +#[derive(Clone, Debug)] +pub struct UpsertPromoSellerArgs { + pub seller_login: String, + pub remaining_sales: u64, + pub min_login_length: u8, + pub signer_pubkey: Pubkey, +} + +#[derive(Clone, Debug)] +pub struct PromoSellerState { + pub version: u8, + pub remaining_sales: u64, + pub min_login_length: u8, + pub signer_pubkey: Pubkey, +} + #[derive(Clone, Debug)] pub struct BlockchainRecord { pub blockchain_type: u8, @@ -255,13 +280,16 @@ impl<'a> Reader<'a> { self.cursor = end; std::str::from_utf8(s).map(|v| v.to_string()).map_err(|_| ProgramError::from(ShineUsersError::InvalidInstruction)) } + fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.cursor) + } fn finish(self) -> Result<(), ProgramError> { require!(self.cursor == self.data.len(), ShineUsersError::InvalidInstruction); Ok(()) } } -fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], instruction_data: &[u8]) -> ProgramResult { +fn process_instruction<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<'a>], instruction_data: &[u8]) -> ProgramResult { let mut r = Reader::new(instruction_data); let tag = r.read_u8()?; match tag { @@ -278,20 +306,36 @@ fn process_instruction(program_id: &Pubkey, accounts: &[AccountInfo], instructio r.finish()?; process_update_users_economy_config(program_id, accounts, args) } - IX_CREATE_USER_PDA => { - let args = parse_create_args(&mut r)?; - r.finish()?; - process_create_user_pda(program_id, accounts, args) - } - IX_UPDATE_USER_PDA => { - let args = parse_update_args(&mut r)?; - r.finish()?; - process_update_user_pda(program_id, accounts, args) - } + IX_CREATE_USER_PDA => dispatch_create_user_pda(program_id, accounts, r), + IX_UPDATE_USER_PDA => dispatch_update_user_pda(program_id, accounts, r), + IX_UPSERT_PROMO_SELLER => dispatch_upsert_promo_seller(program_id, accounts, r), _ => Err(ProgramError::from(ShineUsersError::InvalidInstruction)), } } +fn dispatch_create_user_pda<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<'a>], mut r: Reader<'_>) -> ProgramResult { + let args = Box::new(parse_create_args(&mut r)?); + r.finish()?; + process_create_user_pda(program_id, accounts, args) +} + +fn dispatch_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], mut r: Reader<'_>) -> ProgramResult { + let args = Box::new(parse_update_args(&mut r)?); + r.finish()?; + process_update_user_pda(program_id, accounts, args) +} + +fn dispatch_upsert_promo_seller(program_id: &Pubkey, accounts: &[AccountInfo], mut r: Reader<'_>) -> ProgramResult { + let args = UpsertPromoSellerArgs { + seller_login: r.read_string_u8()?, + remaining_sales: r.read_u64()?, + min_login_length: r.read_u8()?, + signer_pubkey: r.read_pubkey()?, + }; + r.finish()?; + process_upsert_promo_seller(program_id, accounts, args) +} + fn parse_create_args(r: &mut Reader<'_>) -> Result { Ok(CreateUserPdaArgs { login: r.read_string_u8()?, @@ -301,6 +345,7 @@ fn parse_create_args(r: &mut Reader<'_>) -> Result 0 { Some(r.read_string_u8()?) } else { None }, }) } @@ -432,7 +477,50 @@ fn process_update_users_economy_config(program_id: &Pubkey, accounts: &[AccountI Ok(()) } -fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: CreateUserPdaArgs) -> ProgramResult { +fn process_upsert_promo_seller(program_id: &Pubkey, accounts: &[AccountInfo], args: UpsertPromoSellerArgs) -> ProgramResult { + let mut it = accounts.iter(); + let signer = next_account_info(&mut it)?; + let promo_seller_pda = next_account_info(&mut it)?; + let system_program_ai = next_account_info(&mut it)?; + require!(it.next().is_none(), ShineUsersError::InvalidInstruction); + + require!(signer.is_signer, ShineUsersError::InvalidSigner); + require_keys_eq!(*system_program_ai.key, system_program::id(), ShineUsersError::InvalidSystemProgram); + validate_login(&args.seller_login)?; + require!((1..=20).contains(&args.min_login_length), ShineUsersError::InvalidPromoSellerState); + + let dao_authority = Pubkey::from_str(settings::DAO_AUTHORITY).map_err(|_| ProgramError::from(ShineUsersError::InvalidSigner))?; + require_keys_eq!(dao_authority, *signer.key, ShineUsersError::InvalidSigner); + + let seller_seed = login_seed_normalized(&args.seller_login); + let (expected_pda, bump) = find_promo_seller_pda(program_id, &seller_seed); + require_keys_eq!(expected_pda, *promo_seller_pda.key, ShineUsersError::InvalidPdaAddress); + + if promo_seller_pda.owner == &system_program::id() { + create_pda_account( + signer, + promo_seller_pda, + system_program_ai, + program_id, + &[settings::PROMO_SELLER_PDA_SEED_PREFIX.as_bytes(), seller_seed.as_bytes(), &[bump]], + settings::PROMO_SELLER_PDA_SPACE, + )?; + } else { + require!(promo_seller_pda.owner == program_id, ShineUsersError::InvalidPdaAddress); + ensure_pda_size_and_rent(promo_seller_pda, signer, system_program_ai, settings::PROMO_SELLER_PDA_SPACE)?; + } + + let state = PromoSellerState { + version: 1, + remaining_sales: args.remaining_sales, + min_login_length: args.min_login_length, + signer_pubkey: args.signer_pubkey, + }; + write_pda_exact(promo_seller_pda, &serialize_promo_seller_state(&state))?; + Ok(()) +} + +fn process_create_user_pda<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<'a>], args: Box) -> ProgramResult { let mut it = accounts.iter(); let signer = next_account_info(&mut it)?; let user_pda = next_account_info(&mut it)?; @@ -441,6 +529,7 @@ fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: let instructions_sysvar = next_account_info(&mut it)?; let users_economy_config_pda = next_account_info(&mut it)?; let login_guard_program = next_account_info(&mut it)?; + let promo_seller_pda = it.next(); require!(it.next().is_none(), ShineUsersError::InvalidInstruction); require!(signer.is_signer, ShineUsersError::InvalidSigner); @@ -452,9 +541,19 @@ fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: validate_inflow_vault(inflow_vault)?; require!(args.additional_limit % settings::LIMIT_STEP == 0, ShineUsersError::InvalidLimitIncrement); require_keys_eq!(*login_guard_program.key, Pubkey::from_str(settings::SHINE_LOGIN_GUARD_PROGRAM_ID).map_err(|_| ProgramError::from(ShineUsersError::InvalidLoginGuardResponse))?, ShineUsersError::InvalidLoginGuardResponse); - classify_login_or_fail(login_guard_program, &args.login)?; validate_users_economy_config_pda(program_id, users_economy_config_pda)?; + let promo_context = if let Some(promo_code) = args.promo_code.as_deref().filter(|value| !value.trim().is_empty()) { + let seller_pda = promo_seller_pda.ok_or(ProgramError::from(ShineUsersError::InvalidInstruction))?; + Some(verify_promo_registration(program_id, instructions_sysvar, seller_pda, &args.login, promo_code)?) + } else { + require!(promo_seller_pda.is_none(), ShineUsersError::InvalidInstruction); + None + }; + if promo_context.is_none() { + classify_login_or_fail(login_guard_program, &args.login)?; + } + let economy = read_users_economy_config(users_economy_config_pda)?; let login_seed = login_seed_normalized(&args.login); let (expected_pda, bump) = find_user_pda(program_id, &login_seed); @@ -511,12 +610,16 @@ fn process_create_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: )?; write_pda_exact(user_pda, &serialized)?; + if let Some(promo) = promo_context { + write_pda_exact(promo.promo_seller_pda, &serialize_promo_seller_state(&promo.updated_state))?; + } + let total_fee = economy.registration_fee_lamports.checked_add(limit_fee_lamports(args.additional_limit, economy.lamports_per_limit_step)?).ok_or(ProgramError::from(ShineUsersError::MathOverflow))?; transfer_lamports(signer, inflow_vault, system_program_ai, total_fee)?; Ok(()) } -fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: UpdateUserPdaArgs) -> ProgramResult { +fn process_update_user_pda(program_id: &Pubkey, accounts: &[AccountInfo], args: Box) -> ProgramResult { let mut it = accounts.iter(); let signer = next_account_info(&mut it)?; let user_pda = next_account_info(&mut it)?; @@ -615,6 +718,45 @@ fn build_update_record(old_record: &UserRecord, args: &UpdateUserPdaArgs, new_ba }) } +struct PromoRegistrationContext<'a> { + promo_seller_pda: &'a AccountInfo<'a>, + updated_state: PromoSellerState, +} + +fn verify_promo_registration<'a>( + program_id: &Pubkey, + instructions_sysvar: &AccountInfo<'a>, + promo_seller_pda: &'a AccountInfo<'a>, + target_login: &str, + promo_code: &str, +) -> Result, ProgramError> { + let (seller_login, signature58) = parse_promo_code(promo_code)?; + let normalized_seller_login = login_seed_normalized(&seller_login); + let expected_pda = find_promo_seller_pda(program_id, &normalized_seller_login).0; + require_keys_eq!(expected_pda, *promo_seller_pda.key, ShineUsersError::InvalidPdaAddress); + require!(promo_seller_pda.owner == program_id, ShineUsersError::PromoSellerNotFound); + + let state = read_promo_seller_state(promo_seller_pda)?; + require!(state.remaining_sales > 0, ShineUsersError::PromoSalesExhausted); + require!(target_login.len() >= state.min_login_length as usize, ShineUsersError::PromoLoginTooShort); + + let signature = decode_base58_fixed_64(&signature58)?; + let message = build_promo_sign_message(target_login); + verify_ed25519_signature_instruction(instructions_sysvar, -3, &state.signer_pubkey, &signature, message.as_bytes()) + .map_err(|_| ProgramError::from(ShineUsersError::PromoSignatureMismatch))?; + + let updated_state = PromoSellerState { + version: state.version, + remaining_sales: state.remaining_sales.saturating_sub(1), + min_login_length: state.min_login_length, + signer_pubkey: state.signer_pubkey, + }; + Ok(PromoRegistrationContext { + promo_seller_pda, + updated_state, + }) +} + fn classify_login_or_fail(login_guard_program: &AccountInfo, login: &str) -> ProgramResult { let login_guard_program_id = Pubkey::from_str(settings::SHINE_LOGIN_GUARD_PROGRAM_ID) .map_err(|_| ProgramError::from(ShineUsersError::InvalidLoginGuardResponse))?; @@ -646,6 +788,15 @@ fn serialize_users_economy_config(state: &UsersEconomyConfigState) -> Vec { out } +fn serialize_promo_seller_state(state: &PromoSellerState) -> Vec { + let mut out = Vec::with_capacity(1 + 8 + 1 + 32); + out.push(state.version); + out.extend_from_slice(&state.remaining_sales.to_le_bytes()); + out.push(state.min_login_length); + out.extend_from_slice(state.signer_pubkey.as_ref()); + out +} + fn validate_users_economy_config_pda(program_id: &Pubkey, pda: &AccountInfo) -> ProgramResult { let (expected_pda, _) = find_users_economy_config_pda(program_id); require_keys_eq!(expected_pda, *pda.key, ShineUsersError::InvalidPdaAddress); @@ -665,6 +816,19 @@ fn read_users_economy_config(pda: &AccountInfo) -> Result Result { + let raw = read_pda_all(pda)?; + require!(!raw.is_empty(), ShineUsersError::PromoSellerNotFound); + require!(raw.len() >= 42, ShineUsersError::InvalidPromoSellerState); + let signer_pubkey = Pubkey::new_from_array(raw[10..42].try_into().map_err(|_| ProgramError::from(ShineUsersError::InvalidPromoSellerState))?); + Ok(PromoSellerState { + version: raw[0], + remaining_sales: u64::from_le_bytes(raw[1..9].try_into().unwrap()), + min_login_length: raw[9], + signer_pubkey, + }) +} + fn deserialize_record_from_pda(raw: &[u8]) -> Result { require!(raw.len() >= 9, ShineUsersError::InvalidRecordData); require!(sol_memcmp(&raw[0..5], MAGIC, 5) == 0, ShineUsersError::InvalidRecordMagic); @@ -958,6 +1122,74 @@ fn le_u16(data: &[u8], offset: usize) -> Result { Ok(u16::from_le_bytes([s[0], s[1]])) } +fn parse_promo_code(value: &str) -> Result<(String, String), ProgramError> { + let trimmed = value.trim(); + let mut chars = trimmed.chars(); + require!(chars.next() == Some(PROMO_CODE_VERSION_PREFIX), ShineUsersError::InvalidPromoCode); + let rest: String = chars.collect(); + let dash_pos = rest.find('-').ok_or(ProgramError::from(ShineUsersError::InvalidPromoCode))?; + let seller_login = rest[..dash_pos].to_string(); + let signature58 = rest[dash_pos + 1..].to_string(); + require!(!seller_login.is_empty() && !signature58.is_empty(), ShineUsersError::InvalidPromoCode); + validate_login(&seller_login)?; + Ok((seller_login, signature58)) +} + +fn build_promo_sign_message(login: &str) -> String { + let mut message = String::with_capacity(settings::PROMO_SIGN_PREFIX.len() + login.len()); + message.push_str(settings::PROMO_SIGN_PREFIX); + message.push_str(login); + message +} + +fn decode_base58_fixed_64(value: &str) -> Result<[u8; 64], ProgramError> { + let decoded = decode_base58(value)?; + <[u8; 64]>::try_from(decoded.as_slice()).map_err(|_| ProgramError::from(ShineUsersError::InvalidPromoCode)) +} + +fn decode_base58(value: &str) -> Result, ProgramError> { + let text = value.trim(); + require!(!text.is_empty(), ShineUsersError::InvalidPromoCode); + + let mut bytes = vec![0u8]; + for ch in text.bytes() { + let digit = base58_value(ch).ok_or(ProgramError::from(ShineUsersError::InvalidPromoCode))? as u32; + let mut carry = digit; + for byte in &mut bytes { + let x = (*byte as u32).checked_mul(58).and_then(|v| v.checked_add(carry)).ok_or(ProgramError::from(ShineUsersError::MathOverflow))?; + *byte = (x & 0xff) as u8; + carry = x >> 8; + } + while carry > 0 { + bytes.push((carry & 0xff) as u8); + carry >>= 8; + } + } + + for ch in text.bytes() { + if ch == b'1' { + bytes.push(0); + } else { + break; + } + } + + bytes.reverse(); + Ok(bytes) +} + +fn base58_value(ch: u8) -> Option { + match ch { + b'1'..=b'9' => Some(ch - b'1'), + b'A'..=b'H' => Some(9 + (ch - b'A')), + b'J'..=b'N' => Some(17 + (ch - b'J')), + b'P'..=b'Z' => Some(22 + (ch - b'P')), + b'a'..=b'k' => Some(33 + (ch - b'a')), + b'm'..=b'z' => Some(44 + (ch - b'm')), + _ => None, + } +} + fn validate_login(login: &str) -> ProgramResult { require!(!login.is_empty(), ShineUsersError::InvalidLogin); require!(login.len() <= 20, ShineUsersError::InvalidLogin); @@ -1075,6 +1307,7 @@ fn ensure_pda_size_and_rent<'a>(pda: &AccountInfo<'a>, payer: &AccountInfo<'a>, fn find_user_pda(program_id: &Pubkey, login: &str) -> (Pubkey, u8) { Pubkey::find_program_address(&[settings::USER_PDA_SEED_PREFIX.as_bytes(), login.as_bytes()], program_id) } fn find_users_economy_config_pda(program_id: &Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&[settings::USERS_ECONOMY_CONFIG_SEED], program_id) } +fn find_promo_seller_pda(program_id: &Pubkey, seller_login: &str) -> (Pubkey, u8) { Pubkey::find_program_address(&[settings::PROMO_SELLER_PDA_SEED_PREFIX.as_bytes(), seller_login.as_bytes()], program_id) } fn limit_fee_lamports(limit_delta: u64, lamports_per_limit_step: u64) -> Result { (limit_delta / settings::LIMIT_STEP).checked_mul(lamports_per_limit_step).ok_or(ProgramError::from(ShineUsersError::MathOverflow)) } fn pad_to_fixed_size(mut bytes: Vec, target_size: usize) -> Result, ProgramError> { require!(bytes.len() <= target_size, ShineUsersError::RecordTooLarge); bytes.resize(target_size, 0); Ok(bytes) } diff --git a/shine-solana/shine/programs/shine_users/src/settings.rs b/shine-solana/shine/programs/shine_users/src/settings.rs index 730d036..96324c6 100644 --- a/shine-solana/shine/programs/shine_users/src/settings.rs +++ b/shine-solana/shine/programs/shine_users/src/settings.rs @@ -2,10 +2,14 @@ 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"; +/// Префикс seed для PDA продавца promo-логинов. +pub const PROMO_SELLER_PDA_SEED_PREFIX: &str = "promo_seller="; /// Стартовый размер PDA пользователя, дальше запись может расширяться через realloc. pub const USER_PDA_SPACE: usize = 768; /// Размер PDA с экономическими параметрами `shine_users`. pub const USERS_ECONOMY_CONFIG_SPACE: usize = 32; +/// Размер PDA продавца promo-логинов. +pub const PROMO_SELLER_PDA_SPACE: usize = 64; /// Адрес DAO authority, который имеет право обновлять economy-конфиг. pub const DAO_AUTHORITY: &str = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P"; @@ -16,6 +20,8 @@ pub const SHINE_PAYMENTS_PROGRAM_ID: &str = "c4yTa4JT9EtQDCBX9LmWFK6T2gp4JGsuymF pub const SHINE_PAYMENTS_INFLOW_VAULT_SEED: &[u8] = b"shine_payments_inflow_vault"; /// Адрес отдельной программы проверки премиальности логина. pub const SHINE_LOGIN_GUARD_PROGRAM_ID: &str = "3xkopA7cXagxzMFrKdv3NCBfV6BKiRJCk69kr27M2sRo"; +/// Доменный префикс для подписи promo-кода. +pub const PROMO_SIGN_PREFIX: &str = "shine_promo_v1:"; /// Стартовая комиссия регистрации (0.01 SOL) для initial economy-конфига. pub const START_REGISTRATION_FEE_LAMPORTS: u64 = 10_000_000;