Ця інструкція проведе вас крок за кроком через створення повноцінної системи голосування на Solana за допомогою фреймворку Anchor. Ви отримаєте робочу програму з трьома інструкціями — створення пропозиції, голосування та закриття — і зможете перевірити її на Devnet.
Середовище: Solana CLI 1.18.x, Anchor 0.30.x, Rust 1.75+, кластер Devnet.
Передумови: встановлені Solana CLI та Anchor, налаштований гаманець з DEVNET-SOL (отримати можна через solana airdrop 2), базове розуміння PDA (Program Derived Address — детерміновано згенерована адреса, керована програмою).
Концепція голосування
Створення пропозиції, голосування за/проти
Система побудована навколо двох сутностей: пропозиції та запису голосу. Автор пропозиції створює обліковий запис у блокчейні з текстом, описом і часовим вікном для голосування. Будь-який користувач може подати свій голос — «за» або «проти» — один раз. Друге голосування того ж користувача тією ж пропозицією неможливе через архітектуру PDA: адреса запису голосу детерміновано залежить від пропозиції та голосуючого, тому спроба створити дублікат завершиться помилкою ініціалізації.
Період голосування та підрахунок результатів
Кожна пропозиція містить поле end_time — мітку часу (Unix timestamp), після якої голосування припиняється. Програма перевіряє поточний час блокчейну (Clock::get()) під час виклику інструкції vote. Лічильники yes_votes та no_votes оновлюються атомарно в межах однієї транзакції, тому подвійного зарахування не виникає. Після завершення періоду автор пропозиції може закрити обліковий запис і повернути орендну плату (rent).
Структура даних
Proposal account: title, description, yes_votes, no_votes, end_time
Обліковий запис пропозиції зберігає всі дані, необхідні для проведення голосування:
| Поле | Тип | Призначення |
|---|---|---|
proposal_id |
u64 | Числовий ідентифікатор у межах одного автора |
creator |
Pubkey | Адреса автора пропозиції |
title |
String | Коротка назва пропозиції |
description |
String | Детальний опис |
yes_votes |
u64 | Кількість голосів «за» |
no_votes |
u64 | Кількість голосів «проти» |
end_time |
i64 | Мітка завершення періоду голосування (Unix timestamp) |
is_closed |
bool | Прапорець закриття (концептуальна позначка; обліковий запис може бути видалено) |
Voter account: proposal, voter, choice
Запис голосу прив'язує конкретного голосуючого до конкретної пропозиції:
| Поле | Тип | Призначення |
|---|---|---|
proposal |
Pubkey | Адреса облікового запису пропозиції |
voter |
Pubkey | Адреса голосуючого |
choice |
bool | true — «за», false — «проти» |
Розмір облікового запису голосувальника фіксований: 8 (дискримінатор) + 32 + 32 + 1 = 73 байти.
Код структур у Rust:
#[account]
pub struct Proposal {
pub proposal_id: u64,
pub creator: Pubkey,
pub title: String,
pub description: String,
pub yes_votes: u64,
pub no_votes: u64,
pub end_time: i64,
pub is_closed: bool,
}
#[account]
pub struct VoterRecord {
pub proposal: Pubkey,
pub voter: Pubkey,
pub choice: bool,
}
Instruction: create_proposal
Створення proposal account через PDA
Інструкція create_proposal ініціалізує новий обліковий запис пропозиції за адресою PDA. Насіння (seeds) для PDA: [b"proposal", creator.key().as_ref(), proposal_id.to_le_bytes().as_ref()]. Це гарантує унікальність адреси для кожної комбінації «автор + ідентифікатор».
#[derive(Accounts)]
#[instruction(proposal_id: u64, title: String, description: String)]
pub struct CreateProposal<'info> {
#[account(
init,
payer = creator,
space = 8 + 8 + 32 + (4 + title.len()) + (4 + description.len()) + 8 + 8 + 8 + 1,
seeds = [b"proposal", creator.key().as_ref(), proposal_id.to_le_bytes().as_ref()],
bump
)]
pub proposal: Account<'info, Proposal>,
#[account(mut)]
pub creator: Signer<'info>,
pub system_program: Program<'info, System>,
}
Розмір space обчислюється як: 8 байтів (дискримінатор Anchor) + 8 (proposal_id) + 32 (creator) + 4 + довжина title (String у Borsh зберігається з 4-байтовим префіксом) + 4 + довжина description + 8 + 8 + 8 + 1. Автор пропозиції сплачує rent-exempt мінімум через payer = creator.
Встановлення параметрів голосування
Тіло інструкції заповнює всі поля облікового запису:
pub fn create_proposal(
ctx: Context<CreateProposal>,
proposal_id: u64,
title: String,
description: String,
end_time: i64,
) -> Result<()> {
let proposal = &mut ctx.accounts.proposal;
proposal.proposal_id = proposal_id;
proposal.creator = ctx.accounts.creator.key();
proposal.title = title;
proposal.description = description;
proposal.yes_votes = 0;
proposal.no_votes = 0;
proposal.end_time = end_time;
proposal.is_closed = false;
Ok(())
}
Очікуваний результат: після виклику на Devnet обліковий запис пропозиції існує за обчисленою PDA-адресою, усі поля заповнені, лічильники дорівнюють нулю.
Примітка для production: у реальному застосунку замість довільних рядків варто обмежити максимальну довжину title та description константами (наприклад, 128 і 1024 байти відповідно) і використовувати фіксований space, щоб уникнути перевищення ліміту розміру транзакції.
Instruction: vote
Перевірка: голосував вже, період активний
Інструкція vote створює обліковий запис VoterRecord за PDA з насінням [b"voter", proposal.key().as_ref(), voter.key().as_ref()]. Якщо такий запис уже існує, макрос init у Anchor автоматично поверне помилку — це гарантує, що один користувач голосує лише раз.
Друга перевірка — час: програма отримує поточний слот-час через Clock::get() і порівнює з end_time пропозиції.
#[derive(Accounts)]
pub struct Vote<'info> {
#[account(mut)]
pub proposal: Account<'info, Proposal>,
#[account(
init,
payer = voter,
space = 8 + 32 + 32 + 1,
seeds = [b"voter", proposal.key().as_ref(), voter.key().as_ref()],
bump
)]
pub voter_record: Account<'info, VoterRecord>,
#[account(mut)]
pub voter: Signer<'info>,
pub system_program: Program<'info, System>,
}
Оновлення лічильників
pub fn vote(ctx: Context<Vote>, choice: bool) -> Result<()> {
let proposal = &mut ctx.accounts.proposal;
let voter_record = &mut ctx.accounts.voter_record;
let clock = Clock::get()?;
require!(
clock.unix_timestamp < proposal.end_time,
VotingError::VotingPeriodEnded
);
voter_record.proposal = proposal.key();
voter_record.voter = ctx.accounts.voter.key();
voter_record.choice = choice;
if choice {
proposal.yes_votes += 1;
} else {
proposal.no_votes += 1;
}
Ok(())
}
Очікуваний результат: обліковий запис VoterRecord створено за PDA, відповідний лічильник у пропозиції збільшено на одиницю. Повторний виклик тим самим голосуючим завершується помилкою account already in use від Anchor.
Перевірка на Devnet: після виклику vote виконайте solana account <voter_record_pda> — ви побачите серіалізовані дані. Другий виклик з тим самим гаманцем має повернути помилку.
Instruction: close_proposal
Перевірка закінчення періоду
Інструкція close_proposal дозволяє лише автору пропозиції закрити її після завершення періоду голосування. Обмеження constraint = proposal.creator == creator.key() гарантує, що ніхто інший не зможе закрити чужу пропозицію.
#[derive(Accounts)]
pub struct CloseProposal<'info> {
#[account(
mut,
close = creator,
constraint = proposal.creator == creator.key()
)]
pub proposal: Account<'info, Proposal>,
#[account(mut)]
pub creator: Signer<'info>,
}
Очищення даних (rent reclaim)
pub fn close_proposal(ctx: Context<CloseProposal>) -> Result<()> {
let proposal = &mut ctx.accounts.proposal;
let clock = Clock::get()?;
require!(
clock.unix_timestamp >= proposal.end_time,
VotingError::VotingPeriodNotEnded
);
proposal.is_closed = true;
Ok(())
}
Макрос close = creator у Anchor автоматично встановлює лічильник лампортів облікового запису пропозиції на нуль і переносить усі лампорти назад на акаунт creator у тій самій транзакції. Після цього обліковий запис перестає існувати.
Очікуваний результат: обліковий запис пропозиції видалено, rent повернуто автору. Виклик solana account <proposal_pda> повертає порожню відповідь.
Примітка: у цій навчальній реалізації закриття пропозиції також робить недоступними всі пов'язані записи VoterRecord (їхні поля proposal вказують на неіснуючу адресу). У production-системі зазвичай зберігають пропозицію для історії або реалізують окрему логіку очищення записів голосувальників.
Типові помилки
Дубльоване голосування
Симптом: при повторному виклику vote тим самим користувачем транзакція завершується помилкою з текстом на кшталт account already in use або instruction requires an unused account.
Причина: PDA для VoterRecord обчислюється з фіксованого набору насіння: адреса пропозиції та адреса голосуючого. Перша транзакція створює обліковий запис за цією адресою. Друга спроба ініціалізувати той самий PDA завершується невдало, оскільки обліковий запис уже існує та має ненульовий баланс лампортів.
Рішення: це не баг, а запроєктована поведінка. Клієнтський код повинен перевіряти наявність VoterRecord перед викликом інструкції (через program.account.voterRecord.fetch(pda)) і показувати користувачеві відповідне повідомлення: «Ви вже проголосували».
Голосування після закінчення періоду
Симптом: транзакція vote завершується помилкою з кастомним кодом, що відповідає VotingError::VotingPeriodEnded.
Причина: програма порівнює clock.unix_timestamp (поточний час блокчейну на момент виконання транзакції) з proposal.end_time. Якщо поточний час більший або дорівнює end_time, макрос require! перериває виконання.
Рішення: на клієнтській стороні перед викликом vote завантажте обліковий запис пропозиції та перевірте proposal.end_time відносно поточного часу. Це дозволить уникнути марної витрати лампортів на комісію за транзакцію, яка гарантовано завершиться помилкою. Врахуйте, що час клієнта та час блокчейну можуть розходитися на кілька секунд — додайте невеликий буфер.
Повний перелік помилок програми:
#[error_code]
pub enum VotingError {
#[msg("Voting period has ended")]
VotingPeriodEnded,
#[msg("Voting period has not ended yet")]
VotingPeriodNotEnded,
}
Для розгортання на Devnet виконайте anchor deploy --provider.cluster devnet. Для запуску тестів — anchor test --provider.cluster devnet. Переконайтеся, що ваш файл Anchor.toml містить cluster = "devnet" і правильний гаманець у [provider].