Добавлен SHiNE-promo-solana-devnet в основной репозиторий без вложенного git

This commit is contained in:
AidarKC 2026-05-01 13:58:59 +03:00
parent 9b03273055
commit 3061bf3d1e
25 changed files with 1812 additions and 0 deletions

9
SHiNE-promo-solana-devnet/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
.gradle
/build
.idea
out
*.log
config/devnet-wallet.json
!gradle/wrapper/gradle-wrapper.jar

View File

@ -0,0 +1,198 @@
# SHiNE-promo-solana-devnet
Временное промо-приложение для тестеров Web3-социальной сети SHiNE / «Сияние» в Solana Devnet.
Основной сценарий:
- приложение SHiNE открывает страницу вида `/?wallet=SOLANA_PUBLIC_KEY`;
- пользователь вводит имя и промокод;
- backend проверяет промокод и отправляет **реальную** devnet-транзакцию на `0.1 SOL`;
- использованный промокод фиксируется в файле и больше не может быть применён.
## Стек
- Java 21
- Spring Boot
- Gradle
- Thymeleaf + HTML/CSS/JS (server-side UI)
## Локальный запуск
1. Скопируйте пример настроек:
```bash
cp config/application.example.properties src/main/resources/application.properties
```
2. Положите реальный keypair-файл Solana CLI формата в:
```text
config/devnet-wallet.json
```
3. Запустите:
```bash
./gradlew bootRun
```
Приложение будет доступно на `http://localhost:8021`.
## Как указать порт
По умолчанию используется:
```properties
server.port=8021
```
Изменить можно:
- в `src/main/resources/application.properties`;
- или через параметр запуска:
```bash
./gradlew bootRun --args='--server.port=8090'
```
## Настройка Solana RPC Devnet
Параметр:
```properties
solana.rpc.url=https://api.devnet.solana.com
```
Для другого RPC достаточно заменить URL в properties.
## Настройка devnet keypair
- Файл должен быть в формате Solana keypair JSON: массив из 64 чисел (`0..255`).
- Пример лежит в `config/devnet-wallet.example.json`.
- Рабочий файл: `config/devnet-wallet.json`.
Важно: настоящий keypair **нельзя коммитить в GitHub**.
Он добавлен в `.gitignore`.
## Где лежат промокоды
Файл:
```text
data/promo-codes.txt
```
## Где лежит файл использованных промокодов
Файл:
```text
data/promo-used.txt
```
## Формат `promo-codes.txt`
- одна строка = один промокод;
- пустые строки игнорируются;
- строки с `#` игнорируются как комментарии;
- промокод должен соответствовать regex: `[a-z0-9]{8}`.
## Формат `promo-used.txt`
Каждая запись в формате:
```text
promoCode | wallet | name | yyyy.MM.dd HH:mm | signature
```
Пример:
```text
aidar2km | 8xF...abc | Иван Петров | 2026.04.27 18:45 | 5xTxSignature...
```
## Пример URL
```text
http://localhost:8021/?wallet=8zYQ...DevnetAddress
```
## Пример API-запроса
```bash
curl -X POST http://localhost:8021/api/promo/top-up \
-H "Content-Type: application/json" \
-d '{
"wallet":"SOLANA_PUBLIC_KEY",
"name":"Иван Петров",
"promoCode":"aidar2km"
}'
```
## Проверка транзакции в Solana Explorer Devnet
Explorer URL формируется по шаблону:
```text
https://explorer.solana.com/tx/{signature}?cluster=devnet
```
## Сборка jar через Gradle
```bash
./gradlew clean build
```
JAR:
```text
build/libs/SHiNE-promo-solana-devnet.jar
```
## Запуск jar
```bash
java -jar build/libs/SHiNE-promo-solana-devnet.jar
```
## Health endpoint
```text
GET /health
```
Ответ:
```json
{
"status": "ok",
"app": "SHiNE-promo-solana-devnet"
}
```
## Логи
- логируется старт приложения;
- логируются успешные пополнения;
- логируются ошибки транзакций;
- приватный ключ не логируется.
## Gradle-задачи для серверного деплоя
В `build.gradle` добавлены задачи:
- `buildServerBundle` — готовит bundle в `build/server-bundle`;
- `deployToServer` — копирует JAR и `application.properties` на сервер и перезапускает systemd-сервис.
Запуск:
```bash
./gradlew deployToServer
```
Переопределение хоста/пути:
```bash
./gradlew deployToServer \
-PdeployHost=user@10.147.20.7 \
-PdeployPath=/home/user/docker/SHiNE-promo-solana-devnet \
-PdeployService=SHiNE-promo-solana-devnet
```

