French Solana dev #1: Разработка смарт-контракта на блокчейне Solana с помощью фреймворка Anchor ⚓️🧑💻

Преамбула

Совсем недавно я осознал недостаток ресурсов, написанных на французском языке в области разработки и web3.

Даже если появляется все больше ресурсов для написания смарт-контрактов с помощью популярной Solidity, это не относится к блокчейнам, не совместимым с EVM.

Действительно, все эти блокчейны используют разные архитектуры, и, не будучи столь популярными, как Ethereum на момент написания статьи, им сложнее получить хорошие учебные ресурсы на языке оригинала.

Поэтому, если у вас аллергия на английский, я приглашаю вас прочитать эту статью, которая, я надеюсь, поможет вам понять, как работает программа на Solana 🙂 .


Пререквизиты

Для написания нашей программы мы будем использовать язык Rust, поэтому я советую вам уже владеть основами этого языка. Rust Book — это справочный ресурс для ознакомления с Rust (также доступен «частично» на французском языке!).

Безусловно, необходимо, чтобы вы уже были знакомы с архитектурой блокчейна и тем, как он работает.

Вам также придется прочитать документацию разработчика Solana, чтобы понять, о чем идет речь, даже если я вернусь к некоторым моментам, чтобы попытаться представить более популярное видение.


📦 Объекты

Ржавчина

Вы можете установить Rust с помощью этих трех команд:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
rustup component add rustfmt
Войдите в полноэкранный режим Выход из полноэкранного режима

Для более подробной установки обратитесь к книге Rust Book.

Солана

Клиент solana позволяет вам взаимодействовать с различными сетями Solana, создавать и управлять различными учетными записями, а также использовать различные другие утилиты…

На macOS и Linux:

sh -c "$(curl -sSfL https://release.solana.com/v1.10.4/install)"
Войдите в полноэкранный режим Выход из полноэкранного режима

Подробная установка доступна в документации по Solana.

Для остальных вам потребуется учетная запись, чтобы использовать ее с якорем.
🔑 solana-keygen позволяет нам генерировать пару открытый/закрытый ключ:

solana-keygen new
Войдите в полноэкранный режим Выход из полноэкранного режима

Пряжа

Пряжа используется Anchor. Если на вашей машине его нет, его можно установить через NPM:

npm i -g corepack
Войдите в полноэкранный режим Выход из полноэкранного режима

Якорь

Anchor — это фреймворк, который значительно облегчает жизнь разработчикам на Solana и содержит широкий спектр функций, таких как:

  • Ящики и библиотека Rust
  • Полный IDL для наших программ
  • Пакет TypeScript для использования наших программ с IDL
  • CLI и менеджер рабочих пространств для разработки приложений от бэкенда до фронтенда

Речь идет только о создании нашей программы. Тестирование и взаимодействие с нашей развернутой на фронтенде программой больше подходит для будущей игры.

Anchor можно сравнить с Truffle или Hardhat, двумя наиболее используемыми фреймворками для работы над смарт-контрактами в Solidity.

Для установки anchor на вашей машине лучше всего использовать менеджер версий anchor (AVM).

Он устанавливается через груз:

cargo install --git https://github.com/project-serum/anchor avm --locked --force
Войдите в полноэкранный режим Выход из полноэкранного режима

Затем вы можете установить последнюю версию anchor через avm:

avm install latest
avm use latest
Войдите в полноэкранный режим Выход из полноэкранного режима

Убедитесь, что анкер установлен:

anchor --version
Войдите в полноэкранный режим Выход из полноэкранного режима

Другие способы установки приведены в книге по анкерным креплениям.


Создание проекта

Чтобы создать новый якорный проект, используйте следующую команду:

anchor init <nom-du-projet>
Войдите в полноэкранный режим Выход из полноэкранного режима

Это создаст папку с именем вашего проекта, переданным в качестве аргумента, и базу проекта, с которой вы можете начать работу.


Структура проекта Anchor

Важно понимать, из каких файлов и папок состоит проект Anchor:

  • Папка .anchor содержит локальную сеть и различные журналы, связанные с ней.
  • В папке app может храниться front-end, связанный с вашими программами, если вы хотите работать в одном репозитории.
  • Папка migrations содержит наши сценарии миграции и развертывания.
  • Папка programs содержит все наши программы. Действительно, мы можем написать несколько программ для нашего проекта. Обратите внимание, что anchor уже создал программу с именем вашего проекта, которая содержит минималистичный код примера в lib.rs.
  • Папка target типична для Rust и содержит все сборки и скомпилированные файлы. В 99% случаев эту папку трогать не нужно.
  • Папка tests содержит все наши скрипты, написанные для тестирования наших программ.

