Облікові записи
Ми бачили макрос #[account]
, але, звісно, у Solana є різні типи облікових записів. З цієї причини варто приділити час, щоб зрозуміти, як загалом працюють облікові записи в Solana, і більш детально — як вони працюють з Anchor.
Загальний огляд
У Solana кожен елемент стану зберігається в обліковому записі; уявіть реєстр як одну велику таблицю, де кожен рядок має однакову базову схему:
pub struct Account {
/// lamports in the account
pub lamports: u64,
/// data held in this account
#[cfg_attr(feature = "serde", serde(with = "serde_bytes"))]
pub data: Vec<u8>,
/// the program that owns this account and can mutate its lamports or data.
pub owner: Pubkey,
/// `true` if the account is a program; `false` if it merely belongs to one
pub executable: bool,
/// the epoch at which this account will next owe rent (currently deprecated and is set to `0`)
pub rent_epoch: Epoch,
}
Усі облікові записи в Solana мають однакову базову структуру. Відрізняються вони:
- Власником: програмою, яка має виключні права на зміну даних та лампортів облікового запису.
- Даними: використовуються програмою-власником для розрізнення різних типів облікових записів.
Коли ми говоримо про облікові записи Token Program, ми маємо на увазі обліковий запис, де owner
є Token Program. На відміну від System Account, поле даних якого порожнє, обліковий запис Token Program може бути або Mint, або Token обліковим записом. Ми використовуємо дискримінатори, щоб розрізняти їх.
Так само, як Token Program може володіти обліковими записами, так може і будь-яка інша програма, навіть наша власна.
Програмні облікові записи
Програмні облікові записи є основою управління станом у програмах Anchor. Вони дозволяють створювати власні структури даних, якими володіє ваша програма. Давайте розглянемо, як ефективно працювати з ними.
Структура облікових записів та дискримінатори
Кожен програмний обліковий запис в Anchor потребує способу ідентифікації свого типу. Це забезпечується за допомогою дискримінаторів, які можуть бути:
- Дискримінатори за замовчуванням: 8-байтовий префікс, згенерований за допомогою
sha256("account:<StructName>")[0..8]
для облікових записів абоsha256("global:<instruction_name>")[0..8]
для інструкцій. Насіння використовує PascalCase для облікових записів і snake_case для інструкцій.
- Користувацькі дискримінатори: Починаючи з Anchor
v0.31.0
, ви можете вказати власний дискримінатор:
#[account(discriminator = 1)] // single-byte
pub struct Escrow { … }
Важливі примітки щодо дискримінаторів:
- Вони повинні бути унікальними в межах вашої програми
- Використання
[1]
унеможливлює використання[1, 2, …]
, оскільки вони також починаються з1
[0]
не можна використовувати, оскільки це конфліктує з неініціалізованими обліковими записами
Створення програмних облікових записів
Щоб створити програмний обліковий запис, спочатку визначте структуру даних:
use anchor_lang::prelude::*;
#[derive(InitSpace)]
#[account(discriminator = 1)]
pub struct CustomAccountType {
data: u64,
}
Ключові моменти щодо програмних облікових записів:
- Максимальний розмір становить 10 240 байтів (10 КіБ)
- Для більших облікових записів вам знадобиться
zero_copy
та фрагментовані записи - Макрос
InitSpace
автоматично обчислює необхідний простір - Загальний простір =
INIT_SPACE
+DISCRIMINATOR.len()
Загальний простір у байтах, необхідний для облікового запису, є сумою INIT_SPACE
(розмір усіх полів разом) та розміру дискримінатора (DISCRIMINATOR.len()
).
Облікові записи Solana вимагають орендного депозиту в лампортах, який залежить від розміру облікового запису. Знання розміру допомагає нам розрахувати, скільки лампортів потрібно внести, щоб відкрити обліковий запис.
Ось як ми ініціюємо обліковий запис у нашій структурі Account
:
#[account(
init,
payer = <target_account>,
space = <num_bytes> // CustomAccountType::INIT_SPACE + CustomAccountType::DISCRIMINATOR.len(),
)]
pub account: Account<'info, CustomAccountType>,
Ось деякі з полів, що використовуються в макросі #[account]
, окрім полів seeds
та bump
, які ми вже розглянули, та їхнє призначення:
init
: вказує Anchor створити обліковий записpayer
: який підписант фінансує оренду (тут - maker)space
: скільки байтів виділити. Тут також відбувається розрахунок оренди
Після створення ви можете змінювати дані облікового запису. Якщо вам потрібно змінити його розмір, використовуйте перерозподіл:
#[account(
mut, // Mark as mutable
realloc = <space>, // New size
realloc::payer = <target>, // Who pays for the change
realloc::zero = <bool> // Whether to zero new space
)]
Примітка: При зменшенні розміру облікового запису встановіть realloc::zero = true
для забезпечення правильного очищення старих даних.
Нарешті, коли обліковий запис більше не потрібен, ми можемо закрити його, щоб повернути орендну плату:
#[account(
mut, // Mark as mutable
close = <target_account>, // Where to send remaining lamports
)]
pub account: Account<'info, CustomAccountType>,
Потім ми можемо додати PDA (детерміновані адреси, отримані з сідів та ідентифікатора програми, які особливо корисні для створення передбачуваних адрес облікових записів) до цих обмежень таким чином:
#[account(
seeds = <seeds>, // Seeds for derivation
bump // Standard bump seed
)]
pub account: Account<'info, CustomAccountType>,
Примітка: PDA є детермінованими: однакові сіди + програма + бамп завжди створюють однакову адресу, а бамп гарантує, що адреса знаходиться поза кривою ed25519
Оскільки обчислення бампа може "спалити" багато CU, завжди добре зберігати його в обліковому записі або передавати в інструкцію та перевіряти його без необхідності обчислення, як показано нижче:
#[account(
seeds = <seeds>,
bump = <expr>
)]
pub account: Account<'info, CustomAccountType>,
І можливо отримати PDA з іншої програми, передавши адресу програми, з якої він походить, таким чином:
#[account(
seeds = <seeds>,
bump = <expr>,
seeds::program = <expr>
)]
pub account: Account<'info, CustomAccountType>,
Лінивий обліковий запис
Починаючи з Anchor 0.31.0, LazyAccount
забезпечує більш продуктивний спосіб читання даних облікового запису. На відміну від стандартного типу Account
, який десеріалізує весь обліковий запис у стек, LazyAccount
є обліковим записом тільки для читання, розміщеним у купі, який використовує лише 24 байти пам'яті стеку і дозволяє вибірково завантажувати конкретні поля.
Почніть з увімкнення цієї функції у вашому Cargo.toml
:
anchor-lang = { version = "0.31.1", features = ["lazy-account"] }
Тепер ми можемо використовувати це так:
#[derive(Accounts)]
pub struct MyInstruction<'info> {
pub account: LazyAccount<'info, CustomAccountType>,
}
#[account(discriminator = 1)]
pub struct CustomAccountType {
pub balance: u64,
pub metadata: String,
}
pub fn handler(ctx: Context<MyInstruction>) -> Result<()> {
// Load specific field
let balance = ctx.accounts.account.get_balance()?;
let metadata = ctx.accounts.account.get_metadata()?;
Ok(())
}
Коли CPI змінюють обліковий запис, кешовані значення стають застарілими. З цієї причини вам потрібно використовувати функцію unload()
для оновлення:
// Load the initial value
let initial_value = ctx.accounts.my_account.load_field()?;
// Do CPI...
// We still have a reference to the account from `initial_value`, drop it before `unload`
drop(initial_value);
// Load the updated value
let updated_value = ctx.accounts.my_account.unload()?.load_field()?;
Токенові рахунки
Програма Token, частина бібліотеки Solana Program Library (SPL), є вбудованим інструментарієм для створення та переміщення будь-яких активів, які не є нативними SOL. Вона має інструкції для створення токенів, випуску нової пропозиції, переказу балансів, спалювання, заморожування тощо.
Ця програма володіє двома ключовими типами рахунків:
- Рахунок Mint: зберігає метадані для одного конкретного токена: пропозицію, десяткові знаки, повноваження на випуск, повноваження на заморожування тощо
- Токеновий рахунок: містить баланс цього випуску для конкретного власника. Тільки власник може зменшити баланс (переказ, спалювання тощо), але будь-хто може надсилати токени на рахунок, збільшуючи його баланс
Токенові рахунки в Anchor
За замовчуванням основний пакет Anchor включає лише допоміжні засоби CPI та Accounts для System Program. Якщо ви хочете таку ж підтримку для SPL токенів, вам потрібно підключити пакет anchor_spl
.
anchor_spl
додає:
- Допоміжні конструктори для кожної інструкції в програмах SPL Token та Token-2022
- Обгортки типів, які спрощують перевірку та десеріалізацію рахунків Mint і Token
Давайте подивимося, як структуровані рахунки Mint
та Token
:
#[account(
mint::decimals = <expr>,
mint::authority = <target_account>,
mint::freeze_authority = <target_account>
mint::token_program = <target_account>
)]
pub mint: Account<'info, Mint>,
#[account(
mut,
associated_token::mint = <target_account>,
associated_token::authority = <target_account>,
associated_token::token_program = <target_account>
)]
pub maker_ata_a: Account<'info, TokenAccount>,
Account<'info, Mint>
та Account<'info, TokenAccount>
вказують Anchor:
- підтвердити, що рахунок дійсно є рахунком Mint або Token
- десеріалізувати його дані, щоб ви могли безпосередньо читати поля
- застосувати будь-які додаткові обмеження, які ви вказуєте (
authority
,decimals
,mint
,token_program
тощо)
Ці токенові рахунки слідують тому ж шаблону init
, який використовувався раніше. Оскільки Anchor знає їхній фіксований розмір у байтах, нам не потрібно вказувати значення space
, лише платника, який фінансує рахунок.
Anchor також пропонує init_if_needed
макрос: він перевіряє, чи вже існує токен-акаунт і, якщо ні, створює його. Це скорочення не є безпечним для всіх типів акаунтів, але ідеально підходить для токен-акаунтів, тому ми будемо використовувати його тут.
Як було зазначено, anchor_spl
створює допоміжні функції як для програм Token, так і для Token2022, причому остання впроваджує розширення токенів. Головна проблема полягає в тому, що навіть якщо ці акаунти досягають схожих цілей і мають подібну структуру, їх не можна десеріалізувати та перевіряти однаково, оскільки вони належать двом різним програмам.
Ми могли б створити більш "просунуту" логіку для обробки цих різних типів акаунтів, але на щастя Anchor підтримує цей сценарій через InterfaceAccounts:
use anchor_spl::token_interface::{Mint, TokenAccount};
#[account(
mint::decimals = <expr>,
mint::authority = <target_account>,
mint::freeze_authority = <target_account>
)]
pub mint: InterfaceAccounts<'info, Mint>,
#[account(
mut,
associated_token::mint = <target_account>,
associated_token::authority = <target_account>,
associated_token::token_program = <target_account>
)]
pub maker_ata_a: InterfaceAccounts<'info, TokenAccount>,
Ключова відмінність тут полягає в тому, що ми використовуємо InterfaceAccounts
замість Account
. Це дозволяє нашій програмі працювати як з акаунтами Token, так і з Token2022, без необхідності обробляти відмінності в їхній логіці десеріалізації. Інтерфейс надає загальний спосіб взаємодії з обома типами акаунтів, зберігаючи типову безпеку та належну валідацію.
Цей підхід особливо корисний, коли ви хочете, щоб ваша програма була сумісна з обома стандартами токенів, оскільки він усуває необхідність писати окрему логіку для кожної програми. Інтерфейс обробляє всю складність роботи з різними структурами акаунтів за лаштунками.
Якщо ви хочете дізнатися більше про те, як використовувати anchor-spl
, ви можете пройти курси SPL-Token Program with Anchor або Token2022 Program with Anchor.
Additional Accounts Type
Звичайно, системні акаунти, програмні акаунти та токен-акаунти — це не єдині типи акаунтів, які ми можемо мати в Anchor. Тож розглянемо інші типи акаунтів, які ми можемо використовувати:
Signer
Тип Signer
використовується, коли вам потрібно перевірити, що обліковий запис підписав транзакцію. Це критично важливо для безпеки, оскільки гарантує, що лише авторизовані облікові записи можуть виконувати певні дії. Ви використовуватимете цей тип щоразу, коли потрібно гарантувати, що певний обліковий запис схвалив транзакцію, наприклад, при переказі коштів або зміні даних облікового запису, що вимагає явного дозволу. Ось як ви можете його використовувати:
#[derive(Accounts)]
pub struct InstructionAccounts<'info> {
#[account(mut)]
pub signer: Signer<'info>,
}
Тип Signer
автоматично перевіряє, чи підписав обліковий запис транзакцію. Якщо ні, транзакція не виконається. Це особливо корисно, коли вам потрібно забезпечити, щоб лише певні облікові записи могли виконувати певні операції.
AccountInfo і UncheckedAccount
AccountInfo
та UncheckedAccount
— це низькорівневі типи облікових записів, які надають прямий доступ до даних облікового запису без автоматичної валідації. Вони ідентичні за функціональністю, але UncheckedAccount
є кращим вибором, оскільки його назва краще відображає його призначення.
Ці типи корисні у трьох основних сценаріях:
- Робота з обліковими записами, які не мають визначеної структури
- Реалізація власної логіки валідації
- Взаємодія з обліковими записами з інших програм, які не мають визначень типів Anchor
Оскільки ці типи обходять перевірки безпеки Anchor, вони за своєю природою небезпечні і вимагають явного підтвердження за допомогою коментаря /// CHECK
. Цей коментар служить документацією того, що ви розумієте ризики і впровадили відповідну валідацію.
Ось приклад їх використання:
#[derive(Accounts)]
pub struct InstructionAccounts<'info> {
/// CHECK: This is an unchecked account
pub account: UncheckedAccount<'info>,
/// CHECK: This is an unchecked account
pub account_info: AccountInfo<'info>,
}
Option
Тип Option
в Anchor дозволяє зробити облікові записи необов'язковими у вашій інструкції. Коли обліковий запис обгорнутий в Option
, він може бути як наданий, так і пропущений у транзакції. Це особливо корисно для:
- Створення гнучких інструкцій, які можуть працювати з певними обліковими записами або без них
- Реалізації необов'язкових параметрів, які не завжди потрібні
- Створення зворотно сумісних інструкцій, які можуть працювати з новими або старими структурами облікових записів
Коли обліковий запис Option
встановлено як None
, Anchor використовуватиме ідентифікатор програми як адресу облікового запису. Важливо розуміти цю поведінку під час роботи з необов'язковими обліковими записами.
Ось як це реалізувати:
#[derive(Accounts)]
pub struct InstructionAccounts<'info> {
pub optional_account: Option<Account<'info, CustomAccountType>>,
}
Box
Тип Box
використовується для зберігання облікових записів у купі, а не в стеку. Це необхідно в кількох сценаріях:
- Коли маєте справу з великими структурами облікових записів, які було б неефективно зберігати в стеку
- Коли працюєте з рекурсивними структурами даних
- Коли вам потрібно працювати з обліковими записами, розмір яких неможливо визначити під час компіляції
Використання Box
допомагає ефективніше керувати пам'яттю в цих випадках, розміщуючи дані облікового запису в купі. Ось приклад:
#[derive(Accounts)]
pub struct InstructionAccounts<'info> {
pub boxed_account: Box<Account<'info, LargeAccountType>>,
}
Program
Тип Program
використовується для перевірки та взаємодії з іншими програмами Solana. Anchor може легко ідентифікувати програмні облікові записи, оскільки вони мають прапорець executable
встановлений як true
. Цей тип особливо корисний, коли:
- Вам потрібно робити міжпрограмні виклики (CPI)
- Ви хочете переконатися, що взаємодієте з правильною програмою
- Вам потрібно перевірити програмне володіння обліковими записами
Існує два основні способи використання типу Program
:
- Використання вбудованих типів програм (рекомендується, коли доступно):
use anchor_spl::token::Token;
#[derive(Accounts)]
pub struct InstructionAccounts<'info> {
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
}
- Використання користувацької адреси програми, коли тип програми недоступний:
// Address of the Program
const PROGRAM_ADDRESS: Pubkey = pubkey!("22222222222222222222222222222222222222222222")
#[derive(Accounts)]
pub struct InstructionAccounts<'info> {
#[account(address = PROGRAM_ADDRESS)]
/// CHECK: this is fine since we're checking the address
pub program: UncheckedAccount<'info>,
}
Примітка: Під час роботи з токен-програмами вам може знадобитися підтримка як Legacy Token Program, так і Token-2022 Program. У таких випадках використовуйте тип Interface
замість Program
:
use anchor_spl::token_interface::TokenInterface;
#[derive(Accounts)]
pub struct InstructionAccounts<'info> {
pub program: Interface<'info, TokenInterface>,
}
Користувацька валідація облікових записів
Anchor надає потужний набір обмежень, які можна застосувати безпосередньо в атрибуті #[account]
. Ці обмеження допомагають забезпечити валідність облікових записів і застосувати правила програми на рівні облікового запису, перш ніж запуститься логіка вашої інструкції. Ось доступні обмеження:
Обмеження адреси
Обмеження address
перевіряє, чи відповідає публічний ключ облікового запису певному значенню. Це важливо, коли вам потрібно переконатися, що ви взаємодієте з відомим обліковим записом, наприклад, з конкретним PDA або програмним обліковим записом:
#[account(
address = <expr>, // Basic usage
address = <expr> @ CustomError // With custom error
)]
pub account: Account<'info, CustomAccountType>,
Обмеження власника
Обмеження owner
гарантує, що обліковий запис належить певній програмі. Це критично важлива перевірка безпеки при роботі з обліковими записами, якими володіє програма, оскільки вона запобігає несанкціонованому доступу до облікових записів, якими повинна керувати конкретна програма:
#[account(
owner = <expr>, // Basic usage
owner = <expr> @ CustomError // With custom error
)]
pub account: Account<'info, CustomAccountType>,
Обмеження виконуваності
Обмеження executable
перевіряє, чи є обліковий запис програмним (має встановлений прапорець executable
зі значенням true
). Це особливо корисно при виконанні міжпрограмних викликів (CPI), щоб переконатися, що ви взаємодієте з програмою, а не з обліковим записом даних:
#[account(executable)]
pub account: Account<'info, CustomAccountType>,
Обмеження змінюваності
Обмеження mut
позначає обліковий запис як змінюваний, дозволяючи змінювати його дані під час виконання інструкції. Це необхідно для будь-якого облікового запису, який буде оновлюватися, оскільки Anchor за замовчуванням забезпечує незмінність для безпеки:
#[account(
mut, // Basic usage
mut @ CustomError // With custom error
)]
pub account: Account<'info, CustomAccountType>,
Обмеження підписувача
Обмеження signer
перевіряє, чи підписав обліковий запис транзакцію. Це критично важливо для безпеки, коли обліковий запис повинен авторизувати дію, наприклад, переказ коштів або зміну даних. Це більш явний спосіб вимагати підписи порівняно з використанням типу Signer
:
#[account(
signer, // Basic usage
signer @ CustomError // With custom error
)]
pub account: Account<'info, CustomAccountType>,
Обмеження Has One
Обмеження has_one
перевіряє, що певне поле в структурі облікового запису відповідає публічному ключу іншого облікового запису. Це корисно для підтримки зв'язків між обліковими записами, наприклад, для забезпечення того, щоб токен-акаунт належав правильному власнику:
#[account(
has_one = data @ Error::InvalidField
)]
pub account: Account<'info, CustomAccountType>,
Користувацьке обмеження
Коли вбудованих обмежень недостатньо для ваших потреб, ви можете написати власний вираз валідації. Це дозволяє створювати складну логіку валідації, яку неможливо виразити за допомогою інших обмежень, наприклад, перевірку довжини даних облікового запису або валідацію зв'язків між кількома полями:
#[account(
constraint = data == account.data @ Error::InvalidField
)]
pub account: Account<'info, CustomAccountType>,
Ці обмеження можна комбінувати для створення потужних правил валідації для ваших облікових записів. Розміщуючи валідацію на рівні облікового запису, ви тримаєте перевірки безпеки поруч із визначеннями облікових записів і уникаєте розкидання викликів require!()
по всій логіці інструкцій.
Remaining Accounts
Іноді під час написання програм фіксована структура облікових записів інструкцій не забезпечує гнучкості, яка потрібна вашій програмі.
Remaining accounts вирішують цю проблему, дозволяючи передавати додаткові облікові записи, крім визначеної структури інструкцій, що забезпечує динамічну поведінку на основі умов виконання.
Рекомендації з реалізації
Традиційні визначення інструкцій вимагають точного зазначення, які облікові записи будуть використовуватися:
#[derive(Accounts)]
pub struct Transfer<'info> {
pub from: Account<'info, TokenAccount>,
pub to: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
}
Це чудово працює для окремих операцій, але що, якщо ви хочете виконати кілька переказів токенів в одній інструкції? Вам довелося б викликати інструкцію кілька разів, збільшуючи вартість транзакцій та складність.
Remaining accounts дозволяють передавати додаткові облікові записи, які не є частиною фіксованої структури інструкцій, що означає, що ваша програма може перебирати ці облікові записи та динамічно застосовувати повторювану логіку.
Замість того, щоб вимагати окремих інструкцій для кожного переказу, ви можете розробити одну інструкцію, яка обробляє "N" переказів:
#[derive(Accounts)]
pub struct BatchTransfer<'info> {
pub from: Account<'info, TokenAccount>,
pub to: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
}
pub fn batch_transfer(ctx: Context<BatchTransfer>, amounts: Vec<u64>) -> Result<()> {
// Handle the first transfer using fixed accounts
transfer_tokens(&ctx.accounts.from, &ctx.accounts.to, amounts[0])?;
let remaining_accounts = &ctx.remaining_accounts;
// CRITICAL: Validate remaining accounts schema
// For batch transfers, we expect pairs of accounts
require!(
remaining_accounts.len() % 2 == 0,
TransferError::InvalidRemainingAccountsSchema
);
// Process remaining accounts in groups of 2 (from_account, to_account)
for (i, chunk) in remaining_accounts.chunks(2).enumerate() {
let from_account = &chunk[0];
let to_account = &chunk[1];
let amount = amounts[i + 1];
// Apply the same transfer logic to remaining accounts
transfer_tokens(from_account, to_account, amount)?;
}
Ok(())
}
Пакетна обробка інструкцій означає:
- Менший розмір інструкцій: повторювані облікові записи та дані не потрібно включати
- Ефективність: кожен CPI коштує 1000 CU, що означає, що користувач вашої програми може викликати її лише один раз замість 3, якщо їм потрібно виконати пакетні інструкції
Реалізація на стороні клієнта
Ми можемо легко передавати залишкові облікові записи за допомогою SDK Anchor, згенерованого після виконання anchor build
. Оскільки це "сирі" облікові записи, нам потрібно вказати, чи слід передавати їх як підписувачів та/або змінювані, ось так:
await program.methods.someMethod().accounts({
// some accounts
})
.remainingAccounts([
{
isSigner: false,
isWritable: true,
pubkey: new Pubkey().default
}
])
.rpc();