Как создать CRUD dApp на Solana

В этом руководстве вы узнаете, как создать и установить Solana программу и UI для базовой CRUD dApp. Это dApp позволит вам создавать записи в журнале, обновлять записи в журнале, читать записи в журнале и удалять записи в журнале с помощью транзакций на цепочке.

Чему вы научитесь #

  • Настройка окружения
  • Использование npx create-solana-dapp
  • Разработка программы Anchor
  • Якорные PDA и учетные записи
  • Развертывание программы Solana
  • Тестирование программы на цепочке
  • Подключение программы на цепочке к интерфейсу React UI

Предварительные условия #

Для этого руководства вам нужно будет настроить локальную среду разработки с помощью нескольких инструментов:

Настройка проекта #

 

Эта команда CLI позволяет быстро создать Solana dApp. Вы можете найти исходный код здесь.

Теперь ответьте на следующие запросы:

  • Введите название проекта: my-journal-dapp
  • Выберите пресет: Next.js
  • Выберите библиотеку UI: Tailwind
  • Выберите шаблон Anchor: программа counter

При выборе counter для шаблона Anchor для вас будет сгенерирована простая программа-счетчик, написанная на языке rust с использованием фреймворка Anchor. Прежде чем приступить к редактированию сгенерированной программы-шаблона, давайте убедимся, что все работает как надо:

cd my-journal-dapp
 
npm install
 
npm run dev

Написание программы Solana с помощью Anchor #

Если вы только начинаете работать с Anchor, то "Книга Anchor" и "Примеры Anchor" - это отличные рекомендации, которые помогут вам в освоении.

В my-journal-dapp, перейдите по ссылке anchor/programs/journal/src/lib.rs. Там уже будет код шаблона, сгенерированный в этой папке. Давайте удалим его и начнем с нуля, чтобы мы могли пройтись по каждому шагу.

Определите свою программу Anchor #

use anchor_lang::prelude::*;
 
// This is your program's public key and it will update automatically when you build the project.
declare_id!("7AGmMcgd1SjoMsCcXAAYwRgB9ihCyM8cZqjsUqriNRQt");
 
#[program]
pub mod journal {
    use super::*;
}

Определите состояние программы #

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