Также в корне находится файл конфигурации якоря Anchor.toml, содержащий базовую конфигурацию:

  • [programs.localnet] содержит идентификаторы наших различных программ, к этому мы вернемся чуть позже 🙂 .
  • [registry] позволяет занести ваш проект в реестр программ.
  • [provider] содержит сеть для запуска тестовых сценариев и используемую учетную запись.
  • [scripts] содержит команду, которую anchor test выполняет для ваших тестовых сценариев

Структура программы

Программа Solana, написанная с помощью Anchor, состоит из нескольких отдельных частей:

  • Макрос declare_id!, определяющий ID нашей программы.
  • Модуль, включающий атрибут #[program], в котором определены все точки входа нашей программы. Точка входа — это функция, которая может быть вызвана извне, в транзакции, например, с помощью RPC. Эти функции чаще называют инскрукциями, и они изменяют состояние блокчейна.
  • Структуры, реализующие атрибут #[derive(Accounts)], которые определяют все счета, необходимые инструкции. Эти структуры затем передаются в Context наших инструкций.
  • Структуры, реализующие атрибут #[account], позволяют хранить данные в вашей программе.Напомню, что все данные хранятся в виде аккаунта и поэтому у каждого из них есть как минимум один открытый ключ. Мы вернемся к этому позже.

Далее мы будем работать на основе программы, целью которой является определение личности для каждого пользователя. Пользователь сможет создавать свою личность, а затем изменять определенные части своей личности. Он также может удалить свою личность из блокчейна, но только через 2 года после его создания!


Анализ примера программы

После создания своего якорного проекта вы можете перейти к своей первой программе, которая была сгенерирована с именем вашего проекта по следующему пути:

/programs/<nom-du-projet>/src/lib.rs

lib.rs уже содержит минимальный пример кода, который должен выглядеть следующим образом:

use anchor_lang::prelude::*;

/// Notre macro de déclaration d'ID pour notre program
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");

/// Notre module définissant les différentes instructions de notre program
#[program]
pub mod identity {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        Ok(())
    }
}

/// Une structure comportant les accounts à passer dans le Context de notre instruction
#[derive(Accounts)]
pub struct Initialize {}
Войдите в полноэкранный режим Выход из полноэкранного режима

Как вы можете видеть, у нас есть три из четырех частей, которые я представил выше. Зная, что от программы не требуется хранить данные, мы не имеем структур, реализующих атрибут #[account] в настоящее время.

Этот пример программы имеет одну точку входа, оператор initialize().
Все инструкции получают в качестве параметров по крайней мере один Context<T>, содержащий данные о текущем контексте. T является структурой с атрибутом #[derive(Accounts)].

Оператор возвращает Result<T, Error>. Result — это типичный элемент Rust, который в нашем случае может включать Ok<T>, который возвращается, если наша инструкция прошла успешно, или Err(Error) в случае неудачи. Здесь Error — это тип ошибки, которую предоставляет Anchor. (Позже мы увидим, как делать собственные ошибки).

Таким образом, этот оператор ничего не делает, кроме возврата Ok(()) для сигнализации успеха нашего оператора (к счастью, поскольку он ничего не делает…)


Изменение идентификатора нашей программы

Когда якорь генерирует эту программу, идентификатор, предоставляемый программе, всегда один и тот же:

declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS")
Войдите в полноэкранный режим Выход из полноэкранного режима

Хотя на данный момент это нас не беспокоит, лучше, чтобы наша программа была уникальной и, следовательно, чтобы ID тоже был уникальным!

Для этого мы изменим этот ID с помощью открытого ключа учетной записи, созданной для нашей программы.

Действительно, anchor уже генерирует учетную запись со своей парой открытый/закрытый ключ для каждой программы, которую мы создаем.
Доступ к этим учетным записям можно получить, набрав следующую команду:

anchor keys list
Войдите в полноэкранный режим Выход из полноэкранного режима

Вы должны увидеть свою программу и ее открытый ключ.

❯ anchor keys list
identity: GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ
Войдите в полноэкранный режим Выход из полноэкранного режима

Думаю, вы догадались, мы заменим ID нашей программы по умолчанию на открытый ключ учетной записи, созданной для нашей программы.

declare_id!("GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ");
Войдите в полноэкранный режим Выход из полноэкранного режима

Нам также нужно заменить ID по умолчанию в файле конфигурации якоря, Anchor.toml в корне нашего проекта:

[programs.localnet]
identity = "GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ"
Войдите в полноэкранный режим Выход из полноэкранного режима

Как только это будет сделано, можно приступать к деталям 😉.


Определение личности

