Add ticket transfer UI and deploy config checklist

This commit is contained in:
AidarKC 2026-05-16 17:15:23 +03:00
parent 8ce804eb77
commit 08c070bd55
11 changed files with 236 additions and 1148 deletions

View File

@ -92,7 +92,13 @@
- если обе очереди пусты/выплачены: - если обе очереди пусты/выплачены:
- переводит весь доступный остаток inflow-вольта в DAO (без reward). - переводит весь доступный остаток inflow-вольта в DAO (без reward).
9. Экономика покупки 9. `change_ticket_recipient`
- текущий `recipient_wallet` тикета может сменить получателя на другой кошелек;
- тикет должен быть невыплаченным;
- смена запрещена, если этот тикет является следующим к выплате в текущем шаге
(с учетом приоритета очереди 1 над очередью 2).
10. Экономика покупки
- сумма покупки идет в DAO; - сумма покупки идет в DAO;
- тикет получает выплату `purchase_usd_cents * coef_ppm / 1_000_000`; - тикет получает выплату `purchase_usd_cents * coef_ppm / 1_000_000`;
- проверка лимита выполняется по `q1_sum_total_usd_cents` (исторически накопленная сумма, без вычета уже выплаченного). - проверка лимита выполняется по `q1_sum_total_usd_cents` (исторически накопленная сумма, без вычета уже выплаченного).

View File

@ -0,0 +1,81 @@
# DEPLOY CONFIG CHECKLIST (Shine Programs)
Документ для подготовки к реальному деплою (mainnet/prod): какие адреса и где заменить.
## 1) Program IDs
1. `shine/programs/shine_payments/src/lib.rs`
- `declare_id!("...")` для `shine_payments`.
2. `shine/programs/shine_users/src/lib.rs`
- `declare_id!("...")` для `shine_users`.
3. `shine/Anchor.toml`
- обновить `programs.devnet` / `programs.localnet` (и при необходимости добавить/обновить секцию под mainnet workflow).
## 2) Shine Payments on-chain settings
Файл: `shine/programs/shine_payments/src/settings.rs`
Обязательные адреса:
1. `DAO_WALLET`
2. `MANAGER_WALLET`
3. `PYTH_SOL_USD_ACCOUNT`
4. `PYTH_SOL_USD_FEED_ID` (идентификатор feed для SOL/USD)
Параметры экономики (по необходимости):
1. `START_COEF_PPM`
2. `START_LIMIT_USD_CENTS`
3. `START_CALL_REWARD_LAMPORTS`
4. `MAX_CALL_REWARD_LAMPORTS`
5. `ORACLE_MAX_AGE_SECS`
## 3) Shine Users on-chain settings
Файл: `shine/programs/shine_users/src/settings.rs`
Обязательные параметры:
1. `REGISTRATION_FEE_RECEIVER` (куда идет комиссия регистрации)
2. `REGISTRATION_FEE_LAMPORTS`
3. при необходимости скорректировать лимитные/бонусные константы:
- `LIMIT_STEP`
- `LAMPORTS_PER_LIMIT_STEP`
- `START_BONUS_LIMIT`
## 4) Web UI constants (hardcoded values)
Проверить и заменить Program ID / Oracle account в HTML:
1. `shine/programs/shine_payments/web/buy_ticket.html`
2. `shine/programs/shine_payments/web/track_ticket.html`
3. `shine/programs/shine_payments/web/admin_tools.html`
4. `shine/programs/shine_payments/web/dao_tools.html`
5. `shine/programs/shine_payments/web/manager_tools.html`
Проверить RPC endpoint для нужной сети в соответствующих страницах.
## 5) Скрипты и окружение
Проверить конфиги и env-файлы, где участвуют адреса:
1. `shine/scripts/**/config.env`
2. `shine/scripts/**/dao.config.env`
3. `shine/scripts/**/governance_token.config.env`
## 6) Проверка перед деплоем
1. `cargo check -p shine_payments`
2. `cargo check -p shine_users`
3. сверить, что `declare_id` совпадает с ключами деплоя программ.
4. убедиться, что `PYTH_SOL_USD_ACCOUNT` читается в выбранной сети.
5. прогнать smoke-тесты UI (buy / track / admin / dao / manager).
## 7) Проверка после деплоя
1. Выполнить `init` для `shine_payments`.
2. Проверить существование PDA:
- `config_pda`
- `coef_limit_pda`
- `queues_pda`
- `inflow_vault_pda`
3. Проверить покупку тикета и шаг выплаты на малой сумме.
4. Проверить `change_ticket_recipient`:
- разрешено для не-next тикета;
- запрещено для next тикета.

View File

