Добавить promo-регистрацию и оффлайн генератор промокодов

This commit is contained in:
AidarKC
2026-06-30 17:39:40 +04:00
parent b0887648f7
commit 0a416a2f5c
12 changed files with 1154 additions and 42 deletions
+81 -1
View File
@@ -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();
}
@@ -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;
@@ -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);
+80 -3
View File
@@ -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,
});
}
@@ -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 });
}
+2
View File
@@ -269,6 +269,8 @@ function createInitialState({ withStoredSession = true } = {}) {
password: '',
passwordMode: 'single',
passwordWords: emptyPasswordWords(),
usePromoCode: false,
promoCode: '',
sessionId: '',
storagePwd: '',
pendingKeyBundle: null,