Раздел 30 · Web3 на Rust

SPL Token и NFT: баланс как отдельный аккаунт

senior~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.