При использовании Anchor, макрос `#[account]используется для определения вашего состояния программы .

#[account]
#[derive(InitSpace)]
pub struct JournalEntryState {
    pub owner: Pubkey,
    #[max_len(50)]
    pub title: String,
     #[max_len(1000)]
    pub message: String,
}

Для этого журнала dApp, мы будем хранить:

  • владельца журнала
  • название каждой записи в журнале, и
  • сообщение каждой записи в журнале

Примечание: Область должна быть определена при инициализации учетной записи. Макрос InitSpace поможет вычислить пробел, необходимый для инициализации учетной записи. Более подробную информацию о области читайте в разделе here.

Создать запись в журнале #

Теперь давайте добавим в эту программу обработчик инструкции, создающей новую запись в журнале Для этого мы обновим код #[program] , который мы уже определили ранее, чтобы включить инструкцию для create_journal_entry.

При создании записи в журнале пользователю нужно предоставить title и message записи в журнале. Поэтому нам нужно добавить эти две переменные в качестве дополнительных аргументов.

При вызове функции обработчика инструкций, мы хотим сохранить владельца учетной записи запись title и запись журнала message к учетной записи в JournalEntryState.

#[program]
mod journal {
    use super::*;
 
    pub fn create_journal_entry(
        ctx: Context<CreateEntry>,
        title: String,
        message: String,
    ) -> Result<()> {
        msg!("Journal Entry Created");
        msg!("Title: {}", title);
        msg!("Message: {}", message);
 
        let journal_entry = &mut ctx.accounts.journal_entry;
        journal_entry.owner = ctx.accounts.owner.key();
        journal_entry.title = title;
        journal_entry.message = message;
        Ok(())
    }
}

В фреймворке Anchor каждая инструкция принимает в качестве первого аргумента тип Context. Макрос Context используется для определения структуры, инкапсулирующей учетные записи, которые будут переданы данному обработчику инструкций. Поэтому каждый Context должен иметь заданный тип относительно обработчика инструкций. В нашем случае нам нужно определить структуру данных для CreateEntry:

#[derive(Accounts)]
#[instruction(title: String, message: String)]
pub struct CreateEntry<'info> {
    #[account(
        init_if_needed,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        payer = owner,
        space = 8 + JournalEntryState::INIT_SPACE
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

В этом коде мы использовали следующие макросы:

  • Макрос #[derive(Accounts)] используется для десериализации и проверки списка учетных записей, указанных в структуре
  • Макрос атрибута #[instruction(...)] используется для доступа к данным инструкции, переданной в обработчик инструкции
  • Макрос атрибута #[account(...)] затем задает дополнительные ограничения на учетные записи

Каждая запись журнала - это задуманный программный адрес ( PDA), который хранит состояние записей в сети по сети . Поскольку здесь мы создаем новую запись в журнале, ее нужно инициализировать с помощью ограничения init_if_needed.

С помощью якоря PDA инициализирована ограничениями seeds, bumps, и init_if_needed. Ограничение init_if_needed также требует ограничений payer и space ограничения, чтобы определить, кто платит rent за хранение данных этой учетной записи на цепи и сколько места должно быть выделено для этих данных.

Примечание: Используя макрос InitSpace в JournalEntryState, мы можем рассчитать пространство, используя константу INIT_SPACE и прибавляя 8 к ограничению пространства для внутреннего дискриминатора Anchor.

Обновление записи журнала #

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

Для этого инструкция должна будет переписать/обновить данные для конкретного КПК, которые были сохранены в JournalEntryState учетной записи, когда владелец записи вызвал инструкцию update_journal_entry.

#[program]
mod journal {
    use super::*;
 
    ...
 
    pub fn update_journal_entry(
        ctx: Context<UpdateEntry>,
        title: String,
        message: String,
    ) -> Result<()> {
        msg!("Journal Entry Updated");
        msg!("Title: {}", title);
        msg!("Message: {}", message);
 
        let journal_entry = &mut ctx.accounts.journal_entry;
        journal_entry.message = message;
 
        Ok(())
    }
}
 
#[derive(Accounts)]
#[instruction(title: String, message: String)]
pub struct UpdateEntry<'info> {
    #[account(
        mut,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        realloc = 8 + 32 + 1 + 4 + title.len() + 4 + message.len(),
        realloc::payer = owner,
        realloc::zero = true,
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

В приведенном выше коде вы должны заметить, что он очень похож на создание записи в журнале, но есть несколько ключевых различий. Поскольку update_journal_entry редактирует уже существующий PDA, нам не нужно инициализировать его. Однако сообщение, передаваемое обработчику инструкций, может иметь другой размер пространства, необходимого для его хранения (т. е. message может быть короче или длиннее), поэтому нам нужно использовать несколько специальных ограничений realloc для перераспределения пространства для учетной записи на цепи:

  • realloc - задает новое пространство
  • realloc::payer - определяет счет, который будет либо оплачен, либо возвращен на основе новых требуемых lamports
  • realloc::zero - определяет, что учетная запись может обновляться несколько раз, когда установлен в true

Установленные seeds и bump ограничения все еще необходимы для поиска конкретного PDA, который мы хотим обновить.

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

Примечание: В Solana, когда вы выполняете перераспределение, которое изменяет размер счета, транзакция должна покрыть арендную плату для нового размера счета. Атрибут realloc::payer = owner указывает, что счет-владелец будет платить за аренду. Чтобы счет мог покрыть арендную плату, он обычно должен быть подписан (чтобы разрешить вычет средств), а в Anchor он также должен быть мутабельным, чтобы время выполнения могло вычитать из счета ламопорты для покрытия арендной платы.

Удалить запись журнала #

Наконец, мы добавим обработчик инструкций delete_journal_entry с текстом с типом DeleteEntry.

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

#[program]
mod journal {
    use super::*;
 
    ...
 
    pub fn delete_journal_entry(_ctx: Context<DeleteEntry>, title: String) -> Result<()> {
        msg!("Journal entry titled {} deleted", title);
        Ok(())
    }
}
 
#[derive(Accounts)]
#[instruction(title: String)]
pub struct DeleteEntry<'info> {
    #[account(
        mut,
        seeds = [title.as_bytes(), owner.key().as_ref()],
        bump,
        close = owner,
    )]
    pub journal_entry: Account<'info, JournalEntryState>,
    #[account(mut)]
    pub owner: Signer<'info>,
    pub system_program: Program<'info, System>,
}

В приведенном выше коде мы используем ограничение close для закрытия учетной записи на цепочке и возврата арендной платы владельцу записи в журнале.

Для проверки учетной записи необходимы ключи «seeds» и «bump».

Создайте и разверните свою программу Anchor #

npm run anchor build
npm run anchor deploy

Подключение программы Solana к пользовательскому интерфейсу #

create-solana-dapp уже устанавливает пользовательский интерфейс с коннектором кошелька. Все, что нам нужно сделать, это просто модифицировать их, чтобы они соответствовали вашей новой программе.

Поскольку эта программа журнала имеет три инструкции, нам нужны компоненты в пользовательском интерфейсе, которые смогут вызвать каждую из этих инструкций:

  • создать запись
  • обновить запись
  • удалить запись

В репозитории вашего проекта откройте файл web/components/journal/journal-data-access.tsx, чтобы добавить код для вызова каждой из наших инструкций.

Обновите функцию useJournalProgram для создания записи:

 

Обновите функцию useJournalProgram для создания записи:

 

Далее обновите пользовательский интерфейс в web/components/journal/journal-ui.tsx, чтобы получить пользовательские входные значения для title и message при создании записи в журнале:

export function JournalCreate() {
  const { createEntry } = useJournalProgram();
  const { publicKey } = useWallet();
  const [title, setTitle] = useState("");
  const [message, setMessage] = useState("");
 
  const isFormValid = title.trim() !== "" && message.trim() !== "";
 
  const handleSubmit = () => {
    if (publicKey && isFormValid) {
      createEntry.mutateAsync({ title, message, owner: publicKey });
    }
  };
 
  if (!publicKey) {
    return <p>Connect your wallet
</p>;
  }
 
  return (
    <div>
      <input
        type="text"
        placeholder="Title"
        value={title}
        onChange={e => setTitle(e.target.value)}
        className="input input-bordered w-full max-w-xs"
      />
      <textarea
        placeholder="Message"
        value={message}
        onChange={e => setMessage(e.target.value)}
        className="textarea textarea-bordered w-full max-w-xs"
      />
      <br>
</br>
      <button
        type="button"
        className="btn btn-xs lg:btn-md btn-primary"
        onClick={handleSubmit}
        disabled={createEntry.isPending || !isFormValid}
      >
        Create Journal Entry {createEntry.isPending && "..."}
      
</button>
    
</div>
  );
}

Наконец, обновите пользовательский интерфейс в «journal-ui.tsx», чтобы при обновлении записи в журнале брать значения ввода для «Сообщение»:

function JournalCard({ account }: { account: PublicKey }) {
  const { accountQuery, updateEntry, deleteEntry } = useJournalProgramAccount({
    account,
  });
  const { publicKey } = useWallet();
  const [message, setMessage] = useState("");
  const title = accountQuery.data?.title;
 
  const isFormValid = message.trim() !== "";
 
  const handleSubmit = () => {
    if (publicKey && isFormValid && title) {
      updateEntry.mutateAsync({ title, message, owner: publicKey });
    }
  };
 
  if (!publicKey) {
    return <p>Connect your wallet
</p>;
  }
 
  return accountQuery.isLoading ? (
    <span className="loading loading-spinner loading-lg">
</span>
  ) : (
    <div className="card card-bordered border-base-300 border-4 text-neutral-content">
      <div className="card-body items-center text-center">
        <div className="space-y-6">
          <h2
            className="card-title justify-center text-3xl cursor-pointer"
            onClick={() => accountQuery.refetch()}
          >
            {accountQuery.data?.title}
          
</h2>
          <p>{accountQuery.data?.message}
</p>
          <div className="card-actions justify-around">
            <textarea
              placeholder="Update message here"
              value={message}
              onChange={e => setMessage(e.target.value)}
              className="textarea textarea-bordered w-full max-w-xs"
            />
            <button
              className="btn btn-xs lg:btn-md btn-primary"
              onClick={handleSubmit}
              disabled={updateEntry.isPending || !isFormValid}
            >
              Update Journal Entry {updateEntry.isPending && "..."}
            
</button>
          
</div>
          <div className="text-center space-y-4">
            <p>
              <ExplorerLink
                path={`account/${account}`}
                label={ellipsify(account.toString())}
              />
            
</p>
            <button
              className="btn btn-xs btn-secondary btn-outline"
              onClick={() => {
                if (
                  !window.confirm(
                    "Are you sure you want to close this account?",
                  )
                ) {
                  return;
                }
                const title = accountQuery.data?.title;
                if (title) {
                  return deleteEntry.mutateAsync(title);
                }
              }}
              disabled={deleteEntry.isPending}
            >
              Close
            
</button>
          
</div>
        
</div>
      
</div>
    
</div>
  );
}

Ресурсы #