View File

@ -0,0 +1,103 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.6'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'ru.shine'
version = '0.1.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'com.mmorrell:solanaj:1.20.4'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.named('bootJar') {
archiveFileName = 'SHiNE-promo-solana-devnet.jar'
}
// ------------------------------------------------------------
// ДЕПЛОЙ ВРЕМЕННОГО СЕРВИСА "SHiNE-promo-solana-devnet"
//
// Назначение сервиса:
// - это отдельный продукт для тестовой раздачи SOL в devnet;
// - нужен для упрощённого онбординга тестовых пользователей;
// - работает как самостоятельное Spring Boot приложение.
//
// Почему отдельный деплой:
// - сервис изолирован от основного SHiNE-сервера;
// - его можно обновлять/перезапускать независимо;
// - жизненный цикл временного сервиса не должен ломать основной прод.
//
// ВАЖНО:
// - деплой по умолчанию выполняется ЧЕРЕЗ ДОМЕН (shineup.me), а не по IP;
// - целевая папка на сервере: /home/player/SHiNE/SHiNE-promo-solana-devnet;
// - целевой systemd-сервис: SHiNE-promo-solana-devnet.
// ------------------------------------------------------------
def deployHost = project.findProperty('deployHost') ?: 'root@shineup.me'
def deployPath = project.findProperty('deployPath') ?: '/home/player/SHiNE/SHiNE-promo-solana-devnet'
def remoteServiceName = project.findProperty('deployService') ?: 'SHiNE-promo-solana-devnet'
tasks.register('buildServerBundle', Copy) {
// Сборка минимального серверного бандла:
// 1) fat-jar приложения
// 2) шаблон application.properties (чтобы был эталон конфигурации)
dependsOn tasks.named('bootJar')
from(tasks.named('bootJar').flatMap { it.archiveFile }) {
rename { 'SHiNE-promo-solana-devnet.jar' }
}
from('config/application.example.properties') {
rename { 'application.properties' }
}
into(layout.buildDirectory.dir('server-bundle'))
}
tasks.register('deployToServerMkdir', Exec) {
// Шаг 1: гарантируем существование целевой директории на удалённом сервере.
dependsOn tasks.named('buildServerBundle')
commandLine 'bash', '-lc', "ssh ${deployHost} 'mkdir -p ${deployPath}'"
}
tasks.register('deployToServerJar', Exec) {
// Шаг 2: загружаем исполняемый jar.
dependsOn tasks.named('deployToServerMkdir')
commandLine 'bash', '-lc', "scp ${layout.buildDirectory.file('server-bundle/SHiNE-promo-solana-devnet.jar').get().asFile.absolutePath} ${deployHost}:${deployPath}/"
}
tasks.register('deployToServerConfig', Exec) {
// Шаг 3: загружаем конфиг-шаблон.
// На проде при необходимости его можно заменить на рабочий конфиг с секретами.
dependsOn tasks.named('deployToServerJar')
commandLine 'bash', '-lc', "scp ${layout.buildDirectory.file('server-bundle/application.properties').get().asFile.absolutePath} ${deployHost}:${deployPath}/"
}
tasks.register('deployToServerRestart', Exec) {
// Шаг 4: перезапускаем systemd-сервис и показываем статус.
// Если сервис не поднялся, ошибка будет видна сразу в этом шаге.
dependsOn tasks.named('deployToServerConfig')
commandLine 'bash', '-lc', "ssh ${deployHost} 'sudo systemctl restart ${remoteServiceName} && sudo systemctl --no-pager status ${remoteServiceName}'"
}
tasks.register('deployToServer') {
// Единая точка входа для деплоя временного сервиса.
// Запуск: ./gradlew deployToServer
dependsOn tasks.named('deployToServerRestart')
}

View File

@ -0,0 +1,15 @@
server.port=8021
solana.rpc.url=https://api.devnet.solana.com
solana.sender.keypair-file=./config/devnet-wallet.json
promo.transfer.amount-sol=0.1
promo.codes.file=./data/promo-codes.txt
promo.used.file=./data/promo-used.txt
promo.explorer.tx-url-template=https://explorer.solana.com/tx/%s?cluster=devnet
# Вечный промокод для временной раздачи в devnet.
# Если enabled=true, код можно использовать неограниченно (он не "сгорает").
promo.eternal-code.enabled=true
promo.eternal-code.value=0000

View File