Прежде чем мы начнем работать над нашими различными инструкциями и контекстами, наиболее целесообразно определить нашу структуру Identity, которая будет определять, как личность хранится в нашей программе.

Будучи довольно маниакальным в отношении организации кода, я советую выделить определение идентичности в отдельный файл. В моей части он будет находиться в модуле, который я назвал identites.rs.

Чтобы импортировать наш новый модуль в нашу программу и иметь возможность использовать его содержимое:

mod identites;
Войдите в полноэкранный режим Выход из полноэкранного режима

Если вы помните, мы определяем структуру, хранящую данные, с помощью атрибута #[account]:

use anchor_lang::prelude::*;

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {}
Войдите в полноэкранный режим Выход из полноэкранного режима

Мы хотим, чтобы идентификатор содержал имя, фамилию, псевдоним, дату рождения, дату создания, а также пользователь может указать адрес электронной почты без обязательного указания.

Таким образом, наша структура определяется следующим образом:

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {
    pub first_name: String, // max 128 bytes
    pub last_name: String,  // max 128 bytes
    pub username: String,   // max 128 bytes
    pub birth: i64,
    pub mail: Option<String>, // max 128 bytes
    pub created: i64
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Несколько замечаний:

  • Поля, содержащие строку, должны иметь предопределенный максимальный размер, хотя тип String в Rust не обязательно ограничен, мы сами должны определить максимальный размер, и мы увидим почему сразу после! В нашем случае, зная, что символ, закодированный в UTF-8, может иметь размер от 1 до 4 байт, 128 байт обеспечивают нам 32 символа в худшем случае.
  • Поля birth и created содержат дату в виде временной метки Unix. Для оптимизации размера i32 предпочтительнее i64, но i32 опасен и может сделать программу неработоспособной в тот день, когда временная метка unix превысит предел размера i32 (в 2038 году…). i64 гарантирует нам бесперебойную работу до 2262 года 😃!
  • Поскольку наше поле mail является необязательным, здесь лучше всего подходит типичный для Rust Option<T>.

Для наших счетов данных не хватает одного важного момента… набора пространства!


Размер нашей структуры личности

Если вы помните, каждый счет на блокчейне Solana инициализируется с заранее определенным максимальным размером, что позволяет блокчейну знать, сколько нужно заплатить или держать на счету, чтобы «освободиться от арендной платы».

❗Даже если вы не используете все свободное место на счете, вы все равно платите за максимум! Поэтому будьте осторожны и установите постоянный максимальный размер для своих учетных записей данных, чтобы не отпугнуть пользователей…

Вы должны определить максимальный размер, который может иметь идентификатор, и для этого вам нужно обратиться к следующей таблице, доступной в Anchor Book:

В отношении нашей структуры Identity это дает нам:

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {
    pub first_name: String, // 128 + 4 = 132
    pub last_name: String,  // 128 + 4 = 132
    pub username: String,   // 128 + 4 = 132
    pub birth: i64, // 8
    pub mail: Option<String>, // 128 + 1 = 129
    pub created: i64 // 8
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Наконец, мы можем поместить эти данные в константы в нашей структуре Identity:

impl Identity {
    pub const MAX_STRING_SIZE: usize = 128;
    pub const MAX_IDENTITY_SIZE: usize = 132 + 132 + 132 + 8 + 129 + 8;
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Определение наших точек входа

Далее мы определим наши различные инструкции для управления идентификацией.

Мы можем удалить пример initialize(), поскольку мы не будем его использовать.

Возвращаясь к спецификации, которую я указал выше, я определил все эти инструкции:

#[program]
pub mod identity {
    use super::*;

    /// Permet à un utilisateur sans identité de créer son identité
    pub fn create_identity(
        ctx: Context<Initialize>,
        first_name: String,
        last_name: String,
        username: String,
        birth: i64,
        mail: Option<String>
    ) -> Result<()> {
        // TODO
        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour son prénom
    pub fn update_name(ctx: Context<Initialize>, first_name: String) -> Result<()> {
        // TODO
        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour son pseudonyme
    pub fn update_username(ctx: Context<Initialize>, username: String) -> Result<()> {
        // TODO
        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour ou supprimer son mail
    pub fn update_mail(ctx: Context<Initialize>, mail: Option<String>) -> Result<()> {
        // TODO
        Ok(())
    }

        /// Permet à un utilisateur ayant une identité depuis plus de 2 ans
        /// de supprimer son identité 
        pub fn delete_identity(ctx: Context<Initialize>) -> Result<()> {
        // TODO
        Ok(())
    }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Наши инструкции достаточно понятны, я думаю, нет необходимости возвращаться к ним.

Теперь нам нужно определить наши различные Accounts для передачи в наши инструкции…


Определение struct Accounts для передачи в наши инструкции

Хотя это может показаться удивительным, нам понадобится определить всего три различные структуры Accounts!

3 структуры для 5 инструкций? Правильно!

Это потому, что все наши инструкции по обновлению будут использовать одну и ту же логику 🙂 .

Давайте начнем с определения Accounts, которые нужны нашему оператору create_identity().

Напомним, что мы определяем структуру Accounts с помощью атрибута #[derive(Accounts)], как таковую:

#[derive(Accounts)]
pub struct CreateIdentity<'info> {}
Войдите в полноэкранный режим Выход из полноэкранного режима

Затем мы добавляем для каждого нового поля тип счета, который ожидается.

Существует несколько типов, которые вы можете заполнить, вот неполный список:

  • Тип Account<'info, T>, который гарантирует, что T — это данные, которыми владеет наша программа (Например: наша структура Identity).
  • Тип Signer<'info>, который гарантирует, что указанный счет подписал транзакцию.
  • Тип Program<'info, T>, который гарантирует, что указанный счет действительно является программой T, где T — это ID нужной программы.
  • Тип UncheckedAccount<'info>, который не выполняет никакой проверки указанного счета.

Помимо этих различных типов, можно использовать ограничения на наших счетах для выполнения других проверок. Эти ограничения можно добавить, добавив атрибут #[account()] над полем счета, добавив нужные параметры в скобках.

Вот неполный список (более подробная информация здесь):

Есть еще один довольно важный момент, но я не буду пока вас о нем беспокоить, мы в любом случае скоро до него доберемся! Считайте, что это последний босс программного развития на Солане 😃.

Теперь у нас есть все ключи для реализации нашей структуры CreateIdentity.

Подводя итог, нам нужен аккаунт пользователя, который должен будет подписать транзакцию для создания своей личности, а также оплатить создание своих данных. Системная программа также нужна нам для создания учетной записи Identity, которая будет хранить идентификационные данные нашего пользователя.

Вот как выглядит наш контекст CreateIdentity:

#[derive(Accounts)]
pub struct CreateIdentity<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(init, payer = user, space = Identity::MAX_IDENTITY_SIZE + 8)]
    pub identity: Account<'info, Identity>,
    pub system_program: SystemAccount<'info>
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что мы делаем учетную запись пользователя мутабельной, что требуется системной программе для создания идентификатора учетной записи и оплаты аренды за наш идентификатор учетной записи.
Также вам может быть интересно, что это за «+8» для параметра space?
Я не буду углубляться в технические подробности, но это 8 байт, которые требуются якорю при десериализации нашей учетной записи Identity.


Определение других структур счетов

Для утверждений update_name(), update_username() и update_mail() нам нужны учетная запись пользователя, подпись, идентификатор учетной записи и все!

Обратите внимание, что идентификатор счета должен быть изменяемым, поскольку мы будем изменять его данные 🙂 .

#[derive(Accounts)]
pub struct UpdateIdentity<'info> {
    pub user: Signer<'info>,
    #[account(mut)]
    pub identity: Account<'info, Identity>
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Возможно, вы уже заметили, что что-то не так… вернемся к этому позже 😉
Для самых умных сделаем вид, что все в порядке, и продолжим реализацию логики наших инструкций.


Выполнение наших инструкций

создать_идентичность

Давайте начнем с написания первого оператора, который должен вызвать пользователь, который инициализирует его учетную запись Identity и сохранит его личность.

Нет необходимости обрабатывать инициализацию счета Identity, так как это делается с помощью наших ограничений init, как показано выше (хорошая вещь сделана! 🙂 ).

Сначала нам нужно проверить, что String, предоставленная пользователем, не превышает установленный лимит, т.е. 128 байт, как было определено ранее. Если вы знакомы с Solidity, anchor предоставляет макросы require для возврата проверки условия между двумя переменными и возврата ошибки по выбору.

❗ Есть и другие проверки, которые можно (и нужно) сделать для оптимизации безопасности нашей программы, но я пока пропущу их.

// Check des infos fournit par l'utilisateur
require_gte!(Identity::MAX_STRING_SIZE, first_name.len());
require_gte!(Identity::MAX_STRING_SIZE, last_name.len());
require_gte!(Identity::MAX_STRING_SIZE, username.len());
if mail.is_some() {
    require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len());
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Осталось только зарегистрировать эти данные в нашей новой учетной записи Identity.

Для этого нет ничего проще, мы просто передаем значения, предоставленные при вызове пользователем инструкции для каждого поля идентификатора нашей учетной записи.

Мы можем получить доступ к данным и информации различных Accounts, которые мы передали в наш Context с помощью ctx.accounts.

// Enregistrement des données dans notre account Identity
let user_identity = &mut ctx.accounts.identity;
user_identity.first_name = first_name;
user_identity.last_name = last_name;
user_identity.birth = birth;
user_identity.mail = mail;
user_identity.created = Clock::get().unwrap().unix_timestamp;
Войдите в полноэкранный режим Выход из полноэкранного режима
/// Permet à un utilisateur sans identité de créer son identité
    pub fn create_identity(
        ctx: Context<CreateIdentity>,
        first_name: String,
        last_name: String,
        username: String,
        birth: i64,
        mail: Option<String>
    ) -> Result<()> {
        // Check des infos fournit par l'utilisateur
        require_gte!(Identity::MAX_STRING_SIZE, first_name.len());
        require_gte!(Identity::MAX_STRING_SIZE, last_name.len());
        require_gte!(Identity::MAX_STRING_SIZE, username.len());
        if mail.is_some() {
            require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len());
        }

        // Enregistrement des données dans notre account Identity
        let user_identity = &mut ctx.accounts.identity;
        user_identity.first_name = first_name;
        user_identity.last_name = last_name;
        user_identity.birth = birth;
        user_identity.mail = mail;
        user_identity.created = Clock::get().unwrap().unix_timestamp;

        Ok(())
    }
Войдите в полноэкранный режим Выход из полноэкранного режима

И это все! Наша первая инструкция полностью реализована и функционирует…. хорошо почти.

Помните ли вы финального босса, о котором я упоминал ранее? Пришло время взглянуть правде в глаза!

Позвольте мне рассказать вам о КПК.


Производный адрес программы

Текущая проблема

Для начала давайте проанализируем текущую проблему с нашей программой идентификации.

Напомню, что каждый пользователь имеет свою идентичность, то есть для каждого пользователя/публичного ключа должна быть создана и существовать учетная запись Identity.

Во-первых, это означает, что пользователь должен сначала сгенерировать новую пару открытый/закрытый ключ, которая будет его учетной записью Identity, если ему нужна идентификация, что не очень практично. Это также позволит использовать бесконечное количество идентификаторов для одного открытого ключа.
Во-вторых, даже если бы пользователей устраивала эта система, у нее есть огромная проблема с безопасностью. Давайте рассмотрим наши Accounts, которые мы передаем для наших инструкций по обновлению:

#[derive(Accounts)]
pub struct UpdateIdentity<'info> {
    pub user: Signer<'info>,
    #[account(mut)]
    pub identity: Account<'info, Identity>
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Какова цель user? Ну… на данный момент — ничего.
Действительно, независимо от того, кто подписывает транзакцию, и независимо от того, каков открытый ключ идентификатора счета, изменения будут учтены для идентификатора счета…..

Проблема здесь заключается в том, что у нас нет связи между пользователем, подписывающим транзакцию, и предоставленным идентификатором счета, и невозможно проверить принадлежность и уникальность идентификатора по открытому ключу пользователя.

Не волнуйтесь, я говорю вам все это потому, что PDA решает все эти проблемы 🙂 .

Пояснения к КПК

Прежде всего, PDA — это один из самых коварных, но и один из самых важных принципов для разработчика Solana, так что не отчаивайтесь сейчас!

PDA, что означает Program Derived Address, — это адреса, сгенерированные из идентификатора программы и нескольких семян.

У КПК есть особенность, он не должен принадлежать к кривым ed25519!
Это означает, что КПК имеет форму открытого ключа, НО НЕ ИМЕЕТ связанного с ним закрытого ключа. Поэтому пользователю невозможно сгенерировать действительную подпись для учетной записи с КПК в качестве открытого ключа!

PDA является прямой заменой Mapping, который можно использовать в Solidity для связывания адреса с данными. (Для тех, кому интересно, HashMap из Rust не функционирует в программе на Solana «на данный момент»).

Теперь рассмотрим следующую функцию:

findProgramDerivedAddress(programId, seeds)
Войдите в полноэкранный режим Выход из полноэкранного режима

Эта функция возвращает адрес, найденный из предоставленного идентификатора программы и предоставленного семени. Проблема в том, что эта функция имеет 50% шанс на успех.

Помните, что действительный PDA не должен принадлежать кривой ed25519, поэтому мы должны быть уверены, что наша функция возвращает действительный адрес.

Для этого нам нужно добавить третий аргумент, который мы называем «шишка». Эта ошибка представляет собой целое число, которое будет увеличиваться каждый раз, когда возвращается недействительный адрес. Затем функция будет повторять, увеличивая неровность, пока не найдет адрес, который не находится на кривой.

findProgramDerivedAddress(programId, seeds, bump)
Войдите в полноэкранный режим Выход из полноэкранного режима

Благодаря PDA, мы теперь можем создать уникальный счет Identity для каждого пользователя без необходимости хранить что-либо, потому что этот адрес может быть вычислен непосредственно нашей программой или нашими пользователями!

Мы также сможем установить ограничения для того, чтобы пользователь, подписывающий транзакцию, мог получить доступ только к своей учетной записи Identity и изменить ее соответствующим образом.


Реализация КПК

CreateIdentity

Эта реализация осуществляется на уровне наших структур Accounts.

Давайте посмотрим на наши Accounts, которые мы передаем для нашего оператора create identity:

#[derive(Accounts)]
pub struct CreateIdentity<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(
        init,
        payer = user, 
        space = Identity::MAX_IDENTITY_SIZE + 8,
        // PDA à implémenter
    )]
    pub identity: Account<'info, Identity>,
    pub system_program: SystemAccount<'info>
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Хотя концепция PDA может быть сложной для понимания, реализовать ее можно всего за несколько строк!

Нам нужно добавить ограничение seeds при инициализации учетной записи, которое позволит нам вычислить PDA из предоставленных семян и отказаться от любого другого переданного адреса, если он не является вычисленным PDA.

Теперь нам нужно знать, какие семена использовать. Как правило, наш посевной материал будет основываться как минимум на трех параметрах:

  • Строка символов, позволяющая отличить генерацию КПК определенного типа счета от других.
  • Открытый ключ нашего пользователя, подписывающего транзакцию. Именно это позволяет установить связь между его личностью и его открытым ключом! Действительно, каждый открытый ключ будет генерировать свой КПК 🙂 .
  • Шишка, необходимая для того, чтобы наша программа нашла КПК с нашими первыми двумя предоставленными семенами. Наша программа будет использовать первую допустимую неровность, также называемую канонической неровностью

В переводе на наш код получается вот что:

seeds = [b"Identity", user.key().as_ref()], bump
Войдите в полноэкранный режим Выход из полноэкранного режима

Затем нам нужно сохранить найденную нашей программой кочку, чтобы адрес, созданный позже для доступа к нашему счету Identity, всегда был одним и тем же.

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {
    pub first_name: String, // 128 + 4 = 132
    pub last_name: String,  // 128 + 4 = 132
    pub username: String,   // 128 + 4 = 132
    pub birth: i64, // 8
    pub mail: Option<String>, // 128 + 1 = 129
    pub created: i64, // 8
    pub bump: u8 // 1
}
Войдите в полноэкранный режим Выход из полноэкранного режима

!Не забудьте добавить к константе MAX_IDENTITY_SIZE пространство, занимаемое бампом в нашей структуре Identity.

Осталось только сохранить шишку, найденную нашей программой при создании идентификатора, внедрив ее в наше утверждение. Бампы, рассчитанные нашей программой, доступны по адресу ctx.bumps.get(<account>).

user_identity.bump = *ctx.bumps.get("identity").unwrap();
Войдите в полноэкранный режим Выход из полноэкранного режима

UpdateIdentity

Реализация практически идентична для нашего обновления Accounts.
Убедитесь, что переданный адрес является адресом КПК, рассчитанным для пользователя, подписывающего транзакцию.

#[account(
    mut,
    seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
)]
pub identity: Account<'info, Identity>
Войдите в полноэкранный режим Выход из полноэкранного режима

CloseIdentity

#[account(
    mut,
    close = user,
    seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
)]
pub identity: Account<'info, Identity>
Войдите в полноэкранный режим Выход из полноэкранного режима

