SHA256
Добавить promo-регистрацию и оффлайн генератор промокодов
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -269,6 +269,8 @@ function createInitialState({ withStoredSession = true } = {}) {
|
||||
password: '',
|
||||
passwordMode: 'single',
|
||||
passwordWords: emptyPasswordWords(),
|
||||
usePromoCode: false,
|
||||
promoCode: '',
|
||||
sessionId: '',
|
||||
storagePwd: '',
|
||||
pendingKeyBundle: null,
|
||||
|
||||
Reference in New Issue
Block a user