@ -0,0 +1,6 @@
[
151, 22, 193, 47, 88, 234, 15, 201, 42, 19, 180, 76, 211, 5, 164, 91,
207, 135, 44, 173, 61, 242, 14, 99, 158, 208, 39, 117, 10, 226, 95, 132,
56, 72, 164, 209, 41, 189, 76, 239, 121, 18, 66, 213, 30, 145, 201, 9,
177, 53, 120, 87, 200, 65, 33, 251, 102, 74, 186, 44, 160, 7, 92, 137
]

View File

@ -0,0 +1,16 @@
[Unit]
Description=SHiNE Promo Solana Devnet
After=network.target
[Service]
Type=simple
User=player
Group=player
WorkingDirectory=/home/player/SHiNE/SHiNE-promo-solana-devnet
ExecStart=/usr/bin/java -jar /home/player/SHiNE/SHiNE-promo-solana-devnet/SHiNE-promo-solana-devnet.jar --spring.config.location=/home/player/SHiNE/SHiNE-promo-solana-devnet/application.properties
Restart=always
RestartSec=5
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Sun Apr 26 23:49:28 MSK 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
SHiNE-promo-solana-devnet/gradlew vendored Executable file
View File

@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
SHiNE-promo-solana-devnet/gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1 @@
rootProject.name = 'SHiNE-promo-solana-devnet'

View File

@ -0,0 +1,30 @@
package ru.shine.promo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import ru.shine.promo.config.AppProperties;
@SpringBootApplication
public class ShinePromoSolanaDevnetApplication {
private static final Logger log = LoggerFactory.getLogger(ShinePromoSolanaDevnetApplication.class);
public static void main(String[] args) {
SpringApplication.run(ShinePromoSolanaDevnetApplication.class, args);
}
@Bean
CommandLineRunner startupLogger(AppProperties appProperties) {
return args -> log.info(
"SHiNE promo app started. RPC: {}, promoCodesFile: {}, usedFile: {}, transferAmountSol: {}",
appProperties.getSolanaRpcUrl(),
appProperties.getPromoCodesFile(),
appProperties.getPromoUsedFile(),
appProperties.getPromoTransferAmountSol()
);
}
}

View File

@ -0,0 +1,77 @@
package ru.shine.promo.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Component
public class AppProperties {
private static final long LAMPORTS_PER_SOL = 1_000_000_000L;
@Value("${solana.rpc.url}")
private String solanaRpcUrl;
@Value("${solana.sender.keypair-file}")
private String solanaSenderKeypairFile;
@Value("${promo.transfer.amount-sol}")
private BigDecimal promoTransferAmountSol;
@Value("${promo.codes.file}")
private String promoCodesFile;
@Value("${promo.used.file}")
private String promoUsedFile;
@Value("${promo.explorer.tx-url-template}")
private String promoExplorerTxUrlTemplate;
@Value("${promo.eternal-code.enabled:false}")
private boolean promoEternalCodeEnabled;
@Value("${promo.eternal-code.value:0000}")
private String promoEternalCodeValue;
public String getSolanaRpcUrl() {
return solanaRpcUrl;
}
public String getSolanaSenderKeypairFile() {
return solanaSenderKeypairFile;
}
public BigDecimal getPromoTransferAmountSol() {
return promoTransferAmountSol;
}
public String getPromoCodesFile() {
return promoCodesFile;
}
public String getPromoUsedFile() {
return promoUsedFile;
}
public String getPromoExplorerTxUrlTemplate() {
return promoExplorerTxUrlTemplate;
}
public boolean isPromoEternalCodeEnabled() {
return promoEternalCodeEnabled;
}
public String getPromoEternalCodeValue() {
if (promoEternalCodeValue == null) {
return "";
}
return promoEternalCodeValue.trim().toLowerCase();
}
public long getPromoTransferAmountLamports() {
BigDecimal lamports = promoTransferAmountSol.multiply(BigDecimal.valueOf(LAMPORTS_PER_SOL));
return lamports.setScale(0, RoundingMode.UNNECESSARY).longValueExact();
}
}

View File