И это все! Наша логика PDA хорошо реализована, и каждый из наших пользователей может управлять только одной идентификацией и только своей 🙂 .

Выполнение наших инструкций Часть 2

Теперь давайте реализуем логику для наших инструкций изменения.
Это будет одна и та же модель для всех трех наших инструкций:

  1. Проверка данных
  2. Хранение данных

update_name()

// Permet à un utilisateur de mettre à jour son prénom
pub fn update_name(ctx: Context<UpdateIdentity>, first_name: String) -> Result<()> {
    require_gte!(Identity::MAX_STRING_SIZE, first_name.len());

    ctx.accounts.identity.first_name = first_name;

    Ok(())
}
Войдите в полноэкранный режим Выход из полноэкранного режима

update_username()

/// Permet à un utilisateur de mettre à jour son pseudonyme
pub fn update_username(ctx: Context<UpdateIdentity>, username: String) -> Result<()> {
    require_gte!(Identity::MAX_STRING_SIZE, username.len());

    ctx.accounts.identity.username = username;

    Ok(())
}
Войдите в полноэкранный режим Выход из полноэкранного режима

update_mail()

/// Permet à un utilisateur de mettre à jour ou supprimer son mail
pub fn update_mail(ctx: Context<UpdateIdentity>, mail: Option<String>) -> Result<()> {
    if mail.is_some() {
        require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len());
    }

    ctx.accounts.identity.mail = mail;

    Ok(())
}
Войдите в полноэкранный режим Выход из полноэкранного режима

