SPL Token и NFT: баланс как отдельный аккаунт
В Ethereum токен это контракт, а баланс это строка в его
mapping(урок 30). Каждый новый ERC-20 это новый развёрнутый контракт с копией почти одинакового кода. Solana идёт иначе: код токена один на всех, а данные каждого токена и каждого баланса вынесены в аккаунты. Из-за этого модель токенов выглядит непривычно: балансы это отдельные аккаунты, а не записи внутри контракта. Разберём, почему так, и заодно поймём, что NFT это просто вырожденный токен.
Идея: один код, данные в аккаунтах
Вспомним сквозную мысль урока 32: в Solana код и данные разведены. С токенами это выстреливает нагляднее всего. Есть одна программа SPL Token, развёрнутая раз и навсегда, и она обслуживает все токены сети. А различает токены не код, а два вида аккаунтов.
Минт это аккаунт самого токена: кто вправе печатать (authority), сколько выпущено (supply), сколько знаков после запятой (decimals). Адрес минта это и есть идентификатор токена.
Токен-аккаунт это баланс одного токена у одного владельца: к какому минту привязан (mint), чей это баланс (owner), сколько на нём (amount).
Сразу договоримся о рамке, чтобы не было иллюзий: код ниже это наша минимальная учебная модель токен-программы, а не листинг настоящего SPL Token. Настоящий отличается в деталях: сериализация через трейт Pack с фиксированной раскладкой байтов, а не Borsh; authority хранится как COption, и её можно отозвать; есть freeze_authority, делегирование через delegate и поле state (не заморожен ли аккаунт). Но логика проверок, ради которой мы всё это пишем, у настоящего та же самая.
/// Минт: эмиссионный центр одного токена. Аналог контракта ERC-20,
/// но права и счётчик вынесены в аккаунт, а не зашиты в код.
pub struct Mint {
pub authority: [u8; 32],
pub supply: u64,
pub decimals: u8,
pub is_initialized: bool,
}
/// Токен-аккаунт: баланс одного токена у одного владельца.
/// В Ethereum это строка balances[owner], тут отдельный аккаунт.
pub struct TokenAccount {
pub mint: [u8; 32],
pub owner: [u8; 32],
pub amount: u64,
pub is_initialized: bool,
}
Держишь три разных токена, у тебя три токен-аккаунта, по одному на минт. Это и есть главное отличие от ERC-20: там твой баланс живёт внутри контракта токена, тут он живёт в твоём собственном аккаунте.
Печать и перевод: та же модель проверок
Логика операций та же, что мы отработали в уроке 33: разобрать, проверить права, поменять числа. Печать новых токенов вправе делать только authority минта, и она обязана подписать.
fn mint_to(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_iter = &mut accounts.iter();
let mint_account = next_account_info(account_iter)?;
let token_account = next_account_info(account_iter)?;
let authority = next_account_info(account_iter)?;
// Оба аккаунта состояния обязаны принадлежать нашей учебной токен-программе.
assert_owned_by(mint_account, program_id)?;
assert_owned_by(token_account, program_id)?;
let mut mint = Mint::try_from_slice(&mint_account.data.borrow())?;
let mut account = TokenAccount::try_from_slice(&token_account.data.borrow())?;
// Печатать вправе только authority минта, и она обязана подписать.
if !authority.is_signer || authority.key.to_bytes() != mint.authority {
return Err(ProgramError::MissingRequiredSignature);
}
// Токен-аккаунт обязан быть от этого же минта.
if account.mint != mint_account.key.to_bytes() {
return Err(ProgramError::InvalidArgument);
}
// checked_add на обоих счётчиках: токены из воздуха не берутся.
account.amount = account.amount.checked_add(amount).ok_or(ProgramError::ArithmeticOverflow)?;
mint.supply = mint.supply.checked_add(amount).ok_or(ProgramError::ArithmeticOverflow)?;
write_state(token_account, &account)?;
write_state(mint_account, &mint)?;
Ok(())
}
Перевод двигает числа между двумя токен-аккаунтами одного минта, и подписать обязан владелец источника.
fn transfer(program_id: &Pubkey, accounts: &[AccountInfo], amount: u64) -> ProgramResult {
let account_iter = &mut accounts.iter();
let source = next_account_info(account_iter)?;
let destination = next_account_info(account_iter)?;
let owner = next_account_info(account_iter)?;
assert_owned_by(source, program_id)?;
assert_owned_by(destination, program_id)?;
let mut src = TokenAccount::try_from_slice(&source.data.borrow())?;
let mut dst = TokenAccount::try_from_slice(&destination.data.borrow())?;
// Переводит только владелец источника, и он обязан подписать.
if !owner.is_signer || owner.key.to_bytes() != src.owner {
return Err(ProgramError::MissingRequiredSignature);
}
// Токены разных минтов не смешиваются.
if src.mint != dst.mint {
return Err(ProgramError::InvalidArgument);
}
if src.amount < amount {
return Err(ProgramError::InsufficientFunds);
}
src.amount -= amount;
dst.amount = dst.amount.checked_add(amount).ok_or(ProgramError::ArithmeticOverflow)?;
write_state(source, &src)?;
write_state(destination, &dst)?;
Ok(())
}
Обрати внимание: тут нет approve и allowance из ERC-20 (урок 30). Перевод авторизует подпись владельца прямо в транзакции, а не выданное заранее разрешение. Это убирает целый класс атак вокруг гонки approve, которую мы разбирали.
Механизм делегирования у настоящего SPL всё же есть, просто устроен он иначе. Инструкция approve записывает прямо в токен-аккаунт пару полей: delegate (кому доверено) и delegated_amount (сколько), и делегат может тратить с этого аккаунта до лимита, а revoke сбрасывает доверенность. Отличия от allowance ERC-20: у токен-аккаунта делегат ровно один, а не таблица разрешений на много адресов, и лимит виден прямо в аккаунте, а не спрятан в mapping контракта. По умолчанию же модель проще некуда: хочешь перевести, подпиши.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.