Добавлены DAO/менеджер лимиты, очередь 2 в выплатах и расширенный UI

This commit is contained in:
AidarKC 2026-05-05 19:02:36 +03:00
parent 0a9b76055f
commit 7e8ca9157b
15 changed files with 1231 additions and 168 deletions

View File

@ -23,3 +23,11 @@
1. Обновить соответствующий документ в `doc/` в том же изменении. 1. Обновить соответствующий документ в `doc/` в том же изменении.
2. Если документ сразу обновить нельзя, обязательно явно согласовать это с пользователем в чате и зафиксировать план обновления. 2. Если документ сразу обновить нельзя, обязательно явно согласовать это с пользователем в чате и зафиксировать план обновления.
## Rule: Git Push
Для push в удаленный репозиторий использовать токен из переменной окружения:
- `GITEA_TOKEN`
Push выполнять через `http.extraHeader` (Authorization) без вывода токена в логи.

View File

@ -0,0 +1,50 @@
# SHINE Payments v2: тестовый план (Devnet)
## Цель
Проверить:
1. создание PDA и инициализацию;
2. покупку обычных билетов (очередь 1);
3. выдачу DAO-лимитов менеджеру;
4. создание менеджерских билетов в очередь 1 и 2;
5. корректный приоритет выплат (очередь 1 > очередь 2);
6. перевод средств в DAO и награды вызывающему шаг выплат.
## Сценарий А: один кошелек
1. Открыть `admin_tools`, выполнить `init`.
2. В `buy_ticket` купить несколько билетов в очередь 1.
3. В `dao_tools` (тем же кошельком, если он DAO) выдать лимиты менеджеру.
4. В `manager_tools` создать билеты в очередь 1 и 2.
5. Пополнить inflow-вольт вручную.
6. В `track_ticket` запускать шаг выплат и смотреть, что:
- сначала платится очередь 1;
- после исчерпания очереди 1 платится очередь 2;
- если в процессе снова появился билет в очереди 1, приоритет возвращается к ней.
## Сценарий Б: разные кошельки
1. Кошелек DAO: выдает лимиты менеджерам.
2. Кошелек менеджера: добавляет тикеты через `manager_add_ticket`.
3. Кошельки покупателей: покупают обычные билеты.
4. Любой кошелек: вызывает `step_payout`.
## Проверка результата
1. У получателей тикетов растут балансы.
2. В DAO поступает симметричная сумма `X` при каждом шаге выплаты.
3. Вызывающий шаг выплат получает фиксированную награду.
4. Агрегаты очередей (`total/paid/sum_total/sum_paid`) изменяются ожидаемо.
## Возврат средств после теста
1. Перевести остатки SOL с тестовых кошельков на исходный кошелек.
2. При необходимости закрыть неиспользуемые program-аккаунты и вернуть ренту.
3. Проверить, что ключевые кошельки и devnet-параметры не содержат лишних средств/прав после завершения теста.
## Ограничения текущего этапа
1. DAO пока заменен обычным кошельком.
2. Финальный governance (голосование DAO) не подключен.
3. Расчеты пока в SOL/lamports, переход на USDT по курсу запланирован.

View File

@ -1,42 +1,50 @@
# SHINE Payments v2 (краткое описание) # SHINE Payments v2
## Назначение ## Назначение
`shine_payments` v2 — контракт очереди выплат: `shine_payments` v2 — контракт очереди выплат с двумя очередями:
1. Покупка билета в очередь 1: 1. обычная покупка билета (очередь 1);
- пользователь переводит SOL в DAO; 2. менеджерское добавление билетов (очередь 1 и очередь 2 по лимитам от DAO);
- получает тикет с суммой будущей выплаты `input * coef`. 3. пошаговые выплаты из inflow-вольта с приоритетом очереди 1.
2. Шаг выплат:
- из inflow-вольта платится следующий тикет; Сейчас тестовый этап в Devnet: расчеты в SOL/lamports.
- такая же сумма отправляется в DAO; Следующий этап — модель расчета в USDT по курсу.
- фиксированная награда отправляется вызвавшему шаг.
## PDA ## PDA
1. `config_pda` (`shine_payments_v2_config`) 1. `config_pda` (`shine_payments_v2_config`)
- `dao_wallet` - `dao_wallet`
- `manager_wallet` - `manager_wallet` (права на смену coef/limit)
- `inflow_vault` - `inflow_vault`
- `call_reward_lamports` - `call_reward_lamports`
2. `coef_limit_pda` (`shine_payments_v2_coef_limit`) 2. `coef_limit_pda` (`shine_payments_v2_coef_limit`)
- `coef_ppm` (коэффициент в fixed-point, scale = 1_000_000) - `coef_ppm` (fixed-point, scale = 1_000_000)
- `limit_lamports` - `limit_lamports` (лимит долга очереди 1 для обычной покупки)
3. `queues_pda` (`shine_payments_v2_queues`) 3. `queues_pda` (`shine_payments_v2_queues`)
- очередь 1: `tickets_total`, `tickets_paid`, `sum_total`, `sum_paid` - очередь 1: `tickets_total`, `tickets_paid`, `sum_total`, `sum_paid`
- очередь 2: `tickets_total`, `tickets_paid`, `sum_total`, `sum_paid` - очередь 2: `tickets_total`, `tickets_paid`, `sum_total`, `sum_paid`
4. `inflow_vault_pda` (`shine_payments_v2_inflow_vault`) 4. `inflow_vault_pda` (`shine_payments_v2_inflow_vault`)
- PDA-вольт программы для выплат. - входящий PDA-вольт программы для выплат.
5. `q1_ticket_pda` (`shine_payments_v2_q1_ticket + index_le_u64`) 5. `ticket_pda`
- очередь 1: `shine_payments_v2_q1_ticket + index_le_u64`
- очередь 2: `shine_payments_v2_q2_ticket + index_le_u64`
- поля тикета:
- `queue_id`
- `index`
- `is_paid` - `is_paid`
- `recipient_wallet` - `recipient_wallet`
- `payout_lamports` - `payout_lamports`
- `debt_before_lamports` - `debt_before_lamports`
- `index`
6. `manager_allowance_pda` (`shine_payments_v2_manager_allowance + manager_pubkey`)
- `manager_wallet`
- `q1_available_lamports`
- `q2_available_lamports`
## Методы ## Методы
@ -44,24 +52,36 @@
- вызывается один раз (кто угодно); - вызывается один раз (кто угодно);
- создает `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`. - создает `config_pda`, `coef_limit_pda`, `queues_pda`, `inflow_vault_pda`.
2. `update_coef_limit` (только manager) 2. `update_coef_limit` (только `manager_wallet` из config)
- меняет коэффициент и лимит. - меняет коэффициент и лимит покупки в очередь 1.
3. `buy_ticket` 3. `grant_manager_limits` (только `dao_wallet` из config)
- проверяет, что текущий долг очереди 1 меньше лимита; - DAO выдает/добавляет лимиты менеджеру:
- переводит входную сумму в DAO; - `add_q1_lamports`
- создает тикет в очереди 1; - `add_q2_lamports`
- увеличивает агрегаты очереди. - если PDA менеджера нет — создается;
- если есть — лимиты увеличиваются.
4. `step_payout` 4. `buy_ticket`
- если есть тикеты в очереди 1: - обычная покупка билета в очередь 1;
- платит `X` получателю тикета, - сумма покупки идет в DAO;
- платит `X` в DAO, - тикет получает выплату `input * coef_ppm / 1_000_000`.
- платит reward вызвавшему;
- помечает тикет выплаченным, обновляет агрегаты. 5. `manager_add_ticket`
- если обе очереди пусты/полностью выплачены: - менеджер добавляет тикет в очередь 1 или 2;
- переводит из inflow-вольта в DAO весь доступный остаток (сверх ренты), - без денежного перевода;
- reward не платится. - списывает лимит менеджера по выбранной очереди.
6. `step_payout`
- выбирает очередь по приоритету:
1. сначала очередь 1;
2. если в 1-й нет ожидания — очередь 2.
- шаг выплаты:
- `X` получателю тикета,
- `X` в DAO,
- `reward` вызывающему.
- если обе очереди пусты/выплачены:
- переводит весь доступный остаток inflow-вольта в DAO (без reward).
## Стартовые настройки ## Стартовые настройки
@ -70,4 +90,9 @@
- `START_COEF_PPM = 5_000_000` (коэффициент 5.0) - `START_COEF_PPM = 5_000_000` (коэффициент 5.0)
- `START_LIMIT_LAMPORTS = 100 SOL` - `START_LIMIT_LAMPORTS = 100 SOL`
- `START_CALL_REWARD_LAMPORTS = 0.008 SOL` - `START_CALL_REWARD_LAMPORTS = 0.008 SOL`
- `DAO_WALLET`, `MANAGER_WALLET` - `DAO_WALLET`
- `MANAGER_WALLET`
## Тестовый режим
Пока нет финального production-потока пополнения inflow из регистрации/экосистемы, inflow-вольт пополняется вручную в Devnet, после чего выполняются шаги выплат.