delete_identity()

Последним шагом является внедрение заявления, которое позволит пользователю удалить свою личность, если она просуществовала более двух лет.

Поскольку закрытие счета уже обрабатывается ограничением close, здесь нужно только проверить, что личность существует не менее 2 лет, иначе транзакция будет отменена и счет не будет закрыт.

/// Permet à un utilisateur ayant une identité depuis plus de 2 ans
/// de supprimer son identité 
pub fn delete_identity(ctx: Context<CloseIdentity>) -> Result<()> {
    let now = Clock::get().unwrap().unix_timestamp;
    let created = ctx.accounts.identity.created;
    let since = now - created;

    require_gt!(since, CAN_DELETE_AFTER);

    Ok(())
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Определение и эмиссия события (Event)

События — это важные элементы, которые необходимо настроить, чтобы вести эффективную историю определенных данных и облегчить связь с нашими внецепочечными приложениями.
Они смогут подписаться на событие, связанное с определенным контекстом нашей программы, и выполнить одно или несколько действий соответственно каждому новому выданному событию.

Мы настроим событие, которое будет выдаваться каждый раз, когда создается новая личность.
Событие определяется структурой с атрибутом #[event], предложенным anchor. Эта структура может содержать различные поля, определяющие данные, которые будет содержать наше событие.

В нашем случае мы хотим, чтобы событие содержало открытый ключ пользователя, создавшего идентификатор, его псевдоним, а также дату и время создания идентификатора.

#[event]
pub struct IdentityCreated {
    pub pubkey: Pubkey,
    pub username: String,
    pub timestamp: i64,
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь нет ничего сложного, нам просто нужно выдать наше событие в конце нашей инструкции.
Макрос emit!(), предоставляемый anchor, позволяет нам эмитировать структуру с атрибутом #[event] следующим образом:

// Emet un `Event` signifiant qu'une nouvelle identité est crée
        emit!(event::IdentityCreated {
            pubkey: ctx.accounts.user.key(),
            username,
            timestamp: ctx.accounts.identity.created
        });
Войдите в полноэкранный режим Выход из полноэкранного режима

Установка наших пользовательских ошибок

Чтобы завершить нашу программу, мы можем определить и реализовать пользовательские ошибки с более явными сообщениями для наших пользователей в зависимости от различных контекстов.

Anchor предоставляет атрибут #[error_codes], который позволяет нам реализовать тип якоря Error в пользовательское перечисление ошибок.

Опять же, я буду определять ошибки в другом файле, который я назову error.rs.

use anchor_lang::error_code;

#[error_code]
pub enum IdentityError {
    StringTooLarge,
    TimeNotPassed
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь нет ничего сложного 🙂

Чтобы задать пользовательское сообщение об ошибке, мы можем использовать атрибут #[msg]:

#[msg("Specified string is higher than the expected maximum space")]
StringTooLarge,
#[msg("2 year is needed since the creation of the identity to be closed")]
TimeNotPassed
Войдите в полноэкранный режим Выход из полноэкранного режима

Осталось только реализовать наши пользовательские ошибки в наших инструкциях!

require_gt!(since, CAN_DELETE_AFTER, IdentityError::TimeNotPassed);
Войдите в полноэкранный режим Выход из полноэкранного режима

И это все! Теперь наша программа завершена, хотя ее еще можно улучшить, но это не суть этой статьи 🙂 .

mod identites;
mod error;

use anchor_lang::prelude::*;
use identites::Identity;
use error::IdentityError;

declare_id!("GxyJLSDuC7BkeorKoMg87uhaXvuaUxDjDKT7iQWCbxXJ");

#[program]
pub mod identity {
    use super::*;

    pub const CAN_DELETE_AFTER: i64 = 31556926 * 2;

    /// Permet à un utilisateur sans identité de créer son identité
    pub fn create_identity(
        ctx: Context<CreateIdentity>,
        first_name: String,
        last_name: String,
        username: String,
        birth: i64,
        mail: Option<String>
    ) -> Result<()> {
        // Check des infos fournit par l'utilisateur
        require_gte!(Identity::MAX_STRING_SIZE, first_name.len(), IdentityError::StringTooLarge);
        require_gte!(Identity::MAX_STRING_SIZE, last_name.len(), IdentityError::StringTooLarge);
        require_gte!(Identity::MAX_STRING_SIZE, username.len(), IdentityError::StringTooLarge);
        if mail.is_some() {
            require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len(), IdentityError::StringTooLarge);
        }

        // Enregistrement des données dans notre account Identity
        let user_identity = &mut ctx.accounts.identity;
        user_identity.first_name = first_name;
        user_identity.last_name = last_name;
        user_identity.birth = birth;
        user_identity.mail = mail;
        user_identity.created = Clock::get().unwrap().unix_timestamp;
        user_identity.bump = *ctx.bumps.get("identity").unwrap();

        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour son prénom
    pub fn update_name(ctx: Context<UpdateIdentity>, first_name: String) -> Result<()> {
        require_gte!(Identity::MAX_STRING_SIZE, first_name.len(), IdentityError::StringTooLarge);

        ctx.accounts.identity.first_name = first_name;

        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour son pseudonyme
    pub fn update_username(ctx: Context<UpdateIdentity>, username: String) -> Result<()> {
        require_gte!(Identity::MAX_STRING_SIZE, username.len(), IdentityError::StringTooLarge);

        ctx.accounts.identity.username = username;

        Ok(())
    }

    /// Permet à un utilisateur de mettre à jour ou supprimer son mail
    pub fn update_mail(ctx: Context<UpdateIdentity>, mail: Option<String>) -> Result<()> {
        if mail.is_some() {
            require_gte!(Identity::MAX_STRING_SIZE, mail.as_ref().unwrap().len(), IdentityError::StringTooLarge);
        }

        ctx.accounts.identity.mail = mail;

        Ok(())
    }

    /// Permet à un utilisateur ayant une identité depuis plus de 2 ans
    /// de supprimer son identité 
    pub fn delete_identity(ctx: Context<CloseIdentity>) -> Result<()> {
        let now = Clock::get().unwrap().unix_timestamp;
        let created = ctx.accounts.identity.created;
        let since = now - created;

        require_gt!(since, CAN_DELETE_AFTER, IdentityError::TimeNotPassed);

        Ok(())
    }
}

#[derive(Accounts)]
pub struct CreateIdentity<'info> {
    #[account(mut)]
    pub user: Signer<'info>,
    #[account(
        init,
        payer = user, 
        space = Identity::MAX_IDENTITY_SIZE + 8,
        seeds = [b"Identity", user.key().as_ref()], bump
    )]
    pub identity: Account<'info, Identity>,
    pub system_program: SystemAccount<'info>
}

#[derive(Accounts)]
pub struct UpdateIdentity<'info> {
    pub user: Signer<'info>,
    #[account(
        mut,
        seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
    )]
    pub identity: Account<'info, Identity>
}