@ -0,0 +1,136 @@
package ru.shine.promo.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import ru.shine.promo.config.AppProperties;
import ru.shine.promo.dto.PromoRequest;
import ru.shine.promo.dto.PromoResponse;
import ru.shine.promo.service.PromoCodeService;
import ru.shine.promo.service.PromoException;
import ru.shine.promo.service.PromoTransferService;
import ru.shine.promo.service.UsedPromoStorageService;
import java.util.Map;
@RestController
public class PromoApiController {
private static final Logger log = LoggerFactory.getLogger(PromoApiController.class);
private final AppProperties appProperties;
private final PromoCodeService promoCodeService;
private final PromoTransferService promoTransferService;
private final UsedPromoStorageService usedPromoStorageService;
public PromoApiController(
AppProperties appProperties,
PromoCodeService promoCodeService,
PromoTransferService promoTransferService,
UsedPromoStorageService usedPromoStorageService
) {
this.appProperties = appProperties;
this.promoCodeService = promoCodeService;
this.promoTransferService = promoTransferService;
this.usedPromoStorageService = usedPromoStorageService;
}
@PostMapping("/api/promo/top-up")
public ResponseEntity<PromoResponse> topUp(@RequestBody PromoRequest request) {
String wallet = trimToEmpty(request == null ? null : request.getWallet());
String name = trimToEmpty(request == null ? null : request.getName());
String promoCode = promoCodeService.normalizePromoCode(request == null ? null : request.getPromoCode());
if (wallet.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустой wallet");
}
if (!promoTransferService.isSolanaWalletValid(wallet)) {
return error(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса");
}
if (name.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустое имя");
}
if (name.length() < 2 || name.length() > 120) {
return error(HttpStatus.BAD_REQUEST, "Имя должно быть длиной от 2 до 120 символов");
}
if (promoCode.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустой промокод");
}
boolean eternalPromo = promoCodeService.isEternalPromoCode(promoCode);
if (!eternalPromo && !promoCodeService.isPromoCodeFormatValid(promoCode)) {
return error(HttpStatus.BAD_REQUEST, "Неверный формат промокода");
}
try {
if (!eternalPromo && !promoCodeService.promoCodeExists(promoCode)) {
return error(HttpStatus.BAD_REQUEST, "Промокод не найден");
}
String signature = usedPromoStorageService.executeLocked(() -> {
if (!eternalPromo && usedPromoStorageService.isPromoUsed(promoCode)) {
throw new PromoException(HttpStatus.CONFLICT, "Промокод уже использован");
}
String txSignature = promoTransferService.sendPromoTransfer(wallet);
// Для вечного промокода ведём только лог использования, но не блокируем повторное применение.
usedPromoStorageService.appendUsedPromo(promoCode, wallet, name, txSignature);
return txSignature;
});
String explorerUrl = promoTransferService.buildExplorerUrl(signature);
String amount = appProperties.getPromoTransferAmountSol().stripTrailingZeros().toPlainString();
log.info(
"Promo top-up success: wallet={}, name={}, promoCode={}, signature={}",
wallet,
name,
promoCode,
signature
);
PromoResponse response = PromoResponse.success(
"Тестовое пополнение выполнено",
wallet,
name,
amount,
signature,
explorerUrl
);
return ResponseEntity.ok(response);
} catch (PromoException e) {
if (e.getStatus().is5xxServerError()) {
log.error("Top-up failed: {}", e.getMessage(), e);
} else {
log.warn("Top-up rejected: {}", e.getMessage());
}
return error(e.getStatus(), e.getMessage());
} catch (Exception e) {
log.error("Unexpected top-up error", e);
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера");
}
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of(
"status", "ok",
"app", "SHiNE-promo-solana-devnet"
);
}
private ResponseEntity<PromoResponse> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(PromoResponse.error(message));
}
private String trimToEmpty(String value) {
if (value == null) {
return "";
}
return value.trim().replaceAll("\\s{2,}", " ");
}
}

View File

@ -0,0 +1,16 @@
package ru.shine.promo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class PromoPageController {
@GetMapping("/")
public String index(@RequestParam(name = "wallet", required = false) String wallet, Model model) {
model.addAttribute("wallet", wallet == null ? "" : wallet.trim());
return "index";
}
}

View File