View File

@ -121,6 +121,71 @@ pub mod shine_payments {
Ok(()) Ok(())
} }
pub fn grant_manager_limits(
ctx: Context<GrantManagerLimits>,
args: GrantManagerLimitsArgs,
) -> Result<()> {
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
require_keys_eq!(
config.dao_wallet,
ctx.accounts.signer.key(),
PaymentsError::UnauthorizedDao
);
require!(
args.add_q1_lamports > 0 || args.add_q2_lamports > 0,
PaymentsError::InvalidAmount
);
let (expected_pda, bump) = find_manager_allowance_pda(ctx.program_id, &args.manager_wallet);
require_keys_eq!(
expected_pda,
ctx.accounts.manager_allowance_pda.key(),
ErrCode::InvalidPdaAddress
);
let system_program_ai = ctx.accounts.system_program.to_account_info();
let mut state = if ctx.accounts.manager_allowance_pda.owner == &Pubkey::default() {
let initial = ManagerAllowanceState {
version: 1,
manager_wallet: args.manager_wallet,
q1_available_lamports: 0,
q2_available_lamports: 0,
};
create_state_with_seeds(
ctx.program_id,
&ctx.accounts.signer,
&system_program_ai,
&ctx.accounts.manager_allowance_pda,
&[
settings::MANAGER_ALLOWANCE_SEED,
args.manager_wallet.as_ref(),
&[bump],
],
settings::MANAGER_ALLOWANCE_SPACE,
&initial,
)?;
initial
} else {
read_state::<ManagerAllowanceState>(&ctx.accounts.manager_allowance_pda)?
};
require_keys_eq!(
state.manager_wallet,
args.manager_wallet,
PaymentsError::InvalidManagerWallet
);
state.q1_available_lamports = state
.q1_available_lamports
.checked_add(args.add_q1_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
state.q2_available_lamports = state
.q2_available_lamports
.checked_add(args.add_q2_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
write_state(&ctx.accounts.manager_allowance_pda, &state)?;
Ok(())
}
pub fn buy_ticket(ctx: Context<BuyTicket>, args: BuyTicketArgs) -> Result<()> { pub fn buy_ticket(ctx: Context<BuyTicket>, args: BuyTicketArgs) -> Result<()> {
require!(args.amount_lamports > 0, PaymentsError::InvalidAmount); require!(args.amount_lamports > 0, PaymentsError::InvalidAmount);
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?; let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
@ -210,6 +275,125 @@ pub mod shine_payments {
Ok(()) Ok(())
} }
pub fn manager_add_ticket(
ctx: Context<ManagerAddTicket>,
args: ManagerAddTicketArgs,
) -> Result<()> {
require!(args.payout_lamports > 0, PaymentsError::InvalidPayoutAmount);
require!(args.queue_id == 1 || args.queue_id == 2, PaymentsError::InvalidTicketQueue);
let (expected_manager_pda, _) =
find_manager_allowance_pda(ctx.program_id, ctx.accounts.signer.key);
require_keys_eq!(
expected_manager_pda,
ctx.accounts.manager_allowance_pda.key(),
ErrCode::InvalidPdaAddress
);
let mut allowance = read_state::<ManagerAllowanceState>(&ctx.accounts.manager_allowance_pda)?;
require_keys_eq!(
allowance.manager_wallet,
ctx.accounts.signer.key(),
PaymentsError::InvalidManagerWallet
);
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
let current_debt = if args.queue_id == 1 {
queues
.q1_sum_total
.checked_sub(queues.q1_sum_paid)
.ok_or(error!(ErrCode::MathOverflow))?
} else {
queues
.q2_sum_total
.checked_sub(queues.q2_sum_paid)
.ok_or(error!(ErrCode::MathOverflow))?
};
if args.queue_id == 1 {
require!(
allowance.q1_available_lamports >= args.payout_lamports,
PaymentsError::ManagerLimitExceeded
);
} else {
require!(
allowance.q2_available_lamports >= args.payout_lamports,
PaymentsError::ManagerLimitExceeded
);
}
let ticket_index = if args.queue_id == 1 {
queues
.q1_tickets_total
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?
} else {
queues
.q2_tickets_total
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?
};
let (expected_ticket_pda, ticket_bump) =
find_ticket_pda(ctx.program_id, args.queue_id, ticket_index);
require_keys_eq!(
expected_ticket_pda,
ctx.accounts.ticket_pda.key(),
ErrCode::InvalidPdaAddress
);
require!(
ctx.accounts.ticket_pda.owner == &Pubkey::default(),
ErrCode::PdaAlreadyExists
);
let ticket = TicketState {
version: 1,
queue_id: args.queue_id,
index: ticket_index,
is_paid: false,
recipient_wallet: args.recipient_wallet,
payout_lamports: args.payout_lamports,
debt_before_lamports: current_debt,
};
let seed_prefix = if args.queue_id == 1 {
settings::Q1_TICKET_SEED
} else {
settings::Q2_TICKET_SEED
};
create_state_with_seeds(
ctx.program_id,
&ctx.accounts.signer,
&ctx.accounts.system_program.to_account_info(),
&ctx.accounts.ticket_pda,
&[seed_prefix, &ticket_index.to_le_bytes(), &[ticket_bump]],
settings::TICKET_SPACE,
&ticket,
)?;
if args.queue_id == 1 {
allowance.q1_available_lamports = allowance
.q1_available_lamports
.checked_sub(args.payout_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
queues.q1_tickets_total = ticket_index;
queues.q1_sum_total = queues
.q1_sum_total
.checked_add(args.payout_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
} else {
allowance.q2_available_lamports = allowance
.q2_available_lamports
.checked_sub(args.payout_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
queues.q2_tickets_total = ticket_index;
queues.q2_sum_total = queues
.q2_sum_total
.checked_add(args.payout_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
}
write_state(&ctx.accounts.manager_allowance_pda, &allowance)?;
write_state(&ctx.accounts.queues_pda, &queues)?;
Ok(())
}
pub fn step_payout(ctx: Context<StepPayout>) -> Result<()> { pub fn step_payout(ctx: Context<StepPayout>) -> Result<()> {
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?; let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?; let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
@ -235,10 +419,7 @@ pub mod shine_payments {
.checked_sub(queues.q2_tickets_paid) .checked_sub(queues.q2_tickets_paid)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
if q1_pending == 0 { if q1_pending == 0 && q2_pending == 0 {
if q2_pending > 0 {
return Err(error!(PaymentsError::SecondQueueNotImplemented));
}
transfer_all_available_to_dao( transfer_all_available_to_dao(
&ctx.accounts.inflow_vault_pda, &ctx.accounts.inflow_vault_pda,
&ctx.accounts.dao_wallet, &ctx.accounts.dao_wallet,
@ -246,11 +427,19 @@ pub mod shine_payments {
return Ok(()); return Ok(());
} }
let next_index = queues let target_queue = if q1_pending > 0 { 1 } else { 2 };
let next_index = if target_queue == 1 {
queues
.q1_tickets_paid .q1_tickets_paid
.checked_add(1) .checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?
let (expected_ticket_pda, _) = find_ticket_pda(ctx.program_id, 1, next_index); } else {
queues
.q2_tickets_paid
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?
};
let (expected_ticket_pda, _) = find_ticket_pda(ctx.program_id, target_queue, next_index);
require_keys_eq!( require_keys_eq!(
expected_ticket_pda, expected_ticket_pda,
ctx.accounts.next_ticket_pda.key(), ctx.accounts.next_ticket_pda.key(),
@ -258,7 +447,10 @@ pub mod shine_payments {
); );
let mut ticket = read_state::<TicketState>(&ctx.accounts.next_ticket_pda)?; let mut ticket = read_state::<TicketState>(&ctx.accounts.next_ticket_pda)?;
require!(ticket.queue_id == 1, PaymentsError::InvalidTicketQueue); require!(
ticket.queue_id == target_queue,
PaymentsError::InvalidTicketQueue
);
require!(ticket.index == next_index, PaymentsError::InvalidTicketIndex); require!(ticket.index == next_index, PaymentsError::InvalidTicketIndex);
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid); require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
require_keys_eq!( require_keys_eq!(
@ -296,6 +488,7 @@ pub mod shine_payments {
ticket.is_paid = true; ticket.is_paid = true;
write_state(&ctx.accounts.next_ticket_pda, &ticket)?; write_state(&ctx.accounts.next_ticket_pda, &ticket)?;
if target_queue == 1 {
queues.q1_tickets_paid = queues queues.q1_tickets_paid = queues
.q1_tickets_paid .q1_tickets_paid
.checked_add(1) .checked_add(1)
@ -304,6 +497,16 @@ pub mod shine_payments {
.q1_sum_paid .q1_sum_paid
.checked_add(ticket.payout_lamports) .checked_add(ticket.payout_lamports)
.ok_or(error!(ErrCode::MathOverflow))?; .ok_or(error!(ErrCode::MathOverflow))?;
} else {
queues.q2_tickets_paid = queues
.q2_tickets_paid
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?;
queues.q2_sum_paid = queues
.q2_sum_paid
.checked_add(ticket.payout_lamports)
.ok_or(error!(ErrCode::MathOverflow))?;
}
write_state(&ctx.accounts.queues_pda, &queues)?; write_state(&ctx.accounts.queues_pda, &queues)?;
Ok(()) Ok(())
@ -343,6 +546,20 @@ pub struct UpdateCoefLimit<'info> {
pub coef_limit_pda: AccountInfo<'info>, pub coef_limit_pda: AccountInfo<'info>,
} }
#[derive(Accounts)]
pub struct GrantManagerLimits<'info> {
/// CHECK: подписант DAO, проверяется атрибутом `signer` и сверкой с config.dao_wallet.
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA конфига, читается и валидируется вручную.
#[account(mut)]
pub config_pda: AccountInfo<'info>,
/// CHECK: PDA лимитов менеджера, адрес проверяется вручную по manager pubkey.
#[account(mut)]
pub manager_allowance_pda: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)] #[derive(Accounts)]
pub struct BuyTicket<'info> { pub struct BuyTicket<'info> {
/// CHECK: подписант-покупатель, проверяется атрибутом `signer`. /// CHECK: подписант-покупатель, проверяется атрибутом `signer`.
@ -366,6 +583,23 @@ pub struct BuyTicket<'info> {
pub system_program: Program<'info, System>, pub system_program: Program<'info, System>,
} }
#[derive(Accounts)]
pub struct ManagerAddTicket<'info> {
/// CHECK: подписант-менеджер, проверяется атрибутом `signer`.
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA лимитов менеджера, адрес сверяется вручную по signer.
#[account(mut)]
pub manager_allowance_pda: AccountInfo<'info>,
/// CHECK: PDA очередей, читается и валидируется вручную.
#[account(mut)]
pub queues_pda: AccountInfo<'info>,
/// CHECK: PDA тикета, адрес и состояние (должен быть пустым) проверяются вручную.
#[account(mut)]
pub ticket_pda: AccountInfo<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)] #[derive(Accounts)]
pub struct StepPayout<'info> { pub struct StepPayout<'info> {
/// CHECK: подписант-вызвавший шаг выплат, проверяется атрибутом `signer`. /// CHECK: подписант-вызвавший шаг выплат, проверяется атрибутом `signer`.
@ -397,12 +631,26 @@ pub struct UpdateCoefLimitArgs {
pub limit_lamports: u64, pub limit_lamports: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct GrantManagerLimitsArgs {
pub manager_wallet: Pubkey,
pub add_q1_lamports: u64,
pub add_q2_lamports: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct BuyTicketArgs { pub struct BuyTicketArgs {
pub amount_lamports: u64, pub amount_lamports: u64,
pub recipient_wallet: Pubkey, pub recipient_wallet: Pubkey,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ManagerAddTicketArgs {
pub queue_id: u8,
pub recipient_wallet: Pubkey,
pub payout_lamports: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ConfigState { pub struct ConfigState {
pub version: u8, pub version: u8,
@ -443,6 +691,14 @@ pub struct TicketState {
pub debt_before_lamports: u64, pub debt_before_lamports: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ManagerAllowanceState {
pub version: u8,
pub manager_wallet: Pubkey,
pub q1_available_lamports: u64,
pub q2_available_lamports: u64,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct VaultState { pub struct VaultState {
pub version: u8, pub version: u8,
@ -460,6 +716,8 @@ pub enum PaymentsError {
InvalidDaoWallet, InvalidDaoWallet,
#[msg("Управляющий кошелек не авторизован")] #[msg("Управляющий кошелек не авторизован")]
UnauthorizedManager, UnauthorizedManager,
#[msg("DAO кошелек не авторизован для этой операции")]
UnauthorizedDao,
#[msg("Некорректный коэффициент")] #[msg("Некорректный коэффициент")]
InvalidCoefficient, InvalidCoefficient,
#[msg("Некорректный лимит")] #[msg("Некорректный лимит")]
@ -480,8 +738,10 @@ pub enum PaymentsError {
InvalidTicketIndex, InvalidTicketIndex,
#[msg("Неверный тип очереди у тикета")] #[msg("Неверный тип очереди у тикета")]
InvalidTicketQueue, InvalidTicketQueue,
#[msg("Вторая очередь пока не реализована для выплат")] #[msg("Неверный кошелек менеджера")]
SecondQueueNotImplemented, InvalidManagerWallet,
#[msg("Лимит менеджера по выбранной очереди превышен")]
ManagerLimitExceeded,
} }
fn ensure_expected_pdas(program_id: &Pubkey, accounts: &Init) -> Result<()> { fn ensure_expected_pdas(program_id: &Pubkey, accounts: &Init) -> Result<()> {
@ -522,6 +782,13 @@ fn find_ticket_pda(program_id: &Pubkey, queue_id: u8, index: u64) -> (Pubkey, u8
Pubkey::find_program_address(&[seed, &idx], program_id) Pubkey::find_program_address(&[seed, &idx], program_id)
} }
fn find_manager_allowance_pda(program_id: &Pubkey, manager_wallet: &Pubkey) -> (Pubkey, u8) {
Pubkey::find_program_address(
&[settings::MANAGER_ALLOWANCE_SEED, manager_wallet.as_ref()],
program_id,
)
}
fn create_and_store_state<'info, T: AnchorSerialize>( fn create_and_store_state<'info, T: AnchorSerialize>(
program_id: &Pubkey, program_id: &Pubkey,
payer: &AccountInfo<'info>, payer: &AccountInfo<'info>,

View File

@ -4,12 +4,14 @@ pub const QUEUES_SEED: &[u8] = b"shine_payments_v2_queues";
pub const INFLOW_VAULT_SEED: &[u8] = b"shine_payments_v2_inflow_vault"; pub const INFLOW_VAULT_SEED: &[u8] = b"shine_payments_v2_inflow_vault";
pub const Q1_TICKET_SEED: &[u8] = b"shine_payments_v2_q1_ticket"; pub const Q1_TICKET_SEED: &[u8] = b"shine_payments_v2_q1_ticket";
pub const Q2_TICKET_SEED: &[u8] = b"shine_payments_v2_q2_ticket"; pub const Q2_TICKET_SEED: &[u8] = b"shine_payments_v2_q2_ticket";
pub const MANAGER_ALLOWANCE_SEED: &[u8] = b"shine_payments_v2_manager_allowance";
pub const CONFIG_SPACE: usize = 8 + 160; pub const CONFIG_SPACE: usize = 8 + 160;
pub const COEF_LIMIT_SPACE: usize = 8 + 96; pub const COEF_LIMIT_SPACE: usize = 8 + 96;
pub const QUEUES_SPACE: usize = 8 + 192; pub const QUEUES_SPACE: usize = 8 + 192;
pub const INFLOW_VAULT_SPACE: usize = 8 + 32; pub const INFLOW_VAULT_SPACE: usize = 8 + 32;
pub const TICKET_SPACE: usize = 8 + 160; pub const TICKET_SPACE: usize = 8 + 160;
pub const MANAGER_ALLOWANCE_SPACE: usize = 8 + 128;
pub const COEF_SCALE_PPM: u64 = 1_000_000; pub const COEF_SCALE_PPM: u64 = 1_000_000;
pub const START_COEF_PPM: u64 = 5_000_000; // 5.0 pub const START_COEF_PPM: u64 = 5_000_000; // 5.0

View File

@ -46,16 +46,20 @@
</div> </div>
<div class="panel"> <div class="panel">
<h3>Адреса и балансы</h3> <h3>Адреса и агрегаты</h3>
<div id="balances" class="muted">Загрузка...</div> <div id="balances" class="muted">Загрузка...</div>
</div> </div>
<div class="panel"> <div class="panel">
<h3>Очередь 1 (все билеты)</h3> <h3>Очередь 1 (все билеты)</h3>
<div class="row"> <div class="row"><button id="loadQ1Btn">Показать очередь 1</button></div>
<button id="loadQueueBtn">Показать всю очередь</button> <div id="queue1Table" class="muted"></div>
</div> </div>
<div id="queueTable" class="muted"></div>
<div class="panel">
<h3>Очередь 2 (все билеты)</h3>
<div class="row"><button id="loadQ2Btn">Показать очередь 2</button></div>
<div id="queue2Table" class="muted"></div>
</div> </div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script> <script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
@ -69,8 +73,8 @@
queues: "shine_payments_v2_queues", queues: "shine_payments_v2_queues",
inflow: "shine_payments_v2_inflow_vault", inflow: "shine_payments_v2_inflow_vault",
ticketQ1: "shine_payments_v2_q1_ticket", ticketQ1: "shine_payments_v2_q1_ticket",
ticketQ2: "shine_payments_v2_q2_ticket",
}; };
let walletPubkey = null; let walletPubkey = null;
let cache = null; let cache = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58(); document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
@ -160,7 +164,6 @@
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден"); if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
return window.solana; return window.solana;
} }
async function connectWallet() { async function connectWallet() {
const provider = getProvider(); const provider = getProvider();
const r = await provider.connect(); const r = await provider.connect();
@ -168,7 +171,6 @@
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58(); document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
await refreshAll(); await refreshAll();
} }
async function sendInstruction(ix) { async function sendInstruction(ix) {
const provider = getProvider(); const provider = getProvider();
if (!walletPubkey) await connectWallet(); if (!walletPubkey) await connectWallet();
@ -189,9 +191,9 @@
const [inflowPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.inflow)], PROGRAM_ID); const [inflowPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.inflow)], PROGRAM_ID);
return { configPda, coefPda, queuesPda, inflowPda }; return { configPda, coefPda, queuesPda, inflowPda };
} }
function ticketPda(queueId, index) {
function q1TicketPda(index) { const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(index)], PROGRAM_ID); const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
return pda; return pda;
} }
@ -215,10 +217,7 @@
connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed"), connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed"),
]); ]);
cache = { cache = {
pdas, pdas, config, coef, queues,
config,
coef,
queues,
inflowLamports: BigInt(inflowAi.lamports), inflowLamports: BigInt(inflowAi.lamports),
inflowAvailable: BigInt(Math.max(0, inflowAi.lamports - inflowRent)), inflowAvailable: BigInt(Math.max(0, inflowAi.lamports - inflowRent)),
daoBalance: BigInt(daoBal), daoBalance: BigInt(daoBal),
@ -240,7 +239,7 @@
<div>DAO: <code>${core.config.dao.toBase58()}</code></div> <div>DAO: <code>${core.config.dao.toBase58()}</code></div>
<div>Inflow vault: <code>${core.config.inflow.toBase58()}</code></div> <div>Inflow vault: <code>${core.config.inflow.toBase58()}</code></div>
<div class="muted">Inflow vault — это входящий PDA-кошелек выплат программы.</div> <div class="muted">Inflow vault — это входящий PDA-кошелек выплат программы.</div>
<div>Manager: <code>${core.config.manager.toBase58()}</code></div> <div>Manager (для coef/limit): <code>${core.config.manager.toBase58()}</code></div>
<div>Награда за шаг: <b>${lamportsToSolStr(core.config.reward)} SOL</b></div> <div>Награда за шаг: <b>${lamportsToSolStr(core.config.reward)} SOL</b></div>
<div>Коэффициент: <b>${coefText}</b>, лимит: <b>${lamportsToSolStr(core.coef.limit)} SOL</b></div> <div>Коэффициент: <b>${coefText}</b>, лимит: <b>${lamportsToSolStr(core.coef.limit)} SOL</b></div>
<div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div> <div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div>
@ -260,11 +259,9 @@
out.textContent = ""; out.textContent = "";
try { try {
const provider = getProvider(); const provider = getProvider();
if (!walletPubkey) { if (!walletPubkey) await connectWallet();
await connectWallet(); else if (!provider.isConnected) await provider.connect();
} else if (!provider.isConnected) {
await provider.connect();
}
const pdas = derivePdas(); const pdas = derivePdas();
const disc = await ixDiscriminator("init"); const disc = await ixDiscriminator("init");
const keys = [ const keys = [
@ -280,13 +277,7 @@
out.innerHTML = `<span class="ok">Init выполнен. Tx: <code>${sig}</code></span>`; out.innerHTML = `<span class="ok">Init выполнен. Tx: <code>${sig}</code></span>`;
await refreshAll(); await refreshAll();
} catch (e) { } catch (e) {
const raw = String(e.message || e); out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
if (isUnauthorizedManager(raw)) {
const mgr = document.getElementById("managerAllowed").textContent;
out.innerHTML = `<span class="warn">Вы подключены не под тем аккаунтом. Изменение доступно только управляющему кошельку: <code>${mgr}</code>.</span>`;
return;
}
out.innerHTML = `<span class="err">${raw}</span>`;
} }
} }
@ -295,11 +286,8 @@
out.textContent = ""; out.textContent = "";
try { try {
const provider = getProvider(); const provider = getProvider();
if (!walletPubkey) { if (!walletPubkey) await connectWallet();
await connectWallet(); else if (!provider.isConnected) await provider.connect();
} else if (!provider.isConnected) {
await provider.connect();
}
const core = await loadCore(); const core = await loadCore();
if (core.notInited) throw new Error("Сначала выполните init"); if (core.notInited) throw new Error("Сначала выполните init");
@ -320,23 +308,30 @@
out.innerHTML = `<span class="ok">Обновлено. Tx: <code>${sig}</code></span>`; out.innerHTML = `<span class="ok">Обновлено. Tx: <code>${sig}</code></span>`;
await refreshAll(); await refreshAll();
} catch (e) { } catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`; const raw = String(e.message || e);
if (isUnauthorizedManager(raw)) {
const mgr = document.getElementById("managerAllowed").textContent;
out.innerHTML = `<span class="warn">Вы подключены не под тем аккаунтом. Изменение доступно только управляющему кошельку: <code>${mgr}</code>.</span>`;
return;
}
out.innerHTML = `<span class="err">${raw}</span>`;
} }
} }
async function showQueue() { async function showQueue(queueId) {
const out = document.getElementById("queueTable"); const out = document.getElementById(queueId === 1 ? "queue1Table" : "queue2Table");
out.textContent = "Загрузка..."; out.textContent = "Загрузка...";
try { try {
const core = await loadCore(); const core = await loadCore();
if (core.notInited) throw new Error("Сначала выполните init"); if (core.notInited) throw new Error("Сначала выполните init");
if (core.queues.q1Total === 0n) { const total = queueId === 1 ? core.queues.q1Total : core.queues.q2Total;
out.innerHTML = `<span class="muted">Очередь 1 пока пустая.</span>`; if (total === 0n) {
out.innerHTML = `<span class="muted">Очередь ${queueId} пока пустая.</span>`;
return; return;
} }
const rows = []; const rows = [];
for (let i = 1n; i <= core.queues.q1Total; i++) { for (let i = 1n; i <= total; i++) {
const pda = q1TicketPda(i); const pda = ticketPda(queueId, i);
const ai = await connection.getAccountInfo(pda, "confirmed"); const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) { if (!ai) {
rows.push(`<tr><td>${i.toString()}</td><td colspan="6" class="err">PDA не найден</td></tr>`); rows.push(`<tr><td>${i.toString()}</td><td colspan="6" class="err">PDA не найден</td></tr>`);
@ -378,7 +373,8 @@
document.getElementById("refreshBtn").addEventListener("click", refreshAll); document.getElementById("refreshBtn").addEventListener("click", refreshAll);
document.getElementById("initBtn").addEventListener("click", runInit); document.getElementById("initBtn").addEventListener("click", runInit);
document.getElementById("updateCoefBtn").addEventListener("click", updateCoefLimit); document.getElementById("updateCoefBtn").addEventListener("click", updateCoefLimit);
document.getElementById("loadQueueBtn").addEventListener("click", showQueue); document.getElementById("loadQ1Btn").addEventListener("click", () => showQueue(1));
document.getElementById("loadQ2Btn").addEventListener("click", () => showQueue(2));
refreshAll(); refreshAll();
</script> </script>
</body> </body>

View File

@ -37,6 +37,7 @@
<div class="panel"> <div class="panel">
<h3>Покупка билета в 1-й очереди</h3> <h3>Покупка билета в 1-й очереди</h3>
<div class="muted">Покупка создает билет только в 1-й очереди. Билеты 2-й очереди добавляются менеджерами с правами.</div>
<div class="row"> <div class="row">
<label>Сумма (SOL): <input id="amountSol" value="0.1" /></label> <label>Сумма (SOL): <input id="amountSol" value="0.1" /></label>
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label> <label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label>

View File

@ -0,0 +1,253 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DAO-права менеджеров — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 240px; }
button { padding: 8px 12px; cursor: pointer; }
.muted { color: #666; }
.ok { color: #0a7a3c; }
.warn { color: #9f5f00; }
.err { color: #b30000; white-space: pre-wrap; }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<h1>DAO: права менеджеров (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel">
<div class="warn">
Пока реального DAO-голосования нет: роль DAO выполняет обычный кошелек.<br />
Позже это заменяется на вызов из DAO-казначейства/голосования.
</div>
</div>
<div class="panel">
<div class="row">
<button id="connectBtn">Подключить кошелек</button>
<button id="refreshBtn">Обновить</button>
</div>
<div id="walletInfo" class="muted"></div>
<div id="daoInfo" class="muted"></div>
</div>
<div class="panel">
<h3>Выдать/добавить лимиты менеджеру</h3>
<div class="row">
<label>Кошелек менеджера: <input id="managerWallet" placeholder="Base58" /></label>
<label>Добавить лимит Q1 (SOL): <input id="addQ1" value="1" /></label>
<label>Добавить лимит Q2 (SOL): <input id="addQ2" value="0.5" /></label>
</div>
<div class="row">
<button id="grantBtn">Выдать лимиты</button>
</div>
<div id="grantResult" class="muted"></div>
</div>
<div class="panel">
<h3>Текущие лимиты менеджера</h3>
<div class="row">
<button id="loadManagerBtn">Показать лимиты</button>
</div>
<div id="managerState" class="muted"></div>
</div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE");
const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = {
config: "shine_payments_v2_config",
managerAllowance: "shine_payments_v2_manager_allowance",
};
let walletPubkey = null;
let configCache = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
function utf8(s) { return new TextEncoder().encode(s); }
function u64ToBytes(v) {
let x = BigInt(v);
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
return out;
}
function readU64(data, offset) {
let x = 0n;
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x;
}
function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
}
function lamportsToSolStr(l) {
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
}
function solToLamports(solStr) {
const v = Number(solStr);
if (!Number.isFinite(v) || v < 0) throw new Error("Некорректная сумма SOL");
return BigInt(Math.round(v * 1_000_000_000));
}
async function ixDiscriminator(name) {
const msg = utf8("global:" + name);
const hash = await crypto.subtle.digest("SHA-256", msg);
return new Uint8Array(hash).slice(0, 8);
}
function isUnauthorizedDao(msg) {
const s = String(msg || "").toLowerCase();
return s.includes("unauthorizeddao");
}
function parseConfig(data) {
let o = 0;
const version = data[o++];
const dao = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const inflow = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const reward = readU64(data, o); o += 8;
return { version, dao, manager, inflow, reward };
}
function parseManagerAllowance(data) {
let o = 0;
const version = data[o++];
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const q1 = readU64(data, o); o += 8;
const q2 = readU64(data, o); o += 8;
return { version, manager, q1, q2 };
}
function getProvider() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
return window.solana;
}
async function connectWallet() {
const provider = getProvider();
const r = await provider.connect();
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
await refresh();
}
async function sendInstruction(ix) {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
const tx = new solanaWeb3.Transaction().add(ix);
tx.feePayer = walletPubkey;
const bh = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = bh.blockhash;
const signed = await provider.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
return sig;
}
function deriveConfigPda() {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.config)], PROGRAM_ID);
return pda;
}
function deriveManagerAllowancePda(managerWallet) {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.managerAllowance), managerWallet.toBytes()], PROGRAM_ID);
return pda;
}
async function loadConfig() {
const configPda = deriveConfigPda();
const ai = await connection.getAccountInfo(configPda, "confirmed");
if (!ai) throw new Error("Config PDA не найден. Сначала выполните init.");
configCache = { configPda, config: parseConfig(ai.data) };
return configCache;
}
async function refresh() {
const el = document.getElementById("daoInfo");
try {
const { config } = await loadConfig();
el.innerHTML = `
<div>DAO-кошелек: <code>${config.dao.toBase58()}</code></div>
<div class="muted">Выдавать лимиты может только этот кошелек.</div>
`;
} catch (e) {
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
async function grantLimits() {
const out = document.getElementById("grantResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
else if (!provider.isConnected) await provider.connect();
const { configPda, config } = configCache || await loadConfig();
const manager = new solanaWeb3.PublicKey(document.getElementById("managerWallet").value.trim());
const addQ1 = solToLamports(document.getElementById("addQ1").value.trim());
const addQ2 = solToLamports(document.getElementById("addQ2").value.trim());
if (addQ1 === 0n && addQ2 === 0n) throw new Error("Нужно указать сумму хотя бы для одной очереди.");
const allowancePda = deriveManagerAllowancePda(manager);
const disc = await ixDiscriminator("grant_manager_limits");
const data = concat(disc, manager.toBytes(), u64ToBytes(addQ1), u64ToBytes(addQ2));
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: configPda, isSigner: false, isWritable: true },
{ pubkey: allowancePda, isSigner: false, isWritable: true },
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Лимиты выданы. Tx: <code>${sig}</code></span>`;
} catch (e) {
const raw = String(e.message || e);
if (isUnauthorizedDao(raw)) {
const dao = configCache?.config?.dao?.toBase58?.() || "не определен";
out.innerHTML = `<span class="warn">Вы подключены не под DAO-кошельком. Нужен: <code>${dao}</code>.</span>`;
return;
}
out.innerHTML = `<span class="err">${raw}</span>`;
}
}
async function loadManagerLimits() {
const out = document.getElementById("managerState");
out.textContent = "";
try {
const manager = new solanaWeb3.PublicKey(document.getElementById("managerWallet").value.trim());
const allowancePda = deriveManagerAllowancePda(manager);
const ai = await connection.getAccountInfo(allowancePda, "confirmed");
if (!ai) {
out.innerHTML = `<span class="warn">Лимиты для этого менеджера ещё не выданы (PDA не создан).</span>`;
return;
}
const st = parseManagerAllowance(ai.data);
out.innerHTML = `
<div>Manager: <code>${st.manager.toBase58()}</code></div>
<div>PDA: <code>${allowancePda.toBase58()}</code></div>
<div>Доступно Q1: <b>${lamportsToSolStr(st.q1)} SOL</b></div>
<div>Доступно Q2: <b>${lamportsToSolStr(st.q2)} SOL</b></div>
`;
} catch (e) {
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refresh);
document.getElementById("grantBtn").addEventListener("click", grantLimits);
document.getElementById("loadManagerBtn").addEventListener("click", loadManagerLimits);
refresh();
</script>
</body>
</html>

View File

@ -33,12 +33,37 @@
<a class="card" href="./track_ticket.html"> <a class="card" href="./track_ticket.html">
<h3>Отслеживание билета</h3> <h3>Отслеживание билета</h3>
<div class="muted">Проверка позиции в очереди, статуса и шаг выплат.</div> <div class="muted">Проверка позиции, статуса и шаг выплат по 1-й/2-й очереди.</div>
</a> </a>
<a class="card" href="./admin_tools.html"> <a class="card" href="./admin_tools.html">
<h3>Тех. инструменты</h3> <h3>Тех. инструменты</h3>
<div class="muted">Init, просмотр всех билетов, коэффициент/лимит, адреса и балансы.</div> <div class="muted">Init, просмотр всех билетов в обеих очередях, коэффициент/лимит, агрегаты.</div>
</a>
<a class="card" href="./dao_tools.html">
<h3>DAO-права менеджеров</h3>
<div class="muted">Выдача лимитов менеджерам на добавление билетов в очередь 1/2.</div>
</a>
<a class="card" href="./manager_tools.html">
<h3>Инструменты менеджера</h3>
<div class="muted">Показ лимитов менеджера и создание билетов в очередь 1/2.</div>
</a>
<a class="card" href="./logic_overview.html">
<h3>Логика работы</h3>
<div class="muted">Кратко: как работают очереди, выплаты, лимиты и тестовый режим.</div>
</a>
<a class="card" href="./roadmap_dao.html">
<h3>Что ещё нужно до реального DAO</h3>
<div class="muted">Ограничения тестовой версии и шаги к production.</div>
</a>
<a class="card" href="./test_plan.html">
<h3>Сценарий тестирования</h3>
<div class="muted">Пошаговая методика тестов и возврата средств после теста.</div>
</a> </a>
</body> </body>
</html> </html>

View File

@ -0,0 +1,50 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Логика работы — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; line-height: 1.45; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
.muted { color: #666; }
</style>
</head>
<body>
<h1>Логика работы Shine Payments (тестовый этап)</h1>
<div class="panel">
<p>Сейчас система работает в <b>Devnet</b>. Все суммы на этом этапе в SOL/lamports.</p>
<p>В следующей версии расчёт будет считаться в <b>USDT по курсу</b> (переход на курсовую модель).</p>
</div>
<div class="panel">
<h3>1. Очереди и билеты</h3>
<p>Есть две очереди: очередь 1 и очередь 2. Каждый билет — отдельный PDA с полями: очередь, индекс, получатель, сумма выплаты, флаг выплачен/нет, сумма долга перед билетом.</p>
</div>
<div class="panel">
<h3>2. Покупка билета</h3>
<p>Обычная покупка создаёт билет только в очереди 1. Сумма покупки идёт в DAO, а сумма билета рассчитывается как <code>input * coef</code>.</p>
</div>
<div class="panel">
<h3>3. Менеджерские билеты</h3>
<p>DAO может выдать менеджеру лимиты на добавление билетов отдельно в очередь 1 и очередь 2. Менеджер создаёт билеты без денежного перевода, но с уменьшением своего доступного лимита.</p>
</div>
<div class="panel">
<h3>4. Порядок выплат</h3>
<p>Приоритет всегда у очереди 1. Если в очереди 1 нет невыплаченных билетов, идут выплаты очереди 2. Если в процессе выплат очереди 2 снова появляется билет в очереди 1, приоритет возвращается к очереди 1.</p>
<p>Если обе очереди пустые/выплачены — доступный остаток inflow-вольта переводится в DAO (без награды вызывающему).</p>
</div>
<div class="panel">
<h3>5. Тестовый режим пополнения выплат</h3>
<p>Пока регистрация/авто-поток пополнения не завершены, inflow-вольт для выплат пополняется вручную, после чего выполняются шаги выплат.</p>
</div>
<div class="panel muted">
Подробная версия в документе репозитория: <code>shine/doc/SHINE_PAYMENTS_V2.md</code>.
</div>
</body>
</html>

View File

@ -0,0 +1,258 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Менеджерские билеты — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input, select { padding: 8px; min-width: 200px; }
button { padding: 8px 12px; cursor: pointer; }
.muted { color: #666; }
.ok { color: #0a7a3c; }
.warn { color: #9f5f00; }
.err { color: #b30000; white-space: pre-wrap; }
code { background: #f6f6f6; padding: 2px 4px; border-radius: 4px; }
</style>
</head>
<body>
<h1>Менеджер: создание билетов (Devnet)</h1>
<div class="muted">Программа: <code id="programId"></code></div>
<div class="panel">
<div class="row">
<button id="connectBtn">Подключить кошелек менеджера</button>
<button id="refreshBtn">Обновить</button>
</div>
<div id="walletInfo" class="muted"></div>
</div>
<div class="panel">
<h3>Лимиты менеджера</h3>
<div id="limitsInfo" class="muted">Загрузка...</div>
</div>
<div class="panel">
<h3>Создать билет менеджером</h3>
<div class="row">
<label>Очередь:
<select id="queueId">
<option value="1">Очередь 1</option>
<option value="2">Очередь 2</option>
</select>
</label>
<label>Получатель: <input id="recipient" placeholder="Base58 адрес" /></label>
<label>Сумма выплаты (SOL): <input id="payoutSol" value="0.5" /></label>
</div>
<div class="row">
<button id="createBtn">Создать билет</button>
</div>
<div id="createResult" class="muted"></div>
</div>
<script src="https://unpkg.com/@solana/web3.js@1.95.3/lib/index.iife.min.js"></script>
<script>
const PROGRAM_ID = new solanaWeb3.PublicKey("4QDCcaURt7phJGcvDS4VQQtrqDmUSMXvkWnMnMKCiUyE");
const RPC_URL = "https://api.devnet.solana.com";
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
const SEEDS = {
managerAllowance: "shine_payments_v2_manager_allowance",
queues: "shine_payments_v2_queues",
ticketQ1: "shine_payments_v2_q1_ticket",
ticketQ2: "shine_payments_v2_q2_ticket",
};
let walletPubkey = null;
let queuesCache = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
function utf8(s) { return new TextEncoder().encode(s); }
function u64ToBytes(v) {
let x = BigInt(v);
const out = new Uint8Array(8);
for (let i = 0; i < 8; i++) { out[i] = Number(x & 255n); x >>= 8n; }
return out;
}
function readU64(data, offset) {
let x = 0n;
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
return x;
}
function concat(...parts) {
const len = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(len);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function trimZeros(v) {
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
}
function lamportsToSolStr(l) {
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
}
function solToLamports(solStr) {
const v = Number(solStr);
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
return BigInt(Math.round(v * 1_000_000_000));
}
async function ixDiscriminator(name) {
const msg = utf8("global:" + name);
const hash = await crypto.subtle.digest("SHA-256", msg);
return new Uint8Array(hash).slice(0, 8);
}
function isManagerErrors(msg) {
const s = String(msg || "").toLowerCase();
return s.includes("managerlimitexceeded") || s.includes("invalidmanagerwallet");
}
function parseManagerAllowance(data) {
let o = 0;
const version = data[o++];
const manager = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
const q1 = readU64(data, o); o += 8;
const q2 = readU64(data, o); o += 8;
return { version, manager, q1, q2 };
}
function parseQueues(data) {
let o = 0;
const version = data[o++];
const q1Total = readU64(data, o); o += 8;
const q1Paid = readU64(data, o); o += 8;
const q1SumTotal = readU64(data, o); o += 8;
const q1SumPaid = readU64(data, o); o += 8;
const q2Total = readU64(data, o); o += 8;
const q2Paid = readU64(data, o); o += 8;
const q2SumTotal = readU64(data, o); o += 8;
const q2SumPaid = readU64(data, o); o += 8;
return { version, q1Total, q1Paid, q1SumTotal, q1SumPaid, q2Total, q2Paid, q2SumTotal, q2SumPaid };
}
function getProvider() {
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
return window.solana;
}
async function connectWallet() {
const provider = getProvider();
const r = await provider.connect();
walletPubkey = new solanaWeb3.PublicKey(r.publicKey.toString());
document.getElementById("walletInfo").textContent = "Кошелек менеджера: " + walletPubkey.toBase58();
await refresh();
}
async function sendInstruction(ix) {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
const tx = new solanaWeb3.Transaction().add(ix);
tx.feePayer = walletPubkey;
const bh = await connection.getLatestBlockhash("confirmed");
tx.recentBlockhash = bh.blockhash;
const signed = await provider.signTransaction(tx);
const sig = await connection.sendRawTransaction(signed.serialize(), { skipPreflight: false });
await connection.confirmTransaction({ signature: sig, blockhash: bh.blockhash, lastValidBlockHeight: bh.lastValidBlockHeight }, "confirmed");
return sig;
}
function deriveManagerAllowancePda(managerWallet) {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync(
[utf8(SEEDS.managerAllowance), managerWallet.toBytes()],
PROGRAM_ID
);
return pda;
}
function deriveQueuesPda() {
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
return pda;
}
function deriveTicketPda(queueId, index) {
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
return pda;
}
async function loadCore() {
if (!walletPubkey) throw new Error("Сначала подключите кошелек менеджера.");
const allowancePda = deriveManagerAllowancePda(walletPubkey);
const queuesPda = deriveQueuesPda();
const [allowanceAi, queuesAi] = await Promise.all([
connection.getAccountInfo(allowancePda, "confirmed"),
connection.getAccountInfo(queuesPda, "confirmed"),
]);
if (!queuesAi) throw new Error("Queues PDA не найден. Сначала выполните init.");
queuesCache = parseQueues(queuesAi.data);
return {
allowancePda,
allowance: allowanceAi ? parseManagerAllowance(allowanceAi.data) : null,
queuesPda,
queues: queuesCache,
};
}
async function refresh() {
const el = document.getElementById("limitsInfo");
try {
const core = await loadCore();
if (!core.allowance) {
el.innerHTML = `<span class="warn">Для этого кошелька лимиты менеджера пока не выданы.</span>`;
return;
}
el.innerHTML = `
<div>Manager: <code>${core.allowance.manager.toBase58()}</code></div>
<div>PDA лимитов: <code>${core.allowancePda.toBase58()}</code></div>
<div>Доступно Q1: <b>${lamportsToSolStr(core.allowance.q1)} SOL</b></div>
<div>Доступно Q2: <b>${lamportsToSolStr(core.allowance.q2)} SOL</b></div>
`;
} catch (e) {
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
}
}
async function createManagerTicket() {
const out = document.getElementById("createResult");
out.textContent = "";
try {
const provider = getProvider();
if (!walletPubkey) await connectWallet();
else if (!provider.isConnected) await provider.connect();
const core = await loadCore();
if (!core.allowance) throw new Error("Для этого кошелька лимиты менеджера не выданы.");
const queueId = Number(document.getElementById("queueId").value);
if (queueId !== 1 && queueId !== 2) throw new Error("Очередь должна быть 1 или 2");
const recipient = new solanaWeb3.PublicKey(document.getElementById("recipient").value.trim());
const payout = solToLamports(document.getElementById("payoutSol").value.trim());
const nextIndex = queueId === 1 ? (core.queues.q1Total + 1n) : (core.queues.q2Total + 1n);
const ticketPda = deriveTicketPda(queueId, nextIndex);
const disc = await ixDiscriminator("manager_add_ticket");
const data = concat(disc, new Uint8Array([queueId]), recipient.toBytes(), u64ToBytes(payout));
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.allowancePda, isSigner: false, isWritable: true },
{ pubkey: core.queuesPda, isSigner: false, isWritable: true },
{ pubkey: ticketPda, isSigner: false, isWritable: true },
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
out.innerHTML = `<span class="ok">Билет создан. Tx: <code>${sig}</code></span>`;
await refresh();
} catch (e) {
const raw = String(e.message || e);
if (isManagerErrors(raw)) {
out.innerHTML = `<span class="warn">Операция отклонена: лимит менеджера недостаточен или кошелек не имеет прав менеджера.</span>`;
return;
}
out.innerHTML = `<span class="err">${raw}</span>`;
}
}
document.getElementById("connectBtn").addEventListener("click", connectWallet);
document.getElementById("refreshBtn").addEventListener("click", refresh);
document.getElementById("createBtn").addEventListener("click", createManagerTicket);
refresh();
</script>
</body>
</html>

View File

@ -0,0 +1,33 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Что ещё нужно до DAO — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; line-height: 1.45; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
</style>
</head>
<body>
<h1>Что ещё нужно до реального DAO</h1>
<div class="panel">
<p>Сейчас роль DAO выполняет обычный кошелёк (тестовый режим Devnet).</p>
</div>
<div class="panel">
<h3>Что нужно добавить в production:</h3>
<ol>
<li>Заменить обычный DAO-кошелёк на DAO-казначейство/голосование.</li>
<li>DAO-решения (выдача лимитов менеджерам, изменение параметров) проводить через голосование.</li>
<li>Переход с SOL-сумм на модель расчёта в USDT по курсу.</li>
<li>Ограничить тестовые ключи и закрыть доступ к приватным данным.</li>
</ol>
</div>
<div class="panel">
<p>Текущий дизайн уже совместим с DAO-заменой: достаточно сменить авторизацию вызовов на DAO-механику без изменения базовой структуры очередей и билетов.</p>
</div>
</body>
</html>

View File

@ -0,0 +1,51 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Сценарий тестирования — Shine Payments Devnet</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; line-height: 1.45; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
</style>
</head>
<body>
<h1>Сценарий тестирования Shine Payments (Devnet)</h1>
<div class="panel">
<h3>Вариант А: один кошелёк</h3>
<ol>
<li>Открыть <code>admin_tools</code>, выполнить <code>init</code>.</li>
<li>Открыть <code>buy_ticket</code>, купить несколько билетов.</li>
<li>Открыть <code>dao_tools</code>, выдать лимиты менеджеру (тем же кошельком).</li>
<li>Открыть <code>manager_tools</code>, создать билеты в очередь 1 и очередь 2.</li>
<li>Пополнить inflow-вольт вручную.</li>
<li>Открыть <code>track_ticket</code>, выполнять шаги выплат до погашения очередей.</li>
<li>Проверить, что средства уходят получателям/DAO в ожидаемой пропорции.</li>
</ol>
</div>
<div class="panel">
<h3>Вариант Б: несколько кошельков</h3>
<ol>
<li>Кошелёк 1: DAO (выдаёт лимиты менеджерам).</li>
<li>Кошелёк 2: менеджер (создаёт билеты в очередь 1/2).</li>
<li>Кошелёк 3+: покупатели (создают обычные билеты через покупку).</li>
<li>Любой кошелёк может запускать шаг выплат.</li>
</ol>
</div>
<div class="panel">
<h3>Как вернуть средства после тестов</h3>
<ol>
<li>Довести выплаты до нужного состояния (или остановить на текущем шаге).</li>
<li>Сделать переводы с тестовых кошельков обратно на исходный кошелёк.</li>
<li>При необходимости закрыть неиспользуемые program/PDA-аккаунты и вернуть ренту (через CLI).</li>
</ol>
</div>
<div class="panel">
<p>Пока DAO-гovernance не подключена, ключевые действия DAO выполняются обычным тестовым кошельком. В production это заменяется голосованием DAO.</p>
</div>
</body>
</html>

View File

@ -8,7 +8,7 @@
body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; } body { font-family: Arial, sans-serif; margin: 24px; max-width: 1400px; }
.panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; } .panel { border: 1px solid #ddd; border-radius: 8px; padding: 14px; margin-bottom: 14px; }
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; } .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
input { padding: 8px; min-width: 260px; } input { padding: 8px; min-width: 240px; }
button { padding: 8px 12px; cursor: pointer; } button { padding: 8px 12px; cursor: pointer; }
.muted { color: #666; } .muted { color: #666; }
.ok { color: #0a7a3c; } .ok { color: #0a7a3c; }
@ -32,7 +32,7 @@
</div> </div>
<div class="panel"> <div class="panel">
<h3>Поиск билета</h3> <h3>Поиск билетов</h3>
<div class="row"> <div class="row">
<label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label> <label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label>
<label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label> <label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label>
@ -58,14 +58,12 @@
const SEEDS = { const SEEDS = {
config: "shine_payments_v2_config", config: "shine_payments_v2_config",
coef: "shine_payments_v2_coef_limit",
queues: "shine_payments_v2_queues", queues: "shine_payments_v2_queues",
ticketQ1: "shine_payments_v2_q1_ticket", ticketQ1: "shine_payments_v2_q1_ticket",
ticketQ2: "shine_payments_v2_q2_ticket",
}; };
let walletPubkey = null; let walletPubkey = null;
let cachedCore = null; let cachedCore = null;
document.getElementById("programId").textContent = PROGRAM_ID.toBase58(); document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
function utf8(s) { return new TextEncoder().encode(s); } function utf8(s) { return new TextEncoder().encode(s); }
@ -91,8 +89,7 @@
return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, ""); return v.replace(/(\.\d*?[1-9])0+$/u, "$1").replace(/\.0+$/u, "").replace(/\.$/u, "");
} }
function lamportsToSolStr(l) { function lamportsToSolStr(l) {
const sol = Number(l) / 1_000_000_000; return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
return trimZeros(sol.toFixed(9));
} }
async function ixDiscriminator(name) { async function ixDiscriminator(name) {
const msg = utf8("global:" + name); const msg = utf8("global:" + name);
@ -142,7 +139,6 @@
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден"); if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
return window.solana; return window.solana;
} }
async function connectWallet() { async function connectWallet() {
const provider = getProvider(); const provider = getProvider();
const r = await provider.connect(); const r = await provider.connect();
@ -150,7 +146,6 @@
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58(); document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
await refreshAll(); await refreshAll();
} }
async function sendInstruction(ix) { async function sendInstruction(ix) {
const provider = getProvider(); const provider = getProvider();
if (!walletPubkey) await connectWallet(); if (!walletPubkey) await connectWallet();
@ -169,9 +164,9 @@
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID); const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
return { configPda, queuesPda }; return { configPda, queuesPda };
} }
function deriveTicketPda(queueId, index) {
function deriveQ1TicketPda(index) { const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(index)], PROGRAM_ID); const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
return pda; return pda;
} }
@ -192,14 +187,20 @@
return cachedCore; return cachedCore;
} }
function nextStepQueue(queues) {
const q1Pending = queues.q1Total - queues.q1Paid;
const q2Pending = queues.q2Total - queues.q2Paid;
if (q1Pending > 0n) return 1;
if (q2Pending > 0n) return 2;
return 0;
}
async function refreshPayoutInfo() { async function refreshPayoutInfo() {
const el = document.getElementById("payoutInfo"); const el = document.getElementById("payoutInfo");
try { try {
const core = await loadCoreState(); const core = await loadCoreState();
const q1Pending = core.queues.q1Total - core.queues.q1Paid; const queue = nextStepQueue(core.queues);
const q2Pending = core.queues.q2Total - core.queues.q2Paid; if (queue === 0) {
if (q1Pending === 0n && q2Pending === 0n) {
el.innerHTML = ` el.innerHTML = `
<div>Обе очереди пусты/полностью выплачены.</div> <div>Обе очереди пусты/полностью выплачены.</div>
<div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div> <div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
@ -207,22 +208,18 @@
`; `;
return; return;
} }
if (q1Pending === 0n && q2Pending > 0n) { const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
el.innerHTML = `<div class="warn">Во 2-й очереди есть ожидание, но её выплаты пока не реализованы.</div>`; const nextPda = deriveTicketPda(queue, nextIndex);
return;
}
const nextIndex = core.queues.q1Paid + 1n;
const nextPda = deriveQ1TicketPda(nextIndex);
const nextAi = await connection.getAccountInfo(nextPda, "confirmed"); const nextAi = await connection.getAccountInfo(nextPda, "confirmed");
if (!nextAi) { if (!nextAi) {
el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()}</div>`; el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()} для очереди ${queue}</div>`;
return; return;
} }
const next = parseTicket(nextAi.data); const next = parseTicket(nextAi.data);
const need = next.payout * 2n + core.config.reward; const need = next.payout * 2n + core.config.reward;
const missing = core.available >= need ? 0n : (need - core.available); const missing = core.available >= need ? 0n : (need - core.available);
el.innerHTML = ` el.innerHTML = `
<div>Следующий шаг выплат: <b>очередь ${queue}</b></div>
<div>Следующий тикет: <b>#${next.index.toString()}</b></div> <div>Следующий тикет: <b>#${next.index.toString()}</b></div>
<div>Выплата по тикету: <b>${lamportsToSolStr(next.payout)} SOL</b></div> <div>Выплата по тикету: <b>${lamportsToSolStr(next.payout)} SOL</b></div>
<div>Нужно для шага (выплата + DAO + награда): <b>${lamportsToSolStr(need)} SOL</b></div> <div>Нужно для шага (выплата + DAO + награда): <b>${lamportsToSolStr(need)} SOL</b></div>
@ -237,6 +234,34 @@
} }
} }
function renderTicketCard(core, pda, t) {
const nextQ1 = core.queues.q1Paid + 1n;
const nextQ2 = core.queues.q2Paid + 1n;
const isCurrentQ1 = !t.isPaid && t.queueId === 1 && t.index === nextQ1;
const isCurrentQ2 = !t.isPaid && t.queueId === 2 && (core.queues.q1Total - core.queues.q1Paid) === 0n && t.index === nextQ2;
const inFront = t.queueId === 1
? (t.index > nextQ1 ? (t.index - nextQ1) : 0n)
: (t.index > nextQ2 ? (t.index - nextQ2) : 0n);
const sumPaid = t.queueId === 1 ? core.queues.q1SumPaid : core.queues.q2SumPaid;
const remainingToThis = t.debtBefore > sumPaid ? (t.debtBefore - sumPaid) : 0n;
const missingInsideCurrent = (isCurrentQ1 || isCurrentQ2) && core.available < t.payout ? (t.payout - core.available) : 0n;
return `
<div class="panel">
<div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
<div>PDA: <code>${pda.toBase58()}</code></div>
<div>Получатель: <code>${t.recipient.toBase58()}</code></div>
<div>Сумма выплаты: <b>${lamportsToSolStr(t.payout)} SOL</b></div>
<div>Билетов перед ним сейчас: <b>${inFront.toString()}</b></div>
<div>До его выплаты по сумме в предыдущих билетах осталось: <b>${lamportsToSolStr(remainingToThis)} SOL</b></div>
${t.queueId === 2 && !t.isPaid ? `<div class="warn">Для 2-й очереди оценка не окончательная: 1-я очередь может увеличиваться.</div>` : ``}
${(isCurrentQ1 || isCurrentQ2) ? `<div class="warn">Это текущий билет к выплате.</div>` : ``}
${((isCurrentQ1 || isCurrentQ2) && missingInsideCurrent > 0n)
? `<div class="warn">Для выплаты именно этого билета внутри его суммы не хватает: <b>${lamportsToSolStr(missingInsideCurrent)} SOL</b>.</div>`
: ``}
</div>
`;
}
async function findTickets() { async function findTickets() {
const out = document.getElementById("ticketResult"); const out = document.getElementById("ticketResult");
out.textContent = ""; out.textContent = "";
@ -244,19 +269,23 @@
const core = await loadCoreState(); const core = await loadCoreState();
const idxRaw = document.getElementById("ticketIndex").value.trim(); const idxRaw = document.getElementById("ticketIndex").value.trim();
const walletRaw = document.getElementById("recipientWallet").value.trim(); const walletRaw = document.getElementById("recipientWallet").value.trim();
const results = []; const results = [];
if (idxRaw) { if (idxRaw) {
const idx = BigInt(idxRaw); const idx = BigInt(idxRaw);
const pda = deriveQ1TicketPda(idx); for (const queue of [1, 2]) {
const pda = deriveTicketPda(queue, idx);
const ai = await connection.getAccountInfo(pda, "confirmed"); const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) throw new Error(`Тикет #${idx.toString()} не найден`); if (!ai) continue;
const t = parseTicket(ai.data); results.push({ pda, t: parseTicket(ai.data) });
results.push({ pda, t }); }
if (results.length === 0) throw new Error(`Тикет #${idx.toString()} не найден ни в одной очереди`);
} else if (walletRaw) { } else if (walletRaw) {
const recipient = new solanaWeb3.PublicKey(walletRaw); const recipient = new solanaWeb3.PublicKey(walletRaw);
for (let i = 1n; i <= core.queues.q1Total; i++) { for (const queue of [1, 2]) {
const pda = deriveQ1TicketPda(i); const total = queue === 1 ? core.queues.q1Total : core.queues.q2Total;
for (let i = 1n; i <= total; i++) {
const pda = deriveTicketPda(queue, i);
const ai = await connection.getAccountInfo(pda, "confirmed"); const ai = await connection.getAccountInfo(pda, "confirmed");
if (!ai) continue; if (!ai) continue;
const t = parseTicket(ai.data); const t = parseTicket(ai.data);
@ -264,33 +293,13 @@
results.push({ pda, t }); results.push({ pda, t });
} }
} }
}
if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены"); if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены");
} else { } else {
throw new Error("Введите номер тикета или кошелек"); throw new Error("Введите номер билета или кошелек получателя");
} }
const nextIndex = core.queues.q1Paid + 1n; out.innerHTML = results.map(({ pda, t }) => renderTicketCard(core, pda, t)).join("");
const lines = results.map(({ pda, t }) => {
const isCurrent = !t.isPaid && t.index === nextIndex;
const inFront = t.index > nextIndex ? (t.index - nextIndex) : 0n;
const remainingToThis = t.debtBefore > core.queues.q1SumPaid ? (t.debtBefore - core.queues.q1SumPaid) : 0n;
const missingInsideCurrentTicket = isCurrent && core.available < t.payout ? (t.payout - core.available) : 0n;
return `
<div class="panel">
<div>Тикет #<b>${t.index.toString()}</b> (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
<div>PDA: <code>${pda.toBase58()}</code></div>
<div>Получатель: <code>${t.recipient.toBase58()}</code></div>
<div>Сумма выплаты: <b>${lamportsToSolStr(t.payout)} SOL</b></div>
<div>Билетов перед ним сейчас: <b>${inFront.toString()}</b></div>
<div>До его выплаты по сумме в предыдущих билетах осталось: <b>${lamportsToSolStr(remainingToThis)} SOL</b></div>
${isCurrent ? `<div class="warn">Это текущий билет к выплате.</div>` : ``}
${isCurrent && missingInsideCurrentTicket > 0n
? `<div class="warn">Для выплаты именно этого билета внутри его суммы не хватает: <b>${lamportsToSolStr(missingInsideCurrentTicket)} SOL</b>.</div>`
: ``}
</div>
`;
});
out.innerHTML = lines.join("");
} catch (e) { } catch (e) {
out.innerHTML = `<div class="err">${String(e.message || e)}</div>`; out.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
} }
@ -307,19 +316,19 @@
await provider.connect(); await provider.connect();
} }
const core = cachedCore || await loadCoreState(); const core = cachedCore || await loadCoreState();
const q1Pending = core.queues.q1Total - core.queues.q1Paid; const queue = nextStepQueue(core.queues);
let nextTicketPda; let nextTicketPda;
let recipient; let recipient;
if (q1Pending > 0n) { if (queue === 0) {
const nextIndex = core.queues.q1Paid + 1n; nextTicketPda = deriveTicketPda(1, core.queues.q1Paid + 1n);
nextTicketPda = deriveQ1TicketPda(nextIndex);
const ai = await connection.getAccountInfo(nextTicketPda, "confirmed");
if (!ai) throw new Error(`Следующий тикет #${nextIndex.toString()} не найден`);
recipient = parseTicket(ai.data).recipient;
} else {
nextTicketPda = deriveQ1TicketPda(core.queues.q1Paid + 1n);
recipient = walletPubkey; recipient = walletPubkey;
} else {
const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
nextTicketPda = deriveTicketPda(queue, nextIndex);
const ai = await connection.getAccountInfo(nextTicketPda, "confirmed");
if (!ai) throw new Error(`Следующий тикет #${nextIndex.toString()} для очереди ${queue} не найден`);
recipient = parseTicket(ai.data).recipient;
} }
const disc = await ixDiscriminator("step_payout"); const disc = await ixDiscriminator("step_payout");

View File

@ -0,0 +1,35 @@
# Кошельки Devnet (тестовые)
> Только для тестового этапа. Не использовать в production.
## DAO (текущий)
- public: `6bFc5Gz5qF172GQhK5HpDbWs8F6qcSxdHn5XqAstf1fY`
- private (base58): `3VYfYZZ3ugmgwisiQQAfcimX9T65AE9BmwmYVixAUj4jyneccSE9rzbC3g5twvH7ECZ8xgp7emJo3pR4yQqCwjGn`
## Manager для coef/limit (текущий)
- public: `4yzHKs2zFXpyqqCETe8KpAs4xhEo4QhJ2ybyTgRZphZv`
- private (json array):
```json
[90,184,152,226,36,29,0,20,192,35,239,186,138,46,197,219,65,216,46,150,150,113,95,216,66,95,68,79,178,239,166,133,59,44,90,189,77,253,249,234,240,215,66,104,14,194,200,186,203,176,232,154,245,165,226,23,127,115,246,181,134,24,148,45]
```
## Тестовый кошелёк key1
- public: `HMww7YSVfwVm4i8sugqj7wyH26dqzHykzv3wzWwzEvPA`
- private (base58): `5pbFo9Zq1VsNheHwbEp6AZKa6R62CZHoGkJFZnugpMEtCmkQFjuUP7TgA5hSPqv4NABGmPP62qVnDPHmRqEAwvJc`
## Тестовый кошелёк key2
- public: `E3ZDHbWv1qiFvDTmaRc9wjFCgbQw6UmKJLJYbaTNvjAh`
- private (base58): `5qm1GJGXB1fFJ3YsU5Y3XXgTiQfaimqBWk79oEveFASH9D2of3jqUoT7dumBvS449fW5j5Sw8MgAMH2QBMmFPdry`
## Тестовый кошелёк phantomWallet
- private (json array):
```json
[221,119,143,125,90,136,155,115,191,198,210,85,228,111,251,118,168,138,27,60,249,62,247,24,121,228,139,112,218,69,55,143,215,21,229,69,219,1,74,36,10,239,63,163,48,240,58,208,237,251,209,37,17,202,215,77,13,165,178,18,141,21,193,64]
```