#[derive(Accounts)]
pub struct CloseIdentity<'info> {
    pub user: Signer<'info>,
    #[account(
        mut,
        close = user,
        seeds = [b"Identity", user.key().as_ref()], bump = identity.bump
    )]
    pub identity: Account<'info, Identity>
}
Войдите в полноэкранный режим Выход из полноэкранного режима
use anchor_lang::prelude::*;

/// Définit la structure d'une identité d'un utilisateur
#[account]
pub struct Identity {
    pub first_name: String, // 128 + 4 = 132
    pub last_name: String,  // 128 + 4 = 132
    pub username: String,   // 128 + 4 = 132
    pub birth: i64, // 8
    pub mail: Option<String>, // 128 + 1 = 129
    pub created: i64, // 8
    pub bump: u8 // 1
}

impl Identity {
    pub const MAX_STRING_SIZE: usize = 128;
    pub const MAX_IDENTITY_SIZE: usize = 132 + 132 + 132 + 8 + 129 + 8 + 1;
}
Войдите в полноэкранный режим Выход из полноэкранного режима
use anchor_lang::error_code;

#[error_code]
pub enum IdentityError {
    #[msg("Specified string is higher than the expected maximum space")]
    StringTooLarge,
    #[msg("2 year is needed since the creation of the identity to be closed")]
    TimeNotPassed
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Заключение

Теперь у вас должны быть ключи для создания собственных программ Solana!

Более «деревенская» версия проекта доступна на моем github.

В следующей части я расскажу, как тестировать созданные вами программы, по-прежнему через якорь, с помощью Typescript + Chai.

В ближайшее время я также расскажу о токенах Solana (SPL), межпрограммных инвокациях (CPI) или различных статьях о других технологиях web3.

Если вам нравится мой контент и/или если он помогает вам как разработчику, приглашаю вас в мои различные сети 😊.

LinkedIn
Twitter
Instagram

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *