shine_payments: switch to USD cents + Pyth SOL/USD and update devnet UI
This commit is contained in:
parent
ea25b908c1
commit
c680b16e58
5700
shine/Cargo.lock
generated
5700
shine/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -8,43 +8,42 @@
|
||||
2. менеджерское добавление билетов (очередь 1 и очередь 2 по лимитам от DAO);
|
||||
3. пошаговые выплаты из inflow-вольта с приоритетом очереди 1.
|
||||
|
||||
Сейчас тестовый этап в Devnet: расчеты в SOL/lamports.
|
||||
Следующий этап — модель расчета в USDT по курсу.
|
||||
Сейчас тестовый этап в Devnet: расчеты и хранение в USD-центах (1 USD = 100), при этом реальные on-chain переводы выполняются в SOL по курсу Pyth SOL/USD.
|
||||
|
||||
## PDA
|
||||
|
||||
1. `config_pda` (`shine_payments_v2_config`)
|
||||
1. `config_pda` (`shine_payments_v3_config`)
|
||||
- `dao_wallet`
|
||||
- `manager_wallet` (сервисный параметр, для будущих сценариев)
|
||||
- `inflow_vault`
|
||||
|
||||
2. `coef_limit_pda` (`shine_payments_v2_coef_limit`)
|
||||
2. `coef_limit_pda` (`shine_payments_v3_coef_limit`)
|
||||
- `coef_ppm` (fixed-point, scale = 1_000_000)
|
||||
- `limit_lamports` (лимит суммарной исторической суммы очереди 1 для обычной покупки)
|
||||
- `limit_usd_cents` (лимит суммарной исторической суммы очереди 1 для обычной покупки)
|
||||
- `call_reward_lamports` (награда за шаг выплат, максимум 0.01 SOL)
|
||||
|
||||
3. `queues_pda` (`shine_payments_v2_queues`)
|
||||
3. `queues_pda` (`shine_payments_v3_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`)
|
||||
4. `inflow_vault_pda` (`shine_payments_v3_inflow_vault`)
|
||||
- входящий PDA-вольт программы для выплат.
|
||||
|
||||
5. `ticket_pda`
|
||||
- очередь 1: `shine_payments_v2_q1_ticket + index_le_u64`
|
||||
- очередь 2: `shine_payments_v2_q2_ticket + index_le_u64`
|
||||
- очередь 1: `shine_payments_v3_q1_ticket + index_le_u64`
|
||||
- очередь 2: `shine_payments_v3_q2_ticket + index_le_u64`
|
||||
- поля тикета:
|
||||
- `queue_id`
|
||||
- `index`
|
||||
- `is_paid`
|
||||
- `recipient_wallet`
|
||||
- `payout_lamports`
|
||||
- `debt_before_lamports`
|
||||
- `payout_usd_cents`
|
||||
- `debt_before_usd_cents`
|
||||
|
||||
6. `manager_allowance_pda` (`shine_payments_v2_manager_allowance + manager_pubkey`)
|
||||
6. `manager_allowance_pda` (`shine_p_v3_manager_allow + manager_pubkey`)
|
||||
- `manager_wallet`
|
||||
- `q1_available_lamports`
|
||||
- `q2_available_lamports`
|
||||
- `q1_available_usd_cents`
|
||||
- `q2_available_usd_cents`
|
||||
|
||||
## Методы
|
||||
|
||||
@ -58,40 +57,56 @@
|
||||
|
||||
3. `grant_manager_limits` (только `dao_wallet` из config)
|
||||
- DAO выдает/добавляет лимиты менеджеру:
|
||||
- `add_q1_lamports`
|
||||
- `add_q2_lamports`
|
||||
- `add_q1_usd_cents`
|
||||
- `add_q2_usd_cents`
|
||||
- если PDA менеджера нет — создается;
|
||||
- если есть — лимиты увеличиваются.
|
||||
|
||||
4. `buy_ticket`
|
||||
- обычная покупка билета в очередь 1;
|
||||
- сумма покупки идет в DAO;
|
||||
- тикет получает выплату `input * coef_ppm / 1_000_000`;
|
||||
- проверка лимита выполняется по `q1_sum_total` (исторически накопленная сумма, без вычета уже выплаченного).
|
||||
- обратная совместимость: покупка с входом в lamports, но запись тикета в USD-центах;
|
||||
- использует Pyth SOL/USD и stale-check (не старше 120 секунд).
|
||||
|
||||
5. `manager_add_ticket`
|
||||
5. `buy_ticket_usd`
|
||||
- покупка билета в очередь 1 с суммой в USD-центах;
|
||||
- сумма SOL к оплате считается в контракте по Pyth;
|
||||
- есть `max_pay_lamports` для slippage-защиты.
|
||||
|
||||
6. `buy_ticket_sol`
|
||||
- покупка билета в очередь 1 с суммой в lamports;
|
||||
- USD-объём покупки считается в контракте по Pyth;
|
||||
- есть `min_expected_usd_cents` для slippage-защиты.
|
||||
|
||||
7. `manager_add_ticket`
|
||||
- менеджер добавляет тикет в очередь 1 или 2;
|
||||
- без денежного перевода;
|
||||
- списывает лимит менеджера по выбранной очереди.
|
||||
- списывает лимит менеджера по выбранной очереди (в USD-центах).
|
||||
|
||||
6. `step_payout`
|
||||
8. `step_payout`
|
||||
- выбирает очередь по приоритету:
|
||||
1. сначала очередь 1;
|
||||
2. если в 1-й нет ожидания — очередь 2.
|
||||
- перед выплатой получает текущий SOL/USD из Pyth и проверяет stale.
|
||||
- шаг выплаты:
|
||||
- `X` получателю тикета,
|
||||
- `X` в DAO,
|
||||
- `reward` вызывающему (из `coef_limit_pda`).
|
||||
- очередь 1: `X` получателю тикета + `X` в DAO + `reward` вызывающему;
|
||||
- очередь 2: `X` получателю тикета + `2X` в DAO + `reward` вызывающему.
|
||||
- если обе очереди пусты/выплачены:
|
||||
- переводит весь доступный остаток inflow-вольта в DAO (без reward).
|
||||
|
||||
9. Экономика покупки
|
||||
- сумма покупки идет в DAO;
|
||||
- тикет получает выплату `purchase_usd_cents * coef_ppm / 1_000_000`;
|
||||
- проверка лимита выполняется по `q1_sum_total_usd_cents` (исторически накопленная сумма, без вычета уже выплаченного).
|
||||
|
||||
## Стартовые настройки
|
||||
|
||||
См. `programs/shine_payments/src/settings.rs`:
|
||||
|
||||
- `START_COEF_PPM = 5_000_000` (коэффициент 5.0)
|
||||
- `START_LIMIT_LAMPORTS = 100 SOL`
|
||||
- `START_LIMIT_USD_CENTS = 10_000 USD`
|
||||
- `START_CALL_REWARD_LAMPORTS = 0.008 SOL`
|
||||
- `ORACLE_MAX_AGE_SECS = 120`
|
||||
- `PYTH_SOL_USD_FEED_ID`
|
||||
- `PYTH_SOL_USD_ACCOUNT`
|
||||
- `DAO_WALLET`
|
||||
- `MANAGER_WALLET`
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ bench = false
|
||||
[dependencies]
|
||||
anchor-lang = "0.31.1"
|
||||
common = { path = "../common" }
|
||||
pyth-solana-receiver-sdk = { path = "../../.vendor/pyth-crosschain/target_chains/solana/pyth_solana_receiver_sdk" }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
use anchor_lang::prelude::*;
|
||||
use anchor_lang::solana_program::{program::invoke, system_instruction};
|
||||
use common::utils::{create_pda, safe_read_pda, write_to_pda, ErrCode};
|
||||
use pyth_solana_receiver_sdk::error::GetPriceError;
|
||||
use pyth_solana_receiver_sdk::price_update::{get_feed_id_from_hex, Price, PriceUpdateV2};
|
||||
use std::str::FromStr;
|
||||
|
||||
pub mod settings;
|
||||
@ -56,7 +58,7 @@ pub mod shine_payments {
|
||||
let coef_limit = CoefLimitState {
|
||||
version: 1,
|
||||
coef_ppm: settings::START_COEF_PPM,
|
||||
limit_lamports: settings::START_LIMIT_LAMPORTS,
|
||||
limit_usd_cents: settings::START_LIMIT_USD_CENTS,
|
||||
call_reward_lamports: settings::START_CALL_REWARD_LAMPORTS,
|
||||
};
|
||||
create_and_store_state(
|
||||
@ -73,12 +75,12 @@ pub mod shine_payments {
|
||||
version: 1,
|
||||
q1_tickets_total: 0,
|
||||
q1_tickets_paid: 0,
|
||||
q1_sum_total: 0,
|
||||
q1_sum_paid: 0,
|
||||
q1_sum_total_usd_cents: 0,
|
||||
q1_sum_paid_usd_cents: 0,
|
||||
q2_tickets_total: 0,
|
||||
q2_tickets_paid: 0,
|
||||
q2_sum_total: 0,
|
||||
q2_sum_paid: 0,
|
||||
q2_sum_total_usd_cents: 0,
|
||||
q2_sum_paid_usd_cents: 0,
|
||||
};
|
||||
create_and_store_state(
|
||||
ctx.program_id,
|
||||
@ -115,7 +117,7 @@ pub mod shine_payments {
|
||||
PaymentsError::UnauthorizedDao
|
||||
);
|
||||
require!(args.coef_ppm > 0, PaymentsError::InvalidCoefficient);
|
||||
require!(args.limit_lamports > 0, PaymentsError::InvalidLimit);
|
||||
require!(args.limit_usd_cents > 0, PaymentsError::InvalidLimit);
|
||||
require!(
|
||||
args.call_reward_lamports <= settings::MAX_CALL_REWARD_LAMPORTS,
|
||||
PaymentsError::InvalidCallReward
|
||||
@ -123,7 +125,7 @@ pub mod shine_payments {
|
||||
|
||||
let mut coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?;
|
||||
coef_limit.coef_ppm = args.coef_ppm;
|
||||
coef_limit.limit_lamports = args.limit_lamports;
|
||||
coef_limit.limit_usd_cents = args.limit_usd_cents;
|
||||
coef_limit.call_reward_lamports = args.call_reward_lamports;
|
||||
write_state(&ctx.accounts.coef_limit_pda, &coef_limit)?;
|
||||
Ok(())
|
||||
@ -140,7 +142,7 @@ pub mod shine_payments {
|
||||
PaymentsError::UnauthorizedDao
|
||||
);
|
||||
require!(
|
||||
args.add_q1_lamports > 0 || args.add_q2_lamports > 0,
|
||||
args.add_q1_usd_cents > 0 || args.add_q2_usd_cents > 0,
|
||||
PaymentsError::InvalidAmount
|
||||
);
|
||||
|
||||
@ -156,8 +158,8 @@ pub mod shine_payments {
|
||||
let initial = ManagerAllowanceState {
|
||||
version: 1,
|
||||
manager_wallet: args.manager_wallet,
|
||||
q1_available_lamports: 0,
|
||||
q2_available_lamports: 0,
|
||||
q1_available_usd_cents: 0,
|
||||
q2_available_usd_cents: 0,
|
||||
};
|
||||
create_state_with_seeds(
|
||||
ctx.program_id,
|
||||
@ -182,109 +184,78 @@ pub mod shine_payments {
|
||||
PaymentsError::InvalidManagerWallet
|
||||
);
|
||||
|
||||
state.q1_available_lamports = state
|
||||
.q1_available_lamports
|
||||
.checked_add(args.add_q1_lamports)
|
||||
state.q1_available_usd_cents = state
|
||||
.q1_available_usd_cents
|
||||
.checked_add(args.add_q1_usd_cents)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
state.q2_available_lamports = state
|
||||
.q2_available_lamports
|
||||
.checked_add(args.add_q2_lamports)
|
||||
state.q2_available_usd_cents = state
|
||||
.q2_available_usd_cents
|
||||
.checked_add(args.add_q2_usd_cents)
|
||||
.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)?;
|
||||
let coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?;
|
||||
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
|
||||
|
||||
require_keys_eq!(
|
||||
ctx.accounts.dao_wallet.key(),
|
||||
config.dao_wallet,
|
||||
PaymentsError::InvalidDaoWallet
|
||||
);
|
||||
|
||||
let queue1_sum_total_before = queues.q1_sum_total;
|
||||
require!(
|
||||
queue1_sum_total_before < coef_limit.limit_lamports,
|
||||
PaymentsError::QueueTemporarilyPaused
|
||||
);
|
||||
|
||||
let ticket_index = queues
|
||||
.q1_tickets_total
|
||||
.checked_add(1)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
let (expected_ticket_pda, ticket_bump) = find_ticket_pda(ctx.program_id, 1, 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 payout_lamports = args
|
||||
.amount_lamports
|
||||
.checked_mul(coef_limit.coef_ppm)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?
|
||||
/ settings::COEF_SCALE_PPM;
|
||||
require!(payout_lamports > 0, PaymentsError::InvalidPayoutAmount);
|
||||
|
||||
let ix = system_instruction::transfer(
|
||||
ctx.accounts.signer.key,
|
||||
ctx.accounts.dao_wallet.key,
|
||||
let sol_usd = read_sol_usd_price(
|
||||
&ctx.accounts.sol_usd_price_update,
|
||||
&ctx.accounts.sol_usd_price_update.key(),
|
||||
)?;
|
||||
let purchase_usd_cents = lamports_to_usd_cents_floor(args.amount_lamports, &sol_usd)?;
|
||||
require!(purchase_usd_cents > 0, PaymentsError::InvalidAmount);
|
||||
buy_ticket_by_purchase_usd(
|
||||
&ctx,
|
||||
purchase_usd_cents,
|
||||
args.amount_lamports,
|
||||
args.recipient_wallet,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn buy_ticket_usd(ctx: Context<BuyTicket>, args: BuyTicketUsdArgs) -> Result<()> {
|
||||
require!(args.amount_usd_cents > 0, PaymentsError::InvalidAmount);
|
||||
require!(args.max_pay_lamports > 0, PaymentsError::InvalidAmount);
|
||||
|
||||
let sol_usd = read_sol_usd_price(
|
||||
&ctx.accounts.sol_usd_price_update,
|
||||
&ctx.accounts.sol_usd_price_update.key(),
|
||||
)?;
|
||||
let pay_lamports = usd_cents_to_lamports_ceil(args.amount_usd_cents, &sol_usd)?;
|
||||
require!(pay_lamports > 0, PaymentsError::InvalidAmount);
|
||||
require!(
|
||||
pay_lamports <= args.max_pay_lamports,
|
||||
PaymentsError::SlippageExceeded
|
||||
);
|
||||
invoke(
|
||||
&ix,
|
||||
&[
|
||||
ctx.accounts.signer.clone(),
|
||||
ctx.accounts.dao_wallet.clone(),
|
||||
ctx.accounts.system_program.to_account_info(),
|
||||
],
|
||||
)?;
|
||||
|
||||
let ticket = TicketState {
|
||||
version: 1,
|
||||
queue_id: 1,
|
||||
index: ticket_index,
|
||||
is_paid: false,
|
||||
recipient_wallet: args.recipient_wallet,
|
||||
payout_lamports,
|
||||
debt_before_lamports: queue1_sum_total_before,
|
||||
};
|
||||
create_state_with_seeds(
|
||||
ctx.program_id,
|
||||
&ctx.accounts.signer,
|
||||
&ctx.accounts.system_program.to_account_info(),
|
||||
&ctx.accounts.ticket_pda,
|
||||
&[
|
||||
settings::Q1_TICKET_SEED,
|
||||
&ticket_index.to_le_bytes(),
|
||||
&[ticket_bump],
|
||||
],
|
||||
settings::TICKET_SPACE,
|
||||
&ticket,
|
||||
)?;
|
||||
buy_ticket_by_purchase_usd(&ctx, args.amount_usd_cents, pay_lamports, args.recipient_wallet)
|
||||
}
|
||||
|
||||
queues.q1_tickets_total = ticket_index;
|
||||
queues.q1_sum_total = queues
|
||||
.q1_sum_total
|
||||
.checked_add(payout_lamports)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
write_state(&ctx.accounts.queues_pda, &queues)?;
|
||||
Ok(())
|
||||
pub fn buy_ticket_sol(ctx: Context<BuyTicket>, args: BuyTicketSolArgs) -> Result<()> {
|
||||
require!(args.amount_lamports > 0, PaymentsError::InvalidAmount);
|
||||
|
||||
let sol_usd = read_sol_usd_price(
|
||||
&ctx.accounts.sol_usd_price_update,
|
||||
&ctx.accounts.sol_usd_price_update.key(),
|
||||
)?;
|
||||
let purchase_usd_cents = lamports_to_usd_cents_floor(args.amount_lamports, &sol_usd)?;
|
||||
require!(purchase_usd_cents > 0, PaymentsError::InvalidAmount);
|
||||
require!(
|
||||
purchase_usd_cents >= args.min_expected_usd_cents,
|
||||
PaymentsError::SlippageExceeded
|
||||
);
|
||||
|
||||
buy_ticket_by_purchase_usd(
|
||||
&ctx,
|
||||
purchase_usd_cents,
|
||||
args.amount_lamports,
|
||||
args.recipient_wallet,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn manager_add_ticket(
|
||||
ctx: Context<ManagerAddTicket>,
|
||||
args: ManagerAddTicketArgs,
|
||||
) -> Result<()> {
|
||||
require!(args.payout_lamports > 0, PaymentsError::InvalidPayoutAmount);
|
||||
require!(args.payout_usd_cents > 0, PaymentsError::InvalidPayoutAmount);
|
||||
require!(
|
||||
args.queue_id == 1 || args.queue_id == 2,
|
||||
PaymentsError::InvalidTicketQueue
|
||||
@ -307,19 +278,19 @@ pub mod shine_payments {
|
||||
|
||||
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
|
||||
let debt_before_total = if args.queue_id == 1 {
|
||||
queues.q1_sum_total
|
||||
queues.q1_sum_total_usd_cents
|
||||
} else {
|
||||
queues.q2_sum_total
|
||||
queues.q2_sum_total_usd_cents
|
||||
};
|
||||
|
||||
if args.queue_id == 1 {
|
||||
require!(
|
||||
allowance.q1_available_lamports >= args.payout_lamports,
|
||||
allowance.q1_available_usd_cents >= args.payout_usd_cents,
|
||||
PaymentsError::ManagerLimitExceeded
|
||||
);
|
||||
} else {
|
||||
require!(
|
||||
allowance.q2_available_lamports >= args.payout_lamports,
|
||||
allowance.q2_available_usd_cents >= args.payout_usd_cents,
|
||||
PaymentsError::ManagerLimitExceeded
|
||||
);
|
||||
}
|
||||
@ -353,8 +324,8 @@ pub mod shine_payments {
|
||||
index: ticket_index,
|
||||
is_paid: false,
|
||||
recipient_wallet: args.recipient_wallet,
|
||||
payout_lamports: args.payout_lamports,
|
||||
debt_before_lamports: debt_before_total,
|
||||
payout_usd_cents: args.payout_usd_cents,
|
||||
debt_before_usd_cents: debt_before_total,
|
||||
};
|
||||
let seed_prefix = if args.queue_id == 1 {
|
||||
settings::Q1_TICKET_SEED
|
||||
@ -372,24 +343,24 @@ pub mod shine_payments {
|
||||
)?;
|
||||
|
||||
if args.queue_id == 1 {
|
||||
allowance.q1_available_lamports = allowance
|
||||
.q1_available_lamports
|
||||
.checked_sub(args.payout_lamports)
|
||||
allowance.q1_available_usd_cents = allowance
|
||||
.q1_available_usd_cents
|
||||
.checked_sub(args.payout_usd_cents)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
queues.q1_tickets_total = ticket_index;
|
||||
queues.q1_sum_total = queues
|
||||
.q1_sum_total
|
||||
.checked_add(args.payout_lamports)
|
||||
queues.q1_sum_total_usd_cents = queues
|
||||
.q1_sum_total_usd_cents
|
||||
.checked_add(args.payout_usd_cents)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
} else {
|
||||
allowance.q2_available_lamports = allowance
|
||||
.q2_available_lamports
|
||||
.checked_sub(args.payout_lamports)
|
||||
allowance.q2_available_usd_cents = allowance
|
||||
.q2_available_usd_cents
|
||||
.checked_sub(args.payout_usd_cents)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
queues.q2_tickets_total = ticket_index;
|
||||
queues.q2_sum_total = queues
|
||||
.q2_sum_total
|
||||
.checked_add(args.payout_lamports)
|
||||
queues.q2_sum_total_usd_cents = queues
|
||||
.q2_sum_total_usd_cents
|
||||
.checked_add(args.payout_usd_cents)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
}
|
||||
write_state(&ctx.accounts.manager_allowance_pda, &allowance)?;
|
||||
@ -466,9 +437,20 @@ pub mod shine_payments {
|
||||
PaymentsError::InvalidTicketRecipient
|
||||
);
|
||||
|
||||
let needed = ticket
|
||||
.payout_lamports
|
||||
.checked_add(ticket.payout_lamports)
|
||||
let sol_usd = read_sol_usd_price(
|
||||
&ctx.accounts.sol_usd_price_update,
|
||||
&ctx.accounts.sol_usd_price_update.key(),
|
||||
)?;
|
||||
let ticket_lamports = usd_cents_to_lamports_ceil(ticket.payout_usd_cents, &sol_usd)?;
|
||||
let dao_multiplier: u64 = if target_queue == 1 { 1 } else { 2 };
|
||||
let dao_usd_cents = ticket
|
||||
.payout_usd_cents
|
||||
.checked_mul(dao_multiplier)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
let dao_lamports = usd_cents_to_lamports_ceil(dao_usd_cents, &sol_usd)?;
|
||||
|
||||
let needed = ticket_lamports
|
||||
.checked_add(dao_lamports)
|
||||
.and_then(|v| v.checked_add(coef_limit.call_reward_lamports))
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
require!(
|
||||
@ -479,12 +461,12 @@ pub mod shine_payments {
|
||||
transfer_from_vault(
|
||||
&ctx.accounts.inflow_vault_pda,
|
||||
&ctx.accounts.ticket_recipient_wallet,
|
||||
ticket.payout_lamports,
|
||||
ticket_lamports,
|
||||
)?;
|
||||
transfer_from_vault(
|
||||
&ctx.accounts.inflow_vault_pda,
|
||||
&ctx.accounts.dao_wallet,
|
||||
ticket.payout_lamports,
|
||||
dao_lamports,
|
||||
)?;
|
||||
transfer_from_vault(
|
||||
&ctx.accounts.inflow_vault_pda,
|
||||
@ -500,18 +482,18 @@ pub mod shine_payments {
|
||||
.q1_tickets_paid
|
||||
.checked_add(1)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
queues.q1_sum_paid = queues
|
||||
.q1_sum_paid
|
||||
.checked_add(ticket.payout_lamports)
|
||||
queues.q1_sum_paid_usd_cents = queues
|
||||
.q1_sum_paid_usd_cents
|
||||
.checked_add(ticket.payout_usd_cents)
|
||||
.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)
|
||||
queues.q2_sum_paid_usd_cents = queues
|
||||
.q2_sum_paid_usd_cents
|
||||
.checked_add(ticket.payout_usd_cents)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
}
|
||||
write_state(&ctx.accounts.queues_pda, &queues)?;
|
||||
@ -587,6 +569,7 @@ pub struct BuyTicket<'info> {
|
||||
/// CHECK: DAO-кошелек, адрес сверяется с конфигом вручную.
|
||||
#[account(mut)]
|
||||
pub dao_wallet: AccountInfo<'info>,
|
||||
pub sol_usd_price_update: Account<'info, PriceUpdateV2>,
|
||||
pub system_program: Program<'info, System>,
|
||||
}
|
||||
|
||||
@ -633,20 +616,21 @@ pub struct StepPayout<'info> {
|
||||
/// CHECK: DAO-кошелек, адрес сверяется с конфигом вручную.
|
||||
#[account(mut)]
|
||||
pub dao_wallet: AccountInfo<'info>,
|
||||
pub sol_usd_price_update: Account<'info, PriceUpdateV2>,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
pub struct UpdateCoefLimitArgs {
|
||||
pub coef_ppm: u64,
|
||||
pub limit_lamports: u64,
|
||||
pub limit_usd_cents: u64,
|
||||
pub call_reward_lamports: u64,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
pub struct GrantManagerLimitsArgs {
|
||||
pub manager_wallet: Pubkey,
|
||||
pub add_q1_lamports: u64,
|
||||
pub add_q2_lamports: u64,
|
||||
pub add_q1_usd_cents: u64,
|
||||
pub add_q2_usd_cents: u64,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
@ -655,11 +639,25 @@ pub struct BuyTicketArgs {
|
||||
pub recipient_wallet: Pubkey,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
pub struct BuyTicketUsdArgs {
|
||||
pub amount_usd_cents: u64,
|
||||
pub max_pay_lamports: u64,
|
||||
pub recipient_wallet: Pubkey,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
pub struct BuyTicketSolArgs {
|
||||
pub amount_lamports: u64,
|
||||
pub min_expected_usd_cents: 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,
|
||||
pub payout_usd_cents: u64,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
@ -674,7 +672,7 @@ pub struct ConfigState {
|
||||
pub struct CoefLimitState {
|
||||
pub version: u8,
|
||||
pub coef_ppm: u64,
|
||||
pub limit_lamports: u64,
|
||||
pub limit_usd_cents: u64,
|
||||
pub call_reward_lamports: u64,
|
||||
}
|
||||
|
||||
@ -683,12 +681,12 @@ pub struct QueuesState {
|
||||
pub version: u8,
|
||||
pub q1_tickets_total: u64,
|
||||
pub q1_tickets_paid: u64,
|
||||
pub q1_sum_total: u64,
|
||||
pub q1_sum_paid: u64,
|
||||
pub q1_sum_total_usd_cents: u64,
|
||||
pub q1_sum_paid_usd_cents: u64,
|
||||
pub q2_tickets_total: u64,
|
||||
pub q2_tickets_paid: u64,
|
||||
pub q2_sum_total: u64,
|
||||
pub q2_sum_paid: u64,
|
||||
pub q2_sum_total_usd_cents: u64,
|
||||
pub q2_sum_paid_usd_cents: u64,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
@ -698,16 +696,16 @@ pub struct TicketState {
|
||||
pub index: u64,
|
||||
pub is_paid: bool,
|
||||
pub recipient_wallet: Pubkey,
|
||||
pub payout_lamports: u64,
|
||||
pub debt_before_lamports: u64,
|
||||
pub payout_usd_cents: u64,
|
||||
pub debt_before_usd_cents: 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,
|
||||
pub q1_available_usd_cents: u64,
|
||||
pub q2_available_usd_cents: u64,
|
||||
}
|
||||
|
||||
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
|
||||
@ -715,6 +713,11 @@ pub struct VaultState {
|
||||
pub version: u8,
|
||||
}
|
||||
|
||||
struct SolUsdPrice {
|
||||
price_num: u128,
|
||||
price_den: u128,
|
||||
}
|
||||
|
||||
#[error_code]
|
||||
pub enum PaymentsError {
|
||||
#[msg("Ошибка в адресах кошельков из настроек программы")]
|
||||
@ -755,6 +758,18 @@ pub enum PaymentsError {
|
||||
InvalidManagerWallet,
|
||||
#[msg("Лимит менеджера по выбранной очереди превышен")]
|
||||
ManagerLimitExceeded,
|
||||
#[msg("Оракул передан неверный")]
|
||||
InvalidOracleAccount,
|
||||
#[msg("Некорректный feed id оракула")]
|
||||
InvalidOracleFeed,
|
||||
#[msg("Конфигурация оракула в settings некорректна")]
|
||||
InvalidOracleFeedConfig,
|
||||
#[msg("Цена оракула устарела")]
|
||||
OraclePriceTooOld,
|
||||
#[msg("Цена оракула некорректна")]
|
||||
InvalidOraclePrice,
|
||||
#[msg("Защита от проскальзывания: лимит пользователя не проходит")]
|
||||
SlippageExceeded,
|
||||
}
|
||||
|
||||
fn ensure_expected_pdas(program_id: &Pubkey, accounts: &Init) -> Result<()> {
|
||||
@ -806,6 +821,181 @@ fn find_manager_allowance_pda(program_id: &Pubkey, manager_wallet: &Pubkey) -> (
|
||||
)
|
||||
}
|
||||
|
||||
fn buy_ticket_by_purchase_usd(
|
||||
ctx: &Context<BuyTicket>,
|
||||
purchase_usd_cents: u64,
|
||||
transfer_lamports: u64,
|
||||
recipient_wallet: Pubkey,
|
||||
) -> Result<()> {
|
||||
let config = read_state::<ConfigState>(&ctx.accounts.config_pda)?;
|
||||
let coef_limit = read_state::<CoefLimitState>(&ctx.accounts.coef_limit_pda)?;
|
||||
let mut queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
|
||||
|
||||
require_keys_eq!(
|
||||
ctx.accounts.dao_wallet.key(),
|
||||
config.dao_wallet,
|
||||
PaymentsError::InvalidDaoWallet
|
||||
);
|
||||
|
||||
let queue1_sum_total_before = queues.q1_sum_total_usd_cents;
|
||||
require!(
|
||||
queue1_sum_total_before < coef_limit.limit_usd_cents,
|
||||
PaymentsError::QueueTemporarilyPaused
|
||||
);
|
||||
|
||||
let ticket_index = queues
|
||||
.q1_tickets_total
|
||||
.checked_add(1)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
let (expected_ticket_pda, ticket_bump) = find_ticket_pda(ctx.program_id, 1, 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 payout_usd_cents = purchase_usd_cents
|
||||
.checked_mul(coef_limit.coef_ppm)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?
|
||||
/ settings::COEF_SCALE_PPM;
|
||||
require!(payout_usd_cents > 0, PaymentsError::InvalidPayoutAmount);
|
||||
|
||||
transfer_from_signer_to_target(
|
||||
&ctx.accounts.signer,
|
||||
&ctx.accounts.dao_wallet,
|
||||
&ctx.accounts.system_program.to_account_info(),
|
||||
transfer_lamports,
|
||||
)?;
|
||||
|
||||
let ticket = TicketState {
|
||||
version: 1,
|
||||
queue_id: 1,
|
||||
index: ticket_index,
|
||||
is_paid: false,
|
||||
recipient_wallet,
|
||||
payout_usd_cents,
|
||||
debt_before_usd_cents: queue1_sum_total_before,
|
||||
};
|
||||
create_state_with_seeds(
|
||||
ctx.program_id,
|
||||
&ctx.accounts.signer,
|
||||
&ctx.accounts.system_program.to_account_info(),
|
||||
&ctx.accounts.ticket_pda,
|
||||
&[
|
||||
settings::Q1_TICKET_SEED,
|
||||
&ticket_index.to_le_bytes(),
|
||||
&[ticket_bump],
|
||||
],
|
||||
settings::TICKET_SPACE,
|
||||
&ticket,
|
||||
)?;
|
||||
|
||||
queues.q1_tickets_total = ticket_index;
|
||||
queues.q1_sum_total_usd_cents = queues
|
||||
.q1_sum_total_usd_cents
|
||||
.checked_add(payout_usd_cents)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
write_state(&ctx.accounts.queues_pda, &queues)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transfer_from_signer_to_target<'info>(
|
||||
signer: &AccountInfo<'info>,
|
||||
target: &AccountInfo<'info>,
|
||||
system_program: &AccountInfo<'info>,
|
||||
amount: u64,
|
||||
) -> Result<()> {
|
||||
let ix = system_instruction::transfer(signer.key, target.key, amount);
|
||||
invoke(
|
||||
&ix,
|
||||
&[signer.clone(), target.clone(), system_program.clone()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_sol_usd_price(price_update: &Account<PriceUpdateV2>, key: &Pubkey) -> Result<SolUsdPrice> {
|
||||
let expected_oracle = Pubkey::from_str(settings::PYTH_SOL_USD_ACCOUNT)
|
||||
.map_err(|_| error!(PaymentsError::InvalidOracleFeedConfig))?;
|
||||
require_keys_eq!(expected_oracle, *key, PaymentsError::InvalidOracleAccount);
|
||||
|
||||
let feed_id = get_feed_id_from_hex(settings::PYTH_SOL_USD_FEED_ID)
|
||||
.map_err(|_| error!(PaymentsError::InvalidOracleFeedConfig))?;
|
||||
let clock = Clock::get()?;
|
||||
let price = price_update
|
||||
.get_price_no_older_than(&clock, settings::ORACLE_MAX_AGE_SECS, &feed_id)
|
||||
.map_err(map_oracle_error)?;
|
||||
price_to_ratio(price)
|
||||
}
|
||||
|
||||
fn map_oracle_error(err: GetPriceError) -> anchor_lang::error::Error {
|
||||
match err {
|
||||
GetPriceError::PriceTooOld => error!(PaymentsError::OraclePriceTooOld),
|
||||
GetPriceError::MismatchedFeedId => error!(PaymentsError::InvalidOracleFeed),
|
||||
GetPriceError::InsufficientVerificationLevel => error!(PaymentsError::InvalidOraclePrice),
|
||||
GetPriceError::FeedIdMustBe32Bytes | GetPriceError::FeedIdNonHexCharacter => {
|
||||
error!(PaymentsError::InvalidOracleFeedConfig)
|
||||
}
|
||||
_ => error!(PaymentsError::InvalidOraclePrice),
|
||||
}
|
||||
}
|
||||
|
||||
fn price_to_ratio(price: Price) -> Result<SolUsdPrice> {
|
||||
require!(price.price > 0, PaymentsError::InvalidOraclePrice);
|
||||
let mut num = (price.price as u128)
|
||||
.checked_mul(settings::USD_CENTS_SCALE as u128)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
let mut den: u128 = 1;
|
||||
|
||||
if price.exponent >= 0 {
|
||||
let pow = 10u128
|
||||
.checked_pow(price.exponent as u32)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
num = num.checked_mul(pow).ok_or(error!(ErrCode::MathOverflow))?;
|
||||
} else {
|
||||
let pow = 10u128
|
||||
.checked_pow((-price.exponent) as u32)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
den = den.checked_mul(pow).ok_or(error!(ErrCode::MathOverflow))?;
|
||||
}
|
||||
|
||||
require!(num > 0 && den > 0, PaymentsError::InvalidOraclePrice);
|
||||
Ok(SolUsdPrice {
|
||||
price_num: num,
|
||||
price_den: den,
|
||||
})
|
||||
}
|
||||
|
||||
fn lamports_to_usd_cents_floor(lamports: u64, price: &SolUsdPrice) -> Result<u64> {
|
||||
let numerator = (lamports as u128)
|
||||
.checked_mul(price.price_num)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
let denominator = (settings::LAMPORTS_PER_SOL as u128)
|
||||
.checked_mul(price.price_den)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
require!(denominator > 0, PaymentsError::InvalidOraclePrice);
|
||||
let value = numerator / denominator;
|
||||
u64::try_from(value).map_err(|_| error!(ErrCode::MathOverflow))
|
||||
}
|
||||
|
||||
fn usd_cents_to_lamports_ceil(usd_cents: u64, price: &SolUsdPrice) -> Result<u64> {
|
||||
require!(usd_cents > 0, PaymentsError::InvalidAmount);
|
||||
require!(price.price_num > 0, PaymentsError::InvalidOraclePrice);
|
||||
|
||||
let numerator = (usd_cents as u128)
|
||||
.checked_mul(settings::LAMPORTS_PER_SOL as u128)
|
||||
.and_then(|v| v.checked_mul(price.price_den))
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
let adjusted = numerator
|
||||
.checked_add(price.price_num - 1)
|
||||
.ok_or(error!(ErrCode::MathOverflow))?;
|
||||
let value = adjusted / price.price_num;
|
||||
u64::try_from(value).map_err(|_| error!(ErrCode::MathOverflow))
|
||||
}
|
||||
|
||||
fn create_and_store_state<'info, T: AnchorSerialize>(
|
||||
program_id: &Pubkey,
|
||||
payer: &AccountInfo<'info>,
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
pub const CONFIG_SEED: &[u8] = b"shine_payments_v2_config";
|
||||
pub const COEF_LIMIT_SEED: &[u8] = b"shine_payments_v2_coef_limit";
|
||||
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_p_v2_manager_allow";
|
||||
pub const CONFIG_SEED: &[u8] = b"shine_payments_v3_config";
|
||||
pub const COEF_LIMIT_SEED: &[u8] = b"shine_payments_v3_coef_limit";
|
||||
pub const QUEUES_SEED: &[u8] = b"shine_payments_v3_queues";
|
||||
pub const INFLOW_VAULT_SEED: &[u8] = b"shine_payments_v3_inflow_vault";
|
||||
pub const Q1_TICKET_SEED: &[u8] = b"shine_payments_v3_q1_ticket";
|
||||
pub const Q2_TICKET_SEED: &[u8] = b"shine_payments_v3_q2_ticket";
|
||||
pub const MANAGER_ALLOWANCE_SEED: &[u8] = b"shine_p_v3_manager_allow";
|
||||
|
||||
pub const CONFIG_SPACE: usize = 8 + 160;
|
||||
pub const COEF_LIMIT_SPACE: usize = 8 + 96;
|
||||
@ -15,9 +15,16 @@ 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
|
||||
pub const START_LIMIT_LAMPORTS: u64 = 100 * 1_000_000_000; // 100 SOL
|
||||
pub const START_LIMIT_USD_CENTS: u64 = 10_000 * 100; // 10_000 USD
|
||||
pub const START_CALL_REWARD_LAMPORTS: u64 = 8_000_000; // 0.008 SOL
|
||||
pub const MAX_CALL_REWARD_LAMPORTS: u64 = 10_000_000; // 0.01 SOL
|
||||
pub const USD_CENTS_SCALE: u64 = 100;
|
||||
pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
|
||||
|
||||
pub const DAO_WALLET: &str = "9vXFoN9ngfN1gpqQ3HT5n3y9Wp2r7HnSQckirgwVwWwb";
|
||||
pub const ORACLE_MAX_AGE_SECS: u64 = 120;
|
||||
pub const PYTH_SOL_USD_FEED_ID: &str =
|
||||
"0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d";
|
||||
pub const PYTH_SOL_USD_ACCOUNT: &str = "7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE";
|
||||
|
||||
pub const DAO_WALLET: &str = "FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P";
|
||||
pub const MANAGER_WALLET: &str = "4yzHKs2zFXpyqqCETe8KpAs4xhEo4QhJ2ybyTgRZphZv";
|
||||
|
||||
@ -61,12 +61,12 @@
|
||||
<div class="muted">Право изменения: <code id="daoAllowed">загрузка...</code></div>
|
||||
<div class="row">
|
||||
<label>Коэффициент (например 5): <input id="coefInput" value="5" /></label>
|
||||
<label>Лимит (SOL): <input id="limitInput" value="100" /></label>
|
||||
<label>Лимит (USD): <input id="limitInput" value="10000" /></label>
|
||||
<label>Награда шага (SOL, max 0.01): <input id="rewardInput" value="0.008" /></label>
|
||||
<button id="updateCoefBtn">Обновить</button>
|
||||
</div>
|
||||
<div class="formula">Лимит покупки Q1 = max(limit_lamports - q1_sum_total, 0)</div>
|
||||
<div class="formula">Шаг выплаты = payout + payout + reward</div>
|
||||
<div class="formula">Лимит покупки Q1 = max(limit_usd_cents - q1_sum_total_usd_cents, 0)</div>
|
||||
<div class="formula">Шаг выплаты Q1 = ticket + dao(1x) + reward; Q2 = ticket + dao(2x) + reward</div>
|
||||
<div id="updateResult" class="muted"></div>
|
||||
</div>
|
||||
|
||||
@ -94,12 +94,12 @@
|
||||
const RPC_URL = "https://api.devnet.solana.com";
|
||||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||
const SEEDS = {
|
||||
config: "shine_payments_v2_config",
|
||||
coef: "shine_payments_v2_coef_limit",
|
||||
queues: "shine_payments_v2_queues",
|
||||
inflow: "shine_payments_v2_inflow_vault",
|
||||
ticketQ1: "shine_payments_v2_q1_ticket",
|
||||
ticketQ2: "shine_payments_v2_q2_ticket",
|
||||
config: "shine_payments_v3_config",
|
||||
coef: "shine_payments_v3_coef_limit",
|
||||
queues: "shine_payments_v3_queues",
|
||||
inflow: "shine_payments_v3_inflow_vault",
|
||||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||
ticketQ2: "shine_payments_v3_q2_ticket",
|
||||
};
|
||||
const MAX_REWARD_LAMPORTS = 10_000_000n;
|
||||
let walletPubkey = null;
|
||||
@ -131,11 +131,19 @@
|
||||
function lamportsToSolStr(l) {
|
||||
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
|
||||
}
|
||||
function centsToUsdStr(c) {
|
||||
return trimZeros((Number(c) / 100).toFixed(2));
|
||||
}
|
||||
function solToLamports(solStr) {
|
||||
const v = Number(solStr);
|
||||
const v = Number(solStr.replace(",", "."));
|
||||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
|
||||
return BigInt(Math.round(v * 1_000_000_000));
|
||||
}
|
||||
function usdToCents(usdStr) {
|
||||
const v = Number(usdStr.replace(",", "."));
|
||||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
|
||||
return BigInt(Math.round(v * 100));
|
||||
}
|
||||
async function ixDiscriminator(name) {
|
||||
const msg = utf8("global:" + name);
|
||||
const hash = await crypto.subtle.digest("SHA-256", msg);
|
||||
@ -158,9 +166,9 @@
|
||||
let o = 0;
|
||||
const version = data[o++];
|
||||
const coefPpm = readU64(data, o); o += 8;
|
||||
const limit = readU64(data, o); o += 8;
|
||||
const limitUsdCents = readU64(data, o); o += 8;
|
||||
const reward = readU64(data, o); o += 8;
|
||||
return { version, coefPpm, limit, reward };
|
||||
return { version, coefPpm, limitUsdCents, reward };
|
||||
}
|
||||
function parseQueues(data) {
|
||||
let o = 0;
|
||||
@ -261,21 +269,22 @@
|
||||
return;
|
||||
}
|
||||
const coefText = trimZeros((Number(core.coef.coefPpm) / 1_000_000).toFixed(6));
|
||||
const limitRemain = core.coef.limit > core.queues.q1SumTotal ? (core.coef.limit - core.queues.q1SumTotal) : 0n;
|
||||
const limitRemain = core.coef.limitUsdCents > core.queues.q1SumTotal ? (core.coef.limitUsdCents - core.queues.q1SumTotal) : 0n;
|
||||
document.getElementById("daoAllowed").textContent = core.config.dao.toBase58();
|
||||
el.innerHTML = `
|
||||
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
|
||||
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</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>Награда за шаг: <b>${lamportsToSolStr(core.coef.reward)} SOL</b></div>
|
||||
<div>Коэффициент: <b>${coefText}</b>, лимит: <b>${lamportsToSolStr(core.coef.limit)} SOL</b></div>
|
||||
<div>Осталось лимита для покупки Q1: <b>${lamportsToSolStr(limitRemain)} SOL</b></div>
|
||||
<div>Коэффициент: <b>${coefText}</b>, лимит: <b>${centsToUsdStr(core.coef.limitUsdCents)} USD</b></div>
|
||||
<div>Осталось лимита для покупки Q1: <b>${centsToUsdStr(limitRemain)} USD</b></div>
|
||||
<div>Баланс DAO: <b>${lamportsToSolStr(core.daoBalance)} SOL</b></div>
|
||||
<div>Баланс inflow vault: <b>${lamportsToSolStr(core.inflowLamports)} SOL</b></div>
|
||||
<div>Доступно в inflow (сверх ренты): <b>${lamportsToSolStr(core.inflowAvailable)} SOL</b></div>
|
||||
<div>Q1: total=${core.queues.q1Total}, paid=${core.queues.q1Paid}, sum_total=${lamportsToSolStr(core.queues.q1SumTotal)} SOL, sum_paid=${lamportsToSolStr(core.queues.q1SumPaid)} SOL</div>
|
||||
<div>Q2: total=${core.queues.q2Total}, paid=${core.queues.q2Paid}, sum_total=${lamportsToSolStr(core.queues.q2SumTotal)} SOL, sum_paid=${lamportsToSolStr(core.queues.q2SumPaid)} SOL</div>
|
||||
<div>Q1: total=${core.queues.q1Total}, paid=${core.queues.q1Paid}, sum_total=${centsToUsdStr(core.queues.q1SumTotal)} USD, sum_paid=${centsToUsdStr(core.queues.q1SumPaid)} USD</div>
|
||||
<div>Q2: total=${core.queues.q2Total}, paid=${core.queues.q2Paid}, sum_total=${centsToUsdStr(core.queues.q2SumTotal)} USD, sum_paid=${centsToUsdStr(core.queues.q2SumPaid)} USD</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
@ -323,12 +332,12 @@
|
||||
const coef = Number(document.getElementById("coefInput").value.trim());
|
||||
if (!Number.isFinite(coef) || coef <= 0) throw new Error("Некорректный коэффициент");
|
||||
const coefPpm = BigInt(Math.round(coef * 1_000_000));
|
||||
const limitLamports = solToLamports(document.getElementById("limitInput").value.trim());
|
||||
const limitUsdCents = usdToCents(document.getElementById("limitInput").value.trim());
|
||||
const rewardLamports = solToLamports(document.getElementById("rewardInput").value.trim());
|
||||
if (rewardLamports > MAX_REWARD_LAMPORTS) throw new Error("Награда не должна быть больше 0.01 SOL");
|
||||
|
||||
const disc = await ixDiscriminator("update_coef_limit");
|
||||
const data = concat(disc, u64ToBytes(coefPpm), u64ToBytes(limitLamports), u64ToBytes(rewardLamports));
|
||||
const data = concat(disc, u64ToBytes(coefPpm), u64ToBytes(limitUsdCents), u64ToBytes(rewardLamports));
|
||||
const keys = [
|
||||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||
{ pubkey: core.pdas.configPda, isSigner: false, isWritable: true },
|
||||
@ -381,9 +390,9 @@
|
||||
<td>${t.queueId}</td>
|
||||
<td>${t.isPaid ? '<span class="paid">выплачен</span>' : "ожидание"}</td>
|
||||
<td><code>${t.recipient.toBase58()}</code></td>
|
||||
<td>${lamportsToSolStr(t.payout)} SOL</td>
|
||||
<td>${lamportsToSolStr(t.debtBefore)} SOL</td>
|
||||
<td>${lamportsToSolStr(currentDebtBeforeTicket(t, core.queues))} SOL</td>
|
||||
<td>${centsToUsdStr(t.payout)} USD</td>
|
||||
<td>${centsToUsdStr(t.debtBefore)} USD</td>
|
||||
<td>${centsToUsdStr(currentDebtBeforeTicket(t, core.queues))} USD</td>
|
||||
<td><code>${pda.toBase58()}</code></td>
|
||||
</tr>
|
||||
`);
|
||||
@ -396,7 +405,7 @@
|
||||
<th>Очередь</th>
|
||||
<th>Статус</th>
|
||||
<th>Получатель</th>
|
||||
<th>Сумма выплаты</th>
|
||||
<th>Сумма выплаты (USD)</th>
|
||||
<th>Очередь до него (от старта)</th>
|
||||
<th>Очередь до него (актуально)</th>
|
||||
<th>PDA</th>
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
h1 { margin: 8px 0; }
|
||||
.panel { border: 1px solid var(--line); border-radius: 8px; padding: 14px; margin-bottom: 14px; background: var(--panel); width: 100%; }
|
||||
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 8px 0; }
|
||||
input { padding: 9px 10px; min-width: 260px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
|
||||
input { padding: 9px 10px; min-width: 240px; border: 1px solid var(--line); border-radius: 8px; background: #131824; color: var(--text); }
|
||||
button { padding: 9px 14px; border: 1px solid var(--line); border-radius: 8px; cursor: pointer; background: var(--btn); color: var(--text); }
|
||||
button:hover { background: var(--btn-hover); }
|
||||
.muted { color: var(--muted); }
|
||||
@ -58,14 +58,21 @@
|
||||
|
||||
<div class="panel">
|
||||
<h3>Покупка билета в 1-й очереди</h3>
|
||||
<div class="muted">Покупка создает билет только в 1-й очереди. Билеты 2-й очереди добавляются менеджерами с правами.</div>
|
||||
<div class="muted">Можно купить по USD или по SOL. В очередь и лимиты записываются USD-центы. Выплаты по тикетам считаются в USD, а переводятся в SOL по актуальному курсу Pyth в момент шага выплаты.</div>
|
||||
<div class="row">
|
||||
<label>Сумма (USD): <input id="amountUsd" value="20" /></label>
|
||||
<label>Сумма (SOL): <input id="amountSol" value="0.1" /></label>
|
||||
<label>Допуск (%): <input id="slippagePct" value="3" /></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Кошелек для выплаты: <input id="recipient" placeholder="по умолчанию ваш кошелек" /></label>
|
||||
</div>
|
||||
<div id="quoteInfo" class="muted"></div>
|
||||
<div class="row">
|
||||
<button id="buyBtn">Купить билет</button>
|
||||
<button id="buyUsdBtn">Купить по USD</button>
|
||||
<button id="buySolBtn">Купить по SOL</button>
|
||||
</div>
|
||||
<div class="warn">Дополнительно к сумме покупки кошелек платит сеть за создание записи тикета (обычно около 0.002 SOL).</div>
|
||||
<div id="buyResult" class="muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -74,17 +81,21 @@
|
||||
<script>
|
||||
const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||
const RPC_URL = "https://api.devnet.solana.com";
|
||||
const ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
|
||||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||
|
||||
const SEEDS = {
|
||||
config: "shine_payments_v2_config",
|
||||
coef: "shine_payments_v2_coef_limit",
|
||||
queues: "shine_payments_v2_queues",
|
||||
ticketQ1: "shine_payments_v2_q1_ticket",
|
||||
config: "shine_payments_v3_config",
|
||||
coef: "shine_payments_v3_coef_limit",
|
||||
queues: "shine_payments_v3_queues",
|
||||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||
};
|
||||
|
||||
const COEF_SCALE = 1_000_000n;
|
||||
const LAMPORTS_PER_SOL = 1_000_000_000n;
|
||||
let walletPubkey = null;
|
||||
let lastState = null;
|
||||
let activeEdit = "usd";
|
||||
|
||||
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||
|
||||
@ -100,6 +111,16 @@
|
||||
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||
return x;
|
||||
}
|
||||
function readI32(data, offset) {
|
||||
let x = Number(readU64(data, offset) & 0xffffffffn);
|
||||
if (x > 0x7fffffff) x -= 0x100000000;
|
||||
return x;
|
||||
}
|
||||
function readI64(data, offset) {
|
||||
let x = readU64(data, offset);
|
||||
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
|
||||
return x;
|
||||
}
|
||||
function concat(...parts) {
|
||||
const len = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(len);
|
||||
@ -113,11 +134,48 @@
|
||||
function lamportsToSolStr(l) {
|
||||
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
|
||||
}
|
||||
function solToLamports(solStr) {
|
||||
const v = Number(solStr);
|
||||
function centsToUsdStr(cents) {
|
||||
return trimZeros((Number(cents) / 100).toFixed(2));
|
||||
}
|
||||
function usdTextToCents(text) {
|
||||
const v = Number(text.trim().replace(",", "."));
|
||||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
|
||||
return BigInt(Math.round(v * 100));
|
||||
}
|
||||
function solTextToLamports(text) {
|
||||
const v = Number(text.trim().replace(",", "."));
|
||||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма SOL");
|
||||
return BigInt(Math.round(v * 1_000_000_000));
|
||||
}
|
||||
function parsePythPriceUpdateV2(data) {
|
||||
const price = readI64(data, 74);
|
||||
const exponent = readI32(data, 90);
|
||||
const publishTime = readI64(data, 94);
|
||||
if (price <= 0n) throw new Error("Оракул вернул некорректную цену");
|
||||
let num = price * 100n;
|
||||
let den = 1n;
|
||||
if (exponent >= 0) {
|
||||
num *= 10n ** BigInt(exponent);
|
||||
} else {
|
||||
den *= 10n ** BigInt(-exponent);
|
||||
}
|
||||
return { num, den, publishTime };
|
||||
}
|
||||
function lamportsToUsdCentsFloor(lamports, px) {
|
||||
return (lamports * px.num) / (LAMPORTS_PER_SOL * px.den);
|
||||
}
|
||||
function usdCentsToLamportsCeil(usdCents, px) {
|
||||
const n = usdCents * LAMPORTS_PER_SOL * px.den;
|
||||
return (n + px.num - 1n) / px.num;
|
||||
}
|
||||
function applySlippageUp(lamports, pct) {
|
||||
const bp = BigInt(Math.round(pct * 100));
|
||||
return (lamports * (10_000n + bp) + 9_999n) / 10_000n;
|
||||
}
|
||||
function applySlippageDown(cents, pct) {
|
||||
const bp = BigInt(Math.round(pct * 100));
|
||||
return (cents * (10_000n - bp)) / 10_000n;
|
||||
}
|
||||
|
||||
async function ixDiscriminator(name) {
|
||||
const msg = utf8("global:" + name);
|
||||
@ -137,9 +195,9 @@
|
||||
let o = 0;
|
||||
const version = data[o++];
|
||||
const coefPpm = readU64(data, o); o += 8;
|
||||
const limit = readU64(data, o); o += 8;
|
||||
const limitUsdCents = readU64(data, o); o += 8;
|
||||
const reward = readU64(data, o); o += 8;
|
||||
return { version, coefPpm, limit, reward };
|
||||
return { version, coefPpm, limitUsdCents, reward };
|
||||
}
|
||||
function parseQueues(data) {
|
||||
let o = 0;
|
||||
@ -190,80 +248,115 @@
|
||||
|
||||
async function loadCoreState() {
|
||||
const pdas = derivePdas();
|
||||
const [cfgAi, coefAi, queuesAi] = await Promise.all([
|
||||
const [cfgAi, coefAi, queuesAi, oracleAi] = await Promise.all([
|
||||
connection.getAccountInfo(pdas.configPda, "confirmed"),
|
||||
connection.getAccountInfo(pdas.coefPda, "confirmed"),
|
||||
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
|
||||
connection.getAccountInfo(ORACLE_ACCOUNT, "confirmed"),
|
||||
]);
|
||||
if (!cfgAi || !coefAi || !queuesAi) throw new Error("PDA не инициализированы. Сначала выполните init на странице админки.");
|
||||
if (!oracleAi) throw new Error("SOL/USD oracle account не найден");
|
||||
const config = parseConfig(cfgAi.data);
|
||||
const coef = parseCoef(coefAi.data);
|
||||
const queues = parseQueues(queuesAi.data);
|
||||
return { pdas, config, coef, queues };
|
||||
const pyth = parsePythPriceUpdateV2(oracleAi.data);
|
||||
return { pdas, config, coef, queues, pyth };
|
||||
}
|
||||
|
||||
function renderQuote() {
|
||||
const el = document.getElementById("quoteInfo");
|
||||
if (!lastState) { el.textContent = ""; return; }
|
||||
try {
|
||||
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
|
||||
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
|
||||
const lamports = solTextToLamports(document.getElementById("amountSol").value);
|
||||
const payForUsd = usdCentsToLamportsCeil(usdCents, lastState.pyth);
|
||||
const usdForSol = lamportsToUsdCentsFloor(lamports, lastState.pyth);
|
||||
const maxLamports = applySlippageUp(payForUsd, slippage);
|
||||
const minUsd = applySlippageDown(usdForSol, slippage);
|
||||
el.innerHTML = `
|
||||
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(lastState.pyth.num) / Number(lastState.pyth.den) / 100).toFixed(6))}</b></div>
|
||||
<div>Возраст цены: <b>${Math.max(0, Math.floor(Date.now()/1000 - Number(lastState.pyth.publishTime)))} сек</b></div>
|
||||
<div>Если покупка по USD: к списанию примерно <b>${lamportsToSolStr(payForUsd)} SOL</b>, с допуском максимум <b>${lamportsToSolStr(maxLamports)} SOL</b>.</div>
|
||||
<div>Если покупка по SOL: это примерно <b>${centsToUsdStr(usdForSol)} USD</b>, с допуском минимум <b>${centsToUsdStr(minUsd)} USD</b>.</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function syncFromUsd() {
|
||||
if (!lastState) return;
|
||||
try {
|
||||
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
|
||||
const lamports = usdCentsToLamportsCeil(usdCents, lastState.pyth);
|
||||
document.getElementById("amountSol").value = lamportsToSolStr(lamports);
|
||||
} catch (_) {}
|
||||
renderQuote();
|
||||
}
|
||||
function syncFromSol() {
|
||||
if (!lastState) return;
|
||||
try {
|
||||
const lamports = solTextToLamports(document.getElementById("amountSol").value);
|
||||
const usdCents = lamportsToUsdCentsFloor(lamports, lastState.pyth);
|
||||
document.getElementById("amountUsd").value = centsToUsdStr(usdCents);
|
||||
} catch (_) {}
|
||||
renderQuote();
|
||||
}
|
||||
|
||||
async function refreshState() {
|
||||
const el = document.getElementById("stateInfo");
|
||||
try {
|
||||
const { config, coef, queues } = await loadCoreState();
|
||||
lastState = await loadCoreState();
|
||||
const { config, coef, queues, pyth } = lastState;
|
||||
const coefText = trimZeros((Number(coef.coefPpm) / Number(COEF_SCALE)).toFixed(6));
|
||||
|
||||
const totalBefore = queues.q1Total;
|
||||
const totalBeforeSum = queues.q1SumTotal;
|
||||
const pendingBeforeCount = queues.q1Total - queues.q1Paid;
|
||||
const pendingBeforeSum = queues.q1SumTotal - queues.q1SumPaid;
|
||||
const nextTicketIndex = queues.q1Total + 1n;
|
||||
|
||||
const remainingByTotal = coef.limit > queues.q1SumTotal ? (coef.limit - queues.q1SumTotal) : 0n;
|
||||
const paused = queues.q1SumTotal >= coef.limit;
|
||||
|
||||
const remainingByTotal = coef.limitUsdCents > queues.q1SumTotal ? (coef.limitUsdCents - queues.q1SumTotal) : 0n;
|
||||
const paused = queues.q1SumTotal >= coef.limitUsdCents;
|
||||
el.innerHTML = `
|
||||
<div>DAO: <code>${config.dao}</code></div>
|
||||
<div>Inflow vault: <code>${config.inflow}</code></div>
|
||||
<div class="muted">Inflow vault — входящий PDA-кошелек выплат программы.</div>
|
||||
<div class="muted">Тестовый DAO-кошелек. В production будет реальный адрес DAO.</div>
|
||||
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(pyth.num) / Number(pyth.den) / 100).toFixed(6))}</b></div>
|
||||
<div>Коэффициент: <b>${coefText}</b></div>
|
||||
<div>Лимит очереди 1: <b>${lamportsToSolStr(coef.limit)} SOL</b></div>
|
||||
<div>Лимит очереди 1: <b>${centsToUsdStr(coef.limitUsdCents)} USD</b></div>
|
||||
<div>Награда за шаг выплат: <b>${lamportsToSolStr(coef.reward)} SOL</b></div>
|
||||
<div>До вас всего билетов (исторически): <b>${totalBefore.toString()}</b></div>
|
||||
<div>До вас всего сумма билетов (исторически): <b>${lamportsToSolStr(totalBeforeSum)} SOL</b></div>
|
||||
<div>Из них сейчас не выплачено билетов: <b>${pendingBeforeCount.toString()}</b></div>
|
||||
<div>Из них сейчас не выплачено по сумме: <b>${lamportsToSolStr(pendingBeforeSum)} SOL</b></div>
|
||||
<div>Из них сейчас не выплачено по сумме: <b>${centsToUsdStr(pendingBeforeSum)} USD</b></div>
|
||||
<div>Ваш билет будет номером: <b>${nextTicketIndex.toString()}</b></div>
|
||||
<div>Осталось актуального лимита до изменения коэффициента: <b>${lamportsToSolStr(remainingByTotal)} SOL</b></div>
|
||||
<div>Осталось лимита до паузы: <b>${centsToUsdStr(remainingByTotal)} USD</b></div>
|
||||
<div class="${paused ? "warn" : "ok"}">${paused ? "Покупка временно приостановлена: лимит заполнен." : "Покупка доступна."}</div>
|
||||
`;
|
||||
if (activeEdit === "usd") syncFromUsd();
|
||||
else syncFromSol();
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function buyTicket() {
|
||||
async function buyByUsd() {
|
||||
const out = document.getElementById("buyResult");
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) {
|
||||
await connectWallet();
|
||||
} else if (!provider.isConnected) {
|
||||
await provider.connect();
|
||||
}
|
||||
const { pdas, config, coef, queues } = await loadCoreState();
|
||||
if (queues.q1SumTotal >= coef.limit) {
|
||||
out.innerHTML = `<span class="warn">Пока временно приостановлено: очередь заполнена. После изменения коэффициента/лимита покупка снова заработает.</span>`;
|
||||
return;
|
||||
}
|
||||
const amountLamports = solToLamports(document.getElementById("amountSol").value.trim());
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
const { pdas, config, queues, coef, pyth } = lastState || await loadCoreState();
|
||||
if (queues.q1SumTotal >= coef.limitUsdCents) throw new Error("Покупка временно приостановлена: лимит заполнен.");
|
||||
|
||||
const usdCents = usdTextToCents(document.getElementById("amountUsd").value);
|
||||
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
|
||||
const payLamports = usdCentsToLamportsCeil(usdCents, pyth);
|
||||
const maxPayLamports = applySlippageUp(payLamports, slippage);
|
||||
const recipientRaw = document.getElementById("recipient").value.trim();
|
||||
const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey;
|
||||
|
||||
const nextIndex = queues.q1Total + 1n;
|
||||
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync(
|
||||
[utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)],
|
||||
PROGRAM_ID
|
||||
);
|
||||
const disc = await ixDiscriminator("buy_ticket");
|
||||
const data = concat(disc, u64ToBytes(amountLamports), recipient.toBytes());
|
||||
|
||||
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)], PROGRAM_ID);
|
||||
const disc = await ixDiscriminator("buy_ticket_usd");
|
||||
const data = concat(disc, u64ToBytes(usdCents), u64ToBytes(maxPayLamports), recipient.toBytes());
|
||||
const keys = [
|
||||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||
{ pubkey: pdas.configPda, isSigner: false, isWritable: true },
|
||||
@ -271,6 +364,47 @@
|
||||
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: ticketPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true },
|
||||
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
|
||||
{ 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 refreshState();
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function buyBySol() {
|
||||
const out = document.getElementById("buyResult");
|
||||
out.textContent = "";
|
||||
try {
|
||||
const provider = getProvider();
|
||||
if (!walletPubkey) await connectWallet();
|
||||
else if (!provider.isConnected) await provider.connect();
|
||||
const { pdas, config, queues, coef, pyth } = lastState || await loadCoreState();
|
||||
if (queues.q1SumTotal >= coef.limitUsdCents) throw new Error("Покупка временно приостановлена: лимит заполнен.");
|
||||
|
||||
const lamports = solTextToLamports(document.getElementById("amountSol").value);
|
||||
const slippage = Math.max(0, Number(document.getElementById("slippagePct").value.trim() || "0"));
|
||||
const usdCents = lamportsToUsdCentsFloor(lamports, pyth);
|
||||
const minUsdCents = applySlippageDown(usdCents, slippage);
|
||||
const recipientRaw = document.getElementById("recipient").value.trim();
|
||||
const recipient = recipientRaw ? new solanaWeb3.PublicKey(recipientRaw) : walletPubkey;
|
||||
|
||||
const nextIndex = queues.q1Total + 1n;
|
||||
const [ticketPda] = solanaWeb3.PublicKey.findProgramAddressSync([utf8(SEEDS.ticketQ1), u64ToBytes(nextIndex)], PROGRAM_ID);
|
||||
const disc = await ixDiscriminator("buy_ticket_sol");
|
||||
const data = concat(disc, u64ToBytes(lamports), u64ToBytes(minUsdCents), recipient.toBytes());
|
||||
const keys = [
|
||||
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
|
||||
{ pubkey: pdas.configPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: pdas.coefPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: pdas.queuesPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: ticketPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: new solanaWeb3.PublicKey(config.dao), isSigner: false, isWritable: true },
|
||||
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
|
||||
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
|
||||
];
|
||||
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
|
||||
@ -284,7 +418,11 @@
|
||||
|
||||
document.getElementById("connectBtn").addEventListener("click", connectWallet);
|
||||
document.getElementById("refreshBtn").addEventListener("click", refreshState);
|
||||
document.getElementById("buyBtn").addEventListener("click", buyTicket);
|
||||
document.getElementById("buyUsdBtn").addEventListener("click", buyByUsd);
|
||||
document.getElementById("buySolBtn").addEventListener("click", buyBySol);
|
||||
document.getElementById("amountUsd").addEventListener("input", () => { activeEdit = "usd"; syncFromUsd(); });
|
||||
document.getElementById("amountSol").addEventListener("input", () => { activeEdit = "sol"; syncFromSol(); });
|
||||
document.getElementById("slippagePct").addEventListener("input", renderQuote);
|
||||
refreshState();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -44,8 +44,9 @@
|
||||
|
||||
<div class="panel">
|
||||
<div class="warn">
|
||||
Пока реального DAO-голосования нет: роль DAO выполняет обычный кошелек.<br />
|
||||
Позже это заменяется на вызов из DAO-казначейства/голосования.
|
||||
Пока реального DAO-голосования нет: роль DAO выполняет тестовый кошелек
|
||||
<code>FUc28vNixp7F3nnkpGVt6nuJbgvJ4429v4B5wS52Df6P</code>.<br />
|
||||
Позже это заменяется на вызов из настоящего DAO-казначейства/голосования.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -62,8 +63,8 @@
|
||||
<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>
|
||||
<label>Добавить лимит Q1 (USD): <input id="addQ1" value="100" /></label>
|
||||
<label>Добавить лимит Q2 (USD): <input id="addQ2" value="50" /></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="grantBtn">Выдать лимиты</button>
|
||||
@ -86,8 +87,8 @@
|
||||
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_p_v2_manager_allow",
|
||||
config: "shine_payments_v3_config",
|
||||
managerAllowance: "shine_p_v3_manager_allow",
|
||||
};
|
||||
let walletPubkey = null;
|
||||
let configCache = null;
|
||||
@ -115,13 +116,13 @@
|
||||
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 centsToUsdStr(c) {
|
||||
return trimZeros((Number(c) / 100).toFixed(2));
|
||||
}
|
||||
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));
|
||||
function usdToCents(usdStr) {
|
||||
const v = Number(usdStr.replace(",", "."));
|
||||
if (!Number.isFinite(v) || v < 0) throw new Error("Некорректная сумма USD");
|
||||
return BigInt(Math.round(v * 100));
|
||||
}
|
||||
async function ixDiscriminator(name) {
|
||||
const msg = utf8("global:" + name);
|
||||
@ -214,8 +215,8 @@
|
||||
|
||||
const { configPda } = 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());
|
||||
const addQ1 = usdToCents(document.getElementById("addQ1").value.trim());
|
||||
const addQ2 = usdToCents(document.getElementById("addQ2").value.trim());
|
||||
if (addQ1 === 0n && addQ2 === 0n) throw new Error("Нужно указать сумму хотя бы для одной очереди.");
|
||||
|
||||
const allowancePda = deriveManagerAllowancePda(manager);
|
||||
@ -256,8 +257,8 @@
|
||||
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>
|
||||
<div>Доступно Q1: <b>${centsToUsdStr(st.q1)} USD</b></div>
|
||||
<div>Доступно Q2: <b>${centsToUsdStr(st.q2)} USD</b></div>
|
||||
`;
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
|
||||
@ -43,27 +43,27 @@
|
||||
|
||||
<a class="card" href="./buy_ticket.html">
|
||||
<h3>Покупка билета</h3>
|
||||
<div class="muted">Создание нового билета в 1-й очереди.</div>
|
||||
<div class="muted">Создание нового билета в 1-й очереди: ввод в USD или SOL, хранение в USD-центах.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./track_ticket.html">
|
||||
<h3>Отслеживание билета</h3>
|
||||
<div class="muted">Проверка позиции, статуса и шаг выплат по 1-й/2-й очереди.</div>
|
||||
<div class="muted">Проверка позиции, статуса и шага выплат с SOL/USD курсом Pyth.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./admin_tools.html">
|
||||
<h3>Тех. инструменты</h3>
|
||||
<div class="muted">Init, просмотр всех билетов в обеих очередях, коэффициент/лимит/награда, агрегаты.</div>
|
||||
<div class="muted">Init, просмотр всех билетов, коэффициент/лимит в USD, награда шага в SOL.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./dao_tools.html">
|
||||
<h3>DAO-права менеджеров</h3>
|
||||
<div class="muted">Выдача лимитов менеджерам на добавление билетов в очередь 1/2.</div>
|
||||
<div class="muted">Выдача лимитов менеджерам в USD для добавления билетов в очередь 1/2.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./manager_tools.html">
|
||||
<h3>Инструменты менеджера</h3>
|
||||
<div class="muted">Показ лимитов менеджера и создание билетов в очередь 1/2.</div>
|
||||
<div class="muted">Показ лимитов менеджера и создание билетов в очередь 1/2 в USD.</div>
|
||||
</a>
|
||||
|
||||
<a class="card" href="./logic_overview.html">
|
||||
|
||||
@ -27,8 +27,8 @@
|
||||
<div class="topbar"><a class="back" href="./index.html">← На главную</a></div>
|
||||
<h1>Логика работы Shine Payments (тестовый этап)</h1>
|
||||
<div class="panel">
|
||||
<p>Сейчас система работает в <b>Devnet</b>. Все суммы на этом этапе в SOL/lamports.</p>
|
||||
<p>В следующей версии расчёт будет считаться в <b>USDT по курсу</b> (переход на курсовую модель).</p>
|
||||
<p>Система работает в <b>Devnet</b>. Экономика хранится в <b>USD-центах</b>, а реальные переводы происходят в SOL.</p>
|
||||
<p>Курс SOL/USD берётся из Pyth прямо в контракте при покупке и при шаге выплаты. Цена проверяется на актуальность (не старше 120 секунд).</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
@ -38,7 +38,7 @@
|
||||
|
||||
<div class="panel">
|
||||
<h3>2. Покупка билета</h3>
|
||||
<p>Обычная покупка создаёт билет только в очереди 1. Сумма покупки идёт в DAO, а сумма билета рассчитывается как <code>input * coef</code>.</p>
|
||||
<p>Обычная покупка создаёт билет только в очереди 1. Пользователь может ввести сумму в USD или SOL на UI. В контракте сумма переводится по курсу в USD-центы, а выплата билета рассчитывается как <code>purchase_usd_cents * coef</code>.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
@ -49,6 +49,7 @@
|
||||
<div class="panel">
|
||||
<h3>4. Порядок выплат</h3>
|
||||
<p>Приоритет всегда у очереди 1. Если в очереди 1 нет невыплаченных билетов, идут выплаты очереди 2. Если в процессе выплат очереди 2 снова появляется билет в очереди 1, приоритет возвращается к очереди 1.</p>
|
||||
<p>Шаг выплаты: для очереди 1 в DAO уходит 1x от выплаты тикета, для очереди 2 в DAO уходит 2x от выплаты тикета. Дополнительно вызывающий получает награду в SOL.</p>
|
||||
<p>Если обе очереди пустые/выплачены — доступный остаток inflow-вольта переводится в DAO (без награды вызывающему).</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<label>Получатель: <input id="recipient" placeholder="Base58 адрес" /></label>
|
||||
<label>Сумма выплаты (SOL): <input id="payoutSol" value="0.5" /></label>
|
||||
<label>Сумма выплаты (USD): <input id="payoutUsd" value="50" /></label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="createBtn">Создать билет</button>
|
||||
@ -80,10 +80,10 @@
|
||||
const RPC_URL = "https://api.devnet.solana.com";
|
||||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||
const SEEDS = {
|
||||
managerAllowance: "shine_p_v2_manager_allow",
|
||||
queues: "shine_payments_v2_queues",
|
||||
ticketQ1: "shine_payments_v2_q1_ticket",
|
||||
ticketQ2: "shine_payments_v2_q2_ticket",
|
||||
managerAllowance: "shine_p_v3_manager_allow",
|
||||
queues: "shine_payments_v3_queues",
|
||||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||
ticketQ2: "shine_payments_v3_q2_ticket",
|
||||
};
|
||||
let walletPubkey = null;
|
||||
let queuesCache = null;
|
||||
@ -111,13 +111,13 @@
|
||||
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 centsToUsdStr(c) {
|
||||
return trimZeros((Number(c) / 100).toFixed(2));
|
||||
}
|
||||
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));
|
||||
function usdToCents(usdStr) {
|
||||
const v = Number(usdStr.replace(",", "."));
|
||||
if (!Number.isFinite(v) || v <= 0) throw new Error("Некорректная сумма USD");
|
||||
return BigInt(Math.round(v * 100));
|
||||
}
|
||||
async function ixDiscriminator(name) {
|
||||
const msg = utf8("global:" + name);
|
||||
@ -221,8 +221,8 @@
|
||||
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>
|
||||
<div>Доступно Q1: <b>${centsToUsdStr(core.allowance.q1)} USD</b></div>
|
||||
<div>Доступно Q2: <b>${centsToUsdStr(core.allowance.q2)} USD</b></div>
|
||||
`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<span class="err">${String(e.message || e)}</span>`;
|
||||
@ -243,7 +243,7 @@
|
||||
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 payout = usdToCents(document.getElementById("payoutUsd").value.trim());
|
||||
|
||||
const nextIndex = queueId === 1 ? (core.queues.q1Total + 1n) : (core.queues.q2Total + 1n);
|
||||
const ticketPda = deriveTicketPda(queueId, nextIndex);
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
<ol>
|
||||
<li>Заменить обычный DAO-кошелёк на DAO-казначейство/голосование.</li>
|
||||
<li>DAO-решения (выдача лимитов менеджерам, изменение параметров) проводить через голосование.</li>
|
||||
<li>Переход с SOL-сумм на модель расчёта в USDT по курсу.</li>
|
||||
<li>Зафиксировать production-источник цены (oracle governance, fallback-политика, мониторинг stale-данных).</li>
|
||||
<li>Ограничить тестовые ключи и закрыть доступ к приватным данным.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@ -28,12 +28,12 @@
|
||||
<h3>Вариант А: один кошелёк</h3>
|
||||
<ol>
|
||||
<li>Открыть <code>admin_tools</code>, выполнить <code>init</code>.</li>
|
||||
<li>Открыть <code>buy_ticket</code>, купить несколько билетов.</li>
|
||||
<li>Открыть <code>buy_ticket</code>, купить несколько билетов (часть через USD, часть через SOL).</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>
|
||||
<li>Проверить, что в шагах: Q1 = ticket + DAO(1x) + reward, Q2 = ticket + DAO(2x) + reward.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
|
||||
@ -69,6 +69,8 @@
|
||||
<button id="stepBtn">Сделать шаг выплат</button>
|
||||
</div>
|
||||
<div id="stepResult" class="muted"></div>
|
||||
<div class="warn">Вызывающий шаг выплат платит сетевую комиссию транзакции и получает on-chain награду. Идея в том, что награда делает вызов экономически выгодным, поэтому всегда есть мотивация нажимать кнопку шага выплат.</div>
|
||||
<div class="muted">Автоматического таймера в контракте нет: в Solana любая инструкция должна быть инициирована внешним вызовом.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -76,15 +78,18 @@
|
||||
<script>
|
||||
const PROGRAM_ID = new solanaWeb3.PublicKey("m48pWRGWrMj3TEHjuU4zsp5Gju4e7ZaPovk8RcVt7kR");
|
||||
const RPC_URL = "https://api.devnet.solana.com";
|
||||
const ORACLE_ACCOUNT = new solanaWeb3.PublicKey("7UVimffxr9ow1uXYxsr4LHAcV58mLzhmwaeKvJ1pjLiE");
|
||||
const connection = new solanaWeb3.Connection(RPC_URL, "confirmed");
|
||||
|
||||
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",
|
||||
config: "shine_payments_v3_config",
|
||||
coef: "shine_payments_v3_coef_limit",
|
||||
queues: "shine_payments_v3_queues",
|
||||
ticketQ1: "shine_payments_v3_q1_ticket",
|
||||
ticketQ2: "shine_payments_v3_q2_ticket",
|
||||
};
|
||||
|
||||
const LAMPORTS_PER_SOL = 1_000_000_000n;
|
||||
let walletPubkey = null;
|
||||
let cachedCore = null;
|
||||
document.getElementById("programId").textContent = PROGRAM_ID.toBase58();
|
||||
@ -101,6 +106,16 @@
|
||||
for (let i = 0; i < 8; i++) x |= BigInt(data[offset + i]) << (8n * BigInt(i));
|
||||
return x;
|
||||
}
|
||||
function readI32(data, offset) {
|
||||
let x = Number(readU64(data, offset) & 0xffffffffn);
|
||||
if (x > 0x7fffffff) x -= 0x100000000;
|
||||
return x;
|
||||
}
|
||||
function readI64(data, offset) {
|
||||
let x = readU64(data, offset);
|
||||
if (x > 0x7fffffffffffffffn) x -= 0x10000000000000000n;
|
||||
return x;
|
||||
}
|
||||
function concat(...parts) {
|
||||
const len = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(len);
|
||||
@ -114,6 +129,9 @@
|
||||
function lamportsToSolStr(l) {
|
||||
return trimZeros((Number(l) / 1_000_000_000).toFixed(9));
|
||||
}
|
||||
function centsToUsdStr(c) {
|
||||
return trimZeros((Number(c) / 100).toFixed(2));
|
||||
}
|
||||
async function ixDiscriminator(name) {
|
||||
const msg = utf8("global:" + name);
|
||||
const hash = await crypto.subtle.digest("SHA-256", msg);
|
||||
@ -123,6 +141,24 @@
|
||||
const s = String(msg || "").toLowerCase();
|
||||
return s.includes("notenoughinflowforstep") || s.includes("0x177a");
|
||||
}
|
||||
function parsePythPriceUpdateV2(data) {
|
||||
const price = readI64(data, 74);
|
||||
const exponent = readI32(data, 90);
|
||||
const publishTime = readI64(data, 94);
|
||||
if (price <= 0n) throw new Error("Оракул вернул некорректную цену");
|
||||
let num = price * 100n;
|
||||
let den = 1n;
|
||||
if (exponent >= 0) num *= 10n ** BigInt(exponent);
|
||||
else den *= 10n ** BigInt(-exponent);
|
||||
return { num, den, publishTime };
|
||||
}
|
||||
function usdCentsToLamportsCeil(usdCents, px) {
|
||||
const n = usdCents * LAMPORTS_PER_SOL * px.den;
|
||||
return (n + px.num - 1n) / px.num;
|
||||
}
|
||||
function usdCentsToSolStr(usdCents, px) {
|
||||
return lamportsToSolStr(usdCentsToLamportsCeil(usdCents, px));
|
||||
}
|
||||
|
||||
function parseConfig(data) {
|
||||
let o = 0;
|
||||
@ -136,9 +172,9 @@
|
||||
let o = 0;
|
||||
const version = data[o++];
|
||||
const coefPpm = readU64(data, o); o += 8;
|
||||
const limit = readU64(data, o); o += 8;
|
||||
const limitUsdCents = readU64(data, o); o += 8;
|
||||
const reward = readU64(data, o); o += 8;
|
||||
return { version, coefPpm, limit, reward };
|
||||
return { version, coefPpm, limitUsdCents, reward };
|
||||
}
|
||||
function parseQueues(data) {
|
||||
let o = 0;
|
||||
@ -160,9 +196,9 @@
|
||||
const index = readU64(data, o); o += 8;
|
||||
const isPaid = data[o++] === 1;
|
||||
const recipient = new solanaWeb3.PublicKey(data.slice(o, o + 32)); o += 32;
|
||||
const payout = readU64(data, o); o += 8;
|
||||
const debtBefore = readU64(data, o); o += 8;
|
||||
return { version, queueId, index, isPaid, recipient, payout, debtBefore };
|
||||
const payoutUsdCents = readU64(data, o); o += 8;
|
||||
const debtBeforeUsdCents = readU64(data, o); o += 8;
|
||||
return { version, queueId, index, isPaid, recipient, payoutUsdCents, debtBeforeUsdCents };
|
||||
}
|
||||
|
||||
function getProvider() {
|
||||
@ -203,20 +239,23 @@
|
||||
|
||||
async function loadCoreState() {
|
||||
const pdas = deriveCorePdas();
|
||||
const [cfgAi, coefAi, qAi] = await Promise.all([
|
||||
const [cfgAi, coefAi, qAi, oracleAi] = await Promise.all([
|
||||
connection.getAccountInfo(pdas.configPda, "confirmed"),
|
||||
connection.getAccountInfo(pdas.coefPda, "confirmed"),
|
||||
connection.getAccountInfo(pdas.queuesPda, "confirmed"),
|
||||
connection.getAccountInfo(ORACLE_ACCOUNT, "confirmed"),
|
||||
]);
|
||||
if (!cfgAi || !coefAi || !qAi) throw new Error("PDA не инициализированы. Запустите init на странице админки.");
|
||||
if (!oracleAi) throw new Error("SOL/USD oracle account не найден");
|
||||
const config = parseConfig(cfgAi.data);
|
||||
const coef = parseCoef(coefAi.data);
|
||||
const queues = parseQueues(qAi.data);
|
||||
const pyth = parsePythPriceUpdateV2(oracleAi.data);
|
||||
const inflowAi = await connection.getAccountInfo(config.inflow, "confirmed");
|
||||
if (!inflowAi) throw new Error("Inflow vault отсутствует");
|
||||
const rentMin = await connection.getMinimumBalanceForRentExemption(inflowAi.data.length, "confirmed");
|
||||
const available = BigInt(Math.max(0, inflowAi.lamports - rentMin));
|
||||
cachedCore = { pdas, config, coef, queues, inflowAi, available };
|
||||
cachedCore = { pdas, config, coef, queues, pyth, available };
|
||||
return cachedCore;
|
||||
}
|
||||
|
||||
@ -233,11 +272,15 @@
|
||||
try {
|
||||
const core = await loadCoreState();
|
||||
const queue = nextStepQueue(core.queues);
|
||||
const pythAge = Math.max(0, Math.floor(Date.now() / 1000 - Number(core.pyth.publishTime)));
|
||||
if (queue === 0) {
|
||||
el.innerHTML = `
|
||||
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(core.pyth.num) / Number(core.pyth.den) / 100).toFixed(6))}</b>, возраст <b>${pythAge} сек</b></div>
|
||||
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
|
||||
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
|
||||
<div>Обе очереди пусты/полностью выплачены.</div>
|
||||
<div>На inflow vault доступно (сверх ренты): <b>${lamportsToSolStr(core.available)} SOL</b></div>
|
||||
<div class="warn">При шаге выплат эта сумма будет переведена в DAO, награда вызывающему не начисляется.</div>
|
||||
<div class="warn">При шаге эта сумма уйдет в DAO, награда не начисляется.</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
@ -249,14 +292,22 @@
|
||||
return;
|
||||
}
|
||||
const next = parseTicket(nextAi.data);
|
||||
const need = next.payout * 2n + core.coef.reward;
|
||||
const ticketLamports = usdCentsToLamportsCeil(next.payoutUsdCents, core.pyth);
|
||||
const daoUsd = queue === 1 ? next.payoutUsdCents : (next.payoutUsdCents * 2n);
|
||||
const daoLamports = usdCentsToLamportsCeil(daoUsd, core.pyth);
|
||||
const need = ticketLamports + daoLamports + core.coef.reward;
|
||||
const missing = core.available >= need ? 0n : (need - core.available);
|
||||
el.innerHTML = `
|
||||
<div>Курс SOL/USD (Pyth): <b>${trimZeros((Number(core.pyth.num) / Number(core.pyth.den) / 100).toFixed(6))}</b>, возраст <b>${pythAge} сек</b></div>
|
||||
<div>DAO: <code>${core.config.dao.toBase58()}</code></div>
|
||||
<div class="muted">Сейчас это тестовый DAO-кошелек. В production здесь будет адрес реального DAO.</div>
|
||||
<div>Следующий шаг выплат: <b>очередь ${queue}</b></div>
|
||||
<div>Следующий тикет: <b>#${next.index.toString()}</b></div>
|
||||
<div>Выплата по тикету: <b>${lamportsToSolStr(next.payout)} SOL</b></div>
|
||||
<div>Тикет: <b>${centsToUsdStr(next.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(next.payoutUsdCents, core.pyth)} SOL)</div>
|
||||
<div>DAO на этом шаге: <b>${centsToUsdStr(daoUsd)} USD</b> (~${lamportsToSolStr(daoLamports)} SOL)</div>
|
||||
<div>Награда за шаг: <b>${lamportsToSolStr(core.coef.reward)} SOL</b></div>
|
||||
<div>Нужно для шага (выплата + DAO + награда): <b>${lamportsToSolStr(need)} SOL</b></div>
|
||||
<div>Нужно для шага: <b>${lamportsToSolStr(need)} SOL</b></div>
|
||||
<div>Формула: <b>${queue === 1 ? "ticket + dao(1x) + reward" : "ticket + dao(2x) + reward"}</b></div>
|
||||
<div>Доступно в inflow vault: <b>${lamportsToSolStr(core.available)} SOL</b></div>
|
||||
<div>${missing === 0n
|
||||
? '<span class="ok">Хватает для шага выплаты.</span>'
|
||||
@ -268,50 +319,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function ticketAnalytics(core, t) {
|
||||
const q1PendingCount = core.queues.q1Total - core.queues.q1Paid;
|
||||
const q1PendingSum = core.queues.q1SumTotal - core.queues.q1SumPaid;
|
||||
|
||||
const initialCountBefore = t.index > 0n ? (t.index - 1n) : 0n;
|
||||
const initialSumBefore = t.debtBefore;
|
||||
|
||||
let currentCountBefore = 0n;
|
||||
let currentSumBefore = 0n;
|
||||
if (!t.isPaid) {
|
||||
if (t.queueId === 1) {
|
||||
currentCountBefore = initialCountBefore > core.queues.q1Paid ? (initialCountBefore - core.queues.q1Paid) : 0n;
|
||||
currentSumBefore = initialSumBefore > core.queues.q1SumPaid ? (initialSumBefore - core.queues.q1SumPaid) : 0n;
|
||||
} else {
|
||||
const ownCountRemain = initialCountBefore > core.queues.q2Paid ? (initialCountBefore - core.queues.q2Paid) : 0n;
|
||||
const ownSumRemain = initialSumBefore > core.queues.q2SumPaid ? (initialSumBefore - core.queues.q2SumPaid) : 0n;
|
||||
currentCountBefore = q1PendingCount + ownCountRemain;
|
||||
currentSumBefore = q1PendingSum + ownSumRemain;
|
||||
}
|
||||
}
|
||||
return { initialCountBefore, initialSumBefore, currentCountBefore, currentSumBefore };
|
||||
}
|
||||
|
||||
function renderTicketCard(core, pda, t) {
|
||||
const q1Pending = core.queues.q1Total - core.queues.q1Paid;
|
||||
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 && q1Pending === 0n && t.index === nextQ2;
|
||||
const missingInsideCurrent = (isCurrentQ1 || isCurrentQ2) && core.available < t.payout ? (t.payout - core.available) : 0n;
|
||||
const a = ticketAnalytics(core, t);
|
||||
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>${a.initialCountBefore.toString()}</b> билетов на <b>${lamportsToSolStr(a.initialSumBefore)} SOL</b></div>
|
||||
<div>Сейчас перед билетом осталось: <b>${a.currentCountBefore.toString()}</b> билетов на <b>${lamportsToSolStr(a.currentSumBefore)} 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>Сумма выплаты: <b>${centsToUsdStr(t.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(t.payoutUsdCents, core.pyth)} SOL)</div>
|
||||
<div>Изначально очередь перед тикетом: <b>${centsToUsdStr(t.debtBeforeUsdCents)} USD</b></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -343,16 +358,13 @@
|
||||
const ai = await connection.getAccountInfo(pda, "confirmed");
|
||||
if (!ai) continue;
|
||||
const t = parseTicket(ai.data);
|
||||
if (t.recipient.toBase58() === recipient.toBase58()) {
|
||||
results.push({ pda, t });
|
||||
}
|
||||
if (t.recipient.toBase58() === recipient.toBase58()) results.push({ pda, t });
|
||||
}
|
||||
}
|
||||
if (results.length === 0) throw new Error("По этому кошельку тикеты не найдены");
|
||||
} else {
|
||||
throw new Error("Введите номер билета или кошелек получателя");
|
||||
}
|
||||
|
||||
out.innerHTML = results.map(({ pda, t }) => renderTicketCard(core, pda, t)).join("");
|
||||
} catch (e) {
|
||||
out.innerHTML = `<div class="err">${String(e.message || e)}</div>`;
|
||||
@ -364,11 +376,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 core = cachedCore || await loadCoreState();
|
||||
const queue = nextStepQueue(core.queues);
|
||||
|
||||
@ -396,6 +406,7 @@
|
||||
{ pubkey: nextTicketPda, isSigner: false, isWritable: true },
|
||||
{ pubkey: recipient, isSigner: false, isWritable: true },
|
||||
{ pubkey: core.config.dao, isSigner: false, isWritable: true },
|
||||
{ pubkey: ORACLE_ACCOUNT, isSigner: false, isWritable: false },
|
||||
];
|
||||
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
|
||||
const sig = await sendInstruction(ix);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user