@ -0,0 +1,32 @@
package ru.shine.promo.dto;
public class PromoRequest {
private String wallet;
private String name;
private String promoCode;
public String getWallet() {
return wallet;
}
public void setWallet(String wallet) {
this.wallet = wallet;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPromoCode() {
return promoCode;
}
public void setPromoCode(String promoCode) {
this.promoCode = promoCode;
}
}

View File

@ -0,0 +1,69 @@
package ru.shine.promo.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PromoResponse {
private boolean success;
private String message;
private String wallet;
private String name;
private String amountSol;
private String signature;
private String explorerUrl;
public static PromoResponse success(
String message,
String wallet,
String name,
String amountSol,
String signature,
String explorerUrl
) {
PromoResponse response = new PromoResponse();
response.success = true;
response.message = message;
response.wallet = wallet;
response.name = name;
response.amountSol = amountSol;
response.signature = signature;
response.explorerUrl = explorerUrl;
return response;
}
public static PromoResponse error(String message) {
PromoResponse response = new PromoResponse();
response.success = false;
response.message = message;
return response;
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
public String getWallet() {
return wallet;
}
public String getName() {
return name;
}
public String getAmountSol() {
return amountSol;
}
public String getSignature() {
return signature;
}
public String getExplorerUrl() {
return explorerUrl;
}
}

View File

@ -0,0 +1,77 @@
package ru.shine.promo.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Pattern;
@Service
public class PromoCodeService {
private static final Logger log = LoggerFactory.getLogger(PromoCodeService.class);
private static final Pattern PROMO_CODE_PATTERN = Pattern.compile("^[a-z0-9]{8}$");
private final AppProperties appProperties;
public PromoCodeService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public String normalizePromoCode(String rawPromoCode) {
if (rawPromoCode == null) {
return "";
}
return rawPromoCode.trim().toLowerCase();
}
public boolean isPromoCodeFormatValid(String promoCode) {
return PROMO_CODE_PATTERN.matcher(promoCode).matches();
}
public boolean isEternalPromoCode(String promoCode) {
if (!appProperties.isPromoEternalCodeEnabled()) {
return false;
}
String configured = appProperties.getPromoEternalCodeValue();
return !configured.isEmpty() && configured.equals(promoCode);
}
public boolean promoCodeExists(String promoCode) {
Set<String> codes = readPromoCodesFromFile();
return codes.contains(promoCode);
}
private Set<String> readPromoCodesFromFile() {
Path file = Path.of(appProperties.getPromoCodesFile());
if (!Files.exists(file)) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов");
}
try {
Set<String> result = new LinkedHashSet<>();
for (String row : Files.readAllLines(file, StandardCharsets.UTF_8)) {
String line = row.trim().toLowerCase();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
if (!isPromoCodeFormatValid(line)) {
log.warn("Skipped invalid promo code row in {}: {}", file, line);
continue;
}
result.add(line);
}
return result;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов", e);
}
}
}

View File

@ -0,0 +1,22 @@
package ru.shine.promo.service;
import org.springframework.http.HttpStatus;
public class PromoException extends RuntimeException {
private final HttpStatus status;
public PromoException(HttpStatus status, String message) {
super(message);
this.status = status;
}
public PromoException(HttpStatus status, String message, Throwable cause) {
super(message, cause);
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}

View File

@ -0,0 +1,139 @@
package ru.shine.promo.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.p2p.solanaj.core.Account;
import org.p2p.solanaj.core.PublicKey;
import org.p2p.solanaj.core.Transaction;
import org.p2p.solanaj.programs.SystemProgram;
import org.p2p.solanaj.rpc.RpcClient;
import org.p2p.solanaj.rpc.RpcException;
import org.p2p.solanaj.rpc.types.config.Commitment;
import org.p2p.solanaj.rpc.types.config.RpcSendTransactionConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Locale;
@Service
public class PromoTransferService {
private static final Logger log = LoggerFactory.getLogger(PromoTransferService.class);
private final AppProperties appProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
public PromoTransferService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public boolean isSolanaWalletValid(String wallet) {
if (wallet == null || wallet.isBlank()) {
return false;
}
try {
PublicKey publicKey = new PublicKey(wallet.trim());
return publicKey.toByteArray().length == PublicKey.PUBLIC_KEY_LENGTH;
} catch (Exception e) {
return false;
}
}
public String sendPromoTransfer(String recipientWallet) {
PublicKey recipient = parseWalletOrThrow(recipientWallet);
Account sender = readSenderAccount();
long lamports = appProperties.getPromoTransferAmountLamports();
RpcClient rpcClient = new RpcClient(appProperties.getSolanaRpcUrl());
try {
long senderBalance = rpcClient.getApi().getBalance(sender.getPublicKey(), Commitment.CONFIRMED);
if (senderBalance < lamports) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя");
}
Transaction transaction = new Transaction()
.addInstruction(SystemProgram.transfer(sender.getPublicKey(), recipient, lamports));
RpcSendTransactionConfig config = RpcSendTransactionConfig.builder()
.encoding(RpcSendTransactionConfig.Encoding.base64)
.skipPreFlight(false)
.maxRetries(3)
.build();
return rpcClient.getApi().sendTransaction(
transaction,
Collections.singletonList(sender),
null,
config
);
} catch (PromoException e) {
throw e;
} catch (RpcException e) {
String message = e.getMessage() == null ? "" : e.getMessage().toLowerCase(Locale.ROOT);
if (message.contains("insufficient")) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя", e);
}
if (message.contains("rpc")) {
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка RPC Solana", e);
}
log.error("Solana transfer failed: {}", e.getMessage(), e);
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e);
} catch (Exception e) {
log.error("Unexpected Solana transfer error: {}", e.getMessage(), e);
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e);
}
}
public String buildExplorerUrl(String signature) {
return String.format(appProperties.getPromoExplorerTxUrlTemplate(), signature);
}
private PublicKey parseWalletOrThrow(String wallet) {
if (!isSolanaWalletValid(wallet)) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса");
}
return new PublicKey(wallet.trim());
}
private Account readSenderAccount() {
Path keypairFile = Path.of(appProperties.getSolanaSenderKeypairFile());
if (!Files.exists(keypairFile)) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Файл keypair отправителя не найден");
}
try {
int[] keyArray = objectMapper.readValue(Files.readString(keypairFile), int[].class);
if (keyArray.length != 64) {
throw new PromoException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Некорректный формат keypair отправителя: ожидается массив из 64 чисел"
);
}
byte[] secret = new byte[64];
for (int i = 0; i < keyArray.length; i++) {
int value = keyArray[i];
if (value < 0 || value > 255) {
throw new PromoException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Некорректный формат keypair отправителя: числа должны быть в диапазоне 0..255"
);
}
secret[i] = (byte) value;
}
return new Account(secret);
} catch (PromoException e) {
throw e;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения keypair отправителя", e);
}
}
}