@ -500,6 +500,61 @@ pub mod shine_payments {
Ok(()) Ok(())
} }
pub fn change_ticket_recipient(
ctx: Context<ChangeTicketRecipient>,
args: ChangeTicketRecipientArgs,
) -> Result<()> {
let queues = read_state::<QueuesState>(&ctx.accounts.queues_pda)?;
let mut ticket = read_state::<TicketState>(&ctx.accounts.ticket_pda)?;
require!(!ticket.is_paid, PaymentsError::TicketAlreadyPaid);
require_keys_eq!(
ctx.accounts.signer.key(),
ticket.recipient_wallet,
PaymentsError::UnauthorizedTicketOwner
);
let (expected_ticket_pda, _) = find_ticket_pda(ctx.program_id, ticket.queue_id, ticket.index);
require_keys_eq!(
expected_ticket_pda,
ctx.accounts.ticket_pda.key(),
ErrCode::InvalidPdaAddress
);
let q1_pending = queues
.q1_tickets_total
.checked_sub(queues.q1_tickets_paid)
.ok_or(error!(ErrCode::MathOverflow))?;
let q2_pending = queues
.q2_tickets_total
.checked_sub(queues.q2_tickets_paid)
.ok_or(error!(ErrCode::MathOverflow))?;
if q1_pending > 0 || q2_pending > 0 {
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))?
} else {
queues
.q2_tickets_paid
.checked_add(1)
.ok_or(error!(ErrCode::MathOverflow))?
};
require!(
!(ticket.queue_id == target_queue && ticket.index == next_index),
PaymentsError::CannotChangeRecipientForNextPayoutTicket
);
}
ticket.recipient_wallet = args.new_recipient_wallet;
write_state(&ctx.accounts.ticket_pda, &ticket)?;
Ok(())
}
} }
#[derive(Accounts)] #[derive(Accounts)]
@ -619,6 +674,19 @@ pub struct StepPayout<'info> {
pub sol_usd_price_update: Account<'info, PriceUpdateV2>, pub sol_usd_price_update: Account<'info, PriceUpdateV2>,
} }
#[derive(Accounts)]
pub struct ChangeTicketRecipient<'info> {
/// CHECK: подписант-владелец текущего recipient тикета.
#[account(mut, signer)]
pub signer: AccountInfo<'info>,
/// CHECK: PDA очередей, читается вручную.
#[account(mut)]
pub queues_pda: AccountInfo<'info>,
/// CHECK: PDA тикета, читается и валидируется вручную.
#[account(mut)]
pub ticket_pda: AccountInfo<'info>,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct UpdateCoefLimitArgs { pub struct UpdateCoefLimitArgs {
pub coef_ppm: u64, pub coef_ppm: u64,
@ -660,6 +728,11 @@ pub struct ManagerAddTicketArgs {
pub payout_usd_cents: u64, pub payout_usd_cents: u64,
} }
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ChangeTicketRecipientArgs {
pub new_recipient_wallet: Pubkey,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)] #[derive(AnchorSerialize, AnchorDeserialize, Clone, Debug)]
pub struct ConfigState { pub struct ConfigState {
pub version: u8, pub version: u8,
@ -758,6 +831,10 @@ pub enum PaymentsError {
InvalidManagerWallet, InvalidManagerWallet,
#[msg("Лимит менеджера по выбранной очереди превышен")] #[msg("Лимит менеджера по выбранной очереди превышен")]
ManagerLimitExceeded, ManagerLimitExceeded,
#[msg("Только текущий получатель тикета может изменить получателя")]
UnauthorizedTicketOwner,
#[msg("Нельзя менять получателя у следующего тикета на выплату")]
CannotChangeRecipientForNextPayoutTicket,
#[msg("Оракул передан неверный")] #[msg("Оракул передан неверный")]
InvalidOracleAccount, InvalidOracleAccount,
#[msg("Некорректный feed id оракула")] #[msg("Некорректный feed id оракула")]

View File

@ -35,6 +35,7 @@
.err { color: var(--err); white-space: pre-wrap; } .err { color: var(--err); white-space: pre-wrap; }
.paid { color: var(--ok); font-weight: 700; } .paid { color: var(--ok); font-weight: 700; }
.waiting { color: var(--muted); } .waiting { color: var(--muted); }
.xfer { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
code { background: var(--code); padding: 2px 4px; border-radius: 4px; } code { background: var(--code); padding: 2px 4px; border-radius: 4px; }
</style> </style>
</head> </head>
@ -266,6 +267,12 @@
if (q2Pending > 0n) return 2; if (q2Pending > 0n) return 2;
return 0; return 0;
} }
function nextPayoutTicket(queues) {
const queue = nextStepQueue(queues);
if (queue === 0) return null;
const index = queue === 1 ? (queues.q1Paid + 1n) : (queues.q2Paid + 1n);
return { queue, index };
}
async function refreshPayoutInfo() { async function refreshPayoutInfo() {
const el = document.getElementById("payoutInfo"); const el = document.getElementById("payoutInfo");
@ -320,6 +327,17 @@
} }
function renderTicketCard(core, pda, t) { function renderTicketCard(core, pda, t) {
const next = nextPayoutTicket(core.queues);
const isNext = !!next && next.queue === t.queueId && next.index === t.index;
const isOwner = walletPubkey && walletPubkey.toBase58() === t.recipient.toBase58();
const canTransfer = !t.isPaid && isOwner && !isNext;
const whyBlocked = t.isPaid
? "Тикет уже выплачен"
: !isOwner
? "Передача доступна только текущему получателю тикета"
: isNext
? "Это следующий тикет на выплату, передача заблокирована"
: "";
return ` return `
<div class="panel"> <div class="panel">
<div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div> <div>Тикет #<b>${t.index.toString()}</b> (очередь <b>${t.queueId}</b>) (<span class="${t.isPaid ? "paid" : "waiting"}">${t.isPaid ? "выплачен" : "ожидание"}</span>)</div>
@ -327,10 +345,55 @@
<div>Получатель: <code>${t.recipient.toBase58()}</code></div> <div>Получатель: <code>${t.recipient.toBase58()}</code></div>
<div>Сумма выплаты: <b>${centsToUsdStr(t.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(t.payoutUsdCents, core.pyth)} SOL)</div> <div>Сумма выплаты: <b>${centsToUsdStr(t.payoutUsdCents)} USD</b> (~${usdCentsToSolStr(t.payoutUsdCents, core.pyth)} SOL)</div>
<div>Изначально очередь перед тикетом: <b>${centsToUsdStr(t.debtBeforeUsdCents)} USD</b></div> <div>Изначально очередь перед тикетом: <b>${centsToUsdStr(t.debtBeforeUsdCents)} USD</b></div>
<div class="xfer">
<div><b>Передача билета</b></div>
<div class="row">
<input id="newRecipient_${t.queueId}_${t.index.toString()}" placeholder="Новый получатель (Base58)" />
<button
class="transferBtn"
data-queue="${t.queueId}"
data-index="${t.index.toString()}"
data-pda="${pda.toBase58()}"
${canTransfer ? "" : "disabled"}
>Передать</button>
</div>
<div id="transferResult_${t.queueId}_${t.index.toString()}" class="${canTransfer ? "muted" : "warn"}">${canTransfer ? "Доступно для текущего владельца тикета." : whyBlocked}</div>
</div>
</div> </div>
`; `;
} }
async function changeTicketRecipient(queueId, index, ticketPdaBase58) {
const resultEl = document.getElementById(`transferResult_${queueId}_${index}`);
const inputEl = document.getElementById(`newRecipient_${queueId}_${index}`);
resultEl.className = "muted";
resultEl.textContent = "";
try {
if (!walletPubkey) await connectWallet();
const newRecipientRaw = (inputEl.value || "").trim();
if (!newRecipientRaw) throw new Error("Введите адрес нового получателя");
const newRecipient = new solanaWeb3.PublicKey(newRecipientRaw);
const core = cachedCore || await loadCoreState();
const disc = await ixDiscriminator("change_ticket_recipient");
const data = concat(disc, newRecipient.toBytes());
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true },
{ pubkey: core.pdas.queuesPda, isSigner: false, isWritable: true },
{ pubkey: new solanaWeb3.PublicKey(ticketPdaBase58), isSigner: false, isWritable: true },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
const sig = await sendInstruction(ix);
resultEl.className = "ok";
resultEl.innerHTML = `Передача выполнена. Tx: <code>${sig}</code>`;
await refreshAll();
await findTickets();
} catch (e) {
resultEl.className = "err";
resultEl.textContent = String(e.message || e);
}
}
async function findTickets() { async function findTickets() {
const out = document.getElementById("ticketResult"); const out = document.getElementById("ticketResult");
out.textContent = ""; out.textContent = "";
@ -430,6 +493,14 @@
document.getElementById("refreshBtn").addEventListener("click", refreshAll); document.getElementById("refreshBtn").addEventListener("click", refreshAll);
document.getElementById("findBtn").addEventListener("click", findTickets); document.getElementById("findBtn").addEventListener("click", findTickets);
document.getElementById("stepBtn").addEventListener("click", stepPayout); document.getElementById("stepBtn").addEventListener("click", stepPayout);
document.getElementById("ticketResult").addEventListener("click", (e) => {
const btn = e.target.closest(".transferBtn");
if (!btn) return;
const queueId = Number(btn.dataset.queue);
const index = btn.dataset.index;
const pda = btn.dataset.pda;
changeTicketRecipient(queueId, index, pda);
});
refreshAll(); refreshAll();
</script> </script>
</body> </body>

View File

@ -1,33 +0,0 @@
[package]
name = "shine_payments"
version = "0.1.0"
description = "Payments and investments smart contract"
edition = "2021"
[lib]
crate-type = ["cdylib", "lib"]
name = "shine_payments"
test = false
doctest = false
bench = false
[dependencies]
anchor-lang = "0.31.1"
common = { path = "../common" }
# ==== добавлено для NFT-функционала ====
anchor-spl = { version = "0.31.1", features = ["associated_token", "token"] }
mpl-token-metadata = "5.1.1"
spl-token = { version = "4.0.0", features = ["no-entrypoint"] }
# ======================================
[features]
default = []
no-entrypoint = []
no-idl = []
no-log-ix-name = []
anchor-debug = []
custom-heap = []
custom-panic = []
cpi = []
idl-build = ["anchor-lang/idl-build"]

View File

@ -1,6 +0,0 @@
# Важно
Эта папка содержит устаревшую версию `shine_payments`.
- Не использовать для новых доработок.
- Актуальная реализация находится в `programs/shine_payments`.

View File

@ -1,30 +0,0 @@
#!/bin/bash
# Скрипт для копирования dApp на тестовый сервер
# Настройки
LOCAL_FILE="init.html"
REMOTE_USER="aidar"
REMOTE_HOST="shineup.me"
REMOTE_PATH="/home/aidar/Docker_server/site/dApp"
# Проверка, что файл существует
if [ ! -f "$LOCAL_FILE" ]; then
echo "Ошибка: файл $LOCAL_FILE не найден."
exit 1
fi
# Копирование файла
scp "$LOCAL_FILE" "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}"
# Проверка результата
if [ $? -eq 0 ]; then
echo "Файл успешно загружен на сервер."
else
echo "Ошибка при загрузке файла на сервер."
exit 1
fi
#echo
#echo "Нажмите Enter, чтобы закрыть..."
#read

View File

@ -1,404 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta name="robots" content="noindex, nofollow">
<meta charset="UTF-8" />
<title>Shine Payments — Phantom demo (devnet, deep logs)</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 20px; }
h1 { font-size: 18px; margin-bottom: 12px; }
.row { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
button { padding: 8px 12px; border-radius: 8px; border: 1px solid #ccc; cursor: pointer; }
button:hover { background: #f5f5f5; }
#log {
background: #0a0a0a; color: #d1d5db; padding: 12px; border-radius: 10px;
min-height: 220px; max-height: 60vh; overflow: auto; line-height: 1.4; white-space: pre-wrap;
box-shadow: inset 0 0 0 1px #222;
}
.muted { color: #6b7280; }
.ok { color: #86efac; }
.err { color: #fca5a5; }
/* ——— banner с логотипом и предупреждением ——— */
.banner {
display: flex; align-items: center; gap: 14px;
padding: 12px 14px; margin: 12px 0 18px;
border: 1px solid #f1e5a8; border-radius: 12px;
background: #fffbe6; color: #7a5d00;
}
.banner img { width: 44px; height: 44px; border-radius: 8px; flex: 0 0 auto; }
.banner .txt { line-height: 1.35; }
.banner .txt b { font-weight: 700; }
.banner .en { margin-top: 6px; color: #6b7280; font-size: 13px; }
</style>
</head>
<body>
<!-- DEV BANNER (logo + RU/EN notice) -->
<div class="banner" role="note" aria-label="Dev notice">
<img src="./shine_nft_logo_256.png" alt="Shine logo">
<div class="txt">
<div><b>ВНИМАНИЕ:</b> это тестовая страница для внутренней разработки. Я разбираюсь и пишу смарт-контракт и dApp для будущего токена и тестирую его работу в Devnet и подключение кошелька Phantom. Страница не является рабочим продуктом и предназначена только для внутренних тестов.</div>
<div class="en"><b>NOTICE:</b> this is a development test page. Im building a smart contract and dApp for a future token and testing it on Devnet and Phantom wallet connection. This page is not a live product and is intended for internal testing only.</div>
</div>
</div>
<h1>Shine Payments — Phantom wallet (devnet)</h1>
<div class="row">
<button id="btnConnect">Connect Phantom</button>
<button id="btnInfo" disabled>Показать адрес и баланс</button>
<button id="btnAirdrop" disabled>Airdrop 1 SOL (devnet)</button>
<button id="btnInit" disabled>Выполнить init()</button>
<button id="btn-delete-init" disabled>🗑️ Удалить PDA (delete_init)</button>
</div>
<div class="muted">
В Phantom выбери сеть <b>Devnet</b>. Логи ниже и в консоли (F12 → Console).
</div>
<pre id="log"></pre>
<!-- Единственная зависимость: web3.js -->
<script src="https://unpkg.com/@solana/web3.js@1.95.0/lib/index.iife.min.js"></script>
<script>
(async () => {
const logEl = document.getElementById("log");
const autoScroll = () => { logEl.scrollTop = logEl.scrollHeight; };
const log = (...a) => { console.log(...a); logEl.textContent += a.join(" ") + "\n"; autoScroll(); };
const logOk = (...a) => log('%c' + a.join(" "), 'color:#86efac');
const logErr = (...a) => log('%c' + a.join(" "), 'color:#fca5a5');
// ===== ПАРАМЕТРЫ ПРОЕКТА =====
const RPC_URL = "https://api.devnet.solana.com";
const PROGRAM_ID = new solanaWeb3.PublicKey("92sgkgx7KHpbhQu81mNGHaKa7skJB7esArVdPM7paDSW");
const STATE_SEED = "shine_investments_state";
// Лучше "processed" для симуляций + "confirmed" для подтверждений
const connection = new solanaWeb3.Connection(RPC_URL, { commitment: "confirmed" });
const enc = new TextEncoder();
let provider = null; // Phantom provider (window.solana)
let walletPubkey = null; // PublicKey пользователя из Phantom
let logsSubId = null; // id подписки на логи программы
// ===== УТИЛИТЫ ОШИБОК/ЛОГОВ =====
function safeJson(v) {
try { return JSON.stringify(v, null, 2); } catch { return String(v); }
}
function printRpcError(prefix, e) {
// Структура ошибок Solana/Anchor часто лежит в e, e.message, e.data, e.logs, e.code
logErr(prefix);
if (!e) return;
if (e.message) logErr("message:", e.message);
if (e.code !== undefined) logErr("code:", e.code);
if (e.name) logErr("name:", e.name);
// web3.js/JSON-RPC иногда кладёт это сюда:
if (e.data) {
if (e.data.logs) {
logErr("logs:");
(e.data.logs || []).forEach(l => logErr(" " + l));
}
if (e.data.err) {
logErr("rpc err:", safeJson(e.data.err));
}
}
// Некоторые кошельки/обёртки кладут логи прямо в e.logs
if (e.logs) {
logErr("logs:");
(e.logs || []).forEach(l => logErr(" " + l));
}
// Стек — в конце
if (e.stack) {
logErr("stack:\n" + e.stack);
}
}
async function simulateAndLog(tx) {
// Симуляция перед отправкой — ключ к пониманию, где падает инструкция.
try {
const sim = await connection.simulateTransaction(tx, {
sigVerify: false, // подпись не требуется
commitment: "processed"
});
const v = sim.value;
log("🔎 simulate result — err:", safeJson(v.err));
if (v.logs?.length) {
log("🪵 simulate logs:");
v.logs.forEach(l => log(" " + l));
}
if (v.unitsConsumed !== undefined) {
log("⛽ compute units (simulate):", v.unitsConsumed);
}
return v;
} catch (e) {
printRpcError("❌ Ошибка simulateTransaction:", e);
return null;
}
}
async function confirmAndLog(signature, blockhashCtx) {
try {
const { blockhash, lastValidBlockHeight } = blockhashCtx;
log("🧱 confirm with blockhash/lastValid:", blockhash, "/", lastValidBlockHeight);
const res = await connection.confirmTransaction(
{ signature, blockhash, lastValidBlockHeight },
"confirmed"
);
log("📬 confirmation status:", safeJson(res.value));
return res.value;
} catch (e) {
printRpcError("❌ Ошибка confirmTransaction:", e);
return null;
}
}
async function getSigStatus(signature) {
try {
const st = await connection.getSignatureStatus(signature, { searchTransactionHistory: true });
log("🧾 signature status:", safeJson(st?.value));
return st?.value;
} catch (e) {
printRpcError("❌ Ошибка getSignatureStatus:", e);
}
}
// ===== Anchor discriminator (8 байт) =====
async function anchorDiscriminator8(name) {
const hash = await crypto.subtle.digest("SHA-256", enc.encode("global:" + name));
return new Uint8Array(hash).slice(0, 8);
}
// ===== PDA =====
async function getStatePda() {
const [pda] = await solanaWeb3.PublicKey.findProgramAddress(
[enc.encode(STATE_SEED)],
PROGRAM_ID
);
return pda;
}
// ===== Отправка через Phantom с расширенным логированием =====
async function sendViaPhantom(tx, blockhashCtx) {
// Вариант с signAndSendTransaction вернёт сразу signature,
// но иногда теряются preflight-детали. Мы дополнительно логируем simulate.
// Обязательно: транзакция без подписей, место под Lighthouse-инструкции.
await simulateAndLog(tx); // можно оставить, подпись не требуется
if (!provider.signAndSendTransaction) throw new Error("Phantom не поддерживает signAndSendTransaction");
const {signature} = await provider.signAndSendTransaction(tx);
logOk("✍️ отправлено, сигнатура:", signature);
await confirmAndLog(signature, blockhashCtx);
await getSigStatus(signature);
return signature;
}
function setButtonsEnabled(connected) {
document.getElementById("btnInfo").disabled = !connected;
document.getElementById("btnAirdrop").disabled = !connected;
document.getElementById("btnInit").disabled = !connected;
document.getElementById("btn-delete-init").disabled = !connected;
}
// ===== CONNECT =====
document.getElementById("btnConnect").addEventListener("click", async () => {
try {
if (!window.solana || !window.solana.isPhantom) {
logErr("❌ Phantom не найден. Установи расширение Phantom Wallet.");
return;
}
provider = window.solana;
// Подключаемся ТОЛЬКО по клику пользователя:
log("🔌 Ожидаем подключение Phantom…");
await provider.connect(); // без onlyIfTrusted — это уже явный жест пользователя
walletPubkey = provider.publicKey;
logOk("✅ Подключено:", walletPubkey.toBase58());
setButtonsEnabled(true);
// Подписка на логи программы — помогает увидеть то, что в simulate/confirm могло не попасть
try {
if (logsSubId) {
await connection.removeOnLogsListener(logsSubId);
logsSubId = null;
}
logsSubId = connection.onLogs(PROGRAM_ID, (ev) => {
log("🛰 onLogs:", ev.signature, "err:", safeJson(ev.err));
(ev.logs || []).forEach(l => log(" " + l));
}, "confirmed");
log("📡 Подписка на логи программы включена.");
} catch (e) {
printRpcError("⚠️ Не удалось подписаться на логи программы:", e);
}
provider.on?.("disconnect", async () => {
log("🔌 Отключено");
setButtonsEnabled(false);
walletPubkey = null;
if (logsSubId) {
try { await connection.removeOnLogsListener(logsSubId); } catch {}
logsSubId = null;
}
});
} catch (e) {
printRpcError("❌ Connect error:", e);
}
});
// ===== INFO =====
document.getElementById("btnInfo").addEventListener("click", async () => {
try {
if (!walletPubkey) throw new Error("Сначала подключи Phantom.");
const balanceLamports = await connection.getBalance(walletPubkey, "processed");
const statePda = await getStatePda();
log("👛 Кошелёк:", walletPubkey.toBase58());
log("💰 Баланс:", balanceLamports / solanaWeb3.LAMPORTS_PER_SOL, "SOL");
log("📦 statePda:", statePda.toBase58());
log("🌐 RPC:", RPC_URL);
} catch (e) {
printRpcError("❌ Ошибка INFO:", e);
}
});
// ===== AIRDROP (devnet) =====
document.getElementById("btnAirdrop").addEventListener("click", async () => {
try {
if (!walletPubkey) throw new Error("Сначала подключи Phantom.");
log("⛽ Запрос airdrop 1 SOL на", walletPubkey.toBase58());
const sig = await connection.requestAirdrop(walletPubkey, 1 * solanaWeb3.LAMPORTS_PER_SOL);
log("⏳ confirm airdrop…");
const { value: ctx } = await connection.getLatestBlockhashAndContext("processed");
await confirmAndLog(sig, ctx);
logOk("✅ Airdrop tx:", sig);
const bal = await connection.getBalance(walletPubkey);
log("💰 Новый баланс:", bal / solanaWeb3.LAMPORTS_PER_SOL, "SOL");
} catch (e) {
printRpcError("❌ Ошибка airdrop:", e);
}
});
// ===== INIT() =====
document.getElementById("btnInit").addEventListener("click", async () => {
try {
if (!walletPubkey) throw new Error("Сначала подключи Phantom.");
const statePda = await getStatePda();
log("🚀 Вызываем init()");
log(" payer: ", walletPubkey.toBase58());
log(" statePda: ", statePda.toBase58());
log(" programId:", PROGRAM_ID.toBase58());
// 8 байт дискриминатора Anchor для "global:init"
const data = await anchorDiscriminator8("init"); // Uint8Array длиной 8
// Аккаунты в том порядке, который ожидает on-chain метод
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true }, // payer (signer)
{ pubkey: statePda, isSigner: false, isWritable: true }, // state PDA
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({ programId: PROGRAM_ID, keys, data });
// Получаем блокхеш/lastValidBlockHeight и логируем
const { value: ctx } = await connection.getLatestBlockhashAndContext("processed");
const { blockhash, lastValidBlockHeight } = ctx;
log("⏱ blockhash:", blockhash);
log("⏱ lastValidBlockHeight:", lastValidBlockHeight);
const tx = new solanaWeb3.Transaction({
feePayer: walletPubkey,
recentBlockhash: blockhash,
}).add(ix);
// 1) Симуляция — сразу покажет логи Anchor/InstructionError
await simulateAndLog(tx);
log("📝 Подписываем в Phantom…");
// 2) Отправка + подтверждение с расширенными логами
const sig = await sendViaPhantom(tx, { blockhash, lastValidBlockHeight });
logOk("✅ Готово! tx:", sig);
log("🔗 Explorer:", `https://explorer.solana.com/tx/${sig}?cluster=devnet`);
} catch (e) {
// Печатаем максимально подробно
printRpcError("❌ Ошибка init:", e);
}
});
// ===== DELETE INIT =====
document.getElementById("btn-delete-init").addEventListener("click", async () => {
try {
// 1) Проверка подключения
if (!walletPubkey) throw new Error("Сначала подключи Phantom.");
// 2) PDA — те же сиды, что и в init
const statePda = await getStatePda();
log("🗑️ Вызываем delete_init()");
log(" signer: ", walletPubkey.toBase58());
log(" statePda: ", statePda.toBase58());
log(" programId:", PROGRAM_ID.toBase58());
// 3) Дискриминатор Anchor для "global:delete_init"
const data = await anchorDiscriminator8("delete_init"); // 8 байт
// 4) Аккаунты в порядке, который ожидает on-chain метод
const keys = [
{ pubkey: walletPubkey, isSigner: true, isWritable: true }, // signer (получатель ренты)
{ pubkey: statePda, isSigner: false, isWritable: true }, // state_pda
{ pubkey: solanaWeb3.SystemProgram.programId, isSigner: false, isWritable: false },
];
const ix = new solanaWeb3.TransactionInstruction({
programId: PROGRAM_ID,
keys,
data, // только 8 байт дискриминатора, т.к. у метода нет аргументов
});
// 5) Блокхеш, формирование транзакции
const { value: ctx } = await connection.getLatestBlockhashAndContext("processed");
const { blockhash, lastValidBlockHeight } = ctx;
log("⏱ blockhash:", blockhash);
log("⏱ lastValidBlockHeight:", lastValidBlockHeight);
const tx = new solanaWeb3.Transaction({
feePayer: walletPubkey,
recentBlockhash: blockhash,
}).add(ix);
// 6) Симуляция → отправка → подтверждение
await simulateAndLog(tx);
log("📝 Подписываем в Phantom…");
const sig = await sendViaPhantom(tx, { blockhash, lastValidBlockHeight });
logOk("✅ delete_init выполнен. tx:", sig);
log("🔗 Explorer:", `https://explorer.solana.com/tx/${sig}?cluster=devnet`);
alert(`PDA удалён, рента возвращена подписанту.\nTx: ${sig}`);
} catch (e) {
printRpcError("❌ Ошибка delete_init:", e);
alert(`Ошибка delete_init: ${e.message || e}`);
}
});
// Больше НИКАКИХ автоконнектов на загрузке страницы.
if (window.solana?.isPhantom) {
provider = window.solana;
log(" Phantom найден. Нажми «Connect Phantom», чтобы подключиться.");
} else {
log(" Установи Phantom Wallet: https://phantom.app");
}
}
)();
</script>
</body>
</html>

View File

@ -1,326 +0,0 @@
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke_signed, system_instruction};
use common::utils::*; // тянем общие PDA-хелперы из programs/common
// === добавлено: используем наш NFT-модуль ===
use crate::nft::{CreateNftParams, create_nft_with_freeze};
// ============================================
/// Утилита чтения структуры из PDA: читает байты и десериализует.
/// Возвращает ошибку, если данных нет/пустые/неверный формат.
fn read_state_from_pda(pda: &AccountInfo) -> Result<InvestState> {
let raw = safe_read_pda(pda); // ← берём Vec<u8> (или пустой)
require!(!raw.is_empty(), ErrCode::EmptyPdaData); // ← пусто — ошибка
let st = deserialize_invest_state(&raw)?; // ← десериализуем по формату
require!(st.format == INVEST_STATE_FORMAT_V1, ErrCode::UnsupportedFormat); // ← проверяем версию
Ok(st)
}
/// Утилита записи структуры в PDA: сериализует и пишет.
/// Важно: сам аккаунт уже должен существовать и быть #[account(mut)].
fn write_state_to_pda(pda: &AccountInfo, s: &InvestState) -> Result<()> {
let raw = serialize_invest_state_v1(s); // ← 24 байта
write_to_pda(pda, &raw) // ← записываем в начало data
}
/// ==============================================
/// Контексты инструкций (минимально необходимые)
/// ==============================================
/// init: создаём PDA и кладём в него PayStateV1 {format=1, coef=10, ...0}
#[derive(Accounts)]
pub struct Init<'info> {
/// Плательщик аренды за PDA; подписант транзакции.
#[account(mut)]
pub payer: Signer<'info>,
/// Наш PDA (с произвольным типом, чтобы работать через AccountInfo).
/// Проверку адреса делаем в handler (по seed + bump), чтобы избежать подмены.
/// CHECK: проверяется вручную по адресу
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
/// Системная программа.
pub system_program: Program<'info, System>,
}
/// Общие аккаунты для invest/add_bonus/claim:
/// Везде просто читаем/пишем одно и то же состояние из того же PDA.
#[derive(Accounts)]
pub struct UseState<'info> {
/// Любой платящий/подписант (в реальном коде — свои проверки).
pub signer: Signer<'info>,
/// Тот же PDA с состоянием (должен уже существовать).
/// CHECK: проверяется вручную по адресу
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
/// Системная программа (на всякий случай; может не понадобиться).
pub system_program: Program<'info, System>,
}
/// ==============================================
/// Программа
/// ==============================================
use super::*;
use anchor_lang::prelude::*;
/// ------------------------------------------
/// init: создаёт PDA и записывает в него дефолтное состояние.
/// format = 1, coef = 10, остальные поля = 0.
/// ------------------------------------------
pub fn init(ctx: Context<Init>) -> Result<()> {
let program_id = ctx.program_id; // ← адрес этой программы
// 1. Проверка что вызывает именно разрешённый ключ
/* todo пока все могут вызыватьно !! но в итоге будет добавленна проверка что бы только дао могло вызвать эту функцию один раз
require_keys_eq!(
ctx.accounts.payer.key(),
ALLOWED_INIT_CALLER,
ErrCode::InvalidSigner
);
*/
// 2. Проверка что PDA ещё не создан
if ctx.accounts.state_pda.data_len() > 0 && ctx.accounts.state_pda.owner != &System::id() {
return Err(error!(ErrCode::PdaAlreadyExists));
}
// 2. Ещё раз Проверка что PDA ещё не создан
if ctx.accounts.state_pda.owner != &System::id()
|| ctx.accounts.state_pda.lamports() > 0
{
// Если аккаунт уже создан и не пустой
return Err(error!(ErrCode::PdaAlreadyExists));
}
let pda_key_expected = Pubkey::find_program_address(&[crate::PDA_SEED_PREFIX], program_id).0; // ← вычисляем PDA
require_keys_eq!(
pda_key_expected,
ctx.accounts.state_pda.key(),
ErrCode::InvalidPdaAddress
); // ← убеждаемся, что нам подали именно правильный PDA
// Конструируем дефолтную структуру состояния.
let state = InvestState {
format: INVEST_STATE_FORMAT_V1, // ← 1
coef: crate::DEFAULT_COEF, // ← 10
q1_tokens: 0, // ← нули
sum1_bonus: 0,
q1_paid_tokens: 0,
sum1_paid_bonus: 0,
};
// Сериализуем в 24 байта.
let data = serialize_invest_state_v1(&state);
// Для подписи PDA нужен bump; здесь получим (ключ, bump).
let (_pda_key, bump) = Pubkey::find_program_address(&[crate::PDA_SEED_PREFIX], program_id);
// Сиды для invoke_signed: [seed, bump]
let seeds: [&[u8]; 2] = [crate::PDA_SEED_PREFIX, &[bump]];
// Создаём и сразу записываем, арендный минимум оплачивает payer.
create_and_write_pda(
&ctx.accounts.state_pda.to_account_info(), // куда пишем
&ctx.accounts.payer.to_account_info(), // кто платит
&ctx.accounts.system_program.to_account_info(),
program_id,
&seeds,
data,
crate::PAY_STATE_SPACE, // резерв с запасом
)?;
Ok(())
}
/// ------------------------------------------
/// invest: «внос инвестиций».
/// По заданию: в начале читаем состояние, в конце сохраняем.
/// (Здесь логика модификации не задана — оставляем как заглушку.)
/// ------------------------------------------
pub fn invest(ctx: Context<UseState>, _amount: u64) -> Result<()> {
// 1) читаем
let mut st = read_state_from_pda(&ctx.accounts.state_pda.to_account_info())?; // ← PayStateV1
// --- тут можно модифицировать st по твоей бизнес-логике ---
// Например, ничего не меняем сейчас (заглушка).
let _ = &mut st; // чтоб компилятор не ругался, если пока не используем
// 2) сохраняем
write_state_to_pda(&ctx.accounts.state_pda.to_account_info(), &st)?;
Ok(())
}
/// ------------------------------------------
/// add_bonus: «начисление бонусов» (обычно вызывать от DAO).
/// По заданию: читаем в начале, создаём/добавляем NFT в очередь, сохраняем в конце.
/// Для операций с NFT используем расширенный контекст AddBonusCtx (см. lib.rs).
/// ------------------------------------------
pub fn add_bonus(ctx: Context<crate::AddBonusCtx>, investor: Pubkey, amount: u64) -> Result<()> {
// 1) читаем состояние
let mut st = read_state_from_pda(&ctx.accounts.state_pda.to_account_info())?;
// 2) создаём/добавляем NFT через модуль nft (создание metadata, mint 1, freeze, master edition, verify)
let next_index = st.q1_tokens as u64 + 1;
let params = CreateNftParams {
name: format!("Bonus #{}", next_index),
symbol: "BN".to_string(),
uri: "https://example.com/nft.json".to_string(), // заглушка для devnet-теста
index: next_index,
recipient: investor,
};
// ВАЖНО: mint_pda должен быть создан ТЕСТОМ заранее с decimals=0, mint_authority=signer, freeze_authority=signer.
create_nft_with_freeze(&ctx, params)?;
// 3) обновляем агрегаты очереди (минимально: увеличим счётчик и сумму бонусов)
st.q1_tokens = st.q1_tokens.saturating_add(1);
let add = u32::try_from(core::cmp::min(amount, u64::from(u32::MAX))).unwrap_or(u32::MAX);
st.sum1_bonus = st.sum1_bonus.saturating_add(add);
// 4) сохраняем
write_state_to_pda(&ctx.accounts.state_pda.to_account_info(), &st)?;
Ok(())
}
/// ------------------------------------------
/// claim: «выплата».
/// По заданию: читаем в начале, сохраняем в конце.
/// ------------------------------------------
pub fn claim(ctx: Context<UseState>) -> Result<()> {
// 1) читаем
let mut st = read_state_from_pda(&ctx.accounts.state_pda.to_account_info())?;
// --- тут твоя логика списаний/выплат ---
let _ = &mut st; // заглушка
// 2) сохраняем
write_state_to_pda(&ctx.accounts.state_pda.to_account_info(), &st)?;
Ok(())
}
//todo
/// ==============================================
/// Коды ошибок (берём из твоего блока; можно расширять)
/// ==============================================
#[error_code]
pub enum ErrCode {
/// Система уже инициализирована и не может быть инициализирована повторно!
#[msg("Система уже инициализирована и не может быть инициализирована повторно!")]
SystemAlreadyInitialized = 1000,
#[msg("PDA не содержит данных или не инициализирован")]
EmptyPdaData = 1002,
#[msg("Пользователь уже зарегистрирован")]
UserAlreadyExists = 1003,
#[msg("Некорректный логин")]
InvalidLogin = 1004,
#[msg("Не совпадает PDA адрес")]
InvalidPdaAddress = 1006,
#[msg("Формат данных не поддерживается")]
UnsupportedFormat = 1011,
#[msg("Ошибка при десериализации")]
DeserializationError = 1012,
/// PDA уже существует, создание невозможно
#[msg("PDA-аккаунт уже существует и не может быть создан повторно.")]
PdaAlreadyExists = 1009,
#[msg("Подписавший не совпадает с ожидаемым пользователем (временное ограничение)")]
InvalidSigner = 1005,
/// Не получилось создать пользователя
#[msg("Не получилось создать пользователя, система уже перегружена, попробуйте позже!")]
NoSuitableIdPda = 1010,
}
use anchor_lang::prelude::*;
/// ================================
/// КОНСТАНТЫ ФОРМАТА / ДЛИНЫ ДАННЫХ
/// ================================
/// Версия формата хранения состояния.
/// Мы жёстко фиксируем «1», чтобы код мог отличать будущие версии.
pub const INVEST_STATE_FORMAT_V1: u32 = 1;
/// Сырые данные состояния V1 занимают ровно 6 * 4 = 24 байта.
pub const INVEST_STATE_RAW_LEN_V1: usize = 24; // байт
/// ================================
/// ОПИСАНИЕ СТРУКТУРЫ СОСТОЯНИЯ (V1)
/// ================================
#[derive(Clone, Copy, Debug, Default)]
pub struct InvestState {
pub format: u32,
pub coef: u32,
pub q1_tokens: u32,
pub sum1_bonus: u32,
pub q1_paid_tokens: u32,
pub sum1_paid_bonus: u32,
}
/// ========================================
/// СЕРИАЛИЗАЦИЯ (структура -> массив байт)
/// ========================================
pub fn serialize_invest_state_v1(s: &InvestState) -> Vec<u8> {
let mut out = Vec::with_capacity(INVEST_STATE_RAW_LEN_V1);
out.extend_from_slice(&INVEST_STATE_FORMAT_V1.to_le_bytes()); // [0..4)
out.extend_from_slice(&s.coef.to_le_bytes()); // [4..8)
out.extend_from_slice(&s.q1_tokens.to_le_bytes()); // [8..12)
out.extend_from_slice(&s.sum1_bonus.to_le_bytes()); // [12..16)
out.extend_from_slice(&s.q1_paid_tokens.to_le_bytes()); // [16..20)
out.extend_from_slice(&s.sum1_paid_bonus.to_le_bytes()); // [20..24)
debug_assert_eq!(out.len(), INVEST_STATE_RAW_LEN_V1);
out
}
/// ===========================================
/// ДЕСЕРИАЛИЗАЦИЯ (массив байт -> структура)
/// ===========================================
pub fn deserialize_invest_state(data: &[u8]) -> Result<InvestState> {
if data.len() < INVEST_STATE_RAW_LEN_V1 {
return Err(error!(ErrCode::DeserializationError));
}
fn read_u32_le(slice: &[u8], start: usize) -> u32 {
let bytes: [u8; 4] = slice[start..start + 4]
.try_into()
.expect("slice has enough length due to pre-check");
u32::from_le_bytes(bytes)
}
let format = read_u32_le(data, 0);
if format != INVEST_STATE_FORMAT_V1 {
return Err(error!(ErrCode::UnsupportedFormat));
}
let coef = read_u32_le(data, 4);
let q1_tokens = read_u32_le(data, 8);
let sum1_bonus = read_u32_le(data, 12);
let q1_paid_tokens = read_u32_le(data, 16);
let sum1_paid_bonus = read_u32_le(data, 20);
Ok(InvestState { format, coef, q1_tokens, sum1_bonus, q1_paid_tokens, sum1_paid_bonus })
}

View File

@ -1,158 +0,0 @@
use anchor_lang::prelude::*;
declare_id!("qpgnAKhsXgPPaqQWfXhpme7UnG8GyStssuoSjF6Fzy3");
/// Подключаем модуль с полной реализацией.
pub mod investments;
use investments::*; // импортируем всё в корень
// === модуль NFT ===
pub mod nft;
// ==============================================
// Константы формата / сидов / размеров
// ==============================================
/// Префикс (seed) для PDA, где храним глобальное состояние выплат.
/// Важно: сид — это просто набор байт; здесь он фиксированный.
pub const PDA_SEED_PREFIX: &[u8] = b"shine_investments_state";
/// Значение коэффициента «по умолчанию» при инициализации.
pub const DEFAULT_COEF: u32 = 10; // ← «коэффициент» = 10 при init
/// Ровно столько байт резервируем под PDA-данные.
/// (Можно добавить запас на будущее, но по заданию — только 28.)
pub const PAY_STATE_SPACE: u64 = 50; // просто сделал с запасом
// ==============================================
// Программа
// ==============================================
#[program]
pub mod shine_payments {
use super::*;
// Явно подтягиваем типы и функции, чтобы не было путаницы после предыдущих ошибок парсера
use crate::investments::{Init, UseState};
use crate::investments::{
add_bonus as inv_add_bonus, claim as inv_claim, init as inv_init, invest as inv_invest,
ErrCode,
};
/// init — создаёт PDA и кладёт дефолтное состояние.
pub fn init(ctx: Context<Init>) -> Result<()> {
inv_init(ctx)
}
/// invest — в начале читает состояние, в конце сохраняет (логика внутри модуля).
pub fn invest(ctx: Context<UseState>, amount: u64) -> Result<()> {
inv_invest(ctx, amount)
}
/// add_bonus — начисление бонусов (обычно от DAO).
/// Для NFT используем расширенный контекст AddBonusCtx (с аккаунтами коллекции и т.п.).
pub fn add_bonus(ctx: Context<AddBonusCtx>, investor: Pubkey, amount: u64) -> Result<()> {
inv_add_bonus(ctx, investor, amount)
}
/// claim — выплата.
pub fn claim(ctx: Context<UseState>) -> Result<()> {
inv_claim(ctx)
}
/// ВРЕМЕННАЯ ФУНКЦИЯ только для тестов (в итоговой версии её не будет):
/// deleteInit — удалить PDA из init и вернуть ренту подписанту.
pub fn delete_init(ctx: Context<DeleteInit>) -> Result<()> {
let program_id = ctx.program_id;
// PDA по тем же сид/бамп, что и в init
let (expected_pda, _bump) = Pubkey::find_program_address(&[PDA_SEED_PREFIX], program_id);
require_keys_eq!(
expected_pda,
ctx.accounts.state_pda.key(),
ErrCode::InvalidPdaAddress
);
// Рента уйдёт на счёт подписанта (signer)
common::utils::delete_pda_return_rent(
&ctx.accounts.state_pda.to_account_info(),
&ctx.accounts.signer.to_account_info(),
program_id,
)
}
}
// ==============================================
// Контексты вне #[program]
// ==============================================
/// Контекст для deleteInit (временный для тестов)
#[derive(Accounts)]
pub struct DeleteInit<'info> {
/// Подписант транзакции — ПОЛУЧАТЕЛЬ ренты
#[account(mut)]
pub signer: Signer<'info>,
/// Тот самый PDA из init
/// CHECK: адрес валидируем в хендлере по сид-у
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
/// Системная программа
pub system_program: Program<'info, System>,
}
/// Контекст для add_bonus: полный набор аккаунтов для операций с NFT и коллекцией.
/// (Комменты по стилю проекта оставлены.)
#[derive(Accounts)]
pub struct AddBonusCtx<'info> {
/// Любой платящий/подписант (в реальном коде — свои проверки).
#[account(mut)]
pub signer: Signer<'info>,
/// Тот же PDA с состоянием (должен уже существовать).
/// CHECK: проверяется вручную по адресу
#[account(mut)]
pub state_pda: UncheckedAccount<'info>,
// --- аккаунты минтимого NFT ---
/// Mint создаваемого NFT (должен быть создан заранее: decimals=0, mint_authority=signer, freeze_authority=signer)
/// CHECK
#[account(mut)]
pub mint_pda: UncheckedAccount<'info>,
/// ATA получателя (может быть предсоздан тестом)
/// CHECK
#[account(mut)]
pub recipient_ata: UncheckedAccount<'info>,
/// Владелец ATA (инвестор)
/// CHECK
pub recipient_owner: UncheckedAccount<'info>,
// --- аккаунты коллекции (уже созданной заранее) ---
/// CHECK
pub collection_mint: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub collection_metadata_pda: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub collection_master_edition_pda: UncheckedAccount<'info>,
/// Апдейтер коллекции (update authority)
pub collection_update_authority: Signer<'info>,
// --- metadata + master edition для создаваемого NFT ---
/// CHECK
#[account(mut)]
pub metadata_pda: UncheckedAccount<'info>,
/// CHECK
#[account(mut)]
pub master_edition_pda: UncheckedAccount<'info>,
// --- программы ---
/// CHECK: проверяется по ID внутри nft.rs
pub token_metadata_program: UncheckedAccount<'info>,
pub token_program: Program<'info, anchor_spl::token::Token>,
pub associated_token_program: Program<'info, anchor_spl::associated_token::AssociatedToken>,
pub system_program: Program<'info, System>,
}

View File

@ -1,190 +0,0 @@
use anchor_lang::prelude::*;
use anchor_lang::solana_program::{program::invoke, program::invoke_signed};
use anchor_spl::{associated_token::AssociatedToken, token::Token};
use mpl_token_metadata::{
ID as TM_ID,
instructions::{
CreateMasterEditionV3Builder,
CreateMetadataAccountV3Builder,
SetAndVerifySizedCollectionItemBuilder,
},
types::{Collection, Creator, DataV2, Uses, UseMethod},
};
use spl_token::instruction as spl_ix;
/// Параметры для минта NFT
#[derive(Clone)]
pub struct CreateNftParams {
pub name: String,
pub symbol: String,
pub uri: String,
pub index: u64,
pub recipient: Pubkey,
}
/// Создание metadata, чеканка 1 токена, freeze ATA, создание master edition, verify в коллекции.
pub fn create_nft_with_freeze(
ctx: &Context<crate::AddBonusCtx>,
params: CreateNftParams,
) -> Result<()> {
let a = &ctx.accounts;
// Проверяем что это именно программа Metaplex Token Metadata
require_keys_eq!(a.token_metadata_program.key(), TM_ID, CustomError::InvalidMetadataProgram);
// 1) Создание Metadata для нового NFT
let creators = Some(vec![Creator {
address: a.collection_update_authority.key(),
verified: true,
share: 100,
}]);
let data = DataV2 {
name: truncate(&params.name, 32),
symbol: truncate(&params.symbol, 10),
uri: truncate(&params.uri, 256),
seller_fee_basis_points: 0,
creators,
collection: Some(Collection {
verified: false, // отметим как часть коллекции позже через verify
key: a.collection_mint.key(),
}),
uses: Some(Uses {
use_method: UseMethod::Burn,
remaining: 1,
total: 1,
}),
};
// В mpl-token-metadata v5 update_authority(pubkey, is_signer: bool)
let ix_md = CreateMetadataAccountV3Builder::new()
.metadata(a.metadata_pda.key())
.mint(a.mint_pda.key())
.mint_authority(a.signer.key())
.payer(a.signer.key())
.update_authority(a.collection_update_authority.key(), true)
.system_program(a.system_program.key())
.data(data)
.is_mutable(true)
.instruction();
invoke_signed(
&ix_md,
&[
a.metadata_pda.to_account_info(),
a.mint_pda.to_account_info(),
a.signer.to_account_info(),
a.collection_update_authority.to_account_info(),
a.system_program.to_account_info(),
a.token_metadata_program.to_account_info(),
],
&[],
)?;
// 2) Чеканим 1 токен на ATA получателя
let ix_mint_to = spl_ix::mint_to(
&a.token_program.key(),
&a.mint_pda.key(),
&a.recipient_ata.key(),
&a.signer.key(),
&[],
1,
)?;
invoke(
&ix_mint_to,
&[
a.mint_pda.to_account_info(),
a.recipient_ata.to_account_info(),
a.signer.to_account_info(),
a.token_program.to_account_info(),
],
)?;
// 3) Замораживаем ATA получателя (freeze authority = signer)
let ix_freeze = spl_ix::freeze_account(
&a.token_program.key(),
&a.recipient_ata.key(),
&a.mint_pda.key(),
&a.signer.key(),
&[],
)?;
invoke(
&ix_freeze,
&[
a.recipient_ata.to_account_info(),
a.mint_pda.to_account_info(),
a.signer.to_account_info(),
a.token_program.to_account_info(),
],
)?;
// 4) Создаём Master Edition
let ix_me = CreateMasterEditionV3Builder::new()
.edition(a.master_edition_pda.key())
.mint(a.mint_pda.key())
.update_authority(a.collection_update_authority.key())
.mint_authority(a.signer.key())
.payer(a.signer.key())
.metadata(a.metadata_pda.key())
.token_program(a.token_program.key())
.system_program(a.system_program.key())
.max_supply(0)
.instruction();
invoke_signed(
&ix_me,
&[
a.master_edition_pda.to_account_info(),
a.mint_pda.to_account_info(),
a.collection_update_authority.to_account_info(),
a.signer.to_account_info(),
a.metadata_pda.to_account_info(),
a.token_program.to_account_info(),
a.system_program.to_account_info(),
a.token_metadata_program.to_account_info(),
],
&[],
)?;
// 5) Verify как часть коллекции
// Метод называется collection_master_edition_account(...)
let ix_verify = SetAndVerifySizedCollectionItemBuilder::new()
.metadata(a.metadata_pda.key())
.collection_authority(a.collection_update_authority.key())
.payer(a.signer.key())
.update_authority(a.collection_update_authority.key())
.collection_mint(a.collection_mint.key())
.collection(a.collection_metadata_pda.key())
.collection_master_edition_account(a.collection_master_edition_pda.key())
.instruction();
invoke_signed(
&ix_verify,
&[
a.metadata_pda.to_account_info(),
a.collection_update_authority.to_account_info(),
a.signer.to_account_info(),
a.collection_update_authority.to_account_info(),
a.collection_mint.to_account_info(),
a.collection_metadata_pda.to_account_info(),
a.collection_master_edition_pda.to_account_info(),
a.token_metadata_program.to_account_info(),
],
&[],
)?;
msg!("NFT создан, заморожен, мастер-издание создано и верифицировано в коллекции (index={})", params.index);
Ok(())
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max { s.to_string() } else { s.chars().take(max).collect() }
}
#[error_code]
pub enum CustomError {
#[msg("Invalid Token Metadata program account")]
InvalidMetadataProgram,
}