Добавлены DAO/менеджер лимиты, очередь 2 в выплатах и расширенный UI
This commit is contained in:
parent
0a9b76055f
commit
7e8ca9157b
@ -23,3 +23,11 @@
|
||||
|
||||
1. Обновить соответствующий документ в `doc/` в том же изменении.
|
||||
2. Если документ сразу обновить нельзя, обязательно явно согласовать это с пользователем в чате и зафиксировать план обновления.
|
||||
|
||||
## Rule: Git Push
|
||||
|
||||
Для push в удаленный репозиторий использовать токен из переменной окружения:
|
||||
|
||||
- `GITEA_TOKEN`
|
||||
|
||||
Push выполнять через `http.extraHeader` (Authorization) без вывода токена в логи.
|
||||
|
||||
50
shine/doc/SHINE_PAYMENTS_TEST_PLAN.md
Normal file
50
shine/doc/SHINE_PAYMENTS_TEST_PLAN.md
Normal 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 по курсу запланирован.
|
||||
@ -1,42 +1,50 @@
|
||||
# SHINE Payments v2 (краткое описание)
|
||||
# SHINE Payments v2
|
||||
|
||||
## Назначение
|
||||
|
||||
`shine_payments` v2 — контракт очереди выплат:
|
||||
`shine_payments` v2 — контракт очереди выплат с двумя очередями:
|
||||
|
||||
1. Покупка билета в очередь 1:
|
||||
- пользователь переводит SOL в DAO;
|
||||
- получает тикет с суммой будущей выплаты `input * coef`.
|
||||
2. Шаг выплат:
|
||||
- из inflow-вольта платится следующий тикет;
|
||||
- такая же сумма отправляется в DAO;
|
||||
- фиксированная награда отправляется вызвавшему шаг.
|
||||
1. обычная покупка билета (очередь 1);
|
||||
2. менеджерское добавление билетов (очередь 1 и очередь 2 по лимитам от DAO);
|
||||
3. пошаговые выплаты из inflow-вольта с приоритетом очереди 1.
|
||||
|
||||
Сейчас тестовый этап в Devnet: расчеты в SOL/lamports.
|
||||
Следующий этап — модель расчета в USDT по курсу.
|
||||
|
||||
## PDA
|
||||
|
||||
1. `config_pda` (`shine_payments_v2_config`)
|
||||
- `dao_wallet`
|
||||
- `manager_wallet`
|
||||
- `manager_wallet` (права на смену coef/limit)
|
||||
- `inflow_vault`
|
||||
- `call_reward_lamports`
|
||||
|
||||
2. `coef_limit_pda` (`shine_payments_v2_coef_limit`)
|
||||
- `coef_ppm` (коэффициент в fixed-point, scale = 1_000_000)
|
||||
- `limit_lamports`
|
||||
- `coef_ppm` (fixed-point, scale = 1_000_000)
|
||||
- `limit_lamports` (лимит долга очереди 1 для обычной покупки)
|
||||
|
||||
3. `queues_pda` (`shine_payments_v2_queues`)
|
||||
- очередь 1: `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`)
|
||||
- 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`
|
||||
- `recipient_wallet`
|
||||
- `payout_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`.
|
||||
|
||||
2. `update_coef_limit` (только manager)
|
||||
- меняет коэффициент и лимит.
|
||||
2. `update_coef_limit` (только `manager_wallet` из config)
|
||||
- меняет коэффициент и лимит покупки в очередь 1.
|
||||
|
||||
3. `buy_ticket`
|
||||
- проверяет, что текущий долг очереди 1 меньше лимита;
|
||||
- переводит входную сумму в DAO;
|
||||
- создает тикет в очереди 1;
|
||||
- увеличивает агрегаты очереди.
|
||||
3. `grant_manager_limits` (только `dao_wallet` из config)
|
||||
- DAO выдает/добавляет лимиты менеджеру:
|
||||
- `add_q1_lamports`
|
||||
- `add_q2_lamports`
|
||||
- если PDA менеджера нет — создается;
|
||||
- если есть — лимиты увеличиваются.
|
||||
|
||||
4. `step_payout`
|
||||
- если есть тикеты в очереди 1:
|
||||
- платит `X` получателю тикета,
|
||||
- платит `X` в DAO,
|
||||
- платит reward вызвавшему;
|
||||
- помечает тикет выплаченным, обновляет агрегаты.
|
||||
- если обе очереди пусты/полностью выплачены:
|
||||
- переводит из inflow-вольта в DAO весь доступный остаток (сверх ренты),
|
||||
- reward не платится.
|
||||
4. `buy_ticket`
|
||||
- обычная покупка билета в очередь 1;
|
||||
- сумма покупки идет в DAO;
|
||||
- тикет получает выплату `input * coef_ppm / 1_000_000`.
|
||||
|
||||
5. `manager_add_ticket`
|
||||
- менеджер добавляет тикет в очередь 1 или 2;
|
||||
- без денежного перевода;
|
||||
- списывает лимит менеджера по выбранной очереди.
|
||||
|
||||
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_LIMIT_LAMPORTS = 100 SOL`
|
||||
- `START_CALL_REWARD_LAMPORTS = 0.008 SOL`
|
||||
- `DAO_WALLET`, `MANAGER_WALLET`
|
||||
- `DAO_WALLET`
|
||||
- `MANAGER_WALLET`
|
||||
|
||||
## Тестовый режим
|
||||
|
||||
Пока нет финального production-потока пополнения inflow из регистрации/экосистемы, inflow-вольт пополняется вручную в Devnet, после чего выполняются шаги выплат.
|
||||
|
||||
@ -121,6 +121,71 @@ pub mod shine_payments {
|
||||
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<()> {
|
||||
require!(args.amount_lamports > 0, PaymentsError::InvalidAmount);
|
||||
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
|
||||
@ -210,6 +275,125 @@ pub mod shine_payments {
|
||||
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<()> {
|
||||
let config = read_state::<ConfigState>(&ctx.accounts.config_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)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
|
||||
if q1_pending == 0 {
|
||||
if q2_pending > 0 {
|
||||
return Err(error!(PaymentsError::SecondQueueNotImplemented));
|
||||
}
|
||||
if q1_pending == 0 && q2_pending == 0 {
|
||||
transfer_all_available_to_dao(
|
||||
&ctx.accounts.inflow_vault_pda,
|
||||
&ctx.accounts.dao_wallet,
|
||||
@ -246,11 +427,19 @@ pub mod shine_payments {
|
||||
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
|
||||
.checked_add(1)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
let (expected_ticket_pda, _) = find_ticket_pda(ctx.program_id, 1, next_index);
|
||||
.ok_or(error!(ErrCode::MathOverflow))?
|
||||
} 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!(
|
||||
expected_ticket_pda,
|
||||
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)?;
|
||||
require!(ticket.queue_id == 1, PaymentsError::InvalidTicketQueue);
|
||||
require!(
|
||||
ticket.queue_id == target_queue,
|
||||
PaymentsError::InvalidTicketQueue
|
||||
);
|
||||
require!(ticket.index == next_index, PaymentsError::InvalidTicketIndex);
|
||||
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
|
||||
require_keys_eq!(
|
||||
@ -296,6 +488,7 @@ pub mod shine_payments {
|
||||
ticket.is_paid = true;
|
||||
write_state(&ctx.accounts.next_ticket_pda, &ticket)?;
|
||||
|
||||
if target_queue == 1 {
|
||||
queues.q1_tickets_paid = queues
|
||||
.q1_tickets_paid
|
||||
.checked_add(1)
|
||||
@ -304,6 +497,16 @@ pub mod shine_payments {
|
||||
.q1_sum_paid
|
||||
.checked_add(ticket.payout_lamports)
|
||||
.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)?;
|
||||
|
||||
Ok(())
|
||||
@ -343,6 +546,20 @@ pub struct UpdateCoefLimit<'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)]
|
||||
pub struct BuyTicket<'info> {
|
||||
/// CHECK: подписант-покупатель, проверяется атрибутом `signer`.
|
||||
@ -366,6 +583,23 @@ pub struct BuyTicket<'info> {
|
||||
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)]
|
||||
pub struct StepPayout<'info> {
|
||||
/// CHECK: подписант-вызвавший шаг выплат, проверяется атрибутом `signer`.
|
||||
@ -397,12 +631,26 @@ pub struct UpdateCoefLimitArgs {
|
||||
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)]
|
||||
pub struct BuyTicketArgs {
|
||||
pub amount_lamports: u64,
|
||||
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)]
|
||||
pub struct ConfigState {
|
||||
pub version: u8,
|
||||
@ -443,6 +691,14 @@ pub struct TicketState {
|
||||
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)]
|
||||
pub struct VaultState {
|
||||
pub version: u8,
|
||||
@ -460,6 +716,8 @@ pub enum PaymentsError {
|
||||
InvalidDaoWallet,
|
||||
#[msg("Управляющий кошелек не авторизован")]
|
||||
UnauthorizedManager,
|
||||
#[msg("DAO кошелек не авторизован для этой операции")]
|
||||
UnauthorizedDao,
|
||||
#[msg("Некорректный коэффициент")]
|
||||
InvalidCoefficient,
|
||||
#[msg("Некорректный лимит")]
|
||||
@ -480,8 +738,10 @@ pub enum PaymentsError {
|
||||
InvalidTicketIndex,
|
||||
#[msg("Неверный тип очереди у тикета")]
|
||||
InvalidTicketQueue,
|
||||
#[msg("Вторая очередь пока не реализована для выплат")]
|
||||
SecondQueueNotImplemented,
|
||||
#[msg("Неверный кошелек менеджера")]
|
||||
InvalidManagerWallet,
|
||||
#[msg("Лимит менеджера по выбранной очереди превышен")]
|
||||
ManagerLimitExceeded,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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>(
|
||||
program_id: &Pubkey,
|
||||
payer: &AccountInfo<'info>,
|
||||
|
||||
@ -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 Q1_TICKET_SEED: &[u8] = b"shine_payments_v2_q1_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 COEF_LIMIT_SPACE: usize = 8 + 96;
|
||||
pub const QUEUES_SPACE: usize = 8 + 192;
|
||||
pub const INFLOW_VAULT_SPACE: usize = 8 + 32;
|
||||
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 START_COEF_PPM: u64 = 5_000_000; // 5.0
|
||||
|
||||
@ -46,16 +46,20 @@
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Адреса и балансы</h3>
|
||||
<h3>Адреса и агрегаты</h3>
|
||||
<div id="balances" class="muted">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Очередь 1 (все билеты)</h3>
|
||||
<div class="row">
|
||||
<button id="loadQueueBtn">Показать всю очередь</button>
|
||||
<div class="row"><button id="loadQ1Btn">Показать очередь 1</button></div>
|
||||
<div id="queue1Table" class="muted"></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>
|
||||
|
||||
<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",
|
||||
inflow: "shine_payments_v2_inflow_vault",
|
||||
ticketQ1: "shine_payments_v2_q1_ticket",
|
||||
ticketQ2: "shine_payments_v2_q2_ticket",
|
||||
};
|
||||
|
||||
let walletPubkey = null;
|
||||
let cache = null;
|
||||
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||
@ -160,7 +164,6 @@
|
||||
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||||
return window.solana;
|
||||
}
|
||||
|
||||
async function connectWallet() {
|
||||
const provider = getProvider();
|
||||
const r = await provider.connect();
|
||||
@ -168,7 +171,6 @@
|
||||
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function sendInstruction(ix) {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) await connectWallet();
|
||||
@ -189,9 +191,9 @@
|
||||
const [inflowPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.inflow)], PROGRAM_ID);
|
||||
return { configPda, coefPda, queuesPda, inflowPda };
|
||||
}
|
||||
|
||||
function q1TicketPda(index) {
|
||||
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(index)], PROGRAM_ID);
|
||||
function ticketPda(queueId, index) {
|
||||
const seed = queueId === 1 ? SEEDS.ticketQ1 : SEEDS.ticketQ2;
|
||||
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(seed), u64ToBytes(index)], PROGRAM_ID);
|
||||
return pda;
|
||||
}
|
||||
|
||||
@ -215,10 +217,7 @@
|
||||
connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed"),
|
||||
]);
|
||||
cache = {
|
||||
pdas,
|
||||
config,
|
||||
coef,
|
||||
queues,
|
||||
pdas, config, coef, queues,
|
||||
inflowLamports: BigInt(inflowAi.lamports),
|
||||
inflowAvailable: BigInt(Math.max(0, inflowAi.lamports - inflowRent)),
|
||||
daoBalance: BigInt(daoBal),
|
||||
@ -240,7 +239,7 @@
|
||||
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
|
||||
<div>Inflow vault: <code>${core.config.inflow.toBase58()}</code></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>${coefText}</b>, лимит: <b>${lamportsToSolStr(core.coef.limit)} SOL</b></div>
|
||||
<div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div>
|
||||
@ -260,11 +259,9 @@
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) {
|
||||
await connectWallet();
|
||||
} else if (!provider.isConnected) {
|
||||
await provider.connect();
|
||||
}
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
|
||||
const pdas = derivePdas();
|
||||
const disc = await ixDiscriminator("init");
|
||||
const keys = [
|
||||
@ -280,13 +277,7 @@
|
||||
out.innerHTML = `<span class="ok">Init выполнен. Tx: <code>${sig}</code></span>`;
|
||||
await refreshAll();
|
||||
} catch (e) {
|
||||
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>`;
|
||||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,11 +286,8 @@
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) {
|
||||
await connectWallet();
|
||||
} else if (!provider.isConnected) {
|
||||
await provider.connect();
|
||||
}
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
const core = await loadCore();
|
||||
if (core.notInited) throw new Error("Сначала выполните init");
|
||||
|
||||
@ -320,23 +308,30 @@
|
||||
out.innerHTML = `<span class="ok">Обновлено. Tx: <code>${sig}</code></span>`;
|
||||
await refreshAll();
|
||||
} 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() {
|
||||
const out = document.getElementById("queueTable");
|
||||
async function showQueue(queueId) {
|
||||
const out = document.getElementById(queueId === 1 ? "queue1Table" : "queue2Table");
|
||||
out.textContent = "Загрузка...";
|
||||
try {
|
||||
const core = await loadCore();
|
||||
if (core.notInited) throw new Error("Сначала выполните init");
|
||||
if (core.queues.q1Total === 0n) {
|
||||
out.innerHTML = `<span class="muted">Очередь 1 пока пустая.</span>`;
|
||||
const total = queueId === 1 ? core.queues.q1Total : core.queues.q2Total;
|
||||
if (total === 0n) {
|
||||
out.innerHTML = `<span class="muted">Очередь ${queueId} пока пустая.</span>`;
|
||||
return;
|
||||
}
|
||||
const rows = [];
|
||||
for (let i = 1n; i <= core.queues.q1Total; i++) {
|
||||
const pda = q1TicketPda(i);
|
||||
for (let i = 1n; i <= total; i++) {
|
||||
const pda = ticketPda(queueId, i);
|
||||
const ai = await connection.getAccountInfo(pda, "confirmed");
|
||||
if (!ai) {
|
||||
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("initBtn").addEventListener("click", runInit);
|
||||
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();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
|
||||
<div class="panel">
|
||||
<h3>Покупка билета в 1-й очереди</h3>
|
||||
<div class="muted">Покупка создает билет только в 1-й очереди. Билеты 2-й очереди добавляются менеджерами с правами.</div>
|
||||
<div class="row">
|
||||
<label>Сумма (SOL): <input id="amountSol" value="0.1" /></label>
|
||||
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label>
|
||||
|
||||
253
shine/programs/shine_payments/web/dao_tools.html
Normal file
253
shine/programs/shine_payments/web/dao_tools.html
Normal 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>
|
||||
@ -33,12 +33,37 @@
|
||||
|
||||
<a class="card" href="./track_ticket.html">
|
||||
<h3>Отслеживание билета</h3>
|
||||
<div class="muted">Проверка позиции в очереди, статуса и шаг выплат.</div>
|
||||
<div class="muted">Проверка позиции, статуса и шаг выплат по 1-й/2-й очереди.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./admin_tools.html">
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
50
shine/programs/shine_payments/web/logic_overview.html
Normal file
50
shine/programs/shine_payments/web/logic_overview.html
Normal 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>
|
||||
258
shine/programs/shine_payments/web/manager_tools.html
Normal file
258
shine/programs/shine_payments/web/manager_tools.html
Normal 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>
|
||||
33
shine/programs/shine_payments/web/roadmap_dao.html
Normal file
33
shine/programs/shine_payments/web/roadmap_dao.html
Normal 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>
|
||||
51
shine/programs/shine_payments/web/test_plan.html
Normal file
51
shine/programs/shine_payments/web/test_plan.html
Normal 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>
|
||||
@ -8,7 +8,7 @@
|
||||
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: 260px; }
|
||||
input { padding: 8px; min-width: 240px; }
|
||||
button { padding: 8px 12px; cursor: pointer; }
|
||||
.muted { color: #666; }
|
||||
.ok { color: #0a7a3c; }
|
||||
@ -32,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Поиск билета</h3>
|
||||
<h3>Поиск билетов</h3>
|
||||
<div class="row">
|
||||
<label>Номер билета: <input id="ticketIndex" placeholder="например 1" /></label>
|
||||
<label>или кошелек получателя: <input id="recipientWallet" placeholder="Base58 адрес" /></label>
|
||||
@ -58,14 +58,12 @@
|
||||
|
||||
const SEEDS = {
|
||||
config: "shine_payments_v2_config",
|
||||
coef: "shine_payments_v2_coef_limit",
|
||||
queues: "shine_payments_v2_queues",
|
||||
ticketQ1: "shine_payments_v2_q1_ticket",
|
||||
ticketQ2: "shine_payments_v2_q2_ticket",
|
||||
};
|
||||
|
||||
let walletPubkey = null;
|
||||
let cachedCore = null;
|
||||
|
||||
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||
|
||||
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, "");
|
||||
}
|
||||
function lamportsToSolStr(l) {
|
||||
const sol = Number(l) / 1_000_000_000;
|
||||
return trimZeros(sol.toFixed(9));
|
||||
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
|
||||
}
|
||||
async function ixDiscriminator(name) {
|
||||
const msg = utf8("global:" + name);
|
||||
@ -142,7 +139,6 @@
|
||||
if (!window.solana || !window.solana.isPhantom) throw new Error("Phantom не найден");
|
||||
return window.solana;
|
||||
}
|
||||
|
||||
async function connectWallet() {
|
||||
const provider = getProvider();
|
||||
const r = await provider.connect();
|
||||
@ -150,7 +146,6 @@
|
||||
document.getElementById("walletInfo").textContent = "Кошелек: " + walletPubkey.toBase58();
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function sendInstruction(ix) {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) await connectWallet();
|
||||
@ -169,9 +164,9 @@
|
||||
const [queuesPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.queues)], PROGRAM_ID);
|
||||
return { configPda, queuesPda };
|
||||
}
|
||||
|
||||
function deriveQ1TicketPda(index) {
|
||||
const [pda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(index)], PROGRAM_ID);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -192,14 +187,20 @@
|
||||
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() {
|
||||
const el = document.getElementById("payoutInfo");
|
||||
try {
|
||||
const core = await loadCoreState();
|
||||
const q1Pending = core.queues.q1Total - core.queues.q1Paid;
|
||||
const q2Pending = core.queues.q2Total - core.queues.q2Paid;
|
||||
|
||||
if (q1Pending === 0n && q2Pending === 0n) {
|
||||
const queue = nextStepQueue(core.queues);
|
||||
if (queue === 0) {
|
||||
el.innerHTML = `
|
||||
<div>Обе очереди пусты/полностью выплачены.</div>
|
||||
<div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
|
||||
@ -207,22 +208,18 @@
|
||||
`;
|
||||
return;
|
||||
}
|
||||
if (q1Pending === 0n && q2Pending > 0n) {
|
||||
el.innerHTML = `<div class="warn">Во 2-й очереди есть ожидание, но её выплаты пока не реализованы.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = core.queues.q1Paid + 1n;
|
||||
const nextPda = deriveQ1TicketPda(nextIndex);
|
||||
const nextIndex = queue === 1 ? core.queues.q1Paid + 1n : core.queues.q2Paid + 1n;
|
||||
const nextPda = deriveTicketPda(queue, nextIndex);
|
||||
const nextAi = await connection.getAccountInfo(nextPda, "confirmed");
|
||||
if (!nextAi) {
|
||||
el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()}</div>`;
|
||||
el.innerHTML = `<div class="err">Не найден следующий тикет #${nextIndex.toString()} для очереди ${queue}</div>`;
|
||||
return;
|
||||
}
|
||||
const next = parseTicket(nextAi.data);
|
||||
const need = next.payout * 2n + core.config.reward;
|
||||
const missing = core.available >= need ? 0n : (need - core.available);
|
||||
el.innerHTML = `
|
||||
<div>Следующий шаг выплат: <b>очередь ${queue}</b></div>
|
||||
<div>Следующий тикет: <b>#${next.index.toString()}</b></div>
|
||||
<div>Выплата по тикету: <b>${lamportsToSolStr(next.payout)} 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() {
|
||||
const out = document.getElementById("ticketResult");
|
||||
out.textContent = "";
|
||||
@ -244,19 +269,23 @@
|
||||
const core = await loadCoreState();
|
||||
const idxRaw = document.getElementById("ticketIndex").value.trim();
|
||||
const walletRaw = document.getElementById("recipientWallet").value.trim();
|
||||
|
||||
const results = [];
|
||||
|
||||
if (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");
|
||||
if (!ai) throw new Error(`Тикет #${idx.toString()} не найден`);
|
||||
const t = parseTicket(ai.data);
|
||||
results.push({ pda, t });
|
||||
if (!ai) continue;
|
||||
results.push({ pda, t: parseTicket(ai.data) });
|
||||
}
|
||||
if (results.length === 0) throw new Error(`Тикет #${idx.toString()} не найден ни в одной очереди`);
|
||||
} else if (walletRaw) {
|
||||
const recipient = new solanaWeb3.PublicKey(walletRaw);
|
||||
for (let i = 1n; i <= core.queues.q1Total; i++) {
|
||||
const pda = deriveQ1TicketPda(i);
|
||||
for (const queue of [1, 2]) {
|
||||
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");
|
||||
if (!ai) continue;
|
||||
const t = parseTicket(ai.data);
|
||||
@ -264,33 +293,13 @@
|
||||
results.push({ pda, t });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены");
|
||||
} else {
|
||||
throw new Error("Введите номер тикета или кошелек");
|
||||
throw new Error("Введите номер билета или кошелек получателя");
|
||||
}
|
||||
|
||||
const nextIndex = core.queues.q1Paid + 1n;
|
||||
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("");
|
||||
out.innerHTML = results.map(({ pda, t }) => renderTicketCard(core, pda, t)).join("");
|
||||
} catch (e) {
|
||||
out.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||||
}
|
||||
@ -307,19 +316,19 @@
|
||||
await provider.connect();
|
||||
}
|
||||
const core = cachedCore || await loadCoreState();
|
||||
const q1Pending = core.queues.q1Total - core.queues.q1Paid;
|
||||
const queue = nextStepQueue(core.queues);
|
||||
|
||||
let nextTicketPda;
|
||||
let recipient;
|
||||
if (q1Pending > 0n) {
|
||||
const nextIndex = 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);
|
||||
if (queue === 0) {
|
||||
nextTicketPda = deriveTicketPda(1, core.queues.q1Paid + 1n);
|
||||
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");
|
||||
|
||||
35
КОШЕЛЬКИ_DEVNET_ТЕСТ.md
Normal file
35
КОШЕЛЬКИ_DEVNET_ТЕСТ.md
Normal 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]
|
||||
```
|
||||
Loading…
Reference in New Issue
Block a user