View File

@ -0,0 +1,101 @@
package ru.shine.promo.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.ReentrantLock;
@Service
public class UsedPromoStorageService {
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm", Locale.ROOT);
private final AppProperties appProperties;
private final ReentrantLock promoLock = new ReentrantLock(true);
public UsedPromoStorageService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public <T> T executeLocked(LockedOperation<T> operation) {
promoLock.lock();
try {
return operation.run();
} finally {
promoLock.unlock();
}
}
public boolean isPromoUsed(String promoCode) {
Path usedFile = Path.of(appProperties.getPromoUsedFile());
if (!Files.exists(usedFile)) {
return false;
}
try {
List<String> rows = Files.readAllLines(usedFile, StandardCharsets.UTF_8);
for (String row : rows) {
String line = row.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] parts = line.split("\\|");
if (parts.length == 0) {
continue;
}
String usedCode = parts[0].trim().toLowerCase(Locale.ROOT);
if (usedCode.equals(promoCode)) {
return true;
}
}
return false;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла использованных промокодов", e);
}
}
public void appendUsedPromo(String promoCode, String wallet, String name, String signature) {
Path usedFile = Path.of(appProperties.getPromoUsedFile());
try {
Path parent = usedFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
if (!Files.exists(usedFile)) {
Files.createFile(usedFile);
}
String timestamp = LocalDateTime.now().format(DATE_TIME_FORMAT);
String line = String.format(
Locale.ROOT,
"%s | %s | %s | %s | %s%n",
promoCode,
wallet,
name,
timestamp,
signature
);
Files.writeString(usedFile, line, StandardCharsets.UTF_8, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка записи файла использованных промокодов", e);
}
}
@FunctionalInterface
public interface LockedOperation<T> {
T run();
}
}

View File

@ -0,0 +1,17 @@
server.port=8021
spring.application.name=SHiNE-promo-solana-devnet
spring.thymeleaf.cache=false
solana.rpc.url=https://api.devnet.solana.com
solana.sender.keypair-file=./config/devnet-wallet.json
promo.transfer.amount-sol=0.1
promo.codes.file=./data/promo-codes.txt
promo.used.file=./data/promo-used.txt
promo.explorer.tx-url-template=https://explorer.solana.com/tx/%s?cluster=devnet
# Вечный промокод для временной раздачи в devnet.
# Если enabled=true, код можно использовать неограниченно (он не "сгорает").
promo.eternal-code.enabled=true
promo.eternal-code.value=0000

View File

@ -0,0 +1,200 @@
:root {
--bg-main: #070707;
--bg-card: #111217;
--bg-card-soft: #171a22;
--text-main: #f6f8fb;
--text-muted: #aeb4c1;
--accent: #51ffd3;
--accent-soft: rgba(81, 255, 211, 0.15);
--danger: #ff6f6f;
--border: rgba(255, 255, 255, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", "Noto Sans", system-ui, sans-serif;
color: var(--text-main);
background:
radial-gradient(circle at 20% 5%, rgba(81, 255, 211, 0.08), transparent 34%),
radial-gradient(circle at 100% 0%, rgba(86, 135, 255, 0.12), transparent 40%),
var(--bg-main);
min-height: 100vh;
}
.page {
min-height: calc(100vh - 48px);
display: grid;
place-items: center;
padding: 24px 14px;
}
.card {
width: min(720px, 100%);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid var(--border);
border-radius: 18px;
padding: 20px;
box-shadow: 0 12px 50px rgba(0, 0, 0, 0.45);
}
.hero h1 {
margin: 0;
font-size: clamp(1.3rem, 3.8vw, 1.9rem);
line-height: 1.2;
}
.subtitle {
color: var(--text-muted);
margin-top: 10px;
margin-bottom: 0;
}
.lead {
margin-top: 18px;
line-height: 1.5;
}
.warning {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
padding: 12px;
margin-top: 16px;
color: #f0f4ff;
}
.promo-form {
display: grid;
gap: 10px;
margin-top: 18px;
}
.promo-form label {
font-size: 0.95rem;
color: var(--text-muted);
}
.promo-form input {
width: 100%;
background: var(--bg-card-soft);
color: var(--text-main);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 12px;
font-size: 1rem;
}
.promo-form input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.promo-form button {
margin-top: 8px;
border: none;
border-radius: 12px;
background: linear-gradient(140deg, #2dd4bf, #4cc9f0);
color: #0b1116;
font-weight: 700;
font-size: 1rem;
padding: 14px;
cursor: pointer;
}
.promo-form button:disabled {
opacity: 0.7;
cursor: default;
}
.status {
margin-top: 14px;
padding: 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
}
.status-error {
color: #ffd6d6;
border-color: rgba(255, 111, 111, 0.6);
background: rgba(255, 111, 111, 0.12);
}
.success {
margin-top: 16px;
border: 1px solid rgba(81, 255, 211, 0.35);
background: rgba(81, 255, 211, 0.08);
border-radius: 12px;
padding: 14px;
}
.success h2 {
margin-top: 0;
font-size: 1.12rem;
}
.success p {
margin-top: 8px;
line-height: 1.45;
}
.success ul {
padding-left: 18px;
margin: 10px 0;
display: grid;
gap: 6px;
word-break: break-word;
}
.success a {
color: var(--accent);
}
.faq {
margin-top: 20px;
}
.faq h3 {
margin-bottom: 10px;
}
.faq details {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
}
.faq summary {
cursor: pointer;
font-weight: 600;
}
.faq p {
margin-bottom: 4px;
color: var(--text-muted);
line-height: 1.45;
}
.page-footer {
text-align: center;
color: #727a88;
font-size: 0.8rem;
padding-bottom: 18px;
}
.hidden {
display: none;
}
@media (min-width: 768px) {
.card {
padding: 26px;
}
}

View File

@ -0,0 +1,82 @@
(() => {
const form = document.getElementById("promoForm");
const walletInput = document.getElementById("wallet");
const nameInput = document.getElementById("name");
const promoCodeInput = document.getElementById("promoCode");
const submitButton = document.getElementById("submitButton");
const loadingState = document.getElementById("loadingState");
const errorState = document.getElementById("errorState");
const successState = document.getElementById("successState");
const successAmount = document.getElementById("successAmount");
const successWallet = document.getElementById("successWallet");
const successName = document.getElementById("successName");
const successSignature = document.getElementById("successSignature");
const successExplorerUrl = document.getElementById("successExplorerUrl");
const params = new URLSearchParams(window.location.search);
const walletFromQuery = (params.get("wallet") || "").trim();
if (walletFromQuery && !walletInput.value.trim()) {
walletInput.value = walletFromQuery;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
hideMessages();
toggleLoading(true);
try {
const payload = {
wallet: walletInput.value.trim(),
name: nameInput.value.trim(),
promoCode: promoCodeInput.value.trim()
};
const response = await fetch("/api/promo/top-up", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (!result.success) {
showError(result.message || "Не удалось выполнить операцию");
return;
}
showSuccess(result);
} catch (error) {
showError("Ошибка сети или недоступен backend");
} finally {
toggleLoading(false);
}
});
function toggleLoading(active) {
submitButton.disabled = active;
loadingState.classList.toggle("hidden", !active);
submitButton.textContent = active ? "Отправляем..." : "Пополнить тестовый счёт";
}
function hideMessages() {
errorState.classList.add("hidden");
successState.classList.add("hidden");
}
function showError(message) {
errorState.textContent = message;
errorState.classList.remove("hidden");
}
function showSuccess(data) {
successAmount.textContent = data.amountSol || "0.1";
successWallet.textContent = data.wallet || "—";
successName.textContent = data.name || "—";
successSignature.textContent = data.signature || "—";
successExplorerUrl.href = data.explorerUrl || "#";
successState.classList.remove("hidden");
}
})();

View File

@ -0,0 +1,137 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SHiNE / Сияние — тестовое пополнение</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<main class="page">
<section class="card">
<header class="hero">
<h1>SHiNE / Сияние — тестовое пополнение</h1>
<p class="subtitle">
Временная devnet-страница для приглашённых тестеров Web3-социальной сети SHiNE.
</p>
</header>
<p class="lead">
Если вы получили промокод, введите его ниже. Мы отправим на ваш Solana devnet-кошелёк 0.1 SOL для
тестирования функций SHiNE.
</p>
<div class="warning">
Это тестовая сеть Solana Devnet. Эти SOL не являются настоящими деньгами и используются только для
проверки работы приложения.
</div>
<form id="promoForm" class="promo-form" novalidate>
<label for="wallet">Кошелёк Solana</label>
<input id="wallet" name="wallet" type="text" th:value="${wallet}" placeholder="Введите публичный адрес"
required>
<label for="name">Ваше имя / имя тестера</label>
<input id="name" name="name" type="text" maxlength="120" placeholder="Например, Иван Петров" required>
<label for="promoCode">Промокод</label>
<input id="promoCode" name="promoCode" type="text" maxlength="8" placeholder="Например, x7k3m2qn"
required>
<button id="submitButton" type="submit">Пополнить тестовый счёт</button>
</form>
<div id="loadingState" class="status hidden">Отправляем транзакцию...</div>
<div id="errorState" class="status status-error hidden"></div>
<section id="successState" class="success hidden" aria-live="polite">
<h2>Готово! Тестовое пополнение выполнено</h2>
<p>
На указанный devnet-кошелёк отправлено 0.1 SOL. Возвращайтесь в приложение SHiNE / Сияние и
продолжайте тестирование.
</p>
<p>
Можете закрыть эту страницу и продолжить регистрацию на сайте.
</p>
<ul>
<li><strong>Сумма:</strong> <span id="successAmount"></span> SOL</li>
<li><strong>Кошелёк:</strong> <span id="successWallet"></span></li>
<li><strong>Имя:</strong> <span id="successName"></span></li>
<li><strong>Подпись транзакции:</strong> <span id="successSignature"></span></li>
</ul>
<a id="successExplorerUrl" href="#" target="_blank" rel="noopener noreferrer">
Открыть транзакцию в Solana Explorer
</a>
</section>
<section class="faq">
<h3>FAQ</h3>
<details>
<summary>Что такое SHiNE / Сияние?</summary>
<p>
SHiNE / «Сияние» — это тестируемая Web3-социальная сеть, где аккаунты, ключи и часть действий
связаны с блокчейном Solana. Пользователь управляет своим кошельком, а не просто логином и
паролем на обычном сервере.
</p>
</details>
<details>
<summary>Зачем нужен тестовый баланс?</summary>
<p>
В SHiNE регистрация и некоторые действия требуют небольших списаний. Это помогает тестировать
реальную экономику приложения: регистрацию, переводы, сообщения, звонки и другие функции. Сейчас
всё работает в Solana Devnet, поэтому средства тестовые.
</p>
</details>
<details>
<summary>Сколько стоит регистрация?</summary>
<p>
Текущая тестовая стоимость регистрации в SHiNE — 0.1 SOL. Промо-страница отправляет стартовое
тестовое пополнение 0.1 SOL. Условия тестирования могут меняться по мере развития проекта.
</p>
</details>
<details>
<summary>Можно ли пригласить друга?</summary>
<p>
Да. После получения тестового баланса и регистрации в SHiNE вы сможете переводить часть тестовых
SOL другим пользователям, например друзьям или родственникам, чтобы вместе проверить сообщения,
звонки и взаимодействие внутри социальной сети.
</p>
</details>
<details>
<summary>Это настоящие деньги?</summary>
<p>
Нет. Это Solana Devnet — тестовая сеть. Devnet SOL не имеют реальной рыночной ценности и
используются только для разработки и тестирования.
</p>
</details>
<details>
<summary>Почему в Web3 действия платные?</summary>
<p>
В Web3 часть действий связана с транзакциями, хранением данных, подписями и сетевой
инфраструктурой. Зато такая модель позволяет строить систему без навязчивой рекламы и с большей
прозрачностью: пользователь понимает, за что платит, а ключи остаются у него.
</p>
</details>
<details>
<summary>Кто хранит мои ключи?</summary>
<p>
Эта промо-страница не просит и не хранит приватные ключи пользователя. Для пополнения нужен только
публичный адрес кошелька Solana Devnet.
</p>
</details>
</section>
</section>
</main>
<footer class="page-footer">SHiNE Devnet Promo · temporary testing page</footer>
<script src="/js/app.js"></script>
</